diff --git a/cmd/cli/e2e.go b/cmd/cli/e2e.go
index 2676d57c..d7a61402 100644
--- a/cmd/cli/e2e.go
+++ b/cmd/cli/e2e.go
@@ -20,14 +20,22 @@ The same command runs locally and in CI. The e2e.yaml file is the source of trut
ork e2e
ork e2e -f e2e.yaml
ork e2e -f e2e.yaml --keep-cluster
- ork e2e -f e2e.yaml --cluster my-existing-context`,
+ ork e2e -f e2e.yaml --cluster my-existing-context
+ ork e2e -f e2e.yaml --version v1.2.3 --values values.yaml`,
RunE: func(cmd *cobra.Command, args []string) error {
file, _ := cmd.Flags().GetString("file")
keepCluster, _ := cmd.Flags().GetBool("keep-cluster")
useCurrentCtx, _ := cmd.Flags().GetBool("use-current")
clusterCtx, _ := cmd.Flags().GetString("cluster")
+ version, _ := cmd.Flags().GetString("version")
+ valuesFiles, _ := cmd.Flags().GetStringSlice("values")
+ helmArgRaw, _ := cmd.Flags().GetStringSlice("helm-arg")
+ var helmArgs []string
+ for _, arg := range helmArgRaw {
+ helmArgs = append(helmArgs, "--set", arg)
+ }
- runner, err := e2e.New(file, clusterCtx, useCurrentCtx, keepCluster)
+ runner, err := e2e.New(file, clusterCtx, useCurrentCtx, keepCluster, version, valuesFiles, helmArgs...)
if err != nil {
return err
}
@@ -43,6 +51,9 @@ func init() {
e2eCmd.Flags().Bool("keep-cluster", false, "Keep the kind cluster after the test completes")
e2eCmd.Flags().Bool("use-current", false, "Use the current kubectl context, skip cluster creation")
e2eCmd.Flags().String("cluster", "", "Use an existing kubectl context instead of creating a cluster")
+ e2eCmd.Flags().String("version", "", "Orkestra version to install (e.g., v1.2.3)")
+ e2eCmd.Flags().StringSlice("values", []string{}, "Helm values files to pass to Orkestra installation")
+ e2eCmd.Flags().StringSlice("helm-arg", []string{}, "Additional Helm --set arguments (e.g., key=value)")
// Shadow global flags
e2eCmd.Flags().Bool("debug", false, "")
diff --git a/cmd/cli/init.go b/cmd/cli/init.go
index f6e42a3b..321d5176 100644
--- a/cmd/cli/init.go
+++ b/cmd/cli/init.go
@@ -143,11 +143,15 @@ func initProject(name, pack string, refresh bool) error {
fmt.Printf("\n%s\n\n", green(projectPrint))
+ dir := p.Name
+ if first != "" {
+ dir = dir + "/" + first
+ }
fmt.Println("To run the first example:")
if !isCurrentDirectory(name) {
- fmt.Printf(" cd %s/%s/%s\n", name, pack, first)
+ fmt.Printf(" cd %s/%s\n", name, dir)
} else {
- fmt.Printf(" cd %s/%s\n", pack, first)
+ fmt.Printf(" cd %s\n", dir)
}
if p.isBeginnerPack() {
diff --git a/cmd/cli/init_helper.go b/cmd/cli/init_helper.go
index 0fc1ecd0..6b0261b7 100644
--- a/cmd/cli/init_helper.go
+++ b/cmd/cli/init_helper.go
@@ -21,13 +21,13 @@ import (
// ──────────────────────────────────────────────────────────────────────────────
func extractEmbeddedPack(root, pack string) error {
- p, ok := Packs[pack]
+ p, ok := GetPack(pack)
if !ok {
return fmt.Errorf("unknown pack %q — run `ork init --list` to see available packs", pack)
}
srcPath := p.Path
- targetDir := filepath.Join(root, pack)
+ targetDir := filepath.Join(root, filepath.Base(p.Path))
if err := fs.WalkDir(examples.FS, srcPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
diff --git a/cmd/cli/init_packs.go b/cmd/cli/init_packs.go
index 1ab3d69b..3a0982fc 100644
--- a/cmd/cli/init_packs.go
+++ b/cmd/cli/init_packs.go
@@ -2,6 +2,12 @@
package cli
+import (
+ "path/filepath"
+
+ "github.com/orkspace/orkestra/examples"
+)
+
// packPaths maps CLI pack names to their paths inside the embedded FS.
// Most packs are top-level directories; rollback is nested under use-cases.
type Pack struct {
@@ -36,21 +42,19 @@ var Packs = map[string]Pack{
Description: "Full-stack, cross-CRD, external gates, once-secrets.",
Path: "use-cases",
},
- // "rollback": {
- // Name: "rollback",
- // Description: "Zero-config and configurable failure recovery",
- // Path: "use-cases/rollback",
- // },
- // "developer": {
- // Name: "developer",
- // Description: "Local to production in minutes — deploy your app without writing operator code.",
- // Path: "developer",
- // },
}
func GetPack(name string) (Pack, bool) {
- p, ok := Packs[name]
- return p, ok
+ if p, ok := Packs[name]; ok {
+ return p, true
+ }
+ // Sub-path fallback: any valid directory in the embedded FS works as a pack.
+ // ork init my-project --pack use-cases/multi-tenancy extracts into my-project/multi-tenancy.
+ if f, err := examples.FS.Open(name); err == nil {
+ f.Close()
+ return Pack{Name: filepath.Base(name), Path: name}, true
+ }
+ return Pack{}, false
}
func ListPacks() []Pack {
diff --git a/cmd/cli/registry_push.go b/cmd/cli/registry_push.go
index c99ef26f..d7e9fbe8 100644
--- a/cmd/cli/registry_push.go
+++ b/cmd/cli/registry_push.go
@@ -166,7 +166,7 @@ var registryPushCmd = &cobra.Command{
}
} else {
fmt.Printf("\nRunning E2E gate (%s)...\n", registry.FileE2E)
- runner, err := e2e.New(e2eFile, "", false, false)
+ runner, err := e2e.New(e2eFile, "", false, false, "", nil)
if err != nil {
return fmt.Errorf("e2e gate: %w\n\nUse --force or --no-e2e to skip", err)
}
diff --git a/cmd/cli/start.go b/cmd/cli/start.go
new file mode 100644
index 00000000..2648d0a7
--- /dev/null
+++ b/cmd/cli/start.go
@@ -0,0 +1,41 @@
+//go:build !runtime && !gateway
+
+package cli
+
+import (
+ "fmt"
+
+ "github.com/orkspace/orkestra/pkg/devserver"
+ "github.com/spf13/cobra"
+)
+
+var startCmd = &cobra.Command{
+ Use: "start",
+ Short: "Start an Orkestra background service",
+}
+
+var startDevServerCmd = &cobra.Command{
+ Use: "dev-server",
+ Short: "Start the mock dev server for external: examples",
+ Long: `Starts a lightweight mock HTTP server on :9999 that handles all endpoints
+used by external:, full-stack, and feature-flag examples — no real services needed.
+
+Press Ctrl+C to stop.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ port, _ := cmd.Flags().GetInt("port")
+
+ if err := devserver.Start(port); err != nil {
+ return fmt.Errorf("starting dev server: %w", err)
+ }
+
+ fmt.Println("Dev server running. Press Ctrl+C to stop.")
+ <-cmd.Context().Done()
+ return nil
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(startCmd)
+ startCmd.AddCommand(startDevServerCmd)
+ startDevServerCmd.Flags().Int("port", devserver.Port, "Port to listen on")
+}
diff --git a/cmd/cli/validate.go b/cmd/cli/validate.go
index 5335b71b..d451c6f2 100644
--- a/cmd/cli/validate.go
+++ b/cmd/cli/validate.go
@@ -8,6 +8,9 @@ import (
"sort"
"strings"
+ "path/filepath"
+
+ "github.com/orkspace/orkestra/pkg/e2e"
"github.com/orkspace/orkestra/pkg/katalog"
"github.com/orkspace/orkestra/pkg/konfig"
orktypes "github.com/orkspace/orkestra/pkg/types"
@@ -179,70 +182,93 @@ func validateE2EFile(path string) error {
return fmt.Errorf("reading %s: %w", path, err)
}
- var e2e orktypes.E2E
- if err := yaml.Unmarshal(data, &e2e); err != nil {
+ var doc orktypes.E2E
+ if err := yaml.Unmarshal(data, &doc); err != nil {
return fmt.Errorf("parsing %s: %w", path, err)
}
+ baseDir := filepath.Dir(path)
+ isAggregator := len(doc.Imports) > 0 && doc.Spec.Katalog == "" && doc.Spec.Init == nil
+
var errs []string
- if e2e.Metadata.Name == "" {
+ if doc.Metadata.Name == "" {
errs = append(errs, "metadata.name is required")
}
- if e2e.Spec.Katalog == "" && e2e.Spec.Init == nil {
- errs = append(errs, "spec.katalog is required (or spec.init for example packs)")
- }
- if e2e.Spec.CRD == "" && e2e.Spec.Init == nil {
- errs = append(errs, "spec.crd is required (or spec.init for example packs)")
- }
- if e2e.Spec.CR == "" && e2e.Spec.Init == nil {
- errs = append(errs, "spec.cr is required (or spec.init for example packs)")
- }
- if len(e2e.Spec.Expect) == 0 {
- errs = append(errs, "spec.expect must contain at least one expectation")
- }
- for i, exp := range e2e.Spec.Expect {
- if exp.Name == "" {
- errs = append(errs, fmt.Sprintf("spec.expect[%d].name is required", i))
+ if !isAggregator {
+ if doc.Spec.Katalog == "" && doc.Spec.Init == nil {
+ errs = append(errs, "spec.katalog is required (or spec.init for example packs, or imports)")
+ }
+ if doc.Spec.CRD == "" && doc.Spec.Init == nil {
+ errs = append(errs, "spec.crd is required (or spec.init for example packs, or imports)")
+ }
+ if doc.Spec.CR == "" && doc.Spec.Init == nil {
+ errs = append(errs, "spec.cr is required (or spec.init for example packs, or imports)")
}
- if exp.After != "cr-applied" && exp.After != "cr-deleted" {
- errs = append(errs, fmt.Sprintf("spec.expect[%d].after must be cr-applied or cr-deleted (got %q)", i, exp.After))
+ if len(doc.Spec.Expect) == 0 {
+ errs = append(errs, "spec.expect must contain at least one expectation")
}
- if len(exp.Resources) == 0 && len(exp.Commands) == 0 {
- errs = append(errs, fmt.Sprintf("spec.expect[%d] (%q): must have at least one resource or command check", i, exp.Name))
+ for i, exp := range doc.Spec.Expect {
+ if exp.Name == "" {
+ errs = append(errs, fmt.Sprintf("spec.expect[%d].name is required", i))
+ }
+ if exp.After != "cr-applied" && exp.After != "cr-deleted" {
+ errs = append(errs, fmt.Sprintf("spec.expect[%d].after must be cr-applied or cr-deleted (got %q)", i, exp.After))
+ }
+ if len(exp.Resources) == 0 && len(exp.Commands) == 0 {
+ errs = append(errs, fmt.Sprintf("spec.expect[%d] (%q): must have at least one resource or command check", i, exp.Name))
+ }
}
}
- if len(errs) > 0 {
+ // Validate imports (collect per-import errors for display).
+ importErrs := e2e.ValidateImports(baseDir, doc.Imports)
+
+ if len(errs) > 0 || len(importErrs) > 0 {
for _, e := range errs {
fmt.Printf(" %s %s\n", failureMark(), e)
}
+ for _, ie := range importErrs {
+ fmt.Printf(" %s import: %s\n", failureMark(), ie)
+ }
fmt.Println()
- return fmt.Errorf("%d validation error(s) in %s", len(errs), path)
+ return fmt.Errorf("%d validation error(s) in %s", len(errs)+len(importErrs), path)
}
icon := healthIcon("ready")
- fmt.Printf("%s %s\n", icon, bold(e2e.Metadata.Name))
- if e2e.Metadata.Description != "" {
- fmt.Printf(" %s\n", gray(e2e.Metadata.Description))
+ fmt.Printf("%s %s\n", icon, bold(doc.Metadata.Name))
+ if doc.Metadata.Description != "" {
+ fmt.Printf(" %s\n", gray(doc.Metadata.Description))
}
- fmt.Printf(" %s\n",
- gray(fmt.Sprintf("katalog : %s\n crd : %s\n cr : %s",
- e2e.Spec.Katalog, e2e.Spec.CRD, e2e.Spec.CR)),
- )
- if s := e2e.Spec.Setup; s != nil {
- if len(s.Apply) > 0 {
- fmt.Printf(" %s\n", gray("setup.apply : "+strings.Join(s.Apply, ", ")))
- }
- if len(s.Helm) > 0 {
- fmt.Printf(" %s\n", gray(fmt.Sprintf("setup.helm : %d chart(s)", len(s.Helm))))
+ if !isAggregator {
+ fmt.Printf(" %s\n",
+ gray(fmt.Sprintf("katalog : %s\n crd : %s\n cr : %s",
+ doc.Spec.Katalog, doc.Spec.CRD, doc.Spec.CR)),
+ )
+ if s := doc.Spec.Setup; s != nil {
+ if len(s.Apply) > 0 {
+ fmt.Printf(" %s\n", gray("setup.apply : "+strings.Join(s.Apply, ", ")))
+ }
+ if len(s.Helm) > 0 {
+ fmt.Printf(" %s\n", gray(fmt.Sprintf("setup.helm : %d chart(s)", len(s.Helm))))
+ }
+ if len(s.Wait) > 0 {
+ fmt.Printf(" %s\n", gray(fmt.Sprintf("setup.wait : %d resource(s)", len(s.Wait))))
+ }
}
- if len(s.Wait) > 0 {
- fmt.Printf(" %s\n", gray(fmt.Sprintf("setup.wait : %d resource(s)", len(s.Wait))))
+ }
+ if len(doc.Imports) > 0 {
+ fmt.Printf(" %s\n", gray(fmt.Sprintf("imports : %d file(s)", len(doc.Imports))))
+ for _, imp := range doc.Imports {
+ label := imp.Path
+ if imp.FreshCluster {
+ label += " (fresh cluster)"
+ }
+ fmt.Printf(" %s %s\n", healthIcon("ready"), gray(label))
}
}
fmt.Println()
- for _, exp := range e2e.Spec.Expect {
+ for _, exp := range doc.Spec.Expect {
to := exp.Timeout
if to == "" {
to = "60s"
@@ -252,7 +278,15 @@ func validateE2EFile(path string) error {
}
fmt.Println()
fmt.Println(strings.Repeat("─", 60))
- fmt.Printf("%d expectation(s) valid\n", len(e2e.Spec.Expect))
+ if isAggregator {
+ fmt.Printf("%d import(s) valid\n", len(doc.Imports))
+ } else {
+ fmt.Printf("%d expectation(s) valid", len(doc.Spec.Expect))
+ if len(doc.Imports) > 0 {
+ fmt.Printf(", %d import(s) valid", len(doc.Imports))
+ }
+ fmt.Println()
+ }
return nil
}
diff --git a/cmd/controlcenter/cc/assets/static/css/style.css b/cmd/controlcenter/cc/assets/static/css/style.css
index 9c86ab6c..e87ca052 100644
--- a/cmd/controlcenter/cc/assets/static/css/style.css
+++ b/cmd/controlcenter/cc/assets/static/css/style.css
@@ -373,13 +373,14 @@ code, .mono {
box-shadow: none;
background: var(--bg-elevated);
}
-/* Name + badge: fixed left column */
+/* Name + badge + pills: fixed left section, does not expand */
.cc-card-grid.list-view .cc-card-body {
- flex: 1;
+ flex: none;
+ min-width: 260px;
display: flex;
align-items: center;
gap: 12px;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
padding: 0;
border: none;
}
@@ -392,12 +393,23 @@ code, .mono {
overflow: hidden;
text-overflow: ellipsis;
}
-/* Show card-meta as inline row of pills */
+/* Footer: takes remaining space, lays stats + button in a row */
+.cc-card-grid.list-view .cc-card-footer {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ padding: 0;
+ border: none;
+ background: none;
+}
+/* Stats row expands to fill the middle */
.cc-card-grid.list-view .cc-card-meta {
display: flex;
flex-direction: row;
gap: 16px;
- flex-wrap: wrap;
+ flex: 1;
+ flex-wrap: nowrap;
margin: 0;
}
.cc-card-grid.list-view .cc-card-row {
@@ -411,24 +423,23 @@ code, .mono {
.cc-card-grid.list-view .cc-card-row-label {
color: var(--text-muted);
}
-/* Hide progress bars and desc in list mode */
+/* Hide progress bars, desc, and version line in list mode */
.cc-card-grid.list-view .cc-progress-bar-wrap,
.cc-card-grid.list-view .cc-card-desc,
.cc-card-grid.list-view .text-red.text-xs,
.cc-card-grid.list-view .text-amber.text-xs {
display: none;
}
-/* Footer (View Details button): always visible, pushed right */
-.cc-card-grid.list-view .cc-card-footer {
- flex-shrink: 0;
- padding: 0;
- border: none;
- background: none;
+/* Hide the version div (direct child of card-body) in list mode */
+.cc-card-grid.list-view .cc-card-body > .font-mono {
+ display: none;
}
+/* Button: stays compact, never shrinks */
.cc-card-grid.list-view .cc-card-footer .cc-btn {
width: auto;
+ flex-shrink: 0;
font-size: 12px;
- padding: 5px 10px;
+ padding: 5px 14px;
}
/* ── Grid view for resource table (cr_list.html) ───────────── */
@@ -632,6 +643,8 @@ code, .mono {
border-radius: var(--radius);
transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition);
overflow: hidden;
+ display: flex;
+ flex-direction: column;
}
.cc-card:hover {
@@ -642,6 +655,7 @@ code, .mono {
.cc-card-body {
padding: 18px;
+ flex: 1;
}
.cc-card-title {
@@ -679,6 +693,33 @@ code, .mono {
.cc-card-row-label { color: var(--text-muted); }
.cc-card-row-value { color: var(--text-primary); font-weight: 500; font-variant-numeric: tabular-nums; }
+/* ── Filter dropdown ────────────────────────────────────────── */
+.cc-dropdown {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ /* --bg-card is not defined; force a solid background so the dropdown is opaque */
+ background: var(--bg-elevated) !important;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+}
+
+.cc-check-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 8px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.cc-check-row:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
/* ── Badges ────────────────────────────────────────────────── */
.cc-badge {
display: inline-flex;
diff --git a/cmd/controlcenter/cc/assets/templates/index.html b/cmd/controlcenter/cc/assets/templates/index.html
index 5b4792c8..1c3c46c4 100644
--- a/cmd/controlcenter/cc/assets/templates/index.html
+++ b/cmd/controlcenter/cc/assets/templates/index.html
@@ -143,14 +143,67 @@
{{ if gt (len .Katalogs) 0 }}
-
Available Katalogs
-
- {{ range .Katalogs }}
-
+
+
+
+
+
+
+
+ {{ if gt (len .AllClusters) 0 }}
+
+
+
+ {{ range $.AllClusters }}
+
+ {{ end }}
+
+
+ {{ end }}
+
+ {{ if gt (len .AllNamespaces) 1 }}
+
+
+
+ {{ range $.AllNamespaces }}
+
+ {{ end }}
+
+
+ {{ end }}
+
+
+
+
+
+ {{ range $kat := .Katalogs }}
+ {{ if gt (len $kat.Namespaces) 1 }}
+ {{ range $ns := $kat.Namespaces }}
+ {{ $detail := index $kat.NamespaceDetails $ns }}
+
-
{{ .Name }}
- {{ if .Healthy }}
+ {{ $ns }}
+ {{ if $detail.Healthy }}
Healthy
@@ -160,19 +213,86 @@ {{ .Name }}
{{ end }}
+ {{ if $kat.ClusterName }}
+
+ ⬡ {{ $kat.ClusterName }}
+ {{ $kat.Name }}
+
+ {{ else }}
+
+ {{ $kat.Name }}
+
+ {{ end }}
- {{ if .Description }}{{ truncate .Description 100 }}{{ else }}No description{{ end }}
+ {{ if $detail.Description }}{{ truncate $detail.Description 100 }}{{ else }}No description{{ end }}
- {{ if .Version }}
-
v{{ .Version }}
+ {{ if $detail.Version }}
+
v{{ $detail.Version }}
{{ end }}
+
+ {{ end }}
+ {{ else }}
+
+
+
+
{{ $kat.Name }}
+ {{ if $kat.Healthy }}
+
+ Healthy
+
+ {{ else }}
+
+ Degraded
+
+ {{ end }}
+
+ {{ if or $kat.ClusterName $kat.Namespaces }}
+
+ {{ if $kat.ClusterName }}⬡ {{ $kat.ClusterName }}{{ end }}
+ {{ range $kat.Namespaces }}{{ . }}{{ end }}
+
+ {{ end }}
+
+ {{ if $kat.Description }}{{ truncate $kat.Description 100 }}{{ else }}No description{{ end }}
+
+ {{ if $kat.Version }}
+
v{{ $kat.Version }}
+ {{ end }}
+
+
{{ end }}
+ {{ end }}
+
+
+
+ {{ if gt (len .Katalogs) 12 }}
+
+ {{ end }}
+
+
+
⬡
+
No katalogs match your filter
+
Try changing your search or filter criteria.
+
+
{{ else }}
⬡
@@ -260,6 +402,135 @@
{{ .Name }}
{{ end }}
+
{{ if .EnableRuntimeManager }}
{{ end }}
diff --git a/cmd/controlcenter/cc/controlcenter.go b/cmd/controlcenter/cc/controlcenter.go
index dde853a2..936e0e53 100644
--- a/cmd/controlcenter/cc/controlcenter.go
+++ b/cmd/controlcenter/cc/controlcenter.go
@@ -540,6 +540,8 @@ func (cc *ControlCenter) handleIndex(w http.ResponseWriter, _ *http.Request) {
var summaries []KatalogSummary
totalCRDs, totalWorkers, totalResources, totalApps, healthyKatalogs := 0, 0, 0, 0, 0
hasOperatorKatalogs := false
+ clusterSeen := map[string]struct{}{}
+ nsSeen := map[string]struct{}{}
for _, inst := range insts {
kat := inst.Katalog
@@ -549,17 +551,33 @@ func (cc *ControlCenter) handleIndex(w http.ResponseWriter, _ *http.Request) {
healthyCRDs++
}
}
+
+ // Collect distinct namespaces from this katalog's namespace grouping.
+ var nsKeys []string
+ for ns := range kat.Namespaces {
+ nsKeys = append(nsKeys, ns)
+ nsSeen[ns] = struct{}{}
+ }
+ sort.Strings(nsKeys)
+
+ if kat.ClusterName != "" {
+ clusterSeen[kat.ClusterName] = struct{}{}
+ }
+
summary := KatalogSummary{
- Name: kat.Name,
- Description: kat.Description,
- Version: kat.Version,
- Healthy: kat.Healthy,
- CreatedBy: kat.CreatedBy,
- AppCount: len(kat.Projects),
- TotalCRDs: len(kat.CRDs),
- HealthyCRDs: healthyCRDs,
- TotalWorkers: sumWorkers(kat.CRDs),
- TotalResources: sumResources(kat.CRDs),
+ Name: kat.Name,
+ Description: kat.Description,
+ Version: kat.Version,
+ Healthy: kat.Healthy,
+ CreatedBy: kat.CreatedBy,
+ AppCount: len(kat.Projects),
+ TotalCRDs: len(kat.CRDs),
+ HealthyCRDs: healthyCRDs,
+ TotalWorkers: sumWorkers(kat.CRDs),
+ TotalResources: sumResources(kat.CRDs),
+ ClusterName: kat.ClusterName,
+ Namespaces: nsKeys,
+ NamespaceDetails: kat.Namespaces,
}
summaries = append(summaries, summary)
if kat.CreatedBy == "orkdoctor" {
@@ -575,6 +593,18 @@ func (cc *ControlCenter) handleIndex(w http.ResponseWriter, _ *http.Request) {
}
}
+ allClusters := make([]string, 0, len(clusterSeen))
+ for c := range clusterSeen {
+ allClusters = append(allClusters, c)
+ }
+ sort.Strings(allClusters)
+
+ allNamespaces := make([]string, 0, len(nsSeen))
+ for ns := range nsSeen {
+ allNamespaces = append(allNamespaces, ns)
+ }
+ sort.Strings(allNamespaces)
+
cc.renderTemplate(w, "index.html", IndexData{
Katalogs: summaries,
TotalKatalogs: len(summaries),
@@ -587,6 +617,8 @@ func (cc *ControlCenter) handleIndex(w http.ResponseWriter, _ *http.Request) {
HasOperatorKatalogs: hasOperatorKatalogs,
OrkestraURLs: strings.Join(cc.urls, ", "),
CCVersion: ccversion.Short(),
+ AllClusters: allClusters,
+ AllNamespaces: allNamespaces,
EnableRuntimeManager: cc.config.EnableRuntimeManager,
})
}
@@ -611,6 +643,8 @@ func (cc *ControlCenter) handleKatalogPanel(w http.ResponseWriter, r *http.Reque
return
}
+ ns := r.URL.Query().Get("ns")
+
// Sort CRDs by name for consistent display
sortedCRDs := make([]CRDSummary, len(kat.CRDs))
copy(sortedCRDs, kat.CRDs)
@@ -618,14 +652,24 @@ func (cc *ControlCenter) handleKatalogPanel(w http.ResponseWriter, r *http.Reque
return sortedCRDs[i].Name < sortedCRDs[j].Name
})
+ if ns != "" {
+ var filtered []CRDSummary
+ for _, crd := range sortedCRDs {
+ if crd.KatalogNamespace == ns {
+ filtered = append(filtered, crd)
+ }
+ }
+ sortedCRDs = filtered
+ }
+
cc.renderTemplate(w, "katalog.html", KatalogData{
CRDs: sortedCRDs,
OrkReady: kat.OrkReady,
DeletionProtection: kat.DeletionProtection,
- TotalCRDs: len(kat.CRDs),
- TotalWorkers: sumWorkers(kat.CRDs),
- TotalResources: sumResources(kat.CRDs),
- HealthyCount: countHealthyCRDs(kat.CRDs),
+ TotalCRDs: len(sortedCRDs),
+ TotalWorkers: sumWorkers(sortedCRDs),
+ TotalResources: sumResources(sortedCRDs),
+ HealthyCount: countHealthyCRDs(sortedCRDs),
KatalogName: kat.Name,
KatalogDescription: kat.Description,
KatalogHealthy: kat.Healthy,
@@ -633,8 +677,9 @@ func (cc *ControlCenter) handleKatalogPanel(w http.ResponseWriter, r *http.Reque
KatalogAuthor: kat.Author,
KatalogLicense: kat.License,
DegradedReason: kat.DegradedReason,
- StatusCounts: kat.StatusCounts,
+ StatusCounts: computeStatusCounts(sortedCRDs),
RuntimeVersion: kat.RuntimeVersion,
+ ClusterName: kat.ClusterName,
})
}
@@ -996,6 +1041,23 @@ func countHealthyCRDs(crds []CRDSummary) int {
return n
}
+func computeStatusCounts(crds []CRDSummary) StatusCounts {
+ var sc StatusCounts
+ for _, c := range crds {
+ switch c.State {
+ case "healthy":
+ sc.Healthy++
+ case "degraded":
+ sc.Degraded++
+ case "started":
+ sc.Started++
+ default:
+ sc.Pending++
+ }
+ }
+ return sc
+}
+
func min(a, b int) int {
if a < b {
return a
diff --git a/cmd/controlcenter/cc/types.go b/cmd/controlcenter/cc/types.go
index 206ca3ad..d2dacfb7 100644
--- a/cmd/controlcenter/cc/types.go
+++ b/cmd/controlcenter/cc/types.go
@@ -14,16 +14,19 @@ type KatalogListData struct {
// KatalogSummary is a summary of a Katalog for the list view
type KatalogSummary struct {
- Name string
- Description string
- Version string
- Healthy bool
- CreatedBy string
- AppCount int
- TotalCRDs int
- HealthyCRDs int
- TotalWorkers int
- TotalResources int
+ Name string
+ Description string
+ Version string
+ Healthy bool
+ CreatedBy string
+ AppCount int
+ TotalCRDs int
+ HealthyCRDs int
+ TotalWorkers int
+ TotalResources int
+ ClusterName string
+ Namespaces []string // distinct namespace values from this Katalog's CRDs
+ NamespaceDetails map[string]KatalogNamespaceSummary // per-namespace stats from the runtime
}
// KatalogData is the data for the Katalog dashboard view
@@ -44,6 +47,7 @@ type KatalogData struct {
DegradedReason string
StatusCounts StatusCounts
RuntimeVersion string
+ ClusterName string
}
// IndexData is the data for the main page
@@ -60,6 +64,8 @@ type IndexData struct {
OrkestraURLs string
EnableRuntimeManager bool
CCVersion string
+ AllClusters []string // distinct cluster names for filter checkboxes
+ AllNamespaces []string // distinct namespace names for filter checkboxes
}
// StatusCounts tracks CRD health counts
@@ -85,27 +91,40 @@ type RBACRule struct {
Description string `json:"description,omitempty"`
}
+// KatalogNamespaceSummary mirrors the runtime's namespace grouping.
+type KatalogNamespaceSummary struct {
+ CRDs []string `json:"crds"`
+ StatusCounts StatusCounts `json:"statusCounts"`
+ Healthy bool `json:"healthy"`
+ Description string `json:"description,omitempty"`
+ Version string `json:"version,omitempty"`
+ Workers int `json:"workers"`
+ Resources int `json:"resources"`
+}
+
// KatalogResponse is the response from the /katalog endpoint
type KatalogResponse struct {
- Total int `json:"total"`
- TotalEnabled int `json:"totalEnabled"`
- Healthy bool `json:"healthy"`
- Status int `json:"status"`
- OrkReady bool `json:"OrkReady"`
- IsKonductor bool `json:"isKonductor"`
- DeletionProtection bool `json:"deletionProtection"`
- CRDs []CRDSummary `json:"crds"`
- Name string `json:"name,omitempty"`
- Version string `json:"version,omitempty"`
- Author string `json:"author,omitempty"`
- Description string `json:"description,omitempty"`
- DegradedReason string `json:"degradedReason,omitempty"`
- StatusCounts StatusCounts `json:"statusCounts"`
- License string `json:"license,omitempty"`
- RuntimeVersion string `json:"runtimeVersion,omitempty"`
- CreatedBy string `json:"createdBy,omitempty"`
- Projects map[string]ProjectInfoSummary `json:"projects,omitempty"`
- GatewayEndpoint string `json:"gatewayEndpoint,omitempty"`
+ Total int `json:"total"`
+ TotalEnabled int `json:"totalEnabled"`
+ Healthy bool `json:"healthy"`
+ Status int `json:"status"`
+ OrkReady bool `json:"OrkReady"`
+ IsKonductor bool `json:"isKonductor"`
+ DeletionProtection bool `json:"deletionProtection"`
+ CRDs []CRDSummary `json:"crds"`
+ Name string `json:"name,omitempty"`
+ Version string `json:"version,omitempty"`
+ Author string `json:"author,omitempty"`
+ Description string `json:"description,omitempty"`
+ DegradedReason string `json:"degradedReason,omitempty"`
+ StatusCounts StatusCounts `json:"statusCounts"`
+ License string `json:"license,omitempty"`
+ RuntimeVersion string `json:"runtimeVersion,omitempty"`
+ ClusterName string `json:"clusterName,omitempty"`
+ CreatedBy string `json:"createdBy,omitempty"`
+ Projects map[string]ProjectInfoSummary `json:"projects,omitempty"`
+ Namespaces map[string]KatalogNamespaceSummary `json:"namespaces,omitempty"`
+ GatewayEndpoint string `json:"gatewayEndpoint,omitempty"`
}
// GatewayKatalogResponse mirrors the response served at GET /katalog by the
@@ -209,6 +228,7 @@ type CRDSummary struct {
HasUnhealthyDependencies bool `json:"hasUnhealthyDependencies"`
DeletionProtection bool `json:"deletionProtection"`
ProviderCount int `json:"providerCount,omitempty"`
+ KatalogNamespace string `json:"katalogNamespace,omitempty"`
}
// CRDHealth is the response from the /katalog/{crd}/health endpoint
diff --git a/documentation/concepts/e2e/01-how-it-works.md b/documentation/concepts/e2e/01-how-it-works.md
new file mode 100644
index 00000000..2e781df3
--- /dev/null
+++ b/documentation/concepts/e2e/01-how-it-works.md
@@ -0,0 +1,112 @@
+# How it works
+
+`ork e2e` runs a fixed lifecycle every time. The phases always execute in the same order, and teardown always runs — whether the test passed, failed, or was interrupted.
+
+---
+
+## The lifecycle
+
+```text
+1. Cluster ready
+ kind cluster created — or existing cluster used (--use-current / --cluster)
+
+2. Dependencies checked
+ kind, helm, kubectl available in PATH
+
+3. CRD applied
+ spec.crd applied to the cluster
+
+4. OCI imports pre-pulled
+ any motif or registry imports in the Katalog pulled before bundle generation
+
+5. Bundle generated and applied
+ ork generate bundle → ConfigMap + RBAC applied
+
+6. Setup runs
+ setup.apply — manifests applied in order
+ setup.helm — prerequisite charts installed
+ setup.wait — blocks until each listed resource is ready
+
+7. Orkestra installed
+ helm upgrade --install orkestra
+ controlCenter disabled automatically
+
+8. Orkestra ready
+ waits for the runtime Deployment to have available replicas
+
+9. Expectations run
+ for each expect block in order:
+ after: cr-applied → CR applied (once), then checkpoint polled
+ after: cr-deleted → CR deleted (once), then checkpoint polled
+
+10. Imports run (if any)
+ each imported E2E runs in sequence in the same or a fresh cluster
+
+11. Cleanup
+ for borrowed clusters: CR → Orkestra → bundle → setup → CRDs deleted
+ for owned kind clusters: kind cluster deleted
+ kubectl context restored to the context that was active before the run
+```
+
+---
+
+## Cluster modes
+
+**Default — kind cluster (no flags):**
+`ork e2e` creates a temporary kind cluster named `ork-e2e` (or the name in `spec.cluster.name`), runs the full lifecycle, and deletes the cluster when done. Teardown is guaranteed: the cluster is always deleted even if the test fails, unless `--keep-cluster` is set.
+
+```bash
+ork e2e # creates and deletes a kind cluster
+ork e2e --keep-cluster # creates but does not delete — inspect the cluster after
+```
+
+**Existing cluster (`--use-current`):**
+Runs against whatever kubectl context is currently active. Nothing is created or deleted at the cluster level. Orkestra and all applied resources are cleaned up in reverse order when the test finishes.
+
+```bash
+ork e2e --use-current
+```
+
+**Named context (`--cluster`):**
+Switches to a specific kubeconfig context, runs the test, then restores the original context. Same cleanup behavior as `--use-current`.
+
+```bash
+ork e2e --cluster kind-staging
+```
+
+---
+
+## Teardown order
+
+Teardown always runs for clusters the test does not own. The order reverses the apply order to avoid dangling dependencies:
+
+```text
+CR deleted (if not already deleted by a cr-deleted checkpoint)
+Orkestra uninstalled (helm uninstall)
+Bundle deleted (RBAC, ConfigMap, Namespace)
+Setup files deleted (in reverse apply order)
+CRDs deleted (cascades to any remaining CRs of those types)
+```
+
+For owned kind clusters, the cluster deletion handles all of this in one shot.
+
+---
+
+## Orkestra installation flags
+
+`ork e2e` always passes `--set controlCenter.enabled=false` to Helm. The Control Center is not needed in automated tests and adds startup time.
+
+Additional flags are passed with `--helm-arg`:
+
+```bash
+# Each --helm-arg becomes --set key=value
+ork e2e --helm-arg "runtime.image.tag=dev" --helm-arg "gateway.replicas=3"
+```
+
+When the Katalog declares a `gateway:` block, `--set gateway.enabled=true` is appended automatically.
+
+If Orkestra is already installed in the cluster, `ork e2e` syncs the runtime instead of reinstalling — the bundle ConfigMap was updated, and the runtime needs a rollout to pick it up.
+
+---
+
+→ Next: [Writing a test](02-writing-a-test.md)
diff --git a/documentation/concepts/e2e/02-writing-a-test.md b/documentation/concepts/e2e/02-writing-a-test.md
new file mode 100644
index 00000000..d2386bd8
--- /dev/null
+++ b/documentation/concepts/e2e/02-writing-a-test.md
@@ -0,0 +1,158 @@
+# Writing a test
+
+If you have not yet written an E2E from scratch, start with [Writing Your First E2E](../../getting-started/06-writing-your-first-e2e.md). It walks through every step — file layout, validate, run, and debugging a failing checkpoint.
+
+This page is a quick reference for what each field does and the most common patterns.
+
+---
+
+## Minimum viable E2E
+
+```yaml
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+
+metadata:
+ name: my-operator-e2e
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+
+ expect:
+ - name: Deployment ready
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ namespace: default
+ ready: true
+```
+
+Four required fields. Everything else is optional.
+
+---
+
+## Field quick reference
+
+| Field | Required | What it controls |
+|-------|----------|-----------------|
+| `spec.katalog` | yes | Katalog under test |
+| `spec.crd` | yes | CRD to install before the operator starts |
+| `spec.cr` | yes | CR to apply at the `cr-applied` phase |
+| `spec.cluster` | no | Cluster name and provider. Defaults to `kind` / `ork-e2e` |
+| `spec.setup` | no | Prerequisite files, charts, and wait conditions |
+| `spec.expect` | yes | Ordered list of checkpoints |
+| `imports` | no | Other E2E files to run after this one |
+
+→ [Full spec reference](../../reference/schema/04-e2e/01-spec.md)
+
+---
+
+## Assertions
+
+**Resource existence:**
+```yaml
+resources:
+ - kind: Deployment
+ name: hello-website
+ namespace: default
+```
+Passes when the resource exists. Polled until timeout.
+
+**Resource readiness:**
+```yaml
+resources:
+ - kind: Deployment
+ namespace: default
+ ready: true
+```
+Passes when `availableReplicas == replicas`. Omit `name` to match any Deployment in the namespace.
+
+**Cleanup assertion (`count: 0`):**
+```yaml
+resources:
+ - kind: Deployment
+ name: hello-website
+ namespace: default
+ count: 0
+```
+Passes when the resource does not exist. Use in `after: cr-deleted` checkpoints to verify finalizer cleanup ran.
+
+**Command assertion:**
+```yaml
+commands:
+ - run: "kubectl get secret -n platform db-creds -o name"
+ exitCode: 0
+ - run: "kubectl exec -n default deploy/api-server -- wget -qO- localhost/health"
+ outputContains: "ok"
+```
+Run alongside resource checks in the same polling loop. `outputContains` checks combined stdout+stderr.
+
+---
+
+## Lifecycle triggers
+
+Every checkpoint has an `after:` field:
+
+| Value | When |
+|-------|------|
+| `cr-applied` | CR has been applied. The reconciler has started. Resources may not yet exist. |
+| `cr-deleted` | CR has been deleted. Finalizers have run. Child resources should be cleaning up. |
+
+The CR is applied or deleted exactly once — when the first checkpoint with that `after:` value is reached. All subsequent checkpoints with the same `after:` use the same lifecycle event.
+
+---
+
+## Annotated example — once: semantics
+
+Testing that a Secret is created on first apply but not recreated on second apply:
+
+```yaml
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+
+ expect:
+ - name: Secret created on first apply
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Secret
+ name: app-creds
+ namespace: default
+
+ - name: Secret still exists after re-apply
+ after: cr-applied
+ timeout: 10s
+ commands:
+ - run: >
+ kubectl apply -f cr.yaml &&
+ kubectl get secret -n default app-creds -o jsonpath='{.metadata.uid}'
+ outputContains: "" # any uid means the secret survived
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: Secret
+ name: app-creds
+ namespace: default
+ count: 0
+```
+
+---
+
+## Validate before running
+
+```bash
+ork validate -f e2e.yaml
+```
+
+Catches missing files, unknown `after` values, empty checkpoints, and invalid imports — before touching a cluster. Always validate first.
+
+---
+
+→ Next: [Suites and imports](03-suite-and-imports.md)
diff --git a/documentation/concepts/e2e/03-suite-and-imports.md b/documentation/concepts/e2e/03-suite-and-imports.md
new file mode 100644
index 00000000..f4bf73c1
--- /dev/null
+++ b/documentation/concepts/e2e/03-suite-and-imports.md
@@ -0,0 +1,148 @@
+# Suites and imports
+
+A test suite is an E2E file whose only job is to run other E2E files. It has no spec of its own — it composes.
+
+---
+
+## Why suites
+
+Each Katalog in a Komposer deserves its own focused E2E: small, fast, easy to debug when it fails. But a consumer pulling the whole pattern — or a CI job verifying a release — should be able to run one command and get a pass/fail for everything.
+
+The `imports` field bridges the two levels. One suite file at the root imports all the sub-tests. `ork e2e -f e2e.yaml` runs the suite. Sub-tests still run individually with `ork e2e -f sub/e2e.yaml`.
+
+---
+
+## Writing a suite file
+
+```yaml
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: multi-tenancy-suite
+ description: >
+ Runs all three multi-tenancy sub-examples in the same cluster.
+
+imports:
+ - ./01-basic-namespacing/e2e.yaml
+ - ./02-cross-access-control/e2e.yaml
+ - ./03-shared-platform/e2e.yaml
+```
+
+No `spec:` needed. This is a pure aggregator. `ork validate` reports each import as a ✓ line:
+
+```text
+✓ multi-tenancy-suite
+ imports : 3 file(s)
+ ✓ ./01-basic-namespacing/e2e.yaml
+ ✓ ./02-cross-access-control/e2e.yaml
+ ✓ ./03-shared-platform/e2e.yaml
+
+3 import(s) valid
+```
+
+**Try it:**
+```bash
+ork init --pack use-cases/multi-tenancy
+ork e2e -f e2e.yaml
+```
+
+---
+
+## String shorthand
+
+A plain path string is equivalent to the full struct form:
+
+```yaml
+# shorthand (equivalent)
+imports:
+ - ./auth-e2e.yaml
+
+# struct form
+imports:
+ - path: ./auth-e2e.yaml
+ freshCluster: false
+```
+
+---
+
+## Cluster strategy
+
+### Shared cluster (default)
+
+All imports without `freshCluster: true` run in the same cluster, one after the other. Each import:
+
+1. Applies its own CRD, bundle, and CR
+2. Runs its own expectations
+3. Cleans up its own resources
+4. The next import starts in the same (now clean) cluster
+
+The cluster is created once for the whole suite. Where it comes from:
+
+| How you invoke `ork e2e` | Cluster for imports |
+|--------------------------|---------------------|
+| No flags (default) | `runImports` provisions a new kind cluster named `
-imports` |
+| `--use-current` | Active kubectl context — the same cluster the parent used |
+| `--cluster myctx` | `myctx` — the same cluster the parent used |
+
+### Fresh cluster (`freshCluster: true`)
+
+An import with `freshCluster: true` provisions its own independent kind cluster, runs its test, and deletes the cluster when done. Use this when the test installs or deletes cluster-scoped resources that would break other tests running in the same cluster.
+
+```yaml
+imports:
+ - ./01-basic-namespacing/e2e.yaml # shared cluster
+ - ./02-cross-access-control/e2e.yaml # shared cluster
+ - path: ./03-cluster-scoped-crd/e2e.yaml
+ freshCluster: true # own cluster
+```
+
+---
+
+## Imports in a test that has its own spec
+
+`imports` works on E2E files that also have a `spec:`. The parent test runs first. Imports run after the parent's expectations pass, before the parent's cleanup:
+
+```yaml
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: platform-with-addons
+
+spec:
+ katalog: ./platform/katalog.yaml
+ crd: ./platform/crd.yaml
+ cr: ./platform/cr.yaml
+ expect:
+ - name: Platform ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: platform
+ namespace: platform
+ ready: true
+
+imports:
+ - ./addon-monitoring/e2e.yaml
+ - ./addon-logging/e2e.yaml
+```
+
+---
+
+## Validation
+
+`ork validate` checks every import file before any cluster work starts:
+
+- The file must exist at the path.
+- The file must declare `kind: E2E`.
+
+If an import file is missing or has the wrong kind, `ork validate` reports it with a ✗ and the run does not start:
+
+```text
+ ✗ import: ./missing-e2e.yaml: open ./missing-e2e.yaml: no such file or directory
+ ✗ import: ./komposer.yaml: expected kind E2E, got "Komposer"
+```
+
+---
+
+→ Next: [Best practices](04-best-practices.md)
diff --git a/documentation/concepts/e2e/04-best-practices.md b/documentation/concepts/e2e/04-best-practices.md
new file mode 100644
index 00000000..7fcb6cdc
--- /dev/null
+++ b/documentation/concepts/e2e/04-best-practices.md
@@ -0,0 +1,157 @@
+# Best practices
+
+---
+
+## One E2E per Katalog
+
+Each Katalog should have its own `e2e.yaml` in the same directory. Keep the scope tight: one Katalog, one CRD, one CR, a small set of checkpoints. When a test fails, the scope of the failure is obvious.
+
+```text
+my-operator/
+ katalog.yaml
+ crd.yaml
+ cr.yaml
+ e2e.yaml ← here, not in a parent directory
+```
+
+When a Komposer combines several Katalogs, write a suite file at the root that imports each sub-test. The sub-tests stay individually runnable. The suite gives CI one entry point.
+
+---
+
+## Use imports instead of one large E2E
+
+Resist putting all assertions into a single `e2e.yaml`. A large file is hard to debug: when checkpoint 7 of 12 fails, you re-run the whole thing and wait through 1-6 again.
+
+Separate concerns into focused files and compose them:
+
+```yaml
+# operator/e2e.yaml — suite
+imports:
+ - ./e2e-basic.yaml # core resources created
+ - ./e2e-once-secret.yaml # once: semantics
+ - ./e2e-cleanup.yaml # finalizer and deletion order
+```
+
+Each file can be run in isolation during development (`ork e2e -f e2e-once-secret.yaml`) and run together in CI via the suite.
+
+---
+
+## Always include a `cr-deleted` cleanup checkpoint
+
+Every test should verify that child resources are cleaned up when the CR is deleted. Without this, the test passes even if the Deployment or Service leaked.
+
+```yaml
+- name: Cleanup verified
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: Deployment
+ name: my-app
+ namespace: default
+ count: 0
+ - kind: Service
+ name: my-app-svc
+ namespace: default
+ count: 0
+ - kind: MyApp # the CR itself
+ name: my-app
+ namespace: default
+ count: 0
+```
+
+`count: 0` on the CR itself confirms the finalizer released and the object is fully gone.
+
+---
+
+## Set realistic timeouts
+
+Timeouts are per-checkpoint. Set them based on what that specific resource actually needs:
+
+| Resource | Typical wait |
+|----------|-------------|
+| Namespace, ConfigMap, Secret | 10–15s |
+| Service | 15–30s |
+| Deployment with fast image | 60–90s |
+| Deployment with slow pull | 120–180s |
+| StatefulSet | 120–300s |
+| Custom operator (depends on logic) | 60–120s |
+
+Too short: flaky tests. Too long: slow CI. A failing test with a 5-minute timeout is painful.
+
+---
+
+## Prefer `name:` over namespace-level any-match for cleanup checks
+
+Any-match (`kind: Deployment, namespace: default, count: 0`) passes when there are zero Deployments in the namespace at all. That's almost never what you want — another test may have left a Deployment there. Name specific resources:
+
+```yaml
+# fragile — passes if anything cleans the namespace
+- kind: Deployment
+ namespace: default
+ count: 0
+
+# correct — asserts this exact resource is gone
+- kind: Deployment
+ name: my-app
+ namespace: default
+ count: 0
+```
+
+---
+
+## Name checkpoints for the behavior, not the resource
+
+```yaml
+# bad — the resource type is already in the resources list
+- name: Deployment check
+
+# good — describes what the operator should have done
+- name: App deployed and serving traffic
+- name: Credentials not recreated on second apply
+- name: Children removed after CR deletion
+```
+
+The checkpoint name appears in pass/fail output. Make it answer "what behavior was verified?"
+
+---
+
+## Run validate before cluster work
+
+```bash
+ork validate -f e2e.yaml
+```
+
+Validate catches file path errors, missing `after:` values, and invalid imports in milliseconds. There is no reason to provision a cluster before validation passes.
+
+In CI, add validate as a separate step before the e2e step:
+
+```yaml
+- name: Validate E2E spec
+ run: ork validate -f e2e.yaml
+
+- name: Run E2E
+ run: ork e2e -f e2e.yaml
+```
+
+---
+
+## CI integration
+
+`ork e2e` exits `0` on pass and `1` on any failure. It works with any CI system without configuration.
+
+```yaml
+# GitHub Actions
+- name: Run E2E
+ run: ork e2e -f e2e.yaml
+```
+
+For parallel test jobs, pass `--cluster` with a unique name per job to avoid kind cluster name collisions:
+
+```yaml
+- name: Run E2E (shard ${{ matrix.shard }})
+ run: ork e2e -f e2e.yaml --cluster ork-e2e-${{ matrix.shard }}
+```
+
+---
+
+→ Back: [Suites and imports](03-suite-and-imports.md) | [Concept index](index.md)
diff --git a/documentation/concepts/e2e/index.md b/documentation/concepts/e2e/index.md
new file mode 100644
index 00000000..dc43e45d
--- /dev/null
+++ b/documentation/concepts/e2e/index.md
@@ -0,0 +1,112 @@
+# Declarative End-to-End Testing
+
+`ork e2e` verifies an operator against a real Kubernetes cluster. One YAML file describes what to apply, which cluster to use, and what the expected state is at each checkpoint. No test framework. No Go. No mocks.
+
+---
+
+## Why it exists
+
+`ork simulate` verifies template logic without a cluster — fast, sub-second, catches expression errors and mis-typed field references. That is the inner loop.
+
+E2E is the outer gate. It runs the same reconciler, but against a real API server with real admission webhooks, real RBAC, real pod scheduling, and real image pulls. The things simulate cannot catch — ordering bugs between CRDs, finalizer races, webhook rejections, actual Deployment readiness — are exactly what E2E catches.
+
+The two tools are complementary, not alternatives. Simulate first. E2E before you push.
+
+---
+
+## Every learning example ships with a runnable E2E
+
+All examples in [Learning-to-orkestrate](../../getting-started/01-learning-to-orkestrate/) include an `e2e.yaml`. You do not need to write one from scratch to see the full cycle:
+
+```bash
+ork init --pack beginner
+cd beginner/01-hello-website
+ork e2e
+```
+
+That command creates a kind cluster, applies the operator, applies the CR, waits for the Deployment and Service to be ready, deletes the CR, and verifies cleanup. The whole cycle takes about 90 seconds on a cold machine.
+
+---
+
+## The shape of a test
+
+```yaml
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+
+metadata:
+ name: hello-website-e2e
+ description: Deploy a website, verify it comes up, verify cleanup on delete.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+
+ expect:
+ - name: Deployment and Service ready
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ namespace: default
+ ready: true
+ - kind: Service
+ namespace: default
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: Deployment
+ name: hello-website
+ namespace: default
+ count: 0
+```
+
+Four inputs drive every test: the katalog under test, the CRD to install, the CR to apply, and a list of checkpoints (`expect`) that must pass in order.
+
+---
+
+## From one test to a suite
+
+As operators grow, a single `e2e.yaml` per Katalog becomes the norm. When a Komposer combines several Katalogs, a suite file at the root imports all of them:
+
+```yaml
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: full-stack-app-suite
+
+imports:
+ - ./01-multi-region/e2e.yaml
+ - ./03-cross-crd/e2e.yaml
+ - ./04-once-secret/e2e.yaml
+ - ./05-anyof/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.
+
+**Try it:**
+```bash
+ork init --pack use-cases/full-stack-app
+ork e2e -f e2e.yaml
+```
+
+---
+
+## Pages
+
+- [How it works](01-how-it-works.md) — the full lifecycle: cluster provisioning, CRD apply, Orkestra install, expectations, cleanup
+- [Writing a test](02-writing-a-test.md) — quick-reference for spec fields with annotated examples
+- [Suites and imports](03-suite-and-imports.md) — composing multiple E2E files, cluster strategy, pure aggregators
+- [Best practices](04-best-practices.md) — focused tests, naming, `count: 0`, CI integration
+
+---
+
+## See also
+
+- [ork simulate](../simulate/index.md) — the fast inner loop; use before E2E
+- [E2E schema reference](../../reference/schema/04-e2e/index.md) — every field, every option
+- [Writing Your First E2E](../../getting-started/06-writing-your-first-e2e.md) — step-by-step walkthrough
+- [ork e2e CLI reference](../../reference/cli/08-e2e.md)
diff --git a/documentation/concepts/index.md b/documentation/concepts/index.md
index b8318474..33b96a37 100644
--- a/documentation/concepts/index.md
+++ b/documentation/concepts/index.md
@@ -99,3 +99,11 @@ The [Health Subsystem](health-subsystem/) is Orkestra's liveness, readiness, and
[ONCOP](oncop/) (Orkestra Native Cross-Operator Protocol) is the cross-binary observation layer. One operator reads another's typed state — health, metrics, or full CR — without hard-coded URLs, with built-in caching, and with the same template surface as same-binary cross: reads.
→ [Read: ONCOP](oncop/)
+
+---
+
+## Declarative End-to-End Testing
+
+[Declarative E2E](e2e/) is how Orkestra verifies an operator against a real cluster — one YAML file, no test framework, no Go. Every learning example ships with a runnable `e2e.yaml`. The `imports:` field composes focused per-Katalog tests into suites that a single `ork e2e` command runs end to end.
+
+→ [Read: Declarative End-to-End Testing](e2e/)
diff --git a/documentation/concepts/operatorbox/08-multi-tenancy/index.md b/documentation/concepts/operatorbox/08-multi-tenancy/index.md
new file mode 100644
index 00000000..aa614127
--- /dev/null
+++ b/documentation/concepts/operatorbox/08-multi-tenancy/index.md
@@ -0,0 +1,87 @@
+# Multi-tenancy
+
+One Orkestra runtime serves multiple teams. Each Katalog declares `metadata.namespace` — a logical tenant scope. The runtime runs all CRDs with independent workers, health tracking, and reconcile loops. The Control Center renders one panel per namespace.
+
+---
+
+## Namespaces and cluster name
+
+```yaml
+apiVersion: orkestra.orkspace.io/v1
+kind: Katalog
+metadata:
+ name: payments
+ namespace: fintech-team
+ clusterName: prod-eu
+```
+
+`namespace` is a logical grouping — it is not a Kubernetes namespace. Omitting it defaults to `"default"`.
+
+`clusterName` identifies which cluster this Katalog runs in. The Control Center uses it to filter across multiple connected runtimes. When set in the Katalog it takes precedence over the `CLUSTER_NAME` environment variable. When neither is set it is omitted from the response.
+
+Declaring both gives the Control Center full coordinates for every CRD: cluster → namespace → CRD.
+
+---
+
+## Composing namespaced Katalogs
+
+A Komposer imports multiple Katalogs. Each Katalog keeps its own namespace:
+
+```yaml
+apiVersion: orkestra.orkspace.io/v1
+kind: Komposer
+metadata:
+ name: platform
+imports:
+ files:
+ - url: ./platform-team/katalog.yaml
+ - url: ./product-team/katalog.yaml
+spec:
+ crds: {}
+```
+
+The `/katalog` endpoint returns a `namespaces` map:
+
+```json
+{
+ "namespaces": {
+ "platform-team": { "crds": ["database", "cache"], "healthy": true },
+ "product-team": { "crds": ["website", "api"], "healthy": false }
+ }
+}
+```
+
+---
+
+## Cross-read access control
+
+Any CRD can read another CRD's CR state via `cross:` by default. Declare `crossAccess: false` on a Katalog to close it:
+
+```yaml
+crossAccess: false
+
+spec:
+ crds:
+ payment: {} # closed — inherits Katalog default
+ ledger:
+ crossAccess: true # open — overrides Katalog default
+```
+
+A `cross:` reference to a closed CRD returns `found: "false"` silently. Use `when: cross.xyz.found == "true"` to gate dependent resources.
+
+---
+
+## Try it
+
+```bash
+ork init my-project --pack use-cases/multi-tenancy
+cd my-project/multi-tenancy
+
+# Follow the steps in the README
+```
+
+| | |
+|---|---|
+| `01-basic-namespacing` | Two teams, separate CC panels |
+| `02-cross-access-control` | `crossAccess: false` with CRD-level override |
+| `03-shared-platform` | Platform infra consumed by application teams |
diff --git a/documentation/concepts/simulate/index.md b/documentation/concepts/simulate/index.md
index 08c32bb6..bc0cd414 100644
--- a/documentation/concepts/simulate/index.md
+++ b/documentation/concepts/simulate/index.md
@@ -6,7 +6,7 @@
## Why it exists
-Most operator frameworks give you two options for testing: write unit tests that mock the Kubernetes client (which do not reflect real merge-patch semantics), or spin up a kind cluster and apply real CRs (which is slow and requires environment setup).
+Traditionally, there are two options for testing: write unit tests that mock the Kubernetes client (which do not reflect real merge-patch semantics), or spin up a kind cluster and apply real CRs (which is slow and requires environment setup).
`ork simulate` takes a third path: it runs the same `GenericReconciler` that runs in production, but wires it to a fake in-memory Kubernetes store. This means:
diff --git a/documentation/orkestra-registry/04-e2e.md b/documentation/orkestra-registry/04-e2e.md
index c2507457..1d0a585e 100644
--- a/documentation/orkestra-registry/04-e2e.md
+++ b/documentation/orkestra-registry/04-e2e.md
@@ -131,7 +131,59 @@ spec:
count: 0
```
-→ Full field reference: **[E2E schema](../reference/schema/04-e2e/)**
+→ Full field reference: **[E2E schema](../reference/schema/04-e2e/index.md)**
+
+---
+
+## Testing a multi-operator pattern
+
+When a pattern contains more than one Katalog — a Komposer that imports several sources — one E2E per sub-Katalog keeps each test small and focused. A suite file at the category root imports them all, so a single `ork e2e` verifies the whole pattern before publication.
+
+Layout in a registry pattern directory:
+
+```text
+multi-tenancy/
+ komposer.yaml
+ e2e.yaml ← suite (pure aggregator)
+ 01-basic-namespacing/
+ katalog.yaml crd.yaml cr.yaml e2e.yaml
+ 02-cross-access-control/
+ katalog.yaml crd.yaml cr.yaml e2e.yaml
+ 03-shared-platform/
+ katalog.yaml crd.yaml cr.yaml e2e.yaml
+```
+
+The suite file:
+
+```yaml
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: multi-tenancy-suite
+ description: >
+ Runs all three multi-tenancy sub-examples in the same cluster.
+
+imports:
+ - ./01-basic-namespacing/e2e.yaml
+ - ./02-cross-access-control/e2e.yaml
+ - ./03-shared-platform/e2e.yaml
+```
+
+`ork registry push` discovers `e2e.yaml` at the pattern root and runs it. The suite provisions one cluster and runs each import in it sequentially — three sub-tests for the cost of one cluster lifecycle.
+
+`ork validate -f e2e.yaml` reports each import with a ✓ before any cluster is involved:
+
+```text
+✓ multi-tenancy-suite
+ imports : 3 file(s)
+ ✓ ./01-basic-namespacing/e2e.yaml
+ ✓ ./02-cross-access-control/e2e.yaml
+ ✓ ./03-shared-platform/e2e.yaml
+
+3 import(s) valid
+```
+
+→ Full field reference: **[imports schema](../reference/schema/04-e2e/04-imports.md)**
---
diff --git a/documentation/reference/schema/04-e2e/01-spec.md b/documentation/reference/schema/04-e2e/01-spec.md
index 19562e64..2b6593ef 100644
--- a/documentation/reference/schema/04-e2e/01-spec.md
+++ b/documentation/reference/schema/04-e2e/01-spec.md
@@ -57,4 +57,20 @@ spec:
---
+---
+
+## `imports`
+
+A top-level list of other E2E files to run after this test completes. Used to compose test suites without a cluster-per-test overhead. See [04-imports.md](04-imports.md) for the full field reference.
+
+```yaml
+imports:
+ - ./auth-e2e.yaml
+ - ./rbac-e2e.yaml
+ - path: ./infra-e2e.yaml
+ freshCluster: true # this one gets its own cluster
+```
+
+---
+
→ Next: [02-setup.md](02-setup.md) — prerequisite resources before the operator starts
diff --git a/documentation/reference/schema/04-e2e/04-imports.md b/documentation/reference/schema/04-e2e/04-imports.md
new file mode 100644
index 00000000..6c1962e2
--- /dev/null
+++ b/documentation/reference/schema/04-e2e/04-imports.md
@@ -0,0 +1,102 @@
+# imports
+
+`imports` is a top-level field (same level as `spec:`) that lists other E2E files to run after the current one completes. It is the building block for test suites.
+
+---
+
+## Wire format
+
+```yaml
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+
+metadata:
+ name: full-stack-app-suite
+
+imports:
+ - ./01-multi-region/e2e.yaml
+ - ./03-cross-crd/e2e.yaml
+ - path: ./infra-e2e.yaml
+ freshCluster: true
+```
+
+---
+
+## Shorthand
+
+A plain string is equivalent to `{path: , freshCluster: false}`:
+
+```yaml
+# shorthand
+imports:
+ - ./auth-e2e.yaml
+
+# equivalent struct form
+imports:
+ - path: ./auth-e2e.yaml
+ freshCluster: false
+```
+
+---
+
+## Fields
+
+| Field | Required | Default | Description |
+|-------|----------|---------|-------------|
+| `path` | yes | — | Path to another E2E spec file. Relative to this file's directory. |
+| `freshCluster` | no | `false` | When `true`, provisions a new kind cluster for this import instead of sharing the suite cluster. |
+
+---
+
+## Validation
+
+`ork validate` and `ork e2e` both check every import file before the test runs:
+
+- The file must exist at the resolved path.
+- The file must declare `kind: E2E`.
+
+A missing file or wrong kind is a validation error — the run does not start.
+
+---
+
+## Cluster strategy
+
+**Shared cluster (default — `freshCluster: false`):**
+All shared imports run in the same cluster, one after the other. Each import:
+- Applies its own CRD, bundle, and CR
+- Runs its own expectations
+- Cleans up its own resources before the next import starts
+
+The cluster is provisioned once (either by the parent E2E or by `runImports` itself) and torn down after all imports finish.
+
+**How the cluster is determined:**
+- If the parent was invoked with `--use-current` or `--cluster`: imports reuse the same active context.
+- If the parent created its own kind cluster (no flags): `runImports` provisions a separate `-imports` cluster for all shared imports to use together.
+
+**Fresh cluster (`freshCluster: true`):**
+A new kind cluster is created for this specific import, independent of all other imports and the parent test. Use this when an import cannot safely share state — for example, a test that installs and uninstalls cluster-scoped resources that would affect other tests running concurrently.
+
+---
+
+## Pure aggregator
+
+An E2E file with `imports:` but no `spec:` is a valid pure aggregator. It has no test of its own — it exists only to run a suite:
+
+```yaml
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: multi-tenancy-suite
+ description: Runs all three multi-tenancy sub-examples in the same cluster.
+
+imports:
+ - ./01-basic-namespacing/e2e.yaml
+ - ./02-cross-access-control/e2e.yaml
+ - ./03-shared-platform/e2e.yaml
+```
+
+`ork validate -f e2e.yaml` shows each import as a ✓ line. `ork e2e -f e2e.yaml` creates a cluster and runs all three imports in it.
+
+---
+
+→ Back: [03-expect.md](03-expect.md) | [Schema index](index.md)
diff --git a/documentation/reference/schema/04-e2e/index.md b/documentation/reference/schema/04-e2e/index.md
index 0aa8245e..ed0bc15c 100644
--- a/documentation/reference/schema/04-e2e/index.md
+++ b/documentation/reference/schema/04-e2e/index.md
@@ -66,6 +66,11 @@ spec:
name: hello-website
namespace: default
count: 0
+
+imports: # optional — run other E2E files after this one
+ - ./auth-e2e.yaml
+ - path: ./infra-e2e.yaml
+ freshCluster: true
```
---
@@ -129,6 +134,7 @@ ork e2e --keep-cluster
| [01-spec.md](01-spec.md) | `katalog`, `crd`, `cr`, `cluster` |
| [02-setup.md](02-setup.md) | `setup.apply`, `setup.helm`, `setup.wait` |
| [03-expect.md](03-expect.md) | `expect` — resources, commands, after, timeout |
+| [04-imports.md](04-imports.md) | `imports` — test suites, cluster strategy, pure aggregators |
---
diff --git a/examples/advanced/13-dependencies/01-in-binary/README.md b/examples/advanced/13-dependencies/01-in-binary/README.md
index 1a5d9057..c9db7975 100644
--- a/examples/advanced/13-dependencies/01-in-binary/README.md
+++ b/examples/advanced/13-dependencies/01-in-binary/README.md
@@ -60,7 +60,36 @@ Expected:
---
-## Step 2 — Apply the CRDs
+## Step 2 — Simulate (optional, no cluster needed)
+
+Verify what the App reconciler would produce for this CR:
+
+```bash
+ork simulate --cr cr-app.yaml --crd app
+```
+
+```
+Simulating app/my-database
+
+ Cycle 1:
+ + deployments/my-database-deployment
+ + services/my-database-svc
+ ~ status/my-database
+ Cycle 2:
+ ~ status/my-database
+ (cycles 3–10: identical)
+
+ ✓ Steady state at cycle 3 in 196ms
+```
+
+**What this means:**
+- Simulate models the App reconciler in isolation — it shows what the reconciler produces when it runs. The `dependsOn` enforcement that gates the reconcile in production is a coordinator-level concern, not part of the individual reconciler.
+- Cycle 1 shows the Deployment and Service the App would create once its reconcile is allowed to proceed. The `cross:` read for the database endpoint returns empty (no database in the in-memory cluster), so `DB_HOST` would be absent or empty in the Deployment env — you can verify this by inspecting the template output.
+- **Steady state at cycle 3** — the reconciler is idempotent. On a real cluster, Orkestra only runs this reconcile after the Database CR reaches `healthy`. Simulate confirms the reconciler itself is correct; the dependency gate ensures it runs at the right time.
+
+---
+
+## Step 3 — Apply the CRDs
```bash
kubectl apply -f crd.yaml
@@ -68,7 +97,7 @@ kubectl apply -f crd.yaml
---
-## Step 3 — Run Orkestra and Control Center
+## Step 4 — Run Orkestra and Control Center
```bash
ork run
@@ -79,7 +108,7 @@ ork contro start
---
-## Step 4 — Apply App first (to see the wait)
+## Step 5 — Apply App first (to see the wait)
Apply App before Database to observe the dependency enforcement:
@@ -100,7 +129,7 @@ Select App and scroll down to see why under `"Dependencies"`
---
-## Step 5 — Apply Database
+## Step 6 — Apply Database
```bash
kubectl apply -f cr-database.yaml
@@ -126,7 +155,7 @@ Check the control center and see App become healthy and the phase `Running`.
---
-## Step 6 — Verify the injected env
+## Step 7 — Verify the injected env
```bash
kubectl get deployment my-database-deployment -o jsonpath='{.spec.template.spec.containers[0].env}' | jq .
@@ -143,7 +172,7 @@ kubectl get deployment my-database-deployment -o jsonpath='{.spec.template.spec.
---
-## Step 7 — Simulate a dependency restart
+## Step 8 — Simulate a dependency restart
Delete Database CRD and watch App's behaviour:
diff --git a/examples/beginner/02-with-serviceaccount/README.md b/examples/beginner/02-with-serviceaccount/README.md
index 4b7d0c4e..280daa19 100644
--- a/examples/beginner/02-with-serviceaccount/README.md
+++ b/examples/beginner/02-with-serviceaccount/README.md
@@ -43,7 +43,39 @@ Expected output:
---
-## Step 2 — Start the operator
+## Step 2 — Simulate (optional, no cluster needed)
+
+Before starting against a real cluster, run the reconciler in memory to verify your templates produce the right resources:
+
+```bash
+ork simulate --cr cr.yaml
+```
+
+```
+Simulating website/my-site
+
+ Cycle 1:
+ + serviceaccounts/my-site-sa
+ + deployments/my-site
+ + services/my-site-svc
+ ~ status/my-site
+ Cycle 2:
+ ~ status/my-site
+ (cycles 3–10: identical)
+
+ ✓ Steady state at cycle 3 in 215ms
+```
+
+**What this means:**
+- `+` in Cycle 1 — these resources would be created: the ServiceAccount first, then the Deployment that references it, then the Service. The order matches `onCreate` processing order in the Katalog.
+- `~ status/my-site` — the CR's status fields would be written back on every cycle. Notes like `allReplicasReady` re-evaluate each reconcile.
+- Cycle 2 — no new resources. Status still updates because notes re-run unconditionally.
+- **Steady state at cycle 3** — from cycle 3 onward, reconciling this CR produces no changes. A healthy operator converges and stops acting. If your operator never reaches steady state, simulate tells you which resources keep changing and why.
+- 215ms — wall time for all 10 simulated cycles in memory. No cluster round-trips.
+
+---
+
+## Step 3 — Start the operator
```bash
ork run # add --dev if you don't have a cluster; Orkestra will create a kind cluster
@@ -54,7 +86,7 @@ and starts the operator. Watch the terminal for the informer sync and reconcile
---
-## Step 3 — Open the Control Center
+## Step 4 — Open the Control Center
In a separate terminal:
@@ -68,7 +100,7 @@ CRD health, worker state, reconcile metrics, and the `Website` CR.
---
-## Step 4 — Verify resources
+## Step 5 — Verify resources
```bash
kubectl get websites
@@ -92,7 +124,7 @@ my-site-sa 0
---
-## Step 5 — Verify status
+## Step 6 — Verify status
```bash
kubectl get website my-site -o yaml | grep -A20 "status:"
@@ -120,7 +152,7 @@ kubectl get website my-site -o jsonpath='{.status.allReplicasReady}' && echo
---
-## Step 6 — Test drift correction
+## Step 7 — Test drift correction
Update [cr.yaml](cr.yaml) to change the image to `nginx:1.26` and reapply:
@@ -132,7 +164,7 @@ kubectl get deployment my-site -o jsonpath='{.spec.template.spec.containers[0].i
---
-## Step 7 — Scale and watch the note update
+## Step 8 — Scale and watch the note update
```bash
kubectl patch website my-site --type=merge -p '{"spec":{"replicas":4}}'
diff --git a/examples/intermediate/06-komposer-basic/README.md b/examples/intermediate/06-komposer-basic/README.md
index 07b288c8..21238c20 100644
--- a/examples/intermediate/06-komposer-basic/README.md
+++ b/examples/intermediate/06-komposer-basic/README.md
@@ -72,7 +72,34 @@ Expected:
mode: dynamic / workers: 1
```
-### 4. Start the operator
+### 4. Simulate (optional, no cluster needed)
+
+Before running, verify what the operator would reconcile for a specific CR:
+
+```bash
+ork simulate --cr cr.yaml --crd website
+```
+
+```
+Simulating website/composed-site
+
+ Cycle 1:
+ + deployments/composed-site-deployment
+ + services/composed-site-svc
+ ~ status/composed-site
+ Cycle 2:
+ ~ status/composed-site
+ (cycles 3–10: identical)
+
+ ✓ Steady state at cycle 3 in 193ms
+```
+
+**What this means:**
+- `--crd website` scopes the simulation to the Website CRD only — the namespace-governance CRD from the other Katalog is not simulated here. Use `--crd` to isolate one CRD at a time when a Komposer contains many.
+- Cycle 1 creates a Deployment and a Service — the values come from the merged Komposer configuration, not either source Katalog alone. Worker count, resync, and any inline overrides in `komposer.yaml` are already baked in.
+- **Steady state at cycle 3** — the Website CRD converges in three cycles. This is what you want to see before connecting the Komposer to a real cluster.
+
+### 5. Start the operator
```bash
ork run --file komposer.yaml
diff --git a/examples/use-cases/crd-conversion/with-webhooks/README.md b/examples/use-cases/crd-conversion/with-webhooks/README.md
index 5edd65b4..b75a8cc1 100644
--- a/examples/use-cases/crd-conversion/with-webhooks/README.md
+++ b/examples/use-cases/crd-conversion/with-webhooks/README.md
@@ -262,6 +262,35 @@ In Orkestra they are notes — one word in a template expression.
---
+## E2E
+
+Run the full lifecycle in one command — installs Orkestra with Gateway, applies the multi-version CRD, applies the v1 CR, asserts it is readable via both API versions, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: v1 CronJob CR created
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get cronjobs.v1.demo.orkestra.io print-hello-v1
+ exitCode: 0
+
+ - name: v1 CR readable via v2 API
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get cronjobs.v2.demo.orkestra.io print-hello-v1
+ exitCode: 0
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/crd-conversion/with-webhooks/e2e.yaml b/examples/use-cases/crd-conversion/with-webhooks/e2e.yaml
new file mode 100644
index 00000000..2fcaec3b
--- /dev/null
+++ b/examples/use-cases/crd-conversion/with-webhooks/e2e.yaml
@@ -0,0 +1,67 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: crd-conversion-webhook-e2e
+ description: >
+ Multi-version CronJob operator with conversion webhooks. Orkestra Gateway
+ handles v1 ↔ v2 schedule conversion. The proof: a v1 CR (cron string) and
+ a v2 CR (structured object) both produce a Kubernetes CronJob with the
+ correct normalized schedule — no Go, no webhook server to deploy.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr-v1.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: v1 CR produces Kubernetes CronJob with correct schedule
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get cronjob print-hello-v1 -n default -o jsonpath='{.spec.schedule}'
+ outputContains: "*/1 * * * *"
+
+ - name: v2 CR also produces Kubernetes CronJob with correct schedule
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl apply -f cr-v2.yaml
+ exitCode: 0
+ - run: kubectl get cronjob print-hello-v2 -n default -o jsonpath='{.spec.schedule}'
+ outputContains: "*/1 * * * *"
+ - run: kubectl get cronjob daily-backup -n default -o jsonpath='{.spec.schedule}'
+ outputContains: "0 2 * * 1-5"
+
+ - name: Delete v2 CR
+ after: cr-applied
+ timeout: 30s
+ commands:
+ - run: kubectl delete -f cr-v2.yaml
+ exitCode: 0
+
+ - name: Cleanup verified (v1)
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: CronJob
+ name: daily-backup-string
+ namespace: default
+ count: 0
+
+ - name: Cleanup verified (v2)
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: CronJob
+ name: daily-backup
+ namespace: default
+ count: 0
+ - kind: CronJob
+ name: print-hello-v2
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/crd-conversion/without-webhooks/README.md b/examples/use-cases/crd-conversion/without-webhooks/README.md
index bafc8a7a..d3942120 100644
--- a/examples/use-cases/crd-conversion/without-webhooks/README.md
+++ b/examples/use-cases/crd-conversion/without-webhooks/README.md
@@ -203,6 +203,36 @@ cd examples/advanced/07-validation-mutation
---
+## E2E
+
+Run the full lifecycle in one command — applies the string-format CR, asserts the CronJob is created and the status reflects the normalized schedule, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: CronJob CR created (string schedule)
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: CronJob
+ name: daily-backup-string
+ namespace: default
+
+ - name: Structured schedule CR also accepted
+ after: cr-applied
+ timeout: 30s
+ commands:
+ - run: kubectl apply -f cr-structured-schedule.yaml
+ exitCode: 0
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/crd-conversion/without-webhooks/e2e.yaml b/examples/use-cases/crd-conversion/without-webhooks/e2e.yaml
new file mode 100644
index 00000000..01d8fa88
--- /dev/null
+++ b/examples/use-cases/crd-conversion/without-webhooks/e2e.yaml
@@ -0,0 +1,59 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: crd-conversion-no-webhook-e2e
+ description: >
+ Single-version CronJob operator — accepts both cron string and structured
+ schedule formats without a conversion webhook. normalize collapses both
+ inputs into a canonical cron string before reconcile. Verifies that both
+ CR formats produce a Kubernetes CronJob with the same schedule.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd-single.yaml
+ cr: ./cr-string-schedule.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: CronJob CR created (string schedule)
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: CronJob
+ name: daily-backup-string
+ namespace: default
+
+ - name: Kubernetes CronJob created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: CronJob
+ name: daily-backup-string
+ namespace: default
+
+ - name: Kubernetes CronJob has normalized schedule
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get cronjob daily-backup-string -o jsonpath='{.spec.schedule}'
+ outputContains: "0 2 * * 1-5"
+
+ - name: Structured schedule CR also accepted
+ after: cr-applied
+ timeout: 30s
+ commands:
+ - run: kubectl apply -f cr-structured-schedule.yaml
+ exitCode: 0
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: CronJob
+ name: daily-backup-string
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/enrich/01-pod-health/README.md b/examples/use-cases/enrich/01-pod-health/README.md
index f1a7dd4f..c25d515c 100644
--- a/examples/use-cases/enrich/01-pod-health/README.md
+++ b/examples/use-cases/enrich/01-pod-health/README.md
@@ -2,6 +2,8 @@
`enrich: [pods]` embeds the live pod list into `.children.deployment._pods` on every reconcile. Status fields surface pod count, readiness, and crash detection — without reading the pod list anywhere in your Katalog explicitly.
+**Cost:** one extra pod-list API call per reconcile cycle, always. This is the right pattern when you need pod health surfaced at all times — running count, crash detection, readiness. If you only need this data during degraded state, see [02-warning-events](../02-warning-events/README.md) for the conditional gate pattern that reduces this cost to zero in steady state.
+
**What you learn:** what `enrich` is for, how pod notes require it, the one-line declaration that unlocks `podCount`, `readyPodCount`, `hasCrashingPod`, and more.
---
@@ -104,6 +106,35 @@ kubectl patch microservice api-server --type=merge -p '{"spec":{"image":"nginx:1
---
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies the CRD, starts the operator, applies the CR, asserts pod count and readiness appear in status, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Status has podCount
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get microservice api-server -o jsonpath='{.status.podCount}'
+ outputContains: "2"
+
+ - name: Status shows no crashing pods
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get microservice api-server -o jsonpath='{.status.hasCrashingPod}'
+ outputContains: "false"
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/enrich/01-pod-health/e2e.yaml b/examples/use-cases/enrich/01-pod-health/e2e.yaml
new file mode 100644
index 00000000..163a1dd2
--- /dev/null
+++ b/examples/use-cases/enrich/01-pod-health/e2e.yaml
@@ -0,0 +1,63 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: enrich-pod-health-e2e
+ description: >
+ enrich: [pods] — the live pod list is embedded on every reconcile.
+ Verifies that pod count and readiness appear in the CR status after the
+ Deployment is ready, and that hasCrashingPod is false in a healthy state.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Microservice CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Microservice
+ name: api-server
+ namespace: default
+
+ - name: Deployment ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: api-server
+ namespace: default
+ ready: true
+
+ - name: Status reaches Ready phase
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get microservice api-server -o jsonpath='{.status.phase}'
+ outputContains: Ready
+
+ - name: Status shows no crashing pods
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get microservice api-server -o jsonpath='{.status.hasCrashingPod}'
+ outputContains: "false"
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Microservice
+ name: api-server
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: api-server
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/enrich/02-warning-events/README.md b/examples/use-cases/enrich/02-warning-events/README.md
index 69ed055f..5c5919a0 100644
--- a/examples/use-cases/enrich/02-warning-events/README.md
+++ b/examples/use-cases/enrich/02-warning-events/README.md
@@ -120,6 +120,35 @@ The event-list call appears only when it is needed.
---
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies the CRD, starts the operator, applies the healthy CR and the broken CR, asserts the conditional gate holds in steady state and fires when degraded, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Healthy CR has no event details (gate held)
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get microservice healthy-app -o jsonpath='{.status.warningEvents}'
+ outputContains: ""
+
+ - name: Broken CR has warning events in status
+ after: cr-applied
+ timeout: 120s
+ commands:
+ - run: kubectl get microservice broken-app -o jsonpath='{.status.warningEvents}'
+ outputContains: ImagePullBackOff
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/enrich/02-warning-events/e2e.yaml b/examples/use-cases/enrich/02-warning-events/e2e.yaml
new file mode 100644
index 00000000..b35a8d8d
--- /dev/null
+++ b/examples/use-cases/enrich/02-warning-events/e2e.yaml
@@ -0,0 +1,61 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: enrich-warning-events-e2e
+ description: >
+ Conditional enrichment — events are fetched only when the deployment is degraded.
+ Verifies that a healthy CR has no event details in status (gate held),
+ and that a broken CR (bad image) surfaces warning events in status.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ./cr-healthy.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Healthy CR created and ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Microservice
+ name: healthy-app
+ namespace: default
+ - kind: Deployment
+ name: healthy-app
+ namespace: default
+ ready: true
+
+ - name: Healthy CR has no event details (conditional gate held)
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get microservice healthy-app -o jsonpath='{.status.warningEvents}'
+ outputContains: ""
+
+ - name: Broken CR created (bad image triggers event enrichment)
+ after: cr-applied
+ timeout: 30s
+ commands:
+ - run: kubectl apply -f cr-broken.yaml
+ exitCode: 0
+
+ - name: Broken CR reaches Degraded phase (gate condition met)
+ after: cr-applied
+ timeout: 120s
+ commands:
+ - run: kubectl get microservice broken-app -o jsonpath='{.status.phase}'
+ outputContains: Degraded
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Microservice
+ name: healthy-app
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/enrich/03-rollout-observer/README.md b/examples/use-cases/enrich/03-rollout-observer/README.md
index 969d73dc..5b820e19 100644
--- a/examples/use-cases/enrich/03-rollout-observer/README.md
+++ b/examples/use-cases/enrich/03-rollout-observer/README.md
@@ -1,6 +1,8 @@
# 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, only pod-list runs. During a rollout you can watch the old and new ReplicaSet counts change in real time.
+`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.
+
+**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.
**What you learn:** `anyOf:` in enrichment conditions, combining always-on and conditional targets, debug-mode enrichment without affecting other CRs.
@@ -128,6 +130,37 @@ kubectl patch microservice web-frontend --type=merge -p '{"spec":{"debug":"false
---
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies the CRD, starts the operator, applies the CR, asserts no replicaset data in steady state and that debug mode surfaces it, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: No replicaset data in steady state (anyOf gate held)
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get microservice web-frontend -o jsonpath='{.status.replicaSetCount}'
+ outputContains: ""
+
+ - name: Debug mode surfaces replicaset data
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl patch microservice web-frontend --type=merge -p '{"spec":{"debug":"true"}}'
+ exitCode: 0
+ - run: kubectl get microservice web-frontend -o jsonpath='{.status.replicaSetCount}'
+ outputContains: "1"
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/enrich/03-rollout-observer/e2e.yaml b/examples/use-cases/enrich/03-rollout-observer/e2e.yaml
new file mode 100644
index 00000000..7849be5d
--- /dev/null
+++ b/examples/use-cases/enrich/03-rollout-observer/e2e.yaml
@@ -0,0 +1,65 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: enrich-rollout-observer-e2e
+ description: >
+ anyOf: 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.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Microservice CR created and ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Microservice
+ name: web-frontend
+ namespace: default
+ - kind: Deployment
+ name: web-frontend
+ namespace: default
+ ready: true
+
+ - name: No replicaset data in steady state (anyOf gate held)
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get microservice web-frontend -o jsonpath='{.status.replicaSetCount}'
+ outputContains: ""
+
+ - name: Enable debug mode
+ after: cr-applied
+ timeout: 30s
+ commands:
+ - run: kubectl patch microservice web-frontend --type=merge -p '{"spec":{"debug":"true"}}'
+ exitCode: 0
+
+ - name: Debug mode accepted and phase stays Ready
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get microservice web-frontend -o jsonpath='{.status.phase}'
+ outputContains: Ready
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Microservice
+ name: web-frontend
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: web-frontend
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/external/01-health-gate/e2e.yaml b/examples/use-cases/external/01-health-gate/e2e.yaml
new file mode 100644
index 00000000..43c8e861
--- /dev/null
+++ b/examples/use-cases/external/01-health-gate/e2e.yaml
@@ -0,0 +1,48 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: external-health-gate-e2e
+ description: >
+ external: health gate — Deployment only created when /health returns 200.
+ Requires ork start dev-server in a separate terminal.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ./cr-dev-healthy.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Deployment created when health check passes
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ name: my-app-healthy
+ namespace: default
+ ready: true
+
+ - name: No Deployment when health check fails
+ after: cr-applied
+ timeout: 30s
+ commands:
+ - run: kubectl apply -f cr-dev-degraded.yaml
+ exitCode: 0
+ resources:
+ - kind: Deployment
+ name: my-app-degraded
+ namespace: default
+ count: 0
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: Deployment
+ name: my-app-healthy
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/external/02-config-inject/e2e.yaml b/examples/use-cases/external/02-config-inject/e2e.yaml
new file mode 100644
index 00000000..bbc7737d
--- /dev/null
+++ b/examples/use-cases/external/02-config-inject/e2e.yaml
@@ -0,0 +1,35 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: external-config-inject-e2e
+ description: >
+ external: config inject — response body embedded into a ConfigMap on every
+ reconcile. Requires ork start dev-server in a separate terminal.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: ConfigMap created with injected external config
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: ConfigMap
+ name: my-app-config
+ namespace: default
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: ConfigMap
+ name: my-app-config
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/external/03-image-signing/e2e.yaml b/examples/use-cases/external/03-image-signing/e2e.yaml
new file mode 100644
index 00000000..432f5bf6
--- /dev/null
+++ b/examples/use-cases/external/03-image-signing/e2e.yaml
@@ -0,0 +1,44 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: external-image-signing-e2e
+ description: >
+ external: once-per-image signing gate — the sign call fires only when
+ spec.image changes; 4xx locks out retries. Deployment created only after
+ signing succeeds. Requires ork start dev-server in a separate terminal.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Deployment created after image signing succeeds
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ name: my-app
+ namespace: default
+ ready: true
+
+ - name: Status records signed image
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get webapp my-app -o jsonpath='{.status.signedImage}'
+ outputContains: nginx:1.25
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: Deployment
+ name: my-app
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/full-stack-app/01-multi-region/README.md b/examples/use-cases/full-stack-app/01-multi-region/README.md
index b0cec04a..a3084b7e 100644
--- a/examples/use-cases/full-stack-app/01-multi-region/README.md
+++ b/examples/use-cases/full-stack-app/01-multi-region/README.md
@@ -122,6 +122,38 @@ For per-region replicas and ports — where each region carries its own configur
---
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies the CRD, starts the operator, applies the CR, asserts three regional Deployments are created and ready, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Three regional Deployments ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-multi-region-us-east-1
+ namespace: default
+ ready: true
+ - kind: Deployment
+ name: my-multi-region-eu-west-1
+ namespace: default
+ ready: true
+ - kind: Deployment
+ name: my-multi-region-ap-southeast-1
+ namespace: default
+ ready: true
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/full-stack-app/01-multi-region/e2e.yaml b/examples/use-cases/full-stack-app/01-multi-region/e2e.yaml
new file mode 100644
index 00000000..0bca27de
--- /dev/null
+++ b/examples/use-cases/full-stack-app/01-multi-region/e2e.yaml
@@ -0,0 +1,56 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: multi-region-list-e2e
+ description: >
+ forEach over a list — one CR spec.regions list produces one Deployment per region.
+ Verifies three regional Deployments are created and ready.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: MultiRegionApp CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: MultiRegionApp
+ name: my-multi-region
+ namespace: default
+
+ - name: Three regional Deployments ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-multi-region-us-east-1
+ namespace: default
+ ready: true
+ - kind: Deployment
+ name: my-multi-region-eu-west-1
+ namespace: default
+ ready: true
+ - kind: Deployment
+ name: my-multi-region-ap-southeast-1
+ namespace: default
+ ready: true
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: MultiRegionApp
+ name: my-multi-region
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: my-multi-region-us-east-1
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/full-stack-app/03-cross-crd/README.md b/examples/use-cases/full-stack-app/03-cross-crd/README.md
index 31f0267e..997781fe 100644
--- a/examples/use-cases/full-stack-app/03-cross-crd/README.md
+++ b/examples/use-cases/full-stack-app/03-cross-crd/README.md
@@ -106,6 +106,37 @@ The `cross:` observation reads from the in-memory informer cache — no `client.
---
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies both CRDs, sets up the database dependency, starts the operator, applies the application CR, asserts the cross: read injects the endpoint, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Deployment ready after database is available
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-app
+ namespace: default
+ ready: true
+
+ - name: Status reflects database endpoint
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get databasebackedapp my-app -o jsonpath='{.status.databaseEndpoint}'
+ outputContains: my-app-db
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/full-stack-app/03-cross-crd/e2e.yaml b/examples/use-cases/full-stack-app/03-cross-crd/e2e.yaml
new file mode 100644
index 00000000..44f60289
--- /dev/null
+++ b/examples/use-cases/full-stack-app/03-cross-crd/e2e.yaml
@@ -0,0 +1,73 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: cross-crd-e2e
+ description: >
+ cross: observation — DatabaseBackedApp reads ManagedDatabase status via cross: and
+ injects the database endpoint into its Deployment env and ConfigMap.
+ Verifies the app waits while the database is not ready, then reconciles once it is.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./application-cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ setup:
+ apply:
+ - ./database-cr.yaml
+ wait:
+ - kind: ManagedDatabase
+ name: my-app-db
+ namespace: default
+ timeout: 60s
+
+ expect:
+ - name: DatabaseBackedApp CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: DatabaseBackedApp
+ name: my-app
+ namespace: default
+
+ - name: Deployment ready after database is available
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-app
+ namespace: default
+ ready: true
+
+ - name: ConfigMap carries database endpoint
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: ConfigMap
+ name: my-app-config
+ namespace: default
+
+ - name: Status reflects database endpoint
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get databasebackedapp my-app -o jsonpath='{.status.databaseEndpoint}'
+ outputContains: my-app-db
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: DatabaseBackedApp
+ name: my-app
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: my-app
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/full-stack-app/04-once-secret/README.md b/examples/use-cases/full-stack-app/04-once-secret/README.md
index 17861183..721b1d97 100644
--- a/examples/use-cases/full-stack-app/04-once-secret/README.md
+++ b/examples/use-cases/full-stack-app/04-once-secret/README.md
@@ -108,6 +108,41 @@ kubectl describe deploy my-secure-app | grep -A10 "Environment:"
---
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies the CRD, starts the operator, applies the CR, asserts the Secret is created exactly once and not regenerated on re-apply, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Secret created on first reconcile
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Secret
+ name: my-secure-app-credentials
+ namespace: default
+
+ - name: Secret is not recreated on re-apply (once: semantics)
+ after: cr-applied
+ timeout: 30s
+ commands:
+ - run: >
+ ORIG=$(kubectl get secret my-secure-app-credentials -o jsonpath='{.metadata.resourceVersion}') &&
+ kubectl apply -f cr.yaml &&
+ sleep 5 &&
+ NEW=$(kubectl get secret my-secure-app-credentials -o jsonpath='{.metadata.resourceVersion}') &&
+ [ "$ORIG" = "$NEW" ] && echo "unchanged"
+ outputContains: unchanged
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/full-stack-app/04-once-secret/e2e.yaml b/examples/use-cases/full-stack-app/04-once-secret/e2e.yaml
new file mode 100644
index 00000000..077f8794
--- /dev/null
+++ b/examples/use-cases/full-stack-app/04-once-secret/e2e.yaml
@@ -0,0 +1,64 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: once-secret-e2e
+ description: >
+ once: idempotent secret generation — the Secret is created on first reconcile
+ and never overwritten on subsequent reconciles. Verifies the Secret is created
+ and that re-applying the CR does not regenerate it.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: SecureApp CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: SecureApp
+ name: my-secure-app
+ namespace: default
+
+ - name: Deployment ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-secure-app
+ namespace: default
+ ready: true
+
+ - name: Secret created on first reconcile
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Secret
+ name: my-secure-app-credentials
+ namespace: default
+
+ - name: Secret not recreated on re-apply
+ after: cr-applied
+ timeout: 30s
+ commands:
+ - run: "ORIG=$(kubectl get secret my-secure-app-credentials -o jsonpath='{.metadata.resourceVersion}') && kubectl apply -f cr.yaml && sleep 5 && NEW=$(kubectl get secret my-secure-app-credentials -o jsonpath='{.metadata.resourceVersion}') && [ \"$ORIG\" = \"$NEW\" ] && echo unchanged"
+ outputContains: unchanged
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: SecureApp
+ name: my-secure-app
+ namespace: default
+ count: 0
+ - kind: Secret
+ name: my-secure-app-credentials
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/full-stack-app/05-anyof/README.md b/examples/use-cases/full-stack-app/05-anyof/README.md
index f89d59d9..4ff577f3 100644
--- a/examples/use-cases/full-stack-app/05-anyof/README.md
+++ b/examples/use-cases/full-stack-app/05-anyof/README.md
@@ -115,6 +115,41 @@ 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:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Deployment ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-flex-app
+ namespace: default
+ ready: true
+
+ - name: Notify Job created when anyOf condition is met
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl patch flexapp my-flex-app --type=merge -p '{"spec":{"notify":"true"}}'
+ exitCode: 0
+ resources:
+ - kind: Job
+ name: my-flex-app-notify
+ namespace: default
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/full-stack-app/05-anyof/e2e.yaml b/examples/use-cases/full-stack-app/05-anyof/e2e.yaml
new file mode 100644
index 00000000..5348cfd4
--- /dev/null
+++ b/examples/use-cases/full-stack-app/05-anyof/e2e.yaml
@@ -0,0 +1,73 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: anyof-e2e
+ description: >
+ anyOf: 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.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: FlexApp CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: FlexApp
+ name: my-flex-app
+ namespace: default
+
+ - name: Deployment ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-flex-app
+ namespace: default
+ ready: true
+
+ - name: No notify Job in normal operation
+ after: cr-applied
+ timeout: 30s
+ resources:
+ - kind: Job
+ name: my-flex-app-notify
+ namespace: default
+ count: 0
+
+ - name: Patch notify to true
+ after: cr-applied
+ timeout: 30s
+ commands:
+ - run: kubectl patch flexapp my-flex-app --type=merge -p '{"spec":{"notify":"true"}}'
+ exitCode: 0
+
+ - name: Notify Job created after anyOf condition met
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Job
+ name: my-flex-app-notify
+ namespace: default
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: FlexApp
+ name: my-flex-app
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: my-flex-app
+ namespace: default
+ count: 0
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 b31c2ca0..13a9d116 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
@@ -2,32 +2,19 @@
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.
-**Prerequisite:** the `ManagedDatabase` from [03-cross-crd](../03-cross-crd/README.md) must be running — `FullStackApp` depends on it via `cross:`.
-
---
-## Step 1 — Apply the CRD
+## Step 1 — Apply the CRDs
```bash
kubectl apply -f crd.yaml
```
----
-
-## Step 2 — Apply the database dependency
-
-If not already running from example 03:
-
-```bash
-kubectl apply -f ../03-cross-crd/crd-database.yaml
-kubectl apply -f ../03-cross-crd/database-cr.yaml
-kubectl get manageddatabase my-app-db
-# Wait until PHASE = Ready
-```
+This registers both `ManagedDatabase` (the cross: dependency) and `FullStackApp`.
---
-## Step 3 — Validate
+## Step 2 — Validate
```bash
ork validate
@@ -44,7 +31,7 @@ Expected:
---
-## Step 4 — Start the operator
+## Step 3 — Start the operator
`--dev-server` is required — the health check calls `/health` on every reconcile:
@@ -54,7 +41,7 @@ ork run --dev-server
---
-## Step 5 — Open the Control Center
+## Step 4 — Open the Control Center
In a **separate terminal**:
@@ -67,13 +54,15 @@ Open [http://localhost:8081](http://localhost:8081). Select **full-stack-app**,
---
-## Step 6 — Apply the CR
+## Step 5 — Apply the CR
+
+`cr.yaml` includes both the `ManagedDatabase` dependency and the `FullStackApp` — one apply is enough:
```bash
kubectl apply -f cr.yaml
```
-The CR covers all five patterns in one spec:
+The FullStackApp spec covers all five patterns:
```yaml
spec:
@@ -86,7 +75,7 @@ spec:
---
-## Step 7 — Watch the lifecycle
+## Step 6 — Watch the lifecycle
Phase walks forward as each condition is satisfied:
@@ -115,7 +104,7 @@ configmap/my-app-config (DB_HOST from database CR status)
---
-## Step 8 — Status tells the full story
+## Step 7 — Status tells the full story
```bash
kubectl get fullstackapp my-app -o yaml | grep -A16 "status:"
diff --git a/examples/use-cases/full-stack-app/06-full-stack/cr.yaml b/examples/use-cases/full-stack-app/06-full-stack/cr.yaml
index 57e1ed5e..67b3a125 100644
--- a/examples/use-cases/full-stack-app/06-full-stack/cr.yaml
+++ b/examples/use-cases/full-stack-app/06-full-stack/cr.yaml
@@ -1,8 +1,17 @@
-# 06 — full stack (apply 03a first so database is available)
+# ManagedDatabase — cross: dependency for FullStackApp
+apiVersion: advanced.orkestra.io/v1alpha1
+kind: ManagedDatabase
+metadata:
+ name: my-app-db
+ namespace: default
+spec: {}
+
+---
+# FullStackApp — all five patterns combined
apiVersion: advanced.orkestra.io/v1alpha1
kind: FullStackApp
metadata:
- name: my-app # To make it easy to reference my-app-db in 'cross'
+ name: my-app # cross: references my-app-db by name
namespace: default
spec:
image: nginx:1.25
@@ -13,4 +22,4 @@ spec:
defaultReplicas: 1
serviceUrl: "http://localhost:9999"
environment: "production"
- notify: "true"
\ No newline at end of file
+ notify: "true"
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 aab7a61c..f62cc2a7 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,3 +1,44 @@
+# ManagedDatabase — dependency for the cross: read in FullStackApp
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: manageddatabases.advanced.orkestra.io
+spec:
+ group: advanced.orkestra.io
+ scope: Namespaced
+ names:
+ plural: manageddatabases
+ singular: manageddatabase
+ kind: ManagedDatabase
+ shortNames: [mdb]
+ versions:
+ - name: v1alpha1
+ served: true
+ storage: true
+ subresources:
+ status: {}
+ additionalPrinterColumns:
+ - name: Phase
+ type: string
+ jsonPath: .status.phase
+ - name: Endpoint
+ type: string
+ jsonPath: .status.endpoint
+ - name: Age
+ type: date
+ jsonPath: .metadata.creationTimestamp
+ schema:
+ openAPIV3Schema:
+ type: object
+ properties:
+ spec:
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ status:
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+
+---
# FullStackApp — combines forEach + external + cross + once + anyOf
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
diff --git a/examples/use-cases/full-stack-app/e2e.yaml b/examples/use-cases/full-stack-app/e2e.yaml
new file mode 100644
index 00000000..1ccced5d
--- /dev/null
+++ b/examples/use-cases/full-stack-app/e2e.yaml
@@ -0,0 +1,15 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+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.
+ 02-external-gate and 06-full-stack require a running dev server and are
+ excluded from the automated suite.
+
+imports:
+ - ./01-multi-region/e2e.yaml
+ - ./03-cross-crd/e2e.yaml
+ - ./04-once-secret/e2e.yaml
+ - ./05-anyof/e2e.yaml
diff --git a/examples/use-cases/multi-region-map/README.md b/examples/use-cases/multi-region-map/README.md
index e231da87..66ffc1e7 100644
--- a/examples/use-cases/multi-region-map/README.md
+++ b/examples/use-cases/multi-region-map/README.md
@@ -7,6 +7,8 @@ Here each region carries its own replica count and port — this is what map for
You have one application and you want to run it in three regions, each with its own replica count and port. Normally that means a reconciler loop in Go, building one Deployment and Service per region. Here it is a twelve-line `forEach` block in a Katalog — Orkestra expands it at reconcile time.
+The key property: **adding or changing a region requires editing only the CR** — no Katalog change, no redeployment of Orkestra. Step 6 demonstrates this live.
+
**What you learn:** `forEach` over a map field. How `.item` carries the map key (the region name) and `.value.*` carries the per-region data. How a single CR entry becomes N child resources automatically.
---
@@ -229,6 +231,45 @@ The Service block uses the same `forEach` — so each Deployment gets exactly on
---
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies the CRD, starts the operator, applies the CR, asserts all six regional resources are created with the correct replica counts, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Three Deployments created
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-multi-region-us-east-1
+ namespace: default
+ ready: true
+ - kind: Deployment
+ name: my-multi-region-eu-west-1
+ namespace: default
+ ready: true
+ - kind: Deployment
+ name: my-multi-region-ap-southeast-1
+ namespace: default
+ ready: true
+
+ - name: us-east-1 has 3 replicas
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get deployment my-multi-region-us-east-1 -o jsonpath='{.spec.replicas}'
+ outputContains: "3"
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/multi-region-map/e2e.yaml b/examples/use-cases/multi-region-map/e2e.yaml
new file mode 100644
index 00000000..cf6a50eb
--- /dev/null
+++ b/examples/use-cases/multi-region-map/e2e.yaml
@@ -0,0 +1,94 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: multi-region-map-e2e
+ description: >
+ forEach over a map field — one CR declares three regions each with its own
+ replica count and port. Verifies that six child resources are created
+ (one Deployment + one Service per region) and that replica counts match
+ the per-region declaration in the CR.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: MultiRegionApp CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: MultiRegionApp
+ name: my-multi-region
+ namespace: default
+
+ - name: Three Deployments created
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-multi-region-us-east-1
+ namespace: default
+ ready: true
+ - kind: Deployment
+ name: my-multi-region-eu-west-1
+ namespace: default
+ ready: true
+ - kind: Deployment
+ name: my-multi-region-ap-southeast-1
+ namespace: default
+ ready: true
+
+ - name: Three Services created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Service
+ name: my-multi-region-us-east-1-svc
+ namespace: default
+ - kind: Service
+ name: my-multi-region-eu-west-1-svc
+ namespace: default
+ - kind: Service
+ name: my-multi-region-ap-southeast-1-svc
+ namespace: default
+
+ - name: us-east-1 has 3 replicas
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get deployment my-multi-region-us-east-1 -o jsonpath='{.spec.replicas}'
+ outputContains: "3"
+
+ - name: eu-west-1 has 1 replica
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get deployment my-multi-region-eu-west-1 -o jsonpath='{.spec.replicas}'
+ outputContains: "1"
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: MultiRegionApp
+ name: my-multi-region
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: my-multi-region-us-east-1
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: my-multi-region-eu-west-1
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: my-multi-region-ap-southeast-1
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/multi-tenancy/01-basic-namespacing/README.md b/examples/use-cases/multi-tenancy/01-basic-namespacing/README.md
new file mode 100644
index 00000000..bf23f97b
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/01-basic-namespacing/README.md
@@ -0,0 +1,133 @@
+# Multi-tenancy 01 — Basic namespacing
+
+Two teams, one runtime. `platform-team` manages a Database CRD. `product-team` manages a Website CRD. Both Katalogs run in the same Orkestra process. The Control Center groups them into separate panels — one per namespace.
+
+**What you learn:** declaring `metadata.namespace` on a Katalog, composing namespaced Katalogs with a Komposer, observing the namespace grouping in the Control Center.
+
+---
+
+## Step 1 — Validate
+
+```bash
+ork validate
+```
+
+Expected:
+```
+● database kind: Database / group: multi-tenancy.orkestra.io / version: v1alpha1
+● website kind: Website / group: multi-tenancy.orkestra.io / version: v1alpha1
+
+2 CRDs valid
+```
+
+---
+
+## Step 2 — Apply the CRDs
+
+```bash
+kubectl apply -f crd.yaml
+```
+
+---
+
+## Step 3 — Open the Control Center
+
+In a **separate terminal**:
+
+```bash
+ork control
+# username:password → orkestra
+```
+
+Open [http://localhost:8081](http://localhost:8081).
+
+---
+
+## Step 4 — Start the operator
+
+```bash
+ork run
+```
+
+Both CRDs appear in the Control Center. Observe the **platform-team** panel showing `database` and the **product-team** panel showing `website` — separate sections, independent health.
+
+---
+
+## Step 5 — Apply the CRs
+
+```bash
+kubectl apply -f cr.yaml
+```
+
+Wait one reconcile cycle (~30s). Both CRs reach healthy state:
+
+```bash
+kubectl get databases,websites
+```
+
+```
+NAME PHASE AGE
+database.multi-tenancy.orkestra.io/main-db Running 20s
+
+NAME PHASE AGE
+website.multi-tenancy.orkestra.io/storefront Running 20s
+```
+
+Each CRD card appears under its team's panel in the Control Center. Health counts are tracked independently per namespace.
+
+---
+
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies the CRDs, starts the operator, applies both CRs, asserts every expectation, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Database Deployment ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: main-db
+ namespace: default
+ ready: true
+ - kind: Secret
+ name: main-db-creds
+ namespace: default
+
+ - name: Website Deployment ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: storefront
+ namespace: default
+ ready: true
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ name: main-db
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: storefront
+ namespace: default
+ count: 0
+```
+
+---
+
+## Cleanup
+
+```bash
+chmod +x cleanup.sh && ./cleanup.sh
+```
diff --git a/examples/use-cases/multi-tenancy/01-basic-namespacing/cleanup.sh b/examples/use-cases/multi-tenancy/01-basic-namespacing/cleanup.sh
new file mode 100755
index 00000000..4bd6f34c
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/01-basic-namespacing/cleanup.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -e
+kubectl delete -f cr.yaml --ignore-not-found
+kubectl delete -f crd.yaml --ignore-not-found
diff --git a/examples/use-cases/multi-tenancy/01-basic-namespacing/cr.yaml b/examples/use-cases/multi-tenancy/01-basic-namespacing/cr.yaml
new file mode 100644
index 00000000..4871d83a
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/01-basic-namespacing/cr.yaml
@@ -0,0 +1,19 @@
+---
+# platform-team CRD
+apiVersion: multi-tenancy.orkestra.io/v1alpha1
+kind: Database
+metadata:
+ name: main-db
+ namespace: default
+spec:
+ engine: postgres
+---
+# product-team CRD
+apiVersion: multi-tenancy.orkestra.io/v1alpha1
+kind: Website
+metadata:
+ name: storefront
+ namespace: default
+spec:
+ image: nginx:alpine
+ replicas: 1
diff --git a/examples/use-cases/multi-tenancy/01-basic-namespacing/crd.yaml b/examples/use-cases/multi-tenancy/01-basic-namespacing/crd.yaml
new file mode 100644
index 00000000..aa687d7c
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/01-basic-namespacing/crd.yaml
@@ -0,0 +1,88 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: databases.multi-tenancy.orkestra.io
+spec:
+ group: multi-tenancy.orkestra.io
+ versions:
+ - name: v1alpha1
+ served: true
+ storage: true
+ subresources:
+ status: {}
+ additionalPrinterColumns:
+ - name: Engine
+ type: string
+ jsonPath: .spec.engine
+ - name: Phase
+ type: string
+ jsonPath: .status.phase
+ - name: Age
+ type: date
+ jsonPath: .metadata.creationTimestamp
+ schema:
+ openAPIV3Schema:
+ type: object
+ properties:
+ spec:
+ type: object
+ required: [engine]
+ properties:
+ engine:
+ type: string
+ enum: [postgres, mysql, sqlite]
+ image:
+ type: string
+ status:
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ names:
+ kind: Database
+ plural: databases
+ singular: database
+ scope: Namespaced
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: websites.multi-tenancy.orkestra.io
+spec:
+ group: multi-tenancy.orkestra.io
+ versions:
+ - name: v1alpha1
+ served: true
+ storage: true
+ subresources:
+ status: {}
+ additionalPrinterColumns:
+ - name: Image
+ type: string
+ jsonPath: .spec.image
+ - name: Phase
+ type: string
+ jsonPath: .status.phase
+ - name: Age
+ type: date
+ jsonPath: .metadata.creationTimestamp
+ schema:
+ openAPIV3Schema:
+ type: object
+ properties:
+ spec:
+ type: object
+ required: [image]
+ properties:
+ image:
+ type: string
+ replicas:
+ type: integer
+ default: 1
+ status:
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ names:
+ kind: Website
+ plural: websites
+ singular: website
+ scope: Namespaced
diff --git a/examples/use-cases/multi-tenancy/01-basic-namespacing/e2e.yaml b/examples/use-cases/multi-tenancy/01-basic-namespacing/e2e.yaml
new file mode 100644
index 00000000..c5ebcfdc
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/01-basic-namespacing/e2e.yaml
@@ -0,0 +1,98 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: multi-tenancy-basic-e2e
+ description: >
+ Two namespaced Katalogs in one runtime. platform-team manages a Database CRD;
+ product-team manages a Website CRD. Verifies both CRs reconcile independently
+ and their child resources are created under the correct namespace grouping.
+
+spec:
+ katalog: ./komposer.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Database CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Database
+ name: main-db
+ namespace: default
+
+ - name: Website CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Website
+ name: storefront
+ namespace: default
+
+ - name: Database Deployment ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: main-db
+ namespace: default
+ ready: true
+
+ - name: Database Secret created (once:)
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Secret
+ name: main-db-creds
+ namespace: default
+
+ - name: Database Service created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Service
+ name: main-db
+ namespace: default
+
+ - name: Website Deployment ready
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: storefront
+ namespace: default
+ ready: true
+
+ - name: Website Service created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Service
+ name: storefront-svc
+ namespace: default
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Database
+ name: main-db
+ namespace: default
+ count: 0
+ - kind: Website
+ name: storefront
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: main-db
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: storefront
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/multi-tenancy/01-basic-namespacing/komposer.yaml b/examples/use-cases/multi-tenancy/01-basic-namespacing/komposer.yaml
new file mode 100644
index 00000000..53113780
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/01-basic-namespacing/komposer.yaml
@@ -0,0 +1,15 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: Komposer
+metadata:
+ name: multi-tenant-basic
+ description: >
+ Composes the platform and product Katalogs into a single runtime.
+ Each Katalog keeps its own namespace — the Control Center renders separate panels.
+
+imports:
+ files:
+ - ./platform-team/katalog.yaml
+ - ./product-team/katalog.yaml
+
+spec:
+ crds: {}
diff --git a/examples/use-cases/multi-tenancy/01-basic-namespacing/platform-team/katalog.yaml b/examples/use-cases/multi-tenancy/01-basic-namespacing/platform-team/katalog.yaml
new file mode 100644
index 00000000..f2361d7f
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/01-basic-namespacing/platform-team/katalog.yaml
@@ -0,0 +1,60 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: Katalog
+metadata:
+ name: platform
+ namespace: platform-team
+ description: >
+ Platform team Katalog — namespace: platform-team.
+ Manages a Database CRD. The Control Center groups this Katalog under
+ the "platform-team" panel, separate from other teams in the same runtime.
+
+spec:
+ crds:
+ database:
+ apiTypes:
+ group: multi-tenancy.orkestra.io
+ version: v1alpha1
+ kind: Database
+ plural: databases
+
+ workers: 2
+ resync: 30s
+
+ operatorBox:
+ status:
+ fields:
+ - path: phase
+ value: "Running"
+ - path: endpoint
+ value: "{{ .metadata.name }}.{{ .metadata.namespace }}.svc:5432"
+
+ onCreate:
+ secrets:
+ - name: "{{ .metadata.name }}-creds"
+ once: true
+ rotateAfter: 90d
+ data:
+ password: "{{ randomAlphanumeric 16 }}"
+
+ services:
+ - name: "{{ .metadata.name }}"
+ port: "5432"
+ targetPort: "5432"
+ reconcile: true
+
+ deployments:
+ - name: "{{ .metadata.name }}"
+ image: '{{ default "postgres:15" .spec.image }}'
+ replicas: "1"
+ port: "5432"
+ env:
+ - name: POSTGRES_DB
+ value: "{{ .metadata.name }}"
+ - name: POSTGRES_USER
+ value: "{{ .metadata.name }}-user"
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: "{{ .metadata.name }}-creds"
+ key: password
+ reconcile: true
diff --git a/examples/use-cases/multi-tenancy/01-basic-namespacing/product-team/katalog.yaml b/examples/use-cases/multi-tenancy/01-basic-namespacing/product-team/katalog.yaml
new file mode 100644
index 00000000..3f9117c5
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/01-basic-namespacing/product-team/katalog.yaml
@@ -0,0 +1,43 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: Katalog
+metadata:
+ name: product
+ namespace: product-team
+ description: >
+ Product team Katalog — namespace: product-team.
+ Manages a Website CRD. Runs in the same Orkestra runtime as the platform
+ team but the Control Center shows it under a separate "product-team" panel.
+
+spec:
+ crds:
+ website:
+ apiTypes:
+ group: multi-tenancy.orkestra.io
+ version: v1alpha1
+ kind: Website
+ plural: websites
+
+ workers: 2
+ resync: 15s
+
+ operatorBox:
+ status:
+ fields:
+ - path: phase
+ value: "Running"
+ - path: url
+ value: "http://{{ .metadata.name }}-svc.{{ .metadata.namespace }}.svc.cluster.local"
+
+ onCreate:
+ deployments:
+ - name: "{{ .metadata.name }}"
+ image: "{{ .spec.image }}"
+ replicas: '{{ default "1" .spec.replicas }}'
+ port: "8080"
+ reconcile: true
+
+ services:
+ - name: "{{ .metadata.name }}-svc"
+ port: "80"
+ targetPort: "8080"
+ reconcile: true
diff --git a/examples/use-cases/multi-tenancy/02-cross-access-control/README.md b/examples/use-cases/multi-tenancy/02-cross-access-control/README.md
new file mode 100644
index 00000000..51864f4f
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/02-cross-access-control/README.md
@@ -0,0 +1,123 @@
+# Multi-tenancy 02 — Cross-read access control
+
+`internal-team` declares `crossAccess: false` at the Katalog level — no other team can read any of its CRDs via `cross:`. The `ledger` CRD overrides back to `crossAccess: true` at the CRD level. `analytics-team` reads `ledger` successfully, but its `cross:` reference to `payment` returns `found: "false"` silently.
+
+**What you learn:** Katalog-level `crossAccess: false`, CRD-level override, graceful degradation when a cross read is denied.
+
+---
+
+## Step 1 — Validate
+
+```bash
+ork validate
+```
+
+Expected:
+```
+● payment kind: Payment / group: multi-tenancy.orkestra.io / version: v1alpha1
+● ledger kind: Ledger / group: multi-tenancy.orkestra.io / version: v1alpha1
+● report kind: Report / group: multi-tenancy.orkestra.io / version: v1alpha1
+
+3 CRDs valid
+```
+
+---
+
+## Step 2 — Apply the CRDs
+
+```bash
+kubectl apply -f crd.yaml
+```
+
+---
+
+## Step 3 — Open the Control Center
+
+In a **separate terminal**:
+
+```bash
+ork control
+# username:password → orkestra
+```
+
+Open [http://localhost:8081](http://localhost:8081).
+
+---
+
+## Step 4 — Start the operator
+
+```bash
+ork run
+```
+
+Two namespace panels appear: **internal-team** (payment, ledger) and **analytics-team** (report).
+
+---
+
+## Step 5 — Apply the CRs
+
+```bash
+kubectl apply -f cr.yaml
+```
+
+Wait one reconcile cycle. Check the Report status:
+
+```bash
+kubectl get report daily-summary -o yaml | grep -A5 "status:"
+```
+
+Expected:
+```yaml
+status:
+ phase: ready
+ ledgerPhase: running
+```
+
+The Report reads `ledger` data (allowed) and reflects it in status. It cannot read `payment` — `cross.paymentState.found` returns `"false"` and the Report reconciles gracefully without it.
+
+---
+
+## Step 6 — Confirm payment is blocked
+
+```bash
+kubectl get payment checkout -o yaml | grep -A5 "status:"
+```
+
+The `report` CRD has no access to payment state. Its ConfigMap is created only from ledger data.
+
+---
+
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies the CRDs, starts the operator, applies all CRs, asserts access control behaviour, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Report reaches ready phase
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get report daily-summary -o jsonpath='{.status.phase}'
+ outputContains: ready
+
+ - name: Report status reflects ledger data
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get report daily-summary -o jsonpath='{.status.ledgerPhase}'
+ outputContains: running
+```
+
+---
+
+## Cleanup
+
+```bash
+chmod +x cleanup.sh && ./cleanup.sh
+```
diff --git a/examples/use-cases/multi-tenancy/02-cross-access-control/analytics-team/katalog.yaml b/examples/use-cases/multi-tenancy/02-cross-access-control/analytics-team/katalog.yaml
new file mode 100644
index 00000000..5d43f1d6
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/02-cross-access-control/analytics-team/katalog.yaml
@@ -0,0 +1,47 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: Katalog
+metadata:
+ name: analytics
+ namespace: analytics-team
+ description: >
+ Analytics team — reads ledger state via cross: (allowed, crossAccess: true).
+ Cannot read payment (closed by Katalog default crossAccess: false).
+
+spec:
+ crds:
+ report:
+ apiTypes:
+ group: multi-tenancy.orkestra.io
+ version: v1alpha1
+ kind: Report
+ plural: reports
+
+ workers: 2
+ resync: 60s
+
+ operatorBox:
+ cross:
+ - crd: ledger
+ as: ledgerState
+ selector:
+ name: "{{ .spec.ledgerName }}"
+ namespace: "{{ .metadata.namespace }}"
+
+ status:
+ fields:
+ - path: phase
+ value: '{{ eqTernary .cross.ledgerState.found "true" "Ready" "Waiting" }}'
+ - path: ledgerPhase
+ value: "{{ .cross.ledgerState.status.phase }}"
+
+ onCreate:
+ configMaps:
+ - name: "{{ .metadata.name }}-report"
+ when:
+ - field: cross.ledgerState.found
+ operator: eq
+ value: "true"
+ data:
+ ledgerPhase: "{{ .cross.ledgerState.status.phase }}"
+ entryCount: "{{ .cross.ledgerState.status.entryCount }}"
+ reconcile: true
diff --git a/examples/use-cases/multi-tenancy/02-cross-access-control/cleanup.sh b/examples/use-cases/multi-tenancy/02-cross-access-control/cleanup.sh
new file mode 100755
index 00000000..4bd6f34c
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/02-cross-access-control/cleanup.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -e
+kubectl delete -f cr.yaml --ignore-not-found
+kubectl delete -f crd.yaml --ignore-not-found
diff --git a/examples/use-cases/multi-tenancy/02-cross-access-control/cr.yaml b/examples/use-cases/multi-tenancy/02-cross-access-control/cr.yaml
new file mode 100644
index 00000000..d3e36df8
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/02-cross-access-control/cr.yaml
@@ -0,0 +1,26 @@
+---
+# internal-team CRDs
+apiVersion: multi-tenancy.orkestra.io/v1alpha1
+kind: Payment
+metadata:
+ name: checkout
+ namespace: default
+spec:
+ amount: "99.99"
+---
+apiVersion: multi-tenancy.orkestra.io/v1alpha1
+kind: Ledger
+metadata:
+ name: main-ledger
+ namespace: default
+spec:
+ initialEntries: 0
+---
+# analytics-team CRD — reads ledger (allowed), cannot read payment (blocked)
+apiVersion: multi-tenancy.orkestra.io/v1alpha1
+kind: Report
+metadata:
+ name: daily-summary
+ namespace: default
+spec:
+ ledgerName: main-ledger
diff --git a/examples/use-cases/multi-tenancy/02-cross-access-control/crd.yaml b/examples/use-cases/multi-tenancy/02-cross-access-control/crd.yaml
new file mode 100644
index 00000000..cb807bd0
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/02-cross-access-control/crd.yaml
@@ -0,0 +1,116 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: payments.multi-tenancy.orkestra.io
+spec:
+ group: multi-tenancy.orkestra.io
+ versions:
+ - name: v1alpha1
+ served: true
+ storage: true
+ subresources:
+ status: {}
+ additionalPrinterColumns:
+ - name: Phase
+ type: string
+ jsonPath: .status.phase
+ - name: Age
+ type: date
+ jsonPath: .metadata.creationTimestamp
+ schema:
+ openAPIV3Schema:
+ type: object
+ properties:
+ spec:
+ type: object
+ properties:
+ amount:
+ type: string
+ status:
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ names:
+ kind: Payment
+ plural: payments
+ singular: payment
+ scope: Namespaced
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: ledgers.multi-tenancy.orkestra.io
+spec:
+ group: multi-tenancy.orkestra.io
+ versions:
+ - name: v1alpha1
+ served: true
+ storage: true
+ subresources:
+ status: {}
+ additionalPrinterColumns:
+ - name: Phase
+ type: string
+ jsonPath: .status.phase
+ - name: Entries
+ type: string
+ jsonPath: .status.entryCount
+ - name: Age
+ type: date
+ jsonPath: .metadata.creationTimestamp
+ schema:
+ openAPIV3Schema:
+ type: object
+ properties:
+ spec:
+ type: object
+ properties:
+ initialEntries:
+ type: integer
+ default: 0
+ status:
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ names:
+ kind: Ledger
+ plural: ledgers
+ singular: ledger
+ scope: Namespaced
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: reports.multi-tenancy.orkestra.io
+spec:
+ group: multi-tenancy.orkestra.io
+ versions:
+ - name: v1alpha1
+ served: true
+ storage: true
+ subresources:
+ status: {}
+ additionalPrinterColumns:
+ - name: Phase
+ type: string
+ jsonPath: .status.phase
+ - name: Age
+ type: date
+ jsonPath: .metadata.creationTimestamp
+ schema:
+ openAPIV3Schema:
+ type: object
+ properties:
+ spec:
+ type: object
+ required: [ledgerName]
+ properties:
+ ledgerName:
+ type: string
+ status:
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ names:
+ kind: Report
+ plural: reports
+ singular: report
+ scope: Namespaced
diff --git a/examples/use-cases/multi-tenancy/02-cross-access-control/e2e.yaml b/examples/use-cases/multi-tenancy/02-cross-access-control/e2e.yaml
new file mode 100644
index 00000000..64d81ae4
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/02-cross-access-control/e2e.yaml
@@ -0,0 +1,73 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: cross-access-control-e2e
+ description: >
+ internal-team closes all CRDs with crossAccess: false at the Katalog level.
+ ledger overrides to crossAccess: true at the CRD level.
+ analytics-team's Report reads ledger data (allowed) and cannot read payment (blocked).
+ Verifies graceful degradation when a cross read is denied.
+
+spec:
+ katalog: ./komposer.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: All CRs created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Payment
+ name: checkout
+ namespace: default
+ - kind: Ledger
+ name: main-ledger
+ namespace: default
+ - kind: Report
+ name: daily-summary
+ namespace: default
+
+ - name: Report reaches ready phase (reads ledger, skips payment gracefully)
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get report daily-summary -o jsonpath='{.status.phase}'
+ outputContains: ready
+
+ - name: Report status reflects ledger data
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get report daily-summary -o jsonpath='{.status.ledgerPhase}'
+ outputContains: running
+
+ - name: Report ConfigMap created from ledger data only
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: ConfigMap
+ name: daily-summary-report
+ namespace: default
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Payment
+ name: checkout
+ namespace: default
+ count: 0
+ - kind: Ledger
+ name: main-ledger
+ namespace: default
+ count: 0
+ - kind: Report
+ name: daily-summary
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/multi-tenancy/02-cross-access-control/internal-team/katalog.yaml b/examples/use-cases/multi-tenancy/02-cross-access-control/internal-team/katalog.yaml
new file mode 100644
index 00000000..6c3e2007
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/02-cross-access-control/internal-team/katalog.yaml
@@ -0,0 +1,64 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: Katalog
+metadata:
+ name: internal
+ namespace: internal-team
+ description: >
+ Internal team — crossAccess: false closes all CRDs to cross reads by default.
+ The ledger CRD overrides back to crossAccess: true at the CRD level.
+
+crossAccess: false
+
+spec:
+ crds:
+ payment:
+ apiTypes:
+ group: multi-tenancy.orkestra.io
+ version: v1alpha1
+ kind: Payment
+ plural: payments
+
+ workers: 3
+ resync: 10s
+
+ operatorBox:
+ status:
+ fields:
+ - path: phase
+ value: "active"
+
+ onCreate:
+ deployments:
+ - name: "{{ .metadata.name }}-processor"
+ image: "nginx:alpine"
+ replicas: "2"
+ port: "8080"
+ reconcile: true
+
+ ledger:
+ apiTypes:
+ group: multi-tenancy.orkestra.io
+ version: v1alpha1
+ kind: Ledger
+ plural: ledgers
+
+ workers: 2
+ resync: 30s
+
+ crossAccess: true
+
+ operatorBox:
+ status:
+ fields:
+ - path: phase
+ value: "Running"
+ - path: entryCount
+ value: '{{ default "0" .spec.initialEntries }}'
+
+ onCreate:
+ deployments:
+ - name: "{{ .metadata.name }}-ledger"
+ image: "nginx:alpine"
+ replicas: "1"
+ port: "8080"
+ reconcile: true
diff --git a/examples/use-cases/multi-tenancy/02-cross-access-control/komposer.yaml b/examples/use-cases/multi-tenancy/02-cross-access-control/komposer.yaml
new file mode 100644
index 00000000..d2243e3b
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/02-cross-access-control/komposer.yaml
@@ -0,0 +1,15 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: Komposer
+metadata:
+ name: multi-tenant-access-control
+ description: >
+ Composes the internal and analytics Katalogs.
+ internal-team closes cross reads at Katalog level; ledger overrides to open.
+
+imports:
+ files:
+ - ./internal-team/katalog.yaml
+ - ./analytics-team/katalog.yaml
+
+spec:
+ crds: {}
diff --git a/examples/use-cases/multi-tenancy/03-shared-platform/README.md b/examples/use-cases/multi-tenancy/03-shared-platform/README.md
new file mode 100644
index 00000000..cb308f0b
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/03-shared-platform/README.md
@@ -0,0 +1,126 @@
+# Multi-tenancy 03 — Shared platform
+
+`platform` manages shared infrastructure CRDs (Cache, Queue). `team-a` manages an Api CRD that reads cache and queue endpoints via `cross:` and injects them as environment variables into its Deployment. The Deployment is only created once both platform CRs are healthy.
+
+**What you learn:** cross reads between namespaces, readiness gating on shared infrastructure, `eqTernary` for string-based status branching.
+
+---
+
+## Step 1 — Validate
+
+```bash
+ork validate
+```
+
+Expected:
+```
+● cache kind: Cache / group: multi-tenancy.orkestra.io / version: v1alpha1
+● queue kind: Queue / group: multi-tenancy.orkestra.io / version: v1alpha1
+● api kind: Api / group: multi-tenancy.orkestra.io / version: v1alpha1
+
+3 CRDs valid
+```
+
+---
+
+## Step 2 — Apply the CRDs
+
+```bash
+kubectl apply -f crd.yaml
+```
+
+---
+
+## Step 3 — Open the Control Center
+
+In a **separate terminal**:
+
+```bash
+ork control
+# username:password → orkestra
+```
+
+Open [http://localhost:8081](http://localhost:8081).
+
+---
+
+## Step 4 — Start the operator
+
+```bash
+ork run
+```
+
+Two namespace panels appear: **platform** (cache, queue) and **team-a** (api).
+
+---
+
+## Step 5 — Apply the CRs
+
+```bash
+kubectl apply -f cr.yaml
+```
+
+Watch the Api status as the platform CRs reconcile:
+
+```bash
+kubectl get apis,caches,queues -w
+```
+
+While `app-cache` and `app-queue` are reconciling, `my-api` shows `phase: waiting`. Once both platform CRs reach `phase: ready`, the Api Deployment is created and status flips to `running`.
+
+---
+
+## Step 6 — Verify env injection
+
+```bash
+kubectl get deployment my-api -o jsonpath='{.spec.template.spec.containers[0].env}' | jq .
+```
+
+Expected:
+```json
+[
+ { "name": "REDIS_URL", "value": "app-cache.default.svc.cluster.local:6379" },
+ { "name": "AMQP_URL", "value": "amqp://app-queue.default.svc.cluster.local" }
+]
+```
+
+Orkestra injected the platform endpoints from the informer cache — zero API server calls.
+
+---
+
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies the CRDs, starts the operator, applies all CRs, asserts the Api Deployment is created after platform CRs are healthy, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Api Deployment ready (gated on Cache and Queue)
+ after: cr-applied
+ timeout: 120s
+ resources:
+ - kind: Deployment
+ name: my-api
+ namespace: default
+ ready: true
+
+ - name: Api Deployment has REDIS_URL env var
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get deployment my-api -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="REDIS_URL")].value}'
+ outputContains: app-cache
+```
+
+---
+
+## Cleanup
+
+```bash
+chmod +x cleanup.sh && ./cleanup.sh
+```
diff --git a/examples/use-cases/multi-tenancy/03-shared-platform/cleanup.sh b/examples/use-cases/multi-tenancy/03-shared-platform/cleanup.sh
new file mode 100755
index 00000000..4bd6f34c
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/03-shared-platform/cleanup.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -e
+kubectl delete -f cr.yaml --ignore-not-found
+kubectl delete -f crd.yaml --ignore-not-found
diff --git a/examples/use-cases/multi-tenancy/03-shared-platform/cr.yaml b/examples/use-cases/multi-tenancy/03-shared-platform/cr.yaml
new file mode 100644
index 00000000..d7948d7b
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/03-shared-platform/cr.yaml
@@ -0,0 +1,26 @@
+---
+# platform CRDs — shared infrastructure
+apiVersion: multi-tenancy.orkestra.io/v1alpha1
+kind: Cache
+metadata:
+ name: app-cache
+ namespace: default
+spec: {}
+---
+apiVersion: multi-tenancy.orkestra.io/v1alpha1
+kind: Queue
+metadata:
+ name: app-queue
+ namespace: default
+spec: {}
+---
+# team-a CRD — reads cache and queue endpoints via cross:
+apiVersion: multi-tenancy.orkestra.io/v1alpha1
+kind: Api
+metadata:
+ name: my-api
+ namespace: default
+spec:
+ image: nginx:alpine
+ cacheName: app-cache
+ queueName: app-queue
diff --git a/examples/use-cases/multi-tenancy/03-shared-platform/crd.yaml b/examples/use-cases/multi-tenancy/03-shared-platform/crd.yaml
new file mode 100644
index 00000000..4394f9bc
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/03-shared-platform/crd.yaml
@@ -0,0 +1,143 @@
+# API CRD
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: apis.multi-tenancy.orkestra.io
+spec:
+ group: multi-tenancy.orkestra.io
+ scope: Namespaced
+ names:
+ plural: apis
+ singular: api
+ kind: Api
+ shortNames:
+ - api
+
+ versions:
+ - name: v1alpha1
+ served: true
+ storage: true
+
+ schema:
+ openAPIV3Schema:
+ type: object
+ properties:
+ spec:
+ type: object
+ properties:
+ image:
+ type: string
+ replicas:
+ type: integer
+ cacheName:
+ type: string
+ queueName:
+ type: string
+ required:
+ - image
+ - cacheName
+ - queueName
+
+ status:
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ properties:
+ phase:
+ type: string
+
+ subresources:
+ status: {}
+---
+# Cache CRD
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: caches.multi-tenancy.orkestra.io
+spec:
+ group: multi-tenancy.orkestra.io
+ scope: Namespaced
+ names:
+ plural: caches
+ singular: cache
+ kind: Cache
+ shortNames:
+ - cache
+
+ versions:
+ - name: v1alpha1
+ served: true
+ storage: true
+
+ schema:
+ openAPIV3Schema:
+ type: object
+ properties:
+ spec:
+ type: object
+ properties:
+ image:
+ type: string
+ default: redis:7-alpine
+ replicas:
+ type: integer
+ default: 1
+
+
+ status:
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ properties:
+ phase:
+ type: string
+ endpoint:
+ type: string
+
+ subresources:
+ status: {}
+
+---
+# Queue CRD
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: queues.multi-tenancy.orkestra.io
+spec:
+ group: multi-tenancy.orkestra.io
+ scope: Namespaced
+ names:
+ plural: queues
+ singular: queue
+ kind: Queue
+ shortNames:
+ - queue
+
+ versions:
+ - name: v1alpha1
+ served: true
+ storage: true
+
+ schema:
+ openAPIV3Schema:
+ type: object
+ properties:
+ spec:
+ type: object
+ properties:
+ image:
+ type: string
+ default: rabbitmq:3-alpine
+ replicas:
+ type: integer
+ default: 1
+
+ status:
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ properties:
+ phase:
+ type: string
+ amqpEndpoint:
+ type: string
+
+ subresources:
+ status: {}
diff --git a/examples/use-cases/multi-tenancy/03-shared-platform/e2e.yaml b/examples/use-cases/multi-tenancy/03-shared-platform/e2e.yaml
new file mode 100644
index 00000000..8016a179
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/03-shared-platform/e2e.yaml
@@ -0,0 +1,75 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: shared-platform-e2e
+ description: >
+ platform namespace manages Cache and Queue CRDs. team-a's Api reads their endpoints
+ via cross: and injects them as env vars into its Deployment.
+ Verifies the Api Deployment is created only after both platform CRs are healthy,
+ and that env vars carry the correct cluster-local endpoints.
+
+spec:
+ katalog: ./komposer.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Platform CRs created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Cache
+ name: app-cache
+ namespace: default
+ - kind: Queue
+ name: app-queue
+ namespace: default
+
+ - name: Api CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Api
+ name: my-api
+ namespace: default
+
+ - name: Api Deployment ready (gated on Cache and Queue)
+ after: cr-applied
+ timeout: 120s
+ resources:
+ - kind: Deployment
+ name: my-api
+ namespace: default
+ ready: true
+
+ - name: Api Deployment has REDIS_URL env var
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get deployment my-api -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="REDIS_URL")].value}'
+ outputContains: app-cache
+
+ - name: Api Deployment has AMQP_URL env var
+ after: cr-applied
+ timeout: 90s
+ commands:
+ - run: kubectl get deployment my-api -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="AMQP_URL")].value}'
+ outputContains: app-queue
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Api
+ name: my-api
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: my-api
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/multi-tenancy/03-shared-platform/komposer.yaml b/examples/use-cases/multi-tenancy/03-shared-platform/komposer.yaml
new file mode 100644
index 00000000..720004ad
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/03-shared-platform/komposer.yaml
@@ -0,0 +1,15 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: Komposer
+metadata:
+ name: shared-platform-full
+ description: >
+ Platform infra CRDs + team-a application CRDs.
+ Control Center shows two namespace panels: platform, team-a.
+
+imports:
+ files:
+ - ./platform/katalog.yaml
+ - ./team-a/katalog.yaml
+
+spec:
+ crds: {}
diff --git a/examples/use-cases/multi-tenancy/03-shared-platform/platform/katalog.yaml b/examples/use-cases/multi-tenancy/03-shared-platform/platform/katalog.yaml
new file mode 100644
index 00000000..710499ca
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/03-shared-platform/platform/katalog.yaml
@@ -0,0 +1,73 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: Katalog
+metadata:
+ name: shared-platform
+ namespace: platform
+ description: >
+ Shared platform — cache and queue CRDs readable by all teams (crossAccess defaults to true).
+
+spec:
+ crds:
+ cache:
+ apiTypes:
+ group: multi-tenancy.orkestra.io
+ version: v1alpha1
+ kind: Cache
+ plural: caches
+
+ workers: 2
+ resync: 30s
+
+ operatorBox:
+ status:
+ fields:
+ - path: phase
+ value: "ready"
+ - path: endpoint
+ value: "{{ .metadata.name }}.{{ .metadata.namespace }}.svc.cluster.local:6379"
+
+ onCreate:
+ deployments:
+ - name: "{{ .metadata.name }}-redis"
+ image: "redis:7-alpine"
+ replicas: "1"
+ port: "6379"
+ reconcile: true
+
+ services:
+ - name: "{{ .metadata.name }}"
+ port: "6379"
+ targetPort: "6379"
+ reconcile: true
+
+ queue:
+ apiTypes:
+ group: multi-tenancy.orkestra.io
+ version: v1alpha1
+ kind: Queue
+ plural: queues
+
+ workers: 2
+ resync: 30s
+
+ operatorBox:
+ status:
+ fields:
+ - path: phase
+ value: "Ready"
+ - path: amqpEndpoint
+ value: "amqp://{{ .metadata.name }}.{{ .metadata.namespace }}.svc.cluster.local"
+
+ onCreate:
+ deployments:
+ - name: "{{ .metadata.name }}-broker"
+ image: "rabbitmq:3-alpine"
+ replicas: "1"
+ port: "5672"
+ reconcile: true
+
+ services:
+ - name: "{{ .metadata.name }}"
+ port: "5672"
+ targetPort: "5672"
+ reconcile: true
diff --git a/examples/use-cases/multi-tenancy/03-shared-platform/team-a/katalog.yaml b/examples/use-cases/multi-tenancy/03-shared-platform/team-a/katalog.yaml
new file mode 100644
index 00000000..f631532c
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/03-shared-platform/team-a/katalog.yaml
@@ -0,0 +1,62 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: Katalog
+metadata:
+ name: team-a
+ namespace: team-a
+ description: >
+ Team A — reads shared cache and queue endpoints via cross: and
+ injects them as environment variables into the API deployment.
+
+spec:
+ crds:
+ api:
+ apiTypes:
+ group: multi-tenancy.orkestra.io
+ version: v1alpha1
+ kind: Api
+ plural: apis
+
+ workers: 2
+ resync: 20s
+
+ operatorBox:
+ cross:
+ - crd: cache
+ as: sharedCache
+ selector:
+ name: "{{ .spec.cacheName }}"
+ namespace: "{{ .metadata.namespace }}"
+ - crd: queue
+ as: sharedQueue
+ selector:
+ name: "{{ .spec.queueName }}"
+ namespace: "{{ .metadata.namespace }}"
+
+ status:
+ fields:
+ - path: phase
+ value: '{{ boolTernary (and (eq .cross.sharedCache.found "true") (eq .cross.sharedQueue.found "true")) "Running" "Waiting" }}'
+
+ onCreate:
+ deployments:
+ - name: "{{ .metadata.name }}"
+ image: "{{ .spec.image }}"
+ replicas: '{{ default "1" .spec.replicas }}'
+ port: "8080"
+ when:
+ - field: cross.sharedCache.found
+ equals: "true"
+ - field: cross.sharedQueue.found
+ equals: "true"
+ env:
+ - name: REDIS_URL
+ value: "{{ .cross.sharedCache.status.endpoint }}"
+ - name: AMQP_URL
+ value: "{{ .cross.sharedQueue.status.amqpEndpoint }}"
+ reconcile: true
+
+ services:
+ - name: "{{ .metadata.name }}-svc"
+ port: "80"
+ targetPort: "8080"
+ reconcile: true
diff --git a/examples/use-cases/multi-tenancy/README.md b/examples/use-cases/multi-tenancy/README.md
new file mode 100644
index 00000000..994acd3d
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/README.md
@@ -0,0 +1,54 @@
+# Multi-tenancy
+
+Three focused examples showing how Katalog namespaces scope CRDs by team within a single runtime and how `crossAccess` controls which teams can read each other's CR state.
+
+| Example | What it teaches |
+|---|---|
+| [01 — Basic namespacing](01-basic-namespacing/README.md) | Two teams, one runtime — Control Center renders a separate panel per namespace |
+| [02 — Cross-read access control](02-cross-access-control/README.md) | `crossAccess: false` closes a Katalog; one CRD overrides back to open |
+| [03 — Shared platform](03-shared-platform/README.md) | Platform infra CRDs expose endpoints; application teams read them via `cross:` |
+
+Each example is self-contained with its own `crd.yaml`, `cr.yaml`, `komposer.yaml`, and `cleanup.sh`. Run any example in isolation from its subfolder, or run them all at once with the root `komposer.yaml`:
+
+## Run all at once
+
+### Change to the root Directory
+
+```bash
+cd multi-tenancy
+```
+
+### Apply the CRDs
+
+```bash
+kubectl apply -f 01-basic-namespacing/crd.yaml
+kubectl apply -f 02-cross-access-control/crd.yaml
+kubectl apply -f 03-shared-platform/crd.yaml
+```
+
+### Apply the CRs
+
+```bash
+kubectl apply -f 01-basic-namespacing/cr.yaml
+kubectl apply -f 02-cross-access-control/cr.yaml
+kubectl apply -f 03-shared-platform/cr.yaml
+```
+
+### Validate
+
+```bash
+ork validate
+```
+
+### Run Orkestra and start Control Center
+
+```bash
+ork run
+
+ork control
+```
+
+
+---
+
+**Further reading:** [Multi-tenancy concept doc](https://orkestra.sh/docs/concepts/operatorbox/multi-tenancy/)
diff --git a/examples/use-cases/multi-tenancy/e2e.yaml b/examples/use-cases/multi-tenancy/e2e.yaml
new file mode 100644
index 00000000..10236d7c
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/e2e.yaml
@@ -0,0 +1,13 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: multi-tenancy-suite
+ description: >
+ Suite for the multi-tenancy use-case track. Runs all three sub-examples
+ in the same cluster — basic namespacing, cross-access control, and
+ shared platform services.
+
+imports:
+ - ./01-basic-namespacing/e2e.yaml
+ - ./02-cross-access-control/e2e.yaml
+ - ./03-shared-platform/e2e.yaml
diff --git a/examples/use-cases/multi-tenancy/komposer.yaml b/examples/use-cases/multi-tenancy/komposer.yaml
new file mode 100644
index 00000000..f12316ac
--- /dev/null
+++ b/examples/use-cases/multi-tenancy/komposer.yaml
@@ -0,0 +1,18 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: Komposer
+metadata:
+ name: multi-tenancy-all
+ description: >
+ Root Komposer — imports all three multi-tenancy examples into one runtime.
+
+imports:
+ files:
+ - ./01-basic-namespacing/platform-team/katalog.yaml
+ - ./01-basic-namespacing/product-team/katalog.yaml
+ - ./02-cross-access-control/internal-team/katalog.yaml
+ - ./02-cross-access-control/analytics-team/katalog.yaml
+ - ./03-shared-platform/platform/katalog.yaml
+ - ./03-shared-platform/team-a/katalog.yaml
+
+spec:
+ crds: {}
diff --git a/examples/use-cases/normalize/01-string-cleanup/README.md b/examples/use-cases/normalize/01-string-cleanup/README.md
index 498a6d04..8c642ea4 100644
--- a/examples/use-cases/normalize/01-string-cleanup/README.md
+++ b/examples/use-cases/normalize/01-string-cleanup/README.md
@@ -123,6 +123,35 @@ Both CRs reconcile to the same ConfigMap shape. The operator never knew which fo
---
+## E2E
+
+Run the full lifecycle in one command — spins up a kind cluster, applies the CRD, starts the operator, applies the messy CR, asserts normalized values in status and ConfigMap, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Status shows normalized name (lowercase, trimmed)
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get tenant acme -o jsonpath='{.status.name}'
+ outputContains: acme corp
+
+ - name: Status shows normalized domain (protocol stripped)
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get tenant acme -o jsonpath='{.status.domain}'
+ outputContains: acme.example.com
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/normalize/01-string-cleanup/e2e.yaml b/examples/use-cases/normalize/01-string-cleanup/e2e.yaml
new file mode 100644
index 00000000..e9d8190b
--- /dev/null
+++ b/examples/use-cases/normalize/01-string-cleanup/e2e.yaml
@@ -0,0 +1,63 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: normalize-string-cleanup-e2e
+ description: >
+ normalize: string rules — toLower, trimSpace, domain stripping.
+ Verifies that messy inputs (mixed case, whitespace, protocol prefix) are
+ normalised before any resource is written, and that the ConfigMap and status
+ reflect the canonical values.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr-messy.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Tenant CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Tenant
+ name: acme
+ namespace: default
+
+ - name: ConfigMap created with normalized values
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: ConfigMap
+ name: acme-config
+ namespace: default
+
+ - name: Status shows normalized name (lowercase, trimmed)
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get tenant acme -o jsonpath='{.status.name}'
+ outputContains: acme corp
+
+ - name: Status shows normalized domain (protocol stripped)
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get tenant acme -o jsonpath='{.status.domain}'
+ outputContains: acme.example.com
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: Tenant
+ name: acme
+ namespace: default
+ count: 0
+ - kind: ConfigMap
+ name: acme-config
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/normalize/02-image-normalization/README.md b/examples/use-cases/normalize/02-image-normalization/README.md
index 00b087a8..7f6fcb84 100644
--- a/examples/use-cases/normalize/02-image-normalization/README.md
+++ b/examples/use-cases/normalize/02-image-normalization/README.md
@@ -117,6 +117,28 @@ The Deployment template uses `image: "{{ .spec.image }}"` — one expression, th
---
+## E2E
+
+Run the full lifecycle in one command — applies the bare CR, asserts the normalized image (registry + tag added) appears in status, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Status image has registry and tag added
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get app app-bare -o jsonpath='{.status.image}'
+ outputContains: registry.internal/nginx
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/normalize/02-image-normalization/e2e.yaml b/examples/use-cases/normalize/02-image-normalization/e2e.yaml
new file mode 100644
index 00000000..a9440673
--- /dev/null
+++ b/examples/use-cases/normalize/02-image-normalization/e2e.yaml
@@ -0,0 +1,51 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: normalize-image-e2e
+ description: >
+ normalize: image rules — registry prefix and explicit tag applied regardless
+ of what the user wrote. Verifies bare, tagged, and fully-qualified inputs
+ all produce a canonical image in status after normalization.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr-bare.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: App CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: App
+ name: app-bare
+ namespace: default
+
+ - name: Deployment created with normalized image
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ name: app-bare
+ namespace: default
+
+ - name: Status image has registry and tag added
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get app app-bare -o jsonpath='{.status.image}'
+ outputContains: registry.internal/nginx
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: App
+ name: app-bare
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/normalize/03-defaults-without-webhook/README.md b/examples/use-cases/normalize/03-defaults-without-webhook/README.md
index a54398db..0a613304 100644
--- a/examples/use-cases/normalize/03-defaults-without-webhook/README.md
+++ b/examples/use-cases/normalize/03-defaults-without-webhook/README.md
@@ -121,6 +121,35 @@ Use `mutation:` when the default needs to be stored and visible to external tool
---
+## E2E
+
+Run the full lifecycle in one command — applies the minimal CR, asserts that omitted fields received their defaults (replicas, CPU request), then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Status has defaulted replicas
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get workload api-server -o jsonpath='{.status.replicas}'
+ outputContains: "1"
+
+ - name: Deployment has defaulted resource requests
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get deployment api-server -o jsonpath='{.spec.template.spec.containers[0].resources.requests.cpu}'
+ outputContains: 100m
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/normalize/03-defaults-without-webhook/e2e.yaml b/examples/use-cases/normalize/03-defaults-without-webhook/e2e.yaml
new file mode 100644
index 00000000..354845a1
--- /dev/null
+++ b/examples/use-cases/normalize/03-defaults-without-webhook/e2e.yaml
@@ -0,0 +1,59 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: normalize-defaults-e2e
+ description: >
+ normalize: default injection without a webhook — omitted fields receive
+ their defaults before reconcile logic runs. Verifies that a minimal CR
+ (image only) produces a Deployment with the correct defaulted replica
+ count and resource requests.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr-minimal.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Workload CR created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Workload
+ name: api-server
+ namespace: default
+
+ - name: Deployment created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ name: api-server
+ namespace: default
+
+ - name: Status has defaulted replicas
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get workload api-server -o jsonpath='{.status.replicas}'
+ outputContains: "1"
+
+ - name: Status shows defaulted CPU request
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get workload api-server -o jsonpath='{.status.cpu}'
+ outputContains: 100m
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 30s
+ resources:
+ - kind: Workload
+ name: api-server
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/normalize/04-webservice/README.md b/examples/use-cases/normalize/04-webservice/README.md
index 23062edf..dd8aafb1 100644
--- a/examples/use-cases/normalize/04-webservice/README.md
+++ b/examples/use-cases/normalize/04-webservice/README.md
@@ -22,7 +22,46 @@ Expected:
---
-## Step 2 — Start the operator
+## Step 2 — Simulate (optional, no cluster needed)
+
+Run the reconciler against a fake in-memory cluster to see what normalize produces before applying to a real cluster:
+
+```bash
+ork simulate --cr cr-simple.yaml
+```
+
+```
+Simulating webservice/my-app
+
+ Cycle 1:
+ + secrets/my-app-production-acme-corp-api-key
+ + secrets/my-app-production-jwt
+ + configmaps/my-app-production-config
+ + deployments/my-app-production
+ + deployments/my-app-production-api
+ + deployments/my-app-production-worker
+ ~ status/my-app
+ Cycle 2:
+ ~ secrets/my-app-production-acme-corp-api-key
+ ~ secrets/my-app-production-jwt
+ ~ status/my-app
+ Cycle 3:
+ ~ status/my-app
+ (cycles 4–10: identical)
+
+ ✓ Steady state at cycle 4 in 350ms
+```
+
+**What this means:**
+- The resource names in Cycle 1 already contain `production` — that is `internalName` computed by normalize from the messy `cr-simple.yaml` input (`" Production "` → `production`). Normalize ran before any template was evaluated.
+- 2 secrets, 1 ConfigMap, and 3 Deployments (main + `api` + `worker` forEach backends) — all created from a single CR with bare, untidy inputs.
+- `~ secrets` in Cycle 2 — the `once:` check runs every reconcile. Orkestra sees both secrets already exist and marks them as unchanged (`~` not `+`). They will not be regenerated.
+- **Steady state at cycle 4** — one extra cycle compared to a simple operator, because `once:` takes a cycle to confirm the secret exists and skip rotation. From cycle 4 onward, no changes.
+- If there is a template error — a typo in a field name, a missing normalize rule — simulate catches it here instead of on a live cluster.
+
+---
+
+## Step 3 — Start the operator
```bash
ork run
@@ -30,7 +69,7 @@ ork run
---
-## Step 3 — Open the Control Center
+## Step 4 — Open the Control Center
In a **separate terminal**:
@@ -45,7 +84,7 @@ Select **webservice-operator**, then select the **WebService** CRD. Keep this ta
---
-## Step 4 — Apply the simple CR
+## Step 5 — Apply the simple CR
```bash
kubectl apply -f cr-simple.yaml
@@ -119,7 +158,7 @@ my-app-production-worker 0/1
---
-## Step 5 — Apply the full CR
+## Step 6 — Apply the full CR
```bash
kubectl apply -f cr-full.yaml
diff --git a/examples/use-cases/profiles/01-resource/README.md b/examples/use-cases/profiles/01-resource/README.md
index a7b8e7ab..4591a302 100644
--- a/examples/use-cases/profiles/01-resource/README.md
+++ b/examples/use-cases/profiles/01-resource/README.md
@@ -95,6 +95,39 @@ deployments:
---
+## E2E
+
+Run the full lifecycle in one command — applies the CR, asserts all eight profile Deployments are created with the correct CPU requests, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Eight profile Deployments created
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-service-tiny
+ namespace: default
+ - kind: Deployment
+ name: my-service-large
+ namespace: default
+
+ - name: Tiny profile has correct CPU request
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get deployment my-service-tiny -o jsonpath='{.spec.template.spec.containers[0].resources.requests.cpu}'
+ outputContains: 25m
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/profiles/01-resource/e2e.yaml b/examples/use-cases/profiles/01-resource/e2e.yaml
new file mode 100644
index 00000000..874c3d55
--- /dev/null
+++ b/examples/use-cases/profiles/01-resource/e2e.yaml
@@ -0,0 +1,82 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: profiles-resource-e2e
+ description: >
+ resources.profile — one CR produces eight Deployments each with different
+ CPU and memory budgets from a profile name. Verifies all eight Deployments
+ are created and that resource limits match the declared profile.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ../cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Service CR created
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get services.demo.orkestra.io my-service
+ exitCode: 0
+
+ - name: Eight profile Deployments created
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-service-tiny
+ namespace: default
+ - kind: Deployment
+ name: my-service-small
+ namespace: default
+ - kind: Deployment
+ name: my-service-medium
+ namespace: default
+ - kind: Deployment
+ name: my-service-large
+ namespace: default
+ - kind: Deployment
+ name: my-service-burst
+ namespace: default
+ - kind: Deployment
+ name: my-service-steady
+ namespace: default
+ - kind: Deployment
+ name: my-service-compute-heavy
+ namespace: default
+ - kind: Deployment
+ name: my-service-memory-heavy
+ namespace: default
+
+ - name: Tiny profile has correct CPU request
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get deployment my-service-tiny -o jsonpath='{.spec.template.spec.containers[0].resources.requests.cpu}'
+ outputContains: 25m
+
+ - name: Large profile has correct CPU request
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get deployment my-service-large -o jsonpath='{.spec.template.spec.containers[0].resources.requests.cpu}'
+ outputContains: 500m
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ name: my-service-tiny
+ namespace: default
+ count: 0
+ - kind: Deployment
+ name: my-service-medium
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/profiles/02-security/README.md b/examples/use-cases/profiles/02-security/README.md
index 66915b6f..dd13da59 100644
--- a/examples/use-cases/profiles/02-security/README.md
+++ b/examples/use-cases/profiles/02-security/README.md
@@ -111,6 +111,35 @@ deployments:
---
+## E2E
+
+Run the full lifecycle in one command — applies the CR, asserts all three security profile Deployments are created with the correct securityContext fields, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Restricted profile drops all capabilities
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get deployment my-service-restricted -o jsonpath='{.spec.template.spec.containers[0].securityContext.capabilities.drop[0]}'
+ outputContains: ALL
+
+ - name: Hardened profile sets read-only root filesystem
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get deployment my-service-hardened -o jsonpath='{.spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem}'
+ outputContains: "true"
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/profiles/02-security/e2e.yaml b/examples/use-cases/profiles/02-security/e2e.yaml
new file mode 100644
index 00000000..b42ef570
--- /dev/null
+++ b/examples/use-cases/profiles/02-security/e2e.yaml
@@ -0,0 +1,64 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: profiles-security-e2e
+ description: >
+ securityContext.profile + podSecurity.profile — one CR produces three
+ Deployments each with a different security posture. Verifies all three
+ Deployments are created and that security context fields match the declared
+ profile without any manual securityContext fields in the CR.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ../cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Service CR created
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get services.demo.orkestra.io my-service
+ exitCode: 0
+
+ - name: Three security profile Deployments created
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-service-baseline
+ namespace: default
+ - kind: Deployment
+ name: my-service-restricted
+ namespace: default
+ - kind: Deployment
+ name: my-service-hardened
+ namespace: default
+
+ - name: Restricted profile drops all capabilities
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get deployment my-service-restricted -o jsonpath='{.spec.template.spec.containers[0].securityContext.capabilities.drop[0]}'
+ outputContains: ALL
+
+ - name: Hardened profile sets read-only root filesystem
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get deployment my-service-hardened -o jsonpath='{.spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem}'
+ outputContains: "true"
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ name: my-service-baseline
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/profiles/03-probes/README.md b/examples/use-cases/profiles/03-probes/README.md
index dfd99080..8564b389 100644
--- a/examples/use-cases/profiles/03-probes/README.md
+++ b/examples/use-cases/profiles/03-probes/README.md
@@ -124,6 +124,39 @@ For JVM apps or databases, use a startup probe to get the 5-minute window:
---
+## E2E
+
+Run the full lifecycle in one command — applies the CR, asserts all four probe profile Deployments are created with the correct liveness probe timing, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Four probe profile Deployments created
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-service-fast
+ namespace: default
+ - kind: Deployment
+ name: my-service-slow-start
+ namespace: default
+
+ - name: Fast profile has short probe period
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get deployment my-service-fast -o jsonpath='{.spec.template.spec.containers[0].livenessProbe.periodSeconds}'
+ outputContains: "5"
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/profiles/03-probes/e2e.yaml b/examples/use-cases/profiles/03-probes/e2e.yaml
new file mode 100644
index 00000000..2d70051d
--- /dev/null
+++ b/examples/use-cases/profiles/03-probes/e2e.yaml
@@ -0,0 +1,59 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: profiles-probes-e2e
+ description: >
+ probes.liveness.profile — one CR produces four Deployments each with
+ different probe timing from a profile name. Verifies all four Deployments
+ are created and that liveness probe intervals match the declared profile.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ../cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Service CR created
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get services.demo.orkestra.io my-service
+ exitCode: 0
+
+ - name: Four probe profile Deployments created
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-service-fast
+ namespace: default
+ - kind: Deployment
+ name: my-service-standard
+ namespace: default
+ - kind: Deployment
+ name: my-service-patient
+ namespace: default
+ - kind: Deployment
+ name: my-service-slow-start
+ namespace: default
+
+ - name: Fast profile has short probe period
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get deployment my-service-fast -o jsonpath='{.spec.template.spec.containers[0].livenessProbe.periodSeconds}'
+ outputContains: "10"
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ name: my-service-fast
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/profiles/04-rolling-update/README.md b/examples/use-cases/profiles/04-rolling-update/README.md
index 032e15ff..6c0358a0 100644
--- a/examples/use-cases/profiles/04-rolling-update/README.md
+++ b/examples/use-cases/profiles/04-rolling-update/README.md
@@ -105,6 +105,35 @@ deployments:
---
+## E2E
+
+Run the full lifecycle in one command — applies the CR, asserts all three rolling update profile Deployments are created, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Three rolling update profile Deployments created
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-service-safe
+ namespace: default
+ - kind: Deployment
+ name: my-service-fast
+ namespace: default
+ - kind: Deployment
+ name: my-service-bg
+ namespace: default
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/profiles/04-rolling-update/e2e.yaml b/examples/use-cases/profiles/04-rolling-update/e2e.yaml
new file mode 100644
index 00000000..44cb8209
--- /dev/null
+++ b/examples/use-cases/profiles/04-rolling-update/e2e.yaml
@@ -0,0 +1,49 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: profiles-rolling-update-e2e
+ description: >
+ rollingUpdate.profile — one CR produces three Deployments each with a
+ different rollout strategy from a profile name. Verifies all three
+ Deployments are created and that maxSurge/maxUnavailable match the profile.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ../cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Service CR created
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get services.demo.orkestra.io my-service
+ exitCode: 0
+
+ - name: Three rolling update profile Deployments created
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-service-safe
+ namespace: default
+ - kind: Deployment
+ name: my-service-fast
+ namespace: default
+ - kind: Deployment
+ name: my-service-bg
+ namespace: default
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ name: my-service-safe
+ namespace: default
+ count: 0
diff --git a/examples/use-cases/profiles/05-pdb/README.md b/examples/use-cases/profiles/05-pdb/README.md
index 5a234764..51da6925 100644
--- a/examples/use-cases/profiles/05-pdb/README.md
+++ b/examples/use-cases/profiles/05-pdb/README.md
@@ -80,6 +80,35 @@ pdb:
---
+## E2E
+
+Run the full lifecycle in one command — applies the CR, asserts all three Deployments and three PodDisruptionBudgets are created with the correct disruption limits, then tears down:
+
+```bash
+ork e2e
+```
+
+This runs everything defined in [e2e.yaml](./e2e.yaml):
+
+```yaml
+expect:
+ - name: Three PodDisruptionBudgets created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: PodDisruptionBudget
+ name: my-service-zero-pdb
+ namespace: default
+ - kind: PodDisruptionBudget
+ name: my-service-rolling-pdb
+ namespace: default
+ - kind: PodDisruptionBudget
+ name: my-service-relaxed-pdb
+ namespace: default
+```
+
+---
+
## Cleanup
```bash
diff --git a/examples/use-cases/profiles/05-pdb/e2e.yaml b/examples/use-cases/profiles/05-pdb/e2e.yaml
new file mode 100644
index 00000000..4b5b9097
--- /dev/null
+++ b/examples/use-cases/profiles/05-pdb/e2e.yaml
@@ -0,0 +1,67 @@
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: profiles-pdb-e2e
+ description: >
+ pdb.behavior.profile — one CR produces three Deployments and three
+ PodDisruptionBudgets each with a different disruption policy. Verifies
+ all six resources are created and that PDB limits match the profile.
+
+spec:
+ katalog: ./katalog.yaml
+ crd: ../crd.yaml
+ cr: ../cr.yaml
+
+ cluster:
+ provider: kind
+ name: ork-e2e
+ reuse: false
+
+ expect:
+ - name: Service CR created
+ after: cr-applied
+ timeout: 60s
+ commands:
+ - run: kubectl get services.demo.orkestra.io my-service
+ exitCode: 0
+
+ - name: Three Deployments created
+ after: cr-applied
+ timeout: 90s
+ resources:
+ - kind: Deployment
+ name: my-service-zero
+ namespace: default
+ - kind: Deployment
+ name: my-service-rolling
+ namespace: default
+ - kind: Deployment
+ name: my-service-relaxed
+ namespace: default
+
+ - name: Three PodDisruptionBudgets created
+ after: cr-applied
+ timeout: 60s
+ resources:
+ - kind: PodDisruptionBudget
+ name: my-service-zero-pdb
+ namespace: default
+ - kind: PodDisruptionBudget
+ name: my-service-rolling-pdb
+ namespace: default
+ - kind: PodDisruptionBudget
+ name: my-service-relaxed-pdb
+ namespace: default
+
+ - name: Cleanup verified
+ after: cr-deleted
+ timeout: 60s
+ resources:
+ - kind: Deployment
+ name: my-service-zero
+ namespace: default
+ count: 0
+ - kind: PodDisruptionBudget
+ name: my-service-zero-pdb
+ namespace: default
+ count: 0
diff --git a/pkg/e2e/runner.go b/pkg/e2e/runner.go
index f02293b1..974fb9cb 100644
--- a/pkg/e2e/runner.go
+++ b/pkg/e2e/runner.go
@@ -53,10 +53,15 @@ type Runner struct {
katalogFile string
crFile string
+
+ // Orkestra installation options
+ orkestraVersion string
+ valueFiles []string
+ helmArgs []string
}
// New loads an E2E spec from a YAML file and constructs a Runner.
-func New(e2eFile, clusterCtx string, useCurrentCtx, keepCluster bool) (*Runner, error) {
+func New(e2eFile, clusterCtx string, useCurrentCtx, keepCluster bool, orkestraVersion string, valueFiles []string, helmArgs ...string) (*Runner, error) {
data, err := os.ReadFile(e2eFile)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", e2eFile, err)
@@ -71,16 +76,22 @@ func New(e2eFile, clusterCtx string, useCurrentCtx, keepCluster bool) (*Runner,
}
r := &Runner{
- e2e: e2e,
- e2eDir: filepath.Dir(e2eFile),
- keepCluster: keepCluster,
- clusterCtx: clusterCtx,
- useCurrentCtx: useCurrentCtx,
+ e2e: e2e,
+ e2eDir: filepath.Dir(e2eFile),
+ keepCluster: keepCluster,
+ clusterCtx: clusterCtx,
+ useCurrentCtx: useCurrentCtx,
+ orkestraVersion: orkestraVersion,
+ valueFiles: valueFiles,
+ helmArgs: helmArgs,
}
if err := r.resolveSource(); err != nil {
return nil, err
}
+ if err := r.validateImports(); err != nil {
+ return nil, err
+ }
return r, nil
}
@@ -106,8 +117,12 @@ func (r *Runner) resolveSource() error {
r.katalogFile = r.abs(spec.Katalog)
r.crFile = r.abs(spec.CR)
+ case len(r.e2e.Imports) > 0:
+ // Pure aggregator — no own katalog/CR, just orchestrates imports.
+ return nil
+
default:
- return fmt.Errorf("e2e spec must declare either (katalog + cr) or init")
+ return fmt.Errorf("e2e spec must declare either (katalog + cr) or init, or have imports")
}
if _, err := os.Stat(r.katalogFile); err != nil {
@@ -147,6 +162,8 @@ func (r *Runner) Run(ctx context.Context) (*Result, error) {
//
// When e2e creates and deletes its own ephemeral cluster, teardown is handled
// by the cluster deletion — no per-resource cleanup is needed.
+ isPureAgg := r.katalogFile == ""
+
ownsCluster := r.clusterCtx == "" && !r.keepCluster && !r.useCurrentCtx
var (
appliedCRDPaths []string
@@ -166,141 +183,167 @@ func (r *Runner) Run(ctx context.Context) (*Result, error) {
return nil, fmt.Errorf("cluster: %w", err)
}
- // ── 2. Dependencies ──────────────────────────────────────────────────
- fmt.Println("→ Ensuring dependencies...")
- if err := ork.EnsureDependencies(); err != nil {
- return nil, fmt.Errorf("dependencies: %w", err)
- }
+ // Steps 2–9 are skipped for pure aggregators (no spec — imports only).
+ // Each imported E2E runs its own full lifecycle against the shared cluster.
+ var cases []CaseResult
- // ── 3. Apply operator CRD ────────────────────────────────────────────
- crdPaths, err := r.applyCRD(ctx)
- if err != nil {
- return nil, fmt.Errorf("applying CRD: %w", err)
- }
- appliedCRDPaths = crdPaths
+ if !isPureAgg {
+ // ── 2. Dependencies ──────────────────────────────────────────────
+ fmt.Println("→ Ensuring dependencies...")
+ if err := ork.EnsureDependencies(); err != nil {
+ return nil, fmt.Errorf("dependencies: %w", err)
+ }
- // ── 4. Pre-pull OCI imports so bundle generation works without credentials ──
- if err := r.pullOCIImports(ctx); err != nil {
- return nil, fmt.Errorf("pulling OCI imports: %w", err)
- }
+ // ── 3. Apply operator CRD ────────────────────────────────────────
+ crdPaths, err := r.applyCRD(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("applying CRD: %w", err)
+ }
+ appliedCRDPaths = crdPaths
- // ── 5. Generate and apply bundle ─────────────────────────────────────
- bundleFile, err := r.generateBundle(ctx)
- if err != nil {
- return nil, fmt.Errorf("generate bundle: %w", err)
- }
- // Bundle temp file is removed after teardown uses it (or immediately if
- // cluster is ephemeral and teardown won't need it).
- if ownsCluster {
- defer os.Remove(bundleFile)
- } else {
- appliedBundlePath = bundleFile
- }
+ // ── 4. Pre-pull OCI imports ──────────────────────────────────────
+ if err := r.pullOCIImports(ctx); err != nil {
+ return nil, fmt.Errorf("pulling OCI imports: %w", err)
+ }
- fmt.Printf("→ Applying bundle...\n")
- if out, err := kubectl(ctx, "apply", "-f", bundleFile); err != nil {
- return nil, fmt.Errorf("apply bundle: %w\n%s", err, out)
- }
- fmt.Printf(" ✓ Bundle applied\n")
+ // ── 5. Generate and apply bundle ─────────────────────────────────
+ bundleFile, err := r.generateBundle(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("generate bundle: %w", err)
+ }
+ if ownsCluster {
+ defer os.Remove(bundleFile)
+ } else {
+ appliedBundlePath = bundleFile
+ }
- // ── 5. Setup — namespaces, secrets, extra CRDs, other dependencies ──
- setupPaths, err := r.applySetup(ctx)
- if err != nil {
- return nil, fmt.Errorf("setup: %w", err)
- }
- appliedSetupPaths = setupPaths
+ fmt.Printf("→ Applying bundle...\n")
+ if out, err := kubectl(ctx, "apply", "-f", bundleFile); err != nil {
+ return nil, fmt.Errorf("apply bundle: %w\n%s", err, out)
+ }
+ fmt.Printf(" ✓ Bundle applied\n")
- // ── 6. Install Orkestra ──────────────────────────────────────────────
- args := []string{}
- text := "..."
+ // ── 6. Setup ─────────────────────────────────────────────────────
+ setupPaths, err := r.applySetup(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("setup: %w", err)
+ }
+ appliedSetupPaths = setupPaths
- gatewayEnabled, err := resolveGatewayEnabled(r.katalogFile)
- if err != nil {
- return nil, err
- }
- if gatewayEnabled {
- args = append(args, "--set", "gateway.enabled=true")
- text = " with gateway..."
- }
+ // ── 7. Install Orkestra ──────────────────────────────────────────
+ text := "..."
- if !ork.OrkestraInstalled() {
- fmt.Printf("→ Installing Orkestra%s\n", text)
- if err := ork.InstallOrUpgradeOrkestra("", nil, args...); err != nil {
- return nil, fmt.Errorf("helm install: %w", err)
+ // Control center is never needed in e2e — disable it unconditionally.
+ r.helmArgs = append(r.helmArgs, "--set", "controlCenter.enabled=false")
+
+ gatewayEnabled, err := resolveGatewayEnabled(r.katalogFile)
+ if err != nil {
+ return nil, err
}
- installedOrkestra = true
- fmt.Printf(" ✓ Orkestra installed\n")
- } else if ork.RuntimeDeployed() {
- // Orkestra is installed and the runtime deployment exists — the bundle
- // applied above updated the orkestra-katalog ConfigMap, so the runtime
- // must reload to pick up the new Katalog.
- fmt.Printf("→ Updating Orkestra with current bundle...\n")
- if err := ork.SyncRuntime(); err != nil {
- return nil, fmt.Errorf("syncing Orkestra runtime: %w", err)
+ if gatewayEnabled {
+ fmt.Printf("→ Gateway enabled...\n")
+ r.helmArgs = append(r.helmArgs, "--set", "gateway.enabled=true")
+ text = " with gateway..."
}
- fmt.Printf(" ✓ Orkestra updated\n")
- } else {
- fmt.Printf(" ✓ Orkestra already installed\n")
- }
-
- // ── 7. Wait for Orkestra ready ───────────────────────────────────────
- fmt.Printf("→ Waiting for Orkestra to be ready...\n")
- status := ork.CheckRuntimeHealth()
- if !status.Running {
- return nil, fmt.Errorf("Orkestra not ready: %s", status.Reason)
- }
- fmt.Printf(" ✓ Orkestra runtime ready\n\n")
- // ── 8. Run expectations ──────────────────────────────────────────────
- var cases []CaseResult
- crApplied := false
- crDeleted := false
-
- for _, exp := range r.e2e.Spec.Expect {
- switch exp.After {
- case "cr-applied":
- if !crApplied {
- fmt.Printf("→ Applying CR...\n")
- if out, err := kubectl(ctx, "apply", "-f", r.crFile); err != nil {
- return nil, fmt.Errorf("apply CR: %w\n%s", err, out)
- }
- fmt.Printf(" ✓ CR applied\n\n")
- crApplied = true
+ if !ork.RuntimeInstalled() {
+ fmt.Printf("→ Installing Orkestra%s\n", text)
+ if err := ork.InstallOrUpgradeOrkestra(r.orkestraVersion, r.valueFiles, r.helmArgs...); err != nil {
+ return nil, fmt.Errorf("helm install: %w", err)
}
-
- case "cr-deleted":
- if !crDeleted {
- fmt.Printf("→ Deleting CR...\n")
- if out, err := kubectl(ctx, "delete", "-f", r.crFile, "--ignore-not-found"); err != nil {
- return nil, fmt.Errorf("delete CR: %w\n%s", err, out)
+ installedOrkestra = true
+ fmt.Printf(" ✓ Orkestra installed\n")
+ } else {
+ fmt.Printf("→ Syncing Orkestra runtime with current bundle...\n")
+ if err := ork.SyncRuntime(); err != nil {
+ return nil, fmt.Errorf("syncing Orkestra runtime: %w", err)
+ }
+ fmt.Printf(" ✓ Orkestra runtime synced\n")
+ if gatewayEnabled {
+ if ork.GatewayInstalled() {
+ fmt.Printf("→ Syncing Orkestra gateway with current bundle...\n")
+ if err := ork.SyncGateway(); err != nil {
+ return nil, fmt.Errorf("syncing Orkestra gateway: %w", err)
+ }
+ fmt.Printf(" ✓ Orkestra gateway synced\n")
+ } else {
+ fmt.Printf("→ Upgrading Orkestra to enable gateway...\n")
+ if err := ork.InstallOrUpgradeOrkestra(r.orkestraVersion, r.valueFiles, r.helmArgs...); err != nil {
+ return nil, fmt.Errorf("helm upgrade: %w", err)
+ }
+ installedOrkestra = true
+ fmt.Printf(" ✓ Orkestra upgraded with gateway\n")
}
- fmt.Printf(" ✓ CR deleted\n\n")
- crDeleted = true
}
+ }
- default:
- return nil, fmt.Errorf("unknown after: %q (must be cr-applied or cr-deleted)", exp.After)
+ // ── 8. Wait for Orkestra ready ───────────────────────────────────
+ fmt.Printf("→ Waiting for Orkestra to be ready...\n")
+ status := ork.CheckRuntimeHealth()
+ if !status.Running {
+ return nil, fmt.Errorf("Orkestra runtime not ready: %s", status.Reason)
}
+ fmt.Printf(" ✓ Orkestra runtime ready\n\n")
- to := exp.Timeout
- if to == "" {
- to = defaultTimeout
+ if gatewayEnabled {
+ fmt.Printf("→ Waiting for Orkestra gateway to be ready...\n")
+ status := ork.CheckGatewayHealth()
+ if !status.Running {
+ return nil, fmt.Errorf("Orkestra gateway not ready: %s", status.Reason)
+ }
+ fmt.Printf(" ✓ Orkestra gateway ready\n\n")
}
- fmt.Printf(" Waiting for %q (timeout: %s)...\n", exp.Name, to)
- caseStart := time.Now()
- verifyErr := verifyExpectation(ctx, exp, r.e2eDir)
- caseElapsed := time.Since(caseStart)
- cases = append(cases, CaseResult{
- Name: exp.Name,
- Passed: verifyErr == nil,
- Elapsed: caseElapsed,
- Err: verifyErr,
- })
- if verifyErr != nil {
- fmt.Printf(" ✗ %s (%s): %v\n", exp.Name, caseElapsed.Round(time.Millisecond), verifyErr)
- } else {
- fmt.Printf(" ✓ %s (%s)\n", exp.Name, caseElapsed.Round(time.Millisecond))
+ // ── 9. Run expectations ──────────────────────────────────────────
+ crApplied := false
+ crDeleted := false
+
+ for _, exp := range r.e2e.Spec.Expect {
+ switch exp.After {
+ case "cr-applied":
+ if !crApplied {
+ fmt.Printf("→ Applying CR...\n")
+ if out, err := kubectl(ctx, "apply", "-f", r.crFile); err != nil {
+ return nil, fmt.Errorf("apply CR: %w\n%s", err, out)
+ }
+ fmt.Printf(" ✓ CR applied\n\n")
+ crApplied = true
+ }
+
+ case "cr-deleted":
+ if !crDeleted {
+ fmt.Printf("→ Deleting CR...\n")
+ if out, err := kubectl(ctx, "delete", "-f", r.crFile, "--ignore-not-found"); err != nil {
+ return nil, fmt.Errorf("delete CR: %w\n%s", err, out)
+ }
+ fmt.Printf(" ✓ CR deleted\n\n")
+ crDeleted = true
+ }
+
+ default:
+ return nil, fmt.Errorf("unknown after: %q (must be cr-applied or cr-deleted)", exp.After)
+ }
+
+ to := exp.Timeout
+ if to == "" {
+ to = defaultTimeout
+ }
+ fmt.Printf(" Waiting for %q (timeout: %s)...\n", exp.Name, to)
+ caseStart := time.Now()
+ verifyErr := verifyExpectation(ctx, exp, r.e2eDir)
+ caseElapsed := time.Since(caseStart)
+
+ cases = append(cases, CaseResult{
+ Name: exp.Name,
+ Passed: verifyErr == nil,
+ Elapsed: caseElapsed,
+ Err: verifyErr,
+ })
+ if verifyErr != nil {
+ fmt.Printf(" ✗ %s (%s): %v\n", exp.Name, caseElapsed.Round(time.Millisecond), verifyErr)
+ } else {
+ fmt.Printf(" ✓ %s (%s)\n", exp.Name, caseElapsed.Round(time.Millisecond))
+ }
}
}
@@ -310,22 +353,37 @@ func (r *Runner) Run(ctx context.Context) (*Result, error) {
Elapsed: time.Since(start),
}
- // ── 9. Report ────────────────────────────────────────────────────────
- fmt.Printf("\nE2E Results: %s\n\n", name)
- for _, c := range cases {
- if c.Passed {
- fmt.Printf(" ✓ %-40s (%s)\n", c.Name, c.Elapsed.Round(time.Millisecond))
- } else {
- fmt.Printf(" ✗ %-40s (%s)\n", c.Name, c.Elapsed.Round(time.Millisecond))
+ // ── Report ───────────────────────────────────────────────────────────
+ if !isPureAgg {
+ fmt.Printf("\nE2E Results: %s\n\n", name)
+ for _, c := range cases {
+ if c.Passed {
+ fmt.Printf(" ✓ %-40s (%s)\n", c.Name, c.Elapsed.Round(time.Millisecond))
+ } else {
+ fmt.Printf(" ✗ %-40s (%s)\n", c.Name, c.Elapsed.Round(time.Millisecond))
+ }
+ }
+ clusterInfo := r.clusterName()
+ fmt.Printf("\n %s\n", result.Summary())
+ if clusterInfo != "" {
+ fmt.Printf(" Cluster: %s (%s)\n", clusterInfo, r.provider())
}
}
- clusterInfo := r.clusterName()
- fmt.Printf("\n %s\n", result.Summary())
- if clusterInfo != "" {
- fmt.Printf(" Cluster: %s (%s)\n", clusterInfo, r.provider())
+
+ // ── 10. Imports ──────────────────────────────────────────────────────
+ var importErr error
+ if len(r.e2e.Imports) > 0 {
+ fmt.Printf("\n─── Running %d import(s) ───\n", len(r.e2e.Imports))
+ if errs := r.runImports(ctx); len(errs) > 0 {
+ msgs := make([]string, len(errs))
+ for i, e := range errs {
+ msgs[i] = e.Error()
+ }
+ importErr = fmt.Errorf("%d import(s) failed: %s", len(errs), strings.Join(msgs, "; "))
+ }
}
- // ── 10. Cleanup ──────────────────────────────────────────────────────
+ // ── 11. Cleanup ──────────────────────────────────────────────────────
if !r.useCurrentCtx && !r.keepCluster && r.clusterCtx == "" {
fmt.Printf("\n→ Deleting cluster '%s'...\n", r.clusterName())
if err := r.deleteCluster(ctx); err != nil {
@@ -338,7 +396,7 @@ func (r *Runner) Run(ctx context.Context) (*Result, error) {
if !result.AllPassed() {
return result, fmt.Errorf("%d of %d expectations failed", result.Total()-result.Passed(), result.Total())
}
- return result, nil
+ return result, importErr
}
// resolveGatewayEnabled inspects the katalog file and returns true if the
@@ -447,6 +505,15 @@ func (r *Runner) applyCRD(ctx context.Context) ([]string, error) {
}
func (r *Runner) generateBundle(ctx context.Context) (string, error) {
+ // Anchor the katalog directory as an absolute path. r.katalogFile may be
+ // relative when ork e2e is invoked without an explicit -f path; all temp
+ // file creation and cmd.Dir must use an absolute base to avoid double-nested
+ // paths when cmd.Dir is set.
+ katalogDir, err := filepath.Abs(filepath.Dir(r.katalogFile))
+ if err != nil {
+ return "", fmt.Errorf("resolving katalog directory: %w", err)
+ }
+
// Resolve any crdFile references to inline apiTypes before bundling.
// The Orkestra runtime runs inside a container and cannot read local files —
// all type information must be embedded in the ConfigMap.
@@ -455,7 +522,9 @@ func (r *Runner) generateBundle(ctx context.Context) (string, error) {
return "", fmt.Errorf("resolving crdFile references: %w", err)
}
- resolvedKatalog, err := os.CreateTemp("", "ork-e2e-katalog-*.yaml")
+ // Create the temp file in the katalog's directory (absolute) so that
+ // relative imports.files paths resolve correctly when ork generate bundle runs.
+ resolvedKatalog, err := os.CreateTemp(katalogDir, "ork-e2e-katalog-*.yaml")
if err != nil {
return "", err
}
@@ -482,6 +551,9 @@ func (r *Runner) generateBundle(ctx context.Context) (string, error) {
"-f", resolvedKatalog.Name(),
"-o", bundleFile.Name(),
)
+ // Run from the katalog's directory (absolute) so relative imports.files
+ // paths (e.g. ./platform-team/katalog.yaml) resolve correctly.
+ cmd.Dir = katalogDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
@@ -585,6 +657,12 @@ func (r *Runner) provider() string {
return defaultProvider
}
+// isPureAggregator returns true when this E2E has no spec of its own —
+// it exists only to run imported E2E files.
+func (r *Runner) isPureAggregator() bool {
+ return r.katalogFile == ""
+}
+
func (r *Runner) abs(path string) string {
if filepath.IsAbs(path) {
return path
@@ -670,6 +748,74 @@ func (r *Runner) teardown(ctx context.Context, crdPaths []string, bundlePath str
fmt.Printf(" ✓ Cleanup complete\n")
}
+// validateImports is a backstop that delegates to the exported ValidateImports.
+// ork e2e always calls this at startup so malformed imports are caught before
+// the cluster is provisioned. ork validate calls ValidateImports directly for
+// earlier, friendlier feedback.
+func (r *Runner) validateImports() error {
+ errs := ValidateImports(r.e2eDir, r.e2e.Imports)
+ if len(errs) == 0 {
+ return nil
+ }
+ msgs := make([]string, len(errs))
+ for i, e := range errs {
+ msgs[i] = e.Error()
+ }
+ return fmt.Errorf("invalid imports: %s", strings.Join(msgs, "; "))
+}
+
+// runImports runs each imported E2E file after the main test completes.
+//
+// Cluster strategy for shared-cluster imports (freshCluster: false, the default):
+// - Parent is a pure aggregator: imports use the cluster ensureCluster already set up.
+// - Parent used --use-current or --cluster: imports reuse the same active context.
+// - Parent ran its own test and created a kind cluster: a separate kind cluster
+// is created so imports don't share state with the parent's live resources.
+//
+// Imports with freshCluster: true always provision their own independent cluster.
+func (r *Runner) runImports(ctx context.Context) []error {
+ parentOwnsCluster := r.clusterCtx == "" && !r.useCurrentCtx
+
+ if parentOwnsCluster && !r.isPureAggregator() {
+ // Non-aggregator parent created its own cluster — provision a separate
+ // one so imports don't run alongside the parent's Orkestra install.
+ importCluster := r.clusterName() + "-imports"
+ fmt.Printf("→ Creating imports cluster '%s'...\n", importCluster)
+ if err := ork.EnsureKindCluster(importCluster); err != nil {
+ return []error{fmt.Errorf("creating imports cluster: %w", err)}
+ }
+ if !r.keepCluster {
+ defer func() {
+ fmt.Printf("→ Deleting imports cluster '%s'...\n", importCluster)
+ _ = deleteKindCluster(ctx, importCluster)
+ }()
+ }
+ }
+
+ var errs []error
+ for _, imp := range r.e2e.Imports {
+ absPath := r.abs(imp.Path)
+ var sub *Runner
+ var err error
+ if imp.FreshCluster {
+ // Sub-runner creates and manages its own cluster from scratch.
+ sub, err = New(absPath, "", false, r.keepCluster, r.orkestraVersion, r.valueFiles)
+ } else {
+ // All shared imports use the current kubectl context — either the
+ // imports cluster just created above, or the parent's existing cluster.
+ sub, err = New(absPath, "", true, false, r.orkestraVersion, r.valueFiles)
+ }
+ if err != nil {
+ errs = append(errs, fmt.Errorf("loading import %s: %w", imp.Path, err))
+ continue
+ }
+ if _, err := sub.Run(ctx); err != nil {
+ errs = append(errs, fmt.Errorf("import %s: %w", imp.Path, err))
+ }
+ }
+ return errs
+}
+
func deleteKindCluster(ctx context.Context, name string) error {
cmd := exec.CommandContext(ctx, "kind", "delete", "cluster", "--name", name)
cmd.Stdout = os.Stdout
diff --git a/pkg/e2e/validate.go b/pkg/e2e/validate.go
new file mode 100644
index 00000000..1094b0b9
--- /dev/null
+++ b/pkg/e2e/validate.go
@@ -0,0 +1,39 @@
+package e2e
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ orktypes "github.com/orkspace/orkestra/pkg/types"
+ "gopkg.in/yaml.v3"
+)
+
+// ValidateImports checks that every file listed in imports exists and declares
+// kind: E2E. baseDir is the directory that relative paths are resolved against.
+// Returns one error per invalid import — callers may print all of them.
+func ValidateImports(baseDir string, imports []orktypes.E2EImport) []error {
+ var errs []error
+ for _, imp := range imports {
+ path := imp.Path
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(baseDir, path)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("%s: %w", imp.Path, err))
+ continue
+ }
+ var head struct {
+ Kind string `yaml:"kind"`
+ }
+ if err := yaml.Unmarshal(data, &head); err != nil {
+ errs = append(errs, fmt.Errorf("%s: %w", imp.Path, err))
+ continue
+ }
+ if head.Kind != "E2E" {
+ errs = append(errs, fmt.Errorf("%s: expected kind E2E, got %q", imp.Path, head.Kind))
+ }
+ }
+ return errs
+}
diff --git a/pkg/katalog/security.go b/pkg/katalog/security.go
index d00b3ca5..4fff4e28 100644
--- a/pkg/katalog/security.go
+++ b/pkg/katalog/security.go
@@ -415,6 +415,23 @@ func (k *Katalog) GatewayEndpoint() string {
return ""
}
+// ClusterName returns the effective cluster name for this Katalog.
+//
+// Precedence:
+//
+// metadata.clusterName non-empty → use Katalog value
+// CLUSTER_NAME env var set → use konfig value
+// Neither set → empty string
+func (k *Katalog) ClusterName() string {
+ if k.metadata.ClusterName != "" {
+ return k.metadata.ClusterName
+ }
+ if k.konfig != nil {
+ return k.konfig.Cluster().Name()
+ }
+ return ""
+}
+
// ── Gateway requirement ───────────────────────────────────────────────────────
// NeedsGateway reports whether this Katalog requires a companion gateway process.
diff --git a/pkg/katalog/validation_methods.go b/pkg/katalog/validation_methods.go
index 671f62b2..22cce670 100644
--- a/pkg/katalog/validation_methods.go
+++ b/pkg/katalog/validation_methods.go
@@ -195,7 +195,17 @@ func (k *Katalog) setDefaults(kfg *konfig.Konfig) error {
if k.metadata.Name != "" {
crd.KatalogName = k.metadata.Name
} else {
- crd.KatalogName = kfg.Cluster().Name()
+ crd.KatalogName = k.metadata.ClusterName + "-" + name
+ }
+
+ // Propagate katalog namespace — merger stamps it, but ensure the
+ // default is applied for any path that bypasses the merger (e.g. Go-mode).
+ if crd.KatalogNamespace == "" {
+ if k.metadata.Namespace != "" {
+ crd.KatalogNamespace = k.metadata.Namespace
+ } else {
+ crd.KatalogNamespace = "default"
+ }
}
// Name is already set from map key — normalise it
diff --git a/pkg/konfig/konfig.go b/pkg/konfig/konfig.go
index 3b3fcbbe..5adf7d6d 100644
--- a/pkg/konfig/konfig.go
+++ b/pkg/konfig/konfig.go
@@ -23,7 +23,7 @@ func Init(filenames ...string) (*Konfig, error) {
cluster: clusterKonfig{
kubekonfigPath: GetStrEnv("KUBEKONFIG", ""),
masterURL: GetStrEnv("MASTER_URL", ""),
- name: GetStrEnv("CLUSTER_NAME", "orkestra-cluster"),
+ name: GetStrEnv("CLUSTER_NAME", ""),
namespace: ns,
},
// ── Unified security configuration ───────────────────────────────────
diff --git a/pkg/kordinator/crd_health_handers.go b/pkg/kordinator/crd_health_handers.go
index 6199b1ab..98188aee 100644
--- a/pkg/kordinator/crd_health_handers.go
+++ b/pkg/kordinator/crd_health_handers.go
@@ -476,6 +476,18 @@ func BuildCRDInfoHandler(
// Katalog Response
// ─────────────────────────────────────────────────────────────────────────────
+// KatalogNamespaceSummary groups CRDs that share the same katalog namespace.
+// Namespaces are declared in katalog.metadata.namespace — "default" when not set.
+type KatalogNamespaceSummary struct {
+ CRDs []string `json:"crds"`
+ StatusCounts StatusCounts `json:"statusCounts"`
+ Healthy bool `json:"healthy"`
+ Description string `json:"description,omitempty"`
+ Version string `json:"version,omitempty"`
+ Workers int `json:"workers"`
+ Resources int `json:"resources"`
+}
+
type KatalogResponse struct {
CRDs []CRDSummaryResponse `json:"crds"`
Total int `json:"total"`
@@ -494,7 +506,11 @@ type KatalogResponse struct {
Description string `json:"description,omitempty"`
License string `json:"license,omitempty"`
RuntimeVersion string `json:"runtimeVersion,omitempty"`
+ ClusterName string `json:"clusterName,omitempty"`
Projects map[string]interface{} `json:"projects,omitempty"`
+ // Namespaces groups CRDs by katalog namespace. Always present — at minimum
+ // contains "default" when no katalog declares an explicit namespace.
+ Namespaces map[string]KatalogNamespaceSummary `json:"namespaces,omitempty"`
// GatewayEndpoint is the HTTP base URL of the companion gateway process.
// Set via ORK_GATEWAY_ENDPOINT on the runtime. The control center reads
// this field and fetches gateway:/katalog to merge per-CRD webhook stats.
@@ -533,6 +549,7 @@ type CRDSummaryResponse struct {
RBACCount int `json:"rbacCount,omitempty"`
DeletionProtection bool `json:"deletionProtection"`
ProviderCount int `json:"providerCount,omitempty"`
+ KatalogNamespace string `json:"katalogNamespace,omitempty"`
}
type OperatorBoxSummary struct {
@@ -575,6 +592,7 @@ func BuildKatalogHandler(
return func(w http.ResponseWriter, r *http.Request) {
crds := make([]CRDSummaryResponse, 0)
statusCounts := StatusCounts{}
+ namespaces := make(map[string]KatalogNamespaceSummary)
deletionProtectedCRDs := kat.DeletionProtectedCRDNames()
for _, crd := range kat.Enabled() {
@@ -642,17 +660,44 @@ func BuildKatalogHandler(
HasHooks: crd.OperatorBox.Hooks != nil || crd.OperatorBox.HookFactory != nil,
HasConstructor: crd.OperatorBox.Constructor != nil,
},
- Healthy: isHealthy,
- Started: isStarted,
- Pending: isPending,
- StartedAt: h.StartedAt(),
- Uptime: h.Uptime(),
- ErrorRate: h.ErrorRatePercent(),
+ Healthy: isHealthy,
+ Started: isStarted,
+ Pending: isPending,
+ StartedAt: h.StartedAt(),
+ Uptime: h.Uptime(),
+ ErrorRate: h.ErrorRatePercent(),
+ KatalogNamespace: crd.KatalogNamespace,
Endpoints: EndpointInfo{
Health: "/katalog/" + strings.ToLower(crd.Name) + "/health",
Info: "/katalog/" + strings.ToLower(crd.Name),
},
})
+
+ // Build namespace grouping for the Control Center.
+ ns := crd.KatalogNamespace
+ if ns == "" {
+ ns = "default"
+ }
+ nsSummary := namespaces[ns]
+ nsSummary.CRDs = append(nsSummary.CRDs, crd.Name)
+ switch {
+ case isHealthy:
+ nsSummary.StatusCounts.Healthy++
+ case isStarted && !isHealthy:
+ nsSummary.StatusCounts.Degraded++
+ default:
+ nsSummary.StatusCounts.Pending++
+ }
+ nsSummary.Healthy = nsSummary.StatusCounts.Degraded == 0
+ nsSummary.Workers += v.workers
+ nsSummary.Resources += v.resourceCount
+ if nsSummary.Description == "" {
+ nsSummary.Description = crd.KatalogDescription
+ }
+ if nsSummary.Version == "" {
+ nsSummary.Version = crd.KatalogVersion
+ }
+ namespaces[ns] = nsSummary
}
healthy := statusCounts.Degraded == 0
@@ -698,6 +743,8 @@ func BuildKatalogHandler(
Description: kat.Meta().Description,
Projects: kat.Projects(),
RuntimeVersion: version.Short(),
+ ClusterName: kat.ClusterName(),
+ Namespaces: namespaces,
GatewayEndpoint: kat.GatewayEndpoint(),
})
}
diff --git a/pkg/kordinator/registry_cross.go b/pkg/kordinator/registry_cross.go
index 3ae5804d..2d4f1526 100644
--- a/pkg/kordinator/registry_cross.go
+++ b/pkg/kordinator/registry_cross.go
@@ -55,6 +55,21 @@ func (r *ResourceKatalog) GetInformerByName(name string) (cache.SharedIndexInfor
// This is used by GenericReconciler via the KatalogRegistry interface
// to support cross‑context reads without importing pkg/kordinator
// directly (avoiding import cycles).
+// GetCrossAccessByName returns the CrossAccess field of the named CRD.
+// nil means readable (default). *false means the CRD has opted out of cross reads.
+func (r *ResourceKatalog) GetCrossAccessByName(name string) *bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ nameLower := strings.ToLower(name)
+ for _, entry := range r.entries {
+ if strings.ToLower(entry.CRD.Name) == nameLower {
+ return entry.CRD.CrossAccess
+ }
+ }
+ return nil
+}
+
func (r *ResourceKatalog) GetInformerByLabelSelector(key, value string) (cache.SharedIndexInformer, bool) {
r.mu.Lock()
defer r.mu.Unlock()
diff --git a/pkg/merger/file.go b/pkg/merger/file.go
index a91381de..dcd47971 100644
--- a/pkg/merger/file.go
+++ b/pkg/merger/file.go
@@ -83,6 +83,12 @@ func (m *Merger) loadKatalog(path string, doc *orktypes.KatalogFile) (map[string
result := make(map[string]orktypes.CRDEntry, len(doc.Spec.CRDs))
+ // Resolve the katalog namespace — "default" when not declared.
+ katalogNamespace := doc.Metadata.Namespace
+ if katalogNamespace == "" {
+ katalogNamespace = "default"
+ }
+
for name, crd := range doc.Spec.CRDs {
if name == "" {
return nil, fmt.Errorf("%q spec.crds: CRD with empty key", path)
@@ -90,6 +96,18 @@ func (m *Merger) loadKatalog(path string, doc *orktypes.KatalogFile) (map[string
// Duplicate within the same file is impossible — map keys are unique.
crd.Name = name
+ // Stamp the katalog namespace so the runtime and CC can group by team.
+ crd.KatalogNamespace = katalogNamespace
+ crd.KatalogDescription = doc.Metadata.Description
+ crd.KatalogVersion = doc.Metadata.Version
+
+ // Apply katalog-level CrossAccess as the default for every CRD that
+ // does not declare its own crossAccess field.
+ if crd.CrossAccess == nil && doc.CrossAccess != nil {
+ v := *doc.CrossAccess
+ crd.CrossAccess = &v
+ }
+
// Merge spec-level restrictions into each CRD (additive).
protect := doc.Security.NamespaceProtection
if protect != nil {
@@ -278,6 +296,22 @@ func (m *Merger) loadKomposer(path string, doc *orktypes.KatalogFile) (map[strin
localSeen[name] = inlineKey
}
+ // Fill KatalogDescription and KatalogVersion fallbacks — if the sub-Katalog had none, use the Komposer's.
+ for name, crd := range allCRDs {
+ changed := false
+ if crd.KatalogDescription == "" && doc.Metadata.Description != "" {
+ crd.KatalogDescription = doc.Metadata.Description
+ changed = true
+ }
+ if crd.KatalogVersion == "" && doc.Metadata.Version != "" {
+ crd.KatalogVersion = doc.Metadata.Version
+ changed = true
+ }
+ if changed {
+ allCRDs[name] = crd
+ }
+ }
+
// Merge Komposer-level restrictions into every CRD (additive).
protect := doc.Security.NamespaceProtection
if protect != nil {
diff --git a/pkg/note/catalog_generated.go b/pkg/note/catalog_generated.go
index b518c819..c0f349b0 100644
--- a/pkg/note/catalog_generated.go
+++ b/pkg/note/catalog_generated.go
@@ -103,6 +103,13 @@ var BuiltinNotes = []NoteInfo{
Example: "# Use in a conditional:\n# value: \"{{ if empty .spec.image }}nginx:latest{{ else }}{{ .spec.image }}{{ end }}\"",
Keywords: []string{"conditional", "empty", "check", "boolean", "nil", "absent"},
},
+ {
+ Name: "eqTernary",
+ Domain: "conditional",
+ Description: "Return `trueVal` when `val` equals `target` (string comparison), `falseVal` otherwise. Shorthand for the `boolTernary (eq val target) trueVal falseVal` pattern — useful when branching on a string field value such as a status string, a mode flag, or a cross-CRD `found` result.",
+ Example: "# value: '{{ eqTernary .cross.db.found \"true\" \"ready\" \"waiting\" }}'\n# found=\"true\" → \"ready\"\n# found=\"false\" → \"waiting\"\n\n# value: '{{ eqTernary .spec.mode \"production\" \"strict\" \"permissive\" }}'\n# mode=\"production\" → \"strict\"\n# mode=\"staging\" → \"permissive\"",
+ Keywords: []string{"conditional", "branch", "equality", "string", "ternary", "compare", "match"},
+ },
{
Name: "notEmpty",
Domain: "conditional",
diff --git a/pkg/note/conditional.go b/pkg/note/conditional.go
index 607f7dfa..7322cb5c 100644
--- a/pkg/note/conditional.go
+++ b/pkg/note/conditional.go
@@ -10,6 +10,7 @@ func conditionalNotes() template.FuncMap {
"ternary": noteTernary,
"boolTernary": noteBoolTernary,
"boolDefault": noteBoolDefault,
+ "eqTernary": noteEqTernary,
"coalesce": noteCoalesce,
"default": noteDefault,
"empty": noteEmpty,
@@ -17,6 +18,18 @@ func conditionalNotes() template.FuncMap {
}
}
+// noteEqTernary returns trueVal when val equals target (string comparison),
+// falseVal otherwise. Shorthand for boolTernary (eq val target) trueVal falseVal.
+//
+// {{ eqTernary .cross.db.found "true" "ready" "waiting" }}
+// {{ eqTernary .spec.mode "production" "strict" "permissive" }}
+func noteEqTernary(val, target, trueVal, falseVal interface{}) interface{} {
+ if fmt.Sprintf("%v", val) == fmt.Sprintf("%v", target) {
+ return trueVal
+ }
+ return falseVal
+}
+
// noteTernary returns trueVal when condition is truthy, falseVal otherwise.
// Replaces verbose {{ if }}...{{ else }}...{{ end }} in template expressions.
//
diff --git a/pkg/note/docs/03-conditional.md b/pkg/note/docs/03-conditional.md
index c7b5da3b..ae33c4aa 100644
--- a/pkg/note/docs/03-conditional.md
+++ b/pkg/note/docs/03-conditional.md
@@ -52,6 +52,24 @@ Keywords: conditional, boolean, default, fallback, absent, nil
---
+### `eqTernary`
+
+Return `trueVal` when `val` equals `target` (string comparison), `falseVal` otherwise. Shorthand for the `boolTernary (eq val target) trueVal falseVal` pattern — useful when branching on a string field value such as a status string, a mode flag, or a cross-CRD `found` result.
+
+Keywords: conditional, branch, equality, string, ternary, compare, match
+
+```yaml
+# value: '{{ eqTernary .cross.db.found "true" "ready" "waiting" }}'
+# found="true" → "ready"
+# found="false" → "waiting"
+
+# value: '{{ eqTernary .spec.mode "production" "strict" "permissive" }}'
+# mode="production" → "strict"
+# mode="staging" → "permissive"
+```
+
+---
+
### `default`
Return `val` if non-empty, otherwise return `def`. "Empty" means nil, `""`, `0`, `false`, empty slice, or empty map.
diff --git a/pkg/ork/constants.go b/pkg/ork/constants.go
index ed8a7c20..815843ad 100644
--- a/pkg/ork/constants.go
+++ b/pkg/ork/constants.go
@@ -7,6 +7,9 @@ const (
// OrkestraRuntime is the name of the runtime Deployment.
OrkestraRuntime = "orkestra-runtime"
+ // OrkestraGateway is the name of the gateway Deployment.
+ OrkestraGateway = "orkestra-gateway"
+
// OrkestraNamespace is the Kubernetes namespace Orkestra deploys into.
OrkestraNamespace = "orkestra-system"
diff --git a/pkg/ork/health.go b/pkg/ork/health.go
index 030411dc..b667d350 100644
--- a/pkg/ork/health.go
+++ b/pkg/ork/health.go
@@ -3,83 +3,72 @@ package ork
import (
"bytes"
"context"
- "fmt"
- "os"
"os/exec"
"path/filepath"
- "strings"
"time"
-
- "github.com/orkspace/orkestra/pkg/spinner"
)
const (
- healthCheckTimeout = 200 * time.Second
- controlCenterDeploy = OrkestraControlCenter
- runtimeLogDir = "/tmp/orkestra"
- runtimeLogPath = "/tmp/orkestra/runtime.log"
- controlCenterLogPath = "/tmp/orkestra/controlcenter.log"
+ healthCheckTimeout = 200 * time.Second
+ resourceExistsTimeout = 10 * time.Second
+ controlCenterDeploy = OrkestraControlCenter
+ runtimeLogDir = "/tmp/orkestra"
+ runtimeLogPath = "/tmp/orkestra/runtime.log"
+ gatewayLogDir = "/tmp/orkestra"
+ gatewayLogPath = "/tmp/orkestra/gateway.log"
+ controlCenterLogPath = "/tmp/orkestra/controlcenter.log"
)
-// RuntimeStatus is the result of CheckRuntimeHealth.
-type RuntimeStatus struct {
- Running bool
- Reason string // set when Running is false
-}
-
-// OrkestraInstalled reports whether the Orkestra runtime Deployment exists
-// in OrkestraNamespace.
-func OrkestraInstalled() bool {
- out, err := exec.Command("kubectl", "get", "deploy",
- OrkestraRuntime,
- "-n", OrkestraNamespace,
- "--no-headers",
- "-o", "name",
- ).Output()
- return err == nil && len(strings.TrimSpace(string(out))) > 0
+// RuntimeInstalled reports whether the Orkestra runtime Deployment exists.
+func RuntimeInstalled() bool {
+ return ResourceExists("deploy", OrkestraRuntime, OrkestraNamespace)
}
-// RuntimeDeployed reports whether the runtime Deployment exists, with a
-// short timeout so it is safe to call during startup.
-func RuntimeDeployed() bool {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- out, err := exec.CommandContext(ctx, "kubectl", "get", "deploy", OrkestraRuntime,
- "-n", OrkestraNamespace, "--ignore-not-found", "-o", "name").Output()
- return err == nil && len(bytes.TrimSpace(out)) > 0
+// GatewayInstalled reports whether the Orkestra gateway Deployment exists.
+func GatewayInstalled() bool {
+ return ResourceExists("deploy", OrkestraGateway, OrkestraNamespace)
}
// CheckRuntimeHealth waits up to healthCheckTimeout for the Orkestra runtime
// Deployment to have at least one ready replica. It polls every 2 seconds.
// Returns immediately if pods are in CrashLoopBackOff.
-func CheckRuntimeHealth() RuntimeStatus {
- spin := spinner.Start(" → Checking Orkestra runtime health...")
+var (
+ runtimeChecker = DeploymentHealthChecker{Name: OrkestraRuntime, Namespace: OrkestraNamespace}
+ gatewayChecker = DeploymentHealthChecker{Name: OrkestraGateway, Namespace: OrkestraNamespace}
+ controlCenterChecker = DeploymentHealthChecker{Name: controlCenterDeploy, Namespace: OrkestraNamespace}
+)
+
+// CheckRuntimeHealth waits up to healthCheckTimeout for the Orkestra runtime to be ready
+func CheckRuntimeHealth() DeploymentStatus {
+ return runtimeChecker.CheckHealth(healthCheckTimeout, func(ctx context.Context) string {
+ return crashLoopReason(ctx)
+ })
+}
+
+// CheckGatewayHealth waits up to healthCheckTimeout for the Orkestra gateway to be ready
+func CheckGatewayHealth() DeploymentStatus {
+ return gatewayChecker.CheckHealth(healthCheckTimeout, nil)
+}
+
+// FetchGatewayLogs saves gateway logs and returns the last 10 lines
+func FetchGatewayLogs() (tail string, err error) {
+ return gatewayChecker.FetchLogs(100, gatewayLogDir, gatewayLogPath)
+}
- ctx, cancel := context.WithTimeout(context.Background(), healthCheckTimeout)
+// FetchControlCenterLogsIfNeeded fetches control center logs only if the deployment exists but has no ready replicas
+func FetchControlCenterLogsIfNeeded() error {
+ if !controlCenterChecker.Exists() {
+ return nil
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
- ticker := time.NewTicker(2 * time.Second)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- spin.Failure()
- return RuntimeStatus{
- Reason: fmt.Sprintf("timeout (%s) waiting for Orkestra runtime to become ready", healthCheckTimeout),
- }
- case <-ticker.C:
- status := checkRuntimeOnce(ctx)
- if status.Running {
- spin.Success()
- return status
- }
- if status.Reason != "no ready replicas" {
- spin.Failure()
- return status
- }
- }
+ if !controlCenterChecker.HasReadyReplicas(ctx) {
+ _, err := controlCenterChecker.FetchLogs(100, runtimeLogDir, controlCenterLogPath)
+ return err
}
+ return nil
}
// FetchRuntimeLogs saves the last 100 log lines from the Orkestra runtime to
@@ -87,58 +76,25 @@ func CheckRuntimeHealth() RuntimeStatus {
// no ready replicas, its logs are saved to /tmp/orkestra/controlcenter.log.
// Returns the last 10 lines of the runtime log for inline display.
func FetchRuntimeLogs() (tail string, err error) {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- if mkErr := os.MkdirAll(runtimeLogDir, 0755); mkErr != nil {
- return "", fmt.Errorf("creating log dir: %w", mkErr)
- }
-
- runtimeLogs := fetchDeployLogs(ctx, OrkestraRuntime, OrkestraNamespace, 100)
- if writeErr := os.WriteFile(runtimeLogPath, []byte(runtimeLogs), 0644); writeErr != nil {
- return "", fmt.Errorf("writing runtime log: %w", writeErr)
+ tail, err = runtimeChecker.FetchLogs(100, runtimeLogDir, runtimeLogPath)
+ if err != nil {
+ return "", err
}
- ccName, _ := exec.CommandContext(ctx, "kubectl", "get", "deploy", controlCenterDeploy,
- "-n", OrkestraNamespace, "--ignore-not-found", "-o", "name").Output()
- if len(bytes.TrimSpace(ccName)) > 0 {
- ccReady, _ := exec.CommandContext(ctx, "kubectl", "get", "deploy", controlCenterDeploy,
- "-n", OrkestraNamespace, "-o", `jsonpath={.status.readyReplicas}`).Output()
- if r := strings.TrimSpace(string(ccReady)); r == "" || r == "0" {
- ccLogs := fetchDeployLogs(ctx, controlCenterDeploy, OrkestraNamespace, 100)
- _ = os.WriteFile(controlCenterLogPath, []byte(ccLogs), 0644)
- }
- }
+ // Optionally fetch control center logs if needed
+ _ = FetchControlCenterLogsIfNeeded()
- lines := strings.Split(strings.TrimRight(runtimeLogs, "\n"), "\n")
- if start := len(lines) - 10; start > 0 {
- lines = lines[start:]
- }
- return strings.Join(lines, "\n"), nil
+ return tail, nil
}
-// SyncRuntime restarts the Orkestra runtime Deployment so it picks up a
-// new Katalog ConfigMap. Waits for the rollout to complete (3 minute timeout).
+// SyncRuntime restarts the Orkestra runtime Deployment and waits for rollout.
func SyncRuntime() error {
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
- defer cancel()
-
- restart := exec.CommandContext(ctx, "kubectl", "rollout", "restart",
- "deploy/"+OrkestraRuntime, "-n", OrkestraNamespace)
- restart.Stdout = os.Stdout
- restart.Stderr = os.Stderr
- if err := restart.Run(); err != nil {
- return fmt.Errorf("restarting runtime: %w", err)
- }
+ return SyncDeployment(OrkestraRuntime, OrkestraNamespace, 3*time.Minute)
+}
- wait := exec.CommandContext(ctx, "kubectl", "rollout", "status",
- "deploy/"+OrkestraRuntime, "-n", OrkestraNamespace)
- wait.Stdout = os.Stdout
- wait.Stderr = os.Stderr
- if err := wait.Run(); err != nil {
- return fmt.Errorf("waiting for rollout: %w", err)
- }
- return nil
+// SyncGateway restarts the Orkestra gateway Deployment and waits for rollout.
+func SyncGateway() error {
+ return SyncDeployment(OrkestraGateway, OrkestraNamespace, 3*time.Minute)
}
// KatalogChanged returns true when .orkestra/katalog.yaml has uncommitted
@@ -157,53 +113,3 @@ func KatalogChanged(dir string) bool {
}
return false
}
-
-// ── private helpers ───────────────────────────────────────────────────────────
-
-func checkRuntimeOnce(ctx context.Context) RuntimeStatus {
- nameOut, err := exec.CommandContext(ctx,
- "kubectl", "get", "deploy", OrkestraRuntime,
- "-n", OrkestraNamespace, "--ignore-not-found", "-o", "name",
- ).Output()
- if err != nil || len(bytes.TrimSpace(nameOut)) == 0 {
- return RuntimeStatus{Reason: "deployment " + OrkestraRuntime + " not found in " + OrkestraNamespace}
- }
-
- readyOut, _ := exec.CommandContext(ctx,
- "kubectl", "get", "deploy", OrkestraRuntime,
- "-n", OrkestraNamespace, "-o", `jsonpath={.status.readyReplicas}`,
- ).Output()
- ready := strings.TrimSpace(string(readyOut))
- if ready == "" || ready == "0" {
- if reason := crashLoopReason(ctx); reason != "" {
- return RuntimeStatus{Reason: reason}
- }
- return RuntimeStatus{Reason: "no ready replicas"}
- }
- return RuntimeStatus{Running: true}
-}
-
-func crashLoopReason(ctx context.Context) string {
- out, err := exec.CommandContext(ctx, "kubectl", "get", "pods",
- "-n", OrkestraNamespace,
- "-o", `jsonpath={range .items[*]}{.metadata.name}{"\t"}{range .status.containerStatuses[*]}{.state.waiting.reason}{end}{"\n"}{end}`).Output()
- if err != nil {
- return ""
- }
- for _, line := range strings.Split(string(out), "\n") {
- parts := strings.SplitN(strings.TrimSpace(line), "\t", 2)
- if len(parts) == 2 && strings.Contains(parts[0], "runtime") && parts[1] == "CrashLoopBackOff" {
- return fmt.Sprintf("pod %s is in CrashLoopBackOff", parts[0])
- }
- }
- return ""
-}
-
-func fetchDeployLogs(ctx context.Context, deploy, ns string, tailLines int) string {
- out, err := exec.CommandContext(ctx, "kubectl", "logs",
- "deploy/"+deploy, "-n", ns, fmt.Sprintf("--tail=%d", tailLines)).CombinedOutput()
- if err != nil {
- return fmt.Sprintf("[failed to fetch logs for %s: %v]", deploy, err)
- }
- return string(out)
-}
diff --git a/pkg/ork/helper.go b/pkg/ork/helper.go
new file mode 100644
index 00000000..46121f3e
--- /dev/null
+++ b/pkg/ork/helper.go
@@ -0,0 +1,199 @@
+package ork
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/orkspace/orkestra/pkg/spinner"
+)
+
+// DeploymentHealthChecker handles health checks for any deployment
+type DeploymentHealthChecker struct {
+ Name string
+ Namespace string
+}
+
+// DeploymentStatus represents the status of a Kubernetes deployment
+type DeploymentStatus struct {
+ Running bool
+ Reason string // set when Running is false
+}
+
+// SyncDeployment restarts a Kubernetes Deployment and waits for the rollout to complete.
+// Returns an error if the restart or rollout status check fails.
+// Timeout is configurable (e.g., 3*time.Minute).
+func SyncDeployment(deploymentName, namespace string, timeout time.Duration) error {
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ // Restart the deployment
+ restart := exec.CommandContext(ctx, "kubectl", "rollout", "restart",
+ "deploy/"+deploymentName, "-n", namespace)
+ restart.Stdout = os.Stdout
+ restart.Stderr = os.Stderr
+ if err := restart.Run(); err != nil {
+ return fmt.Errorf("restarting deployment %s/%s: %w", namespace, deploymentName, err)
+ }
+
+ // Wait for rollout to complete
+ wait := exec.CommandContext(ctx, "kubectl", "rollout", "status",
+ "deploy/"+deploymentName, "-n", namespace)
+ wait.Stdout = os.Stdout
+ wait.Stderr = os.Stderr
+ if err := wait.Run(); err != nil {
+ return fmt.Errorf("waiting for rollout of %s/%s: %w", namespace, deploymentName, err)
+ }
+
+ return nil
+}
+
+// ResourceExists checks if a Kubernetes resource exists in the given namespace.
+// Returns true if the resource exists, false otherwise.
+// The resource should be specified in kubectl compatible format (e.g., "deploy", "service", "pod").
+
+// In future if needed:
+// ServiceExists := ResourceExists("service", "my-service", "default")
+// ConfigMapExists := ResourceExists("configmap", "my-config", "kube-system")
+func ResourceExists(resourceType, resourceName, namespace string) bool {
+ ctx, cancel := context.WithTimeout(context.Background(), resourceExistsTimeout)
+ defer cancel()
+
+ out, err := exec.CommandContext(ctx, "kubectl", "get", resourceType, resourceName,
+ "-n", namespace, "--ignore-not-found", "-o", "name").Output()
+
+ return err == nil && len(bytes.TrimSpace(out)) > 0
+}
+
+// CheckHealth waits up to timeout for a deployment to have at least one ready replica.
+// Polls every 2 seconds. Returns immediately if a custom failure condition is detected.
+func (d DeploymentHealthChecker) CheckHealth(timeout time.Duration, checkFailure func(ctx context.Context) string) DeploymentStatus {
+ spin := spinner.Start(fmt.Sprintf(" → Checking %s health...", d.Name))
+
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ ticker := time.NewTicker(2 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ spin.Failure()
+ return DeploymentStatus{
+ Reason: fmt.Sprintf("timeout (%s) waiting for %s to become ready", timeout, d.Name),
+ }
+ case <-ticker.C:
+ status := d.checkOnce(ctx)
+ if status.Running {
+ spin.Success()
+ return status
+ }
+ // Only fail fast on crashloop — everything else
+ // ("not found", "no ready replicas") is transient and should
+ // keep polling until the timeout expires.
+ if checkFailure != nil {
+ if reason := checkFailure(ctx); reason != "" {
+ spin.Failure()
+ return DeploymentStatus{Reason: reason}
+ }
+ }
+ }
+ }
+}
+
+// checkOnce performs a single health check on the deployment
+func (d DeploymentHealthChecker) checkOnce(ctx context.Context) DeploymentStatus {
+ // Check if deployment exists
+ nameOut, err := exec.CommandContext(ctx,
+ "kubectl", "get", "deploy", d.Name,
+ "-n", d.Namespace, "--ignore-not-found", "-o", "name",
+ ).Output()
+ if err != nil || len(bytes.TrimSpace(nameOut)) == 0 {
+ return DeploymentStatus{
+ Reason: fmt.Sprintf("deployment %s not found in %s", d.Name, d.Namespace),
+ }
+ }
+
+ // Check ready replicas
+ readyOut, _ := exec.CommandContext(ctx,
+ "kubectl", "get", "deploy", d.Name,
+ "-n", d.Namespace, "-o", `jsonpath={.status.readyReplicas}`,
+ ).Output()
+ ready := strings.TrimSpace(string(readyOut))
+ if ready == "" || ready == "0" {
+ return DeploymentStatus{
+ Reason: fmt.Sprintf("no ready replicas for %s/%s", d.Namespace, d.Name),
+ }
+ }
+ return DeploymentStatus{Running: true}
+}
+
+// FetchLogs saves the last maxLines from the deployment to a file and returns the last 10 lines
+func (d DeploymentHealthChecker) FetchLogs(maxLines int, logDir, logPath string) (tail string, err error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ if err := os.MkdirAll(logDir, 0755); err != nil {
+ return "", fmt.Errorf("creating log dir: %w", err)
+ }
+
+ logs := fetchDeployLogs(ctx, d.Name, d.Namespace, maxLines)
+ if err := os.WriteFile(logPath, []byte(logs), 0644); err != nil {
+ return "", fmt.Errorf("writing %s log: %w", d.Name, err)
+ }
+
+ lines := strings.Split(strings.TrimRight(logs, "\n"), "\n")
+ if start := len(lines) - 10; start > 0 {
+ lines = lines[start:]
+ }
+ return strings.Join(lines, "\n"), nil
+}
+
+// Exists checks if the deployment exists
+func (d DeploymentHealthChecker) Exists() bool {
+ return ResourceExists("deploy", d.Name, d.Namespace)
+}
+
+// HasReadyReplicas checks if the deployment has at least one ready replica
+func (d DeploymentHealthChecker) HasReadyReplicas(ctx context.Context) bool {
+ readyOut, err := exec.CommandContext(ctx,
+ "kubectl", "get", "deploy", d.Name,
+ "-n", d.Namespace, "-o", `jsonpath={.status.readyReplicas}`,
+ ).Output()
+ if err != nil {
+ return false
+ }
+ ready := strings.TrimSpace(string(readyOut))
+ return ready != "" && ready != "0"
+}
+
+// ── private helpers ───────────────────────────────────────────────────────────
+func crashLoopReason(ctx context.Context) string {
+ out, err := exec.CommandContext(ctx, "kubectl", "get", "pods",
+ "-n", OrkestraNamespace,
+ "-o", `jsonpath={range .items[*]}{.metadata.name}{"\t"}{range .status.containerStatuses[*]}{.state.waiting.reason}{end}{"\n"}{end}`).Output()
+ if err != nil {
+ return ""
+ }
+ for _, line := range strings.Split(string(out), "\n") {
+ parts := strings.SplitN(strings.TrimSpace(line), "\t", 2)
+ if len(parts) == 2 && strings.Contains(parts[0], "runtime") && parts[1] == "CrashLoopBackOff" {
+ return fmt.Sprintf("pod %s is in CrashLoopBackOff", parts[0])
+ }
+ }
+ return ""
+}
+
+func fetchDeployLogs(ctx context.Context, deploy, ns string, tailLines int) string {
+ out, err := exec.CommandContext(ctx, "kubectl", "logs",
+ "deploy/"+deploy, "-n", ns, fmt.Sprintf("--tail=%d", tailLines)).CombinedOutput()
+ if err != nil {
+ return fmt.Sprintf("[failed to fetch logs for %s: %v]", deploy, err)
+ }
+ return string(out)
+}
diff --git a/pkg/reconciler/generic_registry.go b/pkg/reconciler/generic_registry.go
index 938cc4ee..1dfe145f 100644
--- a/pkg/reconciler/generic_registry.go
+++ b/pkg/reconciler/generic_registry.go
@@ -30,4 +30,9 @@ type KatalogRegistry interface {
// to know the CRD's short name. Lookup is case-insensitive on both
// key and value. Returns nil, false when no CRD matches.
GetInformerByLabel(key, value string) (cache.SharedIndexInformer, bool)
+
+ // GetCrossAccessByName returns the CrossAccess field of the named CRD.
+ // nil means the CRD allows cross reads (default). *false means opted out.
+ // Returns nil when the CRD is not registered.
+ GetCrossAccessByName(name string) *bool
}
diff --git a/pkg/reconciler/run_cross.go b/pkg/reconciler/run_cross.go
index 91bcb3aa..b3310bb3 100644
--- a/pkg/reconciler/run_cross.go
+++ b/pkg/reconciler/run_cross.go
@@ -86,13 +86,20 @@ func fetchCrossViaHTTP(ctx context.Context, endpoint, token string) map[string]i
// Zero API server calls — pure in-memory map lookup.
//
// key is "namespace/name" for namespaced CRDs or "name" for cluster-scoped.
+// sourceCrossAccess is the CrossAccess field of the target CRD entry — nil means
+// allowed (default). When false, returns notFoundCrossResult() without reading.
//
// Returns a consistent map shape regardless of whether the CR was found —
// callers use .found == "true" to gate their logic.
func ReadCrossFromInformer(
indexer cache.Indexer,
key string,
+ sourceCrossAccess *bool,
) map[string]interface{} {
+ if sourceCrossAccess != nil && !*sourceCrossAccess {
+ return notFoundCrossResult()
+ }
+
raw, exists, err := indexer.GetByKey(key)
if err != nil || !exists {
return notFoundCrossResult()
diff --git a/pkg/reconciler/run_template_reconcile.go b/pkg/reconciler/run_template_reconcile.go
index 100ed60b..bc189002 100644
--- a/pkg/reconciler/run_template_reconcile.go
+++ b/pkg/reconciler/run_template_reconcile.go
@@ -329,7 +329,8 @@ func (r *GenericReconciler[PTR]) readCross(
if ns == "" {
ns = obj.GetNamespace()
}
- data := ReadCrossFromInformer(inf.GetIndexer(), crossKey(ns, name))
+ crossAccess := r.katalogRegistry.GetCrossAccessByName(decl.Crd)
+ data := ReadCrossFromInformer(inf.GetIndexer(), crossKey(ns, name), crossAccess)
result[as] = data
continue
}
diff --git a/pkg/simulate/docs/02-steady-state.md b/pkg/simulate/docs/02-steady-state.md
index cbde8343..7089bd05 100644
--- a/pkg/simulate/docs/02-steady-state.md
+++ b/pkg/simulate/docs/02-steady-state.md
@@ -17,7 +17,7 @@ If the simulation runs all requested cycles without converging:
`--cycles N` runs exactly N reconcile cycles regardless of steady state. Use it to see how your operator behaves over time:
```sh
-ork simulate -f katalog.yaml --cr cr.yaml --cycles 20
+ork simulate --cr cr.yaml --cycles 20
```
A persistent loop of creates or updates in later cycles usually means a drift condition in `onReconcile` — the operator is always writing even when nothing changed.
diff --git a/pkg/types/e2e.go b/pkg/types/e2e.go
index 88e6ad43..c9ae56ac 100644
--- a/pkg/types/e2e.go
+++ b/pkg/types/e2e.go
@@ -127,10 +127,40 @@ type SetupWait struct {
// Committed alongside the katalog, it drives `ork e2e` — the same command
// that runs locally, in CI, and inside the GitHub Action.
type E2E struct {
- APIVersion string `yaml:"apiVersion"`
- Kind string `yaml:"kind"`
- Metadata E2EMeta `yaml:"metadata"`
- Spec E2ESpec `yaml:"spec"`
+ APIVersion string `yaml:"apiVersion"`
+ Kind string `yaml:"kind"`
+ Metadata E2EMeta `yaml:"metadata"`
+ Spec E2ESpec `yaml:"spec"`
+ Imports []E2EImport `yaml:"imports,omitempty"`
+}
+
+// E2EImport references another E2E file to run after this one completes.
+// By default imports share the same cluster. Set freshCluster: true to
+// provision a new cluster for that import instead.
+//
+// Shorthand — a plain path string is equivalent to {path: }:
+//
+// imports:
+// - ./auth-e2e.yaml
+// - ./rbac-e2e.yaml
+// - path: ./infra-e2e.yaml
+// freshCluster: true
+type E2EImport struct {
+ // Path is the path to another E2E spec file (must be kind: E2E).
+ Path string `yaml:"path"`
+ // FreshCluster provisions a new kind cluster for this import instead of
+ // reusing the parent's cluster. Default: false (share parent cluster).
+ FreshCluster bool `yaml:"freshCluster,omitempty"`
+}
+
+// UnmarshalYAML allows imports to be written as a plain string path.
+func (i *E2EImport) UnmarshalYAML(value *yaml.Node) error {
+ if value.Kind == yaml.ScalarNode {
+ i.Path = value.Value
+ return nil
+ }
+ type plain E2EImport
+ return value.Decode((*plain)(i))
}
type E2EMeta struct {
diff --git a/pkg/types/katalog.go b/pkg/types/katalog.go
index ea7d405d..eada541c 100644
--- a/pkg/types/katalog.go
+++ b/pkg/types/katalog.go
@@ -40,6 +40,12 @@ type KatalogFile struct {
Spec KatalogSpec `yaml:"spec"`
Security KatalogSecurity `yaml:"security"`
+ // CrossAccess sets the default cross-read policy for all CRDs in this Katalog.
+ // When false, no other Katalog may read any CRD in this one via cross:.
+ // Individual CRDs may override with their own crossAccess field.
+ // Defaults to true (open) when not declared.
+ CrossAccess *bool `yaml:"crossAccess,omitempty" json:"crossAccess,omitempty"`
+
// Gateway declares how the gateway is deployed for this Katalog.
// When gateway.standalone: true, the gateway runs without a runtime operator
// and spec: may be empty.
@@ -75,6 +81,18 @@ type KatalogMeta struct {
// Name is the required unique identifier of the Katalog.
Name string `yaml:"name" json:"name,omitempty"`
+ // Namespace scopes this Katalog to a logical tenant or team within a single
+ // runtime. Defaults to "default" when not declared — identical to Kubernetes
+ // namespace semantics. The Control Center groups CRDs by namespace so each
+ // team sees only its own panel.
+ Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"`
+
+ // ClusterName identifies the cluster this Katalog runs in.
+ // Used by the Control Center for cluster-level filtering when multiple
+ // runtimes are connected. Katalog value takes precedence over the
+ // CLUSTER_NAME env var. Empty when neither is set.
+ ClusterName string `yaml:"clusterName,omitempty" json:"clusterName,omitempty"`
+
// Description provides a human-readable explanation of the Katalog's purpose.
Description string `yaml:"description,omitempty" json:"description,omitempty"`
diff --git a/pkg/types/types_crd_entry.go b/pkg/types/types_crd_entry.go
index 2f132c3f..60770019 100644
--- a/pkg/types/types_crd_entry.go
+++ b/pkg/types/types_crd_entry.go
@@ -24,9 +24,28 @@ type CRDEntry struct {
// Injected from the map key during loading — never set from YAML.
Name string `yaml:"-" json:"name" validate:"required,hostname_rfc1123"`
- // KatalogName — unique identifier for the the katalog in the runtime
+ // KatalogName — unique identifier for the katalog in the runtime.
KatalogName string `yaml:"-" json:"katalogName,omitempty"`
+ // KatalogNamespace — the namespace this CRD's Katalog belongs to.
+ // Defaults to "default" when not declared. Used by the Control Center to
+ // group CRDs by team/tenant within a single runtime.
+ KatalogNamespace string `yaml:"-" json:"katalogNamespace,omitempty"`
+
+ // KatalogDescription — the description from the source Katalog's metadata.
+ // Falls back to the Komposer's description when the sub-Katalog has none.
+ KatalogDescription string `yaml:"-" json:"katalogDescription,omitempty"`
+
+ // KatalogVersion — the version from the source Katalog's metadata.
+ // Falls back to the Komposer's version when the sub-Katalog has none.
+ KatalogVersion string `yaml:"-" json:"katalogVersion,omitempty"`
+
+ // CrossAccess controls whether other Katalogs can read this CRD's CR state
+ // via the cross: block. Defaults to true (readable). Set to false to opt
+ // this CRD out of cross reads — the reconciler returns empty for any
+ // cross: reference that targets an opted-out CRD.
+ CrossAccess *bool `yaml:"crossAccess,omitempty" json:"crossAccess,omitempty"`
+
// Enabled — include this CRD in the runtime. false = skipped entirely.
// WARNING: only set to false after stripping Orkestra finalizers from all
// live CRs — disabled CRDs with live finalizers will cause stuck objects.