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
10 changes: 6 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## v0.7.15 — Gateway Webhook Intake + Artifact Signing [UNRELEASED]
## v0.7.15 — Gateway Webhook Intake + Artifact Signing

### Artifact signing — Cosign keyless, `publish:` block, local testing

Expand Down Expand Up @@ -229,7 +229,9 @@ Both gates use the full resolver chain (`.spec`, `.metadata`, serve intent, prof

### 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`.
`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, hooks, and gates instead of the shared CRD-level ones. CRs applied via `kubectl apply` (no annotation) fall back to the CRD-level `operatorBox`.

This applies equally to declarative and typed (hooks/constructor) operators. A per-target `reconciler.hooks.args` block means the same binary receives different resolved values depending on which surface delivered the intent — no code change, no separate operator:

```yaml
operatorBox:
Expand All @@ -255,9 +257,9 @@ serve:
- 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.
`preReconcile`, `status`, and `reconciler.hooks.args` follow the same pattern — a target may declare its own values, with the CRD-level config as the fallback when absent. Reconciler settings (`workers`, `resync`, `autoscale`) are 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.
Cleanup on target switch is handled automatically via a label-selector sweep on `orkestra-owner=<name>.<prevTarget>` — immune to spec fields being cleared before cleanup runs. `keepPreviousSurface: true` skips the sweep when both surfaces should run simultaneously.

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

Expand Down
5 changes: 3 additions & 2 deletions cmd/cli/play_chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/orkspace/orkestra/pkg/katalog"
"github.com/orkspace/orkestra/pkg/merger"
"github.com/orkspace/orkestra/pkg/registry/simulate"
orktarget "github.com/orkspace/orkestra/pkg/intent/target"
orktmpl "github.com/orkspace/orkestra/pkg/resources/template"
orktypes "github.com/orkspace/orkestra/pkg/types"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -62,7 +63,7 @@ func runCreateUpdateChain(k *katalog.Katalog, raw map[string]interface{}, tokenN
// Stage 3: CR construction
printStage(3, "CR construction")
notes := k.Notes
obj, err := api.BuildCRFromTarget(raw, crd, notes)
obj, err := orktarget.BuildCRFromTarget(raw, crd, notes)
if err != nil {
printStageError(err.Error())
return nil, nil, "", err
Expand Down Expand Up @@ -300,7 +301,7 @@ func runIntentPlay(katalogPath, intentFile string) (string, error) {
return target, fmt.Errorf("intent file must declare a 'token' — token: <name>")
}

obj, err := api.BuildCRFromTarget(raw, crd, k.Notes)
obj, err := orktarget.BuildCRFromTarget(raw, crd, k.Notes)
if err != nil {
return target, fmt.Errorf("CR construction: %w", err)
}
Expand Down
14 changes: 12 additions & 2 deletions cmd/cli/serve_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,17 @@ Example (explicit file):
ork serve apply -f intent.yaml --api https://gateway.myorg.io --token "$ORK_TOKEN"

Example (dry run — no CR applied):
ork serve apply -f cr.yaml --api https://gateway.myorg.io --token "$ORK_TOKEN" --dry-run`,
ork serve apply -f cr.yaml --api https://gateway.myorg.io --token "$ORK_TOKEN" --dry-run

Example (override routing surface conflict):
ork serve apply -f intent.yaml --api https://gateway.myorg.io --token "$ORK_TOKEN" --override`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
file, _ := cmd.Flags().GetString("file")
apiURL, _ := cmd.Flags().GetString("api")
token, _ := cmd.Flags().GetString("token")
dryRun, _ := cmd.Flags().GetBool("dry-run")
override, _ := cmd.Flags().GetBool("override")

if file == "" {
file = resolveDefaultIntentFile()
Expand All @@ -60,8 +64,13 @@ Example (dry run — no CR applied):
}

endpoint := strings.TrimRight(apiURL, "/") + "/api/v1/apply"
if dryRun {
switch {
case dryRun && override:
endpoint += "?dryRun=true&override=true"
case dryRun:
endpoint += "?dryRun=true"
case override:
endpoint += "?override=true"
}

req, err := http.NewRequestWithContext(cmd.Context(), http.MethodPost, endpoint, bytes.NewReader(body))
Expand Down Expand Up @@ -154,6 +163,7 @@ func init() {
serveApplyCmd.Flags().StringP("api", "a", "http://localhost:8080", "Gateway base URL")
serveApplyCmd.Flags().StringP("token", "t", "", "Bearer token for the gateway")
serveApplyCmd.Flags().Bool("dry-run", false, "Preview without applying — the CR is not written to the cluster")
serveApplyCmd.Flags().Bool("override", false, "Override routing surface conflict — allows switching a resource to a different target")

_ = serveApplyCmd.MarkFlagRequired("token")

Expand Down
25 changes: 25 additions & 0 deletions cmd/internal/runtime_konstructor.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import (
"github.com/orkspace/orkestra/domain"
"github.com/orkspace/orkestra/pkg/event"
"github.com/orkspace/orkestra/pkg/health"
orktarget "github.com/orkspace/orkestra/pkg/intent/target"
"github.com/orkspace/orkestra/pkg/katalog"
"github.com/orkspace/orkestra/pkg/konfig"
"github.com/orkspace/orkestra/pkg/kubeclient"
Expand Down Expand Up @@ -431,6 +432,30 @@ func konstructRuntime(kfg *konfig.Konfig, m *merger.Merger, ctx context.Context)
}
}

// Wrap with MuxReconciler when per-target constructors are declared.
// MuxReconciler dispatches each reconcile cycle to the matching target's
// domain.Reconciler, falling back to the base factory for CRs with no
// annotation or an unrecognised target name.
if crd.HasTargetConstructorFactories() {
baseFactory := factory
crdCopy := crd
factory = func() domain.Reconciler {
targets := make(map[string]domain.Reconciler, len(crdCopy.TargetReconcilerFactories))
for targetName, ctor := range crdCopy.TargetReconcilerFactories {
var targetKube kubeclient.Interface = kube
if args := crdCopy.TargetConstructorArgs(targetName); len(args) > 0 {
targetKube = kube.WithArgs(kubeclient.Args(args))
}
targets[targetName] = ctor(targetKube, infCopy, ev)
}
return orktarget.NewMuxReconciler(infCopy, targets, baseFactory())
}
logger.Debug().
Str("gvk", gvk).
Int("targets", len(crd.TargetReconcilerFactories)).
Msg("wiring MuxReconciler factory")
}

// Register informs the DependencyKordinator which informer and factory
// belong to this CRD. Workers are not started yet — that happens in Start().
ktrlRegistry.Register(gvk, crd, inf, factory)
Expand Down
8 changes: 8 additions & 0 deletions documentation/concepts/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ The [Operator of Operators](operator-of-operators/) pattern lets one Orkestra op

---

## Reusability and Composition

[Reusability and Composition](reusability/) is the system-level view of how every layer in Orkestra — the runtime, Motifs, Katalogs, Komposers, notes, profiles, args, and targets — follows the same principle: share what is common, declare what varies.

→ [Read: Reusability and Composition](reusability/)

---

## Every CRD is a Live API

Every CRD you declare in a Katalog becomes a live HTTP API outside the cluster — health, config, CR list, CR detail, and events, all served from in-memory cache on port 8080. This is the transport layer for the Control Center, ONCOP, and operator autoscaling.
Expand Down
57 changes: 57 additions & 0 deletions documentation/concepts/reusability/01-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# The Core — Runtime, Gateway, and Control Center

The first and most consequential reuse in Orkestra is the core infrastructure itself.

Every operator built on Orkestra runs on the same engine. The reconcile loop, event watching, retry behaviour, leader election, health tracking, dependency ordering — none of this is written by the operator author. It is supplied by the runtime, once, and shared by every operator that runs on it.

This means an operator author's entire responsibility is: what should happen when a CR arrives or changes. The runtime handles everything before and after that point.

---

## One runtime, many operators

The same runtime processes events for every CRD it is configured to watch. A cluster can run operators for databases, pipelines, tenant namespaces, and feature deployments — all on the same engine, with the same operational model.

**One upgrade, all operators.** When the runtime gains a capability — gateway integration, per-target routing, autoscaling, dependency ordering — every operator using it gains it immediately, without changes to operator code.

**One operational model.** Health endpoints, reconcile stats, dependency graphs, graceful shutdown — identical across every operator. A platform team running ten operators does not learn ten operational models; they learn one.

**One test surface.** The runtime's behaviour is tested once. Operator authors test their own logic — not the retry mechanics, not informer cache semantics, not the workqueue. Those come from the shared layer and are not the operator's concern.

---

## The gateway is shared infrastructure

The gateway — intent translation, token validation, schema catalog, cluster routing, surface conflict detection — is part of the shared layer. An operator author does not build a REST API for their CRD. They declare `serve:` in their Katalog and the gateway handles every caller concern.

This means the same gateway instance serves every CRD in every Katalog the runtime loads. One token, one schema catalog endpoint, one `ork serve apply` command — regardless of which operator or which CRD is being called.

The gateway is also where targets become visible to callers. A caller picks a named surface. The gateway stamps the routing decision onto the CR before apply. The runtime reads it and reconciles accordingly. From the caller's side, there is one API and one schema. The routing is transparent.

---

## The Control Center spans all runtimes

The Control Center connects to any number of running operators — local or remote, across clusters. It reads the same endpoint every Orkestra runtime exposes and surfaces reconcile activity, health state, CR listings, and dependency graphs for all of them in one place.

An operator becomes visible in the Control Center the moment it starts. Nothing to register, no plugin to write, no dashboard to configure. The shared runtime contract does it automatically.

This is reuse at the observability level: one interface, any number of operators, zero per-operator configuration.

---

## Where the operator author's work begins

The runtime delivers a CR to the operator at the point of reconcile. Everything that led to that moment — watching for changes, deduplicating events, managing retries, ordering dependencies — was the runtime's responsibility. Everything from that point forward is the operator author's.

Expressed in the Katalog alone, that boundary is a set of declarations: what to validate, what to mutate, what status fields to compute, what external calls to make, when to gate reconciliation. No Go code required.

When Go is needed, the operator author writes hooks or a constructor. Hooks are called by the runtime for each reconcile event. A constructor produces a reconciler that the runtime runs. In both cases, the runtime owns the loop and the operator owns the logic.

---

## Related topics

- [Orkestra Core](../../orkestra-core/index.md) — runtime, gateway, and Control Center in depth
- [OperatorBox](../operatorbox/index.md) — the execution unit each CRD becomes inside the runtime
- [Live API](../live-api/index.md) — the HTTP contract every runtime exposes for the Control Center and ONCOP
141 changes: 141 additions & 0 deletions documentation/concepts/reusability/02-building-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Building Blocks — Motifs, Katalogs, Komposers

Orkestra's composition model is layered. Each layer is a reusable unit that can be authored independently, versioned, distributed, and composed into a larger whole.

```text
Motif — a named, reusable fragment of a Katalog
↓ imported by
Katalog — a complete operator declaration
↓ imported by
Komposer — aggregates multiple Katalogs into one runtime
```

---

## Motifs

A Motif is a Katalog fragment packaged for reuse. It can contain anything a Katalog section can: validation rules, mutation rules, hook declarations, resource templates, notes, profiles, external calls.

An operator team publishes a Motif for tenant isolation and RBAC. Every team that provisions namespaces imports it with their own parameters. When the policy changes, the Motif is updated once — no changes in any consumer Katalog.

```yaml
# katalog.yaml
spec:
crds:
namespace-provisioner:
imports:
- motif: ../motifs/tenant-isolation/motif.yaml
with:
namespace: "{{ .spec.targetNamespace }}"
team: "{{ .spec.team }}"

- motif: ../motifs/tenant-rbac/motif.yaml
with:
team: "{{ .spec.team }}"
targetNamespace: "{{ .spec.targetNamespace }}"
owner: "{{ .spec.owner }}"
```

`imports:` is declared per-CRD. Each import names a Motif (local path or OCI reference) and passes `with:` values — static strings or template expressions evaluated per-CR. The consumer declares which version to pull. The Motif author publishes updates independently.

---

## Include — sharing within a unit

`include:` reads a file in the same directory tree and merges it at load time. By the time the runtime starts, every `include:` has been resolved — the runtime sees only the merged result.

Include is available almost everywhere a Katalog can declare structure:

| Location | What include merges |
|----------|---------------------|
| Validation rules | A shared `validation-rules.yaml` across multiple CRDs |
| Mutation rules | Common defaulting logic extracted to a file |
| External call configs | Reusable HTTP call declarations |
| Notes | A function library shared by multiple CRDs in the same Katalog |
| Profiles | Profile sets declared once, included where needed |
| Serve target entries | Token and config declarations for a named surface |
| Conversion webhooks | Shared conversion logic |

A Katalog with dozens of CRDs does not repeat common declarations. Validation rules that apply to every CRD live in one file. Profile sets are declared once. External call patterns are shared.

---

## E2E and Simulate — test composition

The same composition model extends to tests. A Simulate file can import other Simulate files; an E2E suite can import other E2E suites. A platform team can aggregate test coverage across multiple operator packages without duplicating test declarations.

```yaml
# platform-e2e.yaml
apiVersion: orkestra.orkspace.io/v1
kind: E2E
metadata:
name: platform-suite

imports:
- ./operators/database/e2e.yaml
- ./operators/cache/e2e.yaml
- ./operators/network-policy/e2e.yaml
```

Each entry is a bare file path. The aggregated suite runs all imported suites in sequence. Assertions within each imported file remain scoped to their CRDs — the aggregator does not merge or flatten them.

---

## Komposer

A Komposer is the top-level aggregator. It imports multiple Katalogs from local files and merges them into a single runtime.

```yaml
# komposer.yaml
apiVersion: orkestra.orkspace.io/v1
kind: Komposer
metadata:
name: platform-operators

imports:
registry:
- oci://ghcr.io/myorg/patterns/deployment-stack:v1.0.0
files:
- ./database-operator/katalog.yaml
- ./cache-operator/katalog.yaml
- ./network-policy/katalog.yaml
```

The operator teams maintain and version their Katalogs independently. The platform team composes them in the Komposer.

### Overriding a public pattern

A Katalog declares the schema it was written for — the CRD's API types, the field paths its hooks read. When you import a public Katalog pattern from a registry, those API types may not match your internal CRD.

The Komposer lets you replace the `apiTypes` block — the schema mapping — without touching any of the pattern's logic. Hook behaviour, validation rules, profiles, and gateway declarations are inherited unchanged. Only the API shape is replaced with your own.

This is how a community-published operator pattern becomes an internal operator: import the pattern, declare your CRD's schema in the override, keep everything else.

```yaml
# komposer.yaml
apiVersion: orkestra.orkspace.io/v1
kind: Komposer
metadata:
name: platform-operators

imports:
registry:
- oci://ghcr.io/postgres/patterns/postgres:v1.0.0

# Replace the upstream apiTypes with internal ones
spec:
crds:
postgres:
apiTypes:
group: myorg.io
version: v1
kind: MyOrgDatabase
```

---

## Related topics

- [Orkestra Registry](../../orkestra-registry/index.md) — community Motifs, Katalogs, and Komposers available to import
- [Composition](../composition/index.md) — imports and include explained in depth
- [Schema reference](../../reference/schema/index.md) — Motif, Katalog, Komposer, E2E, and Simulate schemas
Loading
Loading