diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 2967f9a2..603c15e2 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -190,3 +190,22 @@ jobs: echo "=== ReconcilerProbe ===" && kubectl get reconcilerprobe probe -o yaml || true echo "=== All resources in default ===" && kubectl get all || true echo "=== Operator logs ===" && kubectl logs -l app.kubernetes.io/name=orkestra --tail=100 || true + + # ── Fixture: Orkestra Helm chart ──────────────────────────────────────────── + # Runs only when the chart or its fixtures change. + # Installs the chart into a kind cluster via ork e2e and asserts infrastructure. + fixture-chart: + name: Fixture — Orkestra chart (kind cluster) + runs-on: ubuntu-latest + if: > + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.changed_files, 'charts/') + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run chart e2e + uses: orkspace/orkestra-action@main + with: + working-directory: charts/orkestra + e2e: "true" diff --git a/charts/orkestra/Chart.yaml b/charts/orkestra/Chart.yaml index b55d963d..f05dc5be 100644 --- a/charts/orkestra/Chart.yaml +++ b/charts/orkestra/Chart.yaml @@ -7,7 +7,7 @@ description: >- Control Center. Runtime and Gateway deploy independently. type: application version: 1.7.7 -appVersion: "v0.7.7" +appVersion: "0.7.7" keywords: - orkestra diff --git a/charts/orkestra/e2e.yaml b/charts/orkestra/e2e.yaml new file mode 100644 index 00000000..85a1fbef --- /dev/null +++ b/charts/orkestra/e2e.yaml @@ -0,0 +1,141 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: E2E +metadata: + name: orkestra-chart-e2e + description: > + custom.target: kubernetes — Orkestra Helm chart installed via setup.helm + with gateway enabled. CRD, bundle, and fixture CR are applied before the + chart so Orkestra reconciles on first boot. Asserts that all three + deployments are ready, that conditional templates are not created when their + flags are off (default values), and that the runtime reconciles the fixture + CR producing a Deployment and Service. + +spec: + custom: + target: kubernetes + + cluster: + provider: kind + name: ork-chart-e2e + reuse: false + + setup: + apply: + - path: fixtures/crd.yaml + wait: + - kind: CustomResourceDefinition + name: websites.demo.orkestra.io + timeout: 30s + - path: fixtures/orkestra-bundle.yaml + wait: + - kind: ConfigMap + name: orkestra-katalog + namespace: orkestra-system + timeout: 30s + - path: fixtures/cr.yaml + wait: + - kind: Website + name: hello-website + namespace: default + timeout: 30s + helm: + - chart: ./ + release: orkestra + namespace: orkestra-system + createNamespace: true + values: + gateway.enabled: true + wait: + - kind: Deployment + name: orkestra-runtime + namespace: orkestra-system + ready: true + timeout: 120s + - kind: Deployment + name: orkestra-gateway + namespace: orkestra-system + ready: true + timeout: 120s + - kind: Deployment + name: orkestra-cc + namespace: orkestra-system + ready: true + timeout: 120s + + expect: + - name: All three deployments are ready + after: setup-complete + timeout: 120s + resources: + - kind: Deployment + name: orkestra-runtime + namespace: orkestra-system + ready: true + - kind: Deployment + name: orkestra-gateway + namespace: orkestra-system + ready: true + - kind: Deployment + name: orkestra-cc + namespace: orkestra-system + ready: true + + - name: Services created for runtime, gateway, and control center + after: setup-complete + timeout: 30s + resources: + - kind: Service + name: orkestra-runtime + namespace: orkestra-system + - kind: Service + name: orkestra-gateway + namespace: orkestra-system + - kind: Service + name: orkestra-cc + namespace: orkestra-system + + - name: PodDisruptionBudgets created (runtime, gateway, and cc — enabled by default) + after: setup-complete + timeout: 30s + resources: + - kind: PodDisruptionBudget + name: orkestra-runtime + namespace: orkestra-system + - kind: PodDisruptionBudget + name: orkestra-gateway + namespace: orkestra-system + - kind: PodDisruptionBudget + name: orkestra-cc + namespace: orkestra-system + + - name: Ingress not created (controlCenter.ingress.enabled defaults to false) + after: setup-complete + timeout: 30s + resources: + - kind: Ingress + namespace: orkestra-system + count: 0 + + - name: NetworkPolicies not created (networkPolicy.enabled defaults to false) + timeout: 30s + resources: + - kind: NetworkPolicy + namespace: orkestra-system + count: 0 + + - name: Namespace protection webhook registered by gateway + timeout: 60s + resources: + - kind: ValidatingWebhookConfiguration + name: orkestra-namespace-protection + + - name: Runtime reconciles fixture CR — Deployment and Service created + timeout: 90s + resources: + - kind: Deployment + name: hello-website + namespace: default + ready: true + - kind: Service + name: hello-website-svc + namespace: default diff --git a/charts/orkestra/fixtures/cr.yaml b/charts/orkestra/fixtures/cr.yaml new file mode 100644 index 00000000..9e660b54 --- /dev/null +++ b/charts/orkestra/fixtures/cr.yaml @@ -0,0 +1,9 @@ +apiVersion: demo.orkestra.io/v1alpha1 +kind: Website +metadata: + name: hello-website + namespace: default +spec: + image: nginx:1.25 + replicas: 1 + port: 80 diff --git a/charts/orkestra/fixtures/crd.yaml b/charts/orkestra/fixtures/crd.yaml new file mode 100644 index 00000000..b50858a8 --- /dev/null +++ b/charts/orkestra/fixtures/crd.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: websites.demo.orkestra.io +spec: + group: demo.orkestra.io + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [image] + properties: + image: + type: string + replicas: + type: integer + default: 1 + port: + type: integer + default: 80 + status: + type: object + x-kubernetes-preserve-unknown-fields: true + names: + kind: Website + plural: websites + singular: website + scope: Namespaced diff --git a/charts/orkestra/fixtures/katalog.yaml b/charts/orkestra/fixtures/katalog.yaml new file mode 100644 index 00000000..b43b8874 --- /dev/null +++ b/charts/orkestra/fixtures/katalog.yaml @@ -0,0 +1,45 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: hello-website + version: 0.1.0 + description: Fixture katalog for chart e2e — one CRD, one Deployment, one Service. + +security: + namespaceProtection: + enabled: true + cleanupOnShutdown: true + +gateway: + enabled: true + +spec: + crds: + website: + apiTypes: + group: demo.orkestra.io + version: v1alpha1 + kind: Website + plural: websites + allowedNamespaces: + - default + + operatorBox: + status: + fields: + - path: phase + value: "Running" + + onCreate: + deployments: + - name: "{{ .metadata.name }}" + image: "{{ .spec.image }}" + replicas: "{{ .spec.replicas }}" + port: "{{ .spec.port }}" + reconcile: true + + services: + - name: "{{ .metadata.name }}-svc" + port: "80" + targetPort: "{{ .spec.port }}" + reconcile: true diff --git a/charts/orkestra/fixtures/orkestra-bundle.yaml b/charts/orkestra/fixtures/orkestra-bundle.yaml new file mode 100644 index 00000000..b27a8695 --- /dev/null +++ b/charts/orkestra/fixtures/orkestra-bundle.yaml @@ -0,0 +1,253 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + labels: + app.kubernetes.io/name: orkestra + app.kubernetes.io/tag: orkestra-internal + orkestra.io/deletion-protection: "true" + name: orkestra-system +spec: {} +status: {} + + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: orkestra + app.kubernetes.io/tag: orkestra-internal + orkestra.io/deletion-protection: "true" + name: orkestra + namespace: orkestra-system + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: orkestra + app.kubernetes.io/tag: orkestra-internal + orkestra.io/deletion-protection: "true" + name: orkestra-gateway + namespace: orkestra-system + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: orkestra + app.kubernetes.io/tag: orkestra-internal + orkestra.io/deletion-protection: "true" + name: orkestra-cc + namespace: orkestra-system + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: orkestra + app.kubernetes.io/tag: orkestra-internal + orkestra.io/deletion-protection: "true" + name: orkestra-orkestra-system +rules: +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - create + - update +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - demo.orkestra.io + resources: + - websites + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - demo.orkestra.io + resources: + - websites/status + verbs: + - get + - update + - patch +- apiGroups: + - "" + resources: + - services + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - apps + resources: + - deployments + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: orkestra + app.kubernetes.io/tag: orkestra-internal + orkestra.io/deletion-protection: "true" + name: orkestra-orkestra-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: orkestra-orkestra-system +subjects: +- kind: ServiceAccount + name: orkestra + namespace: orkestra-system + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: orkestra + app.kubernetes.io/tag: orkestra-internal + orkestra.io/deletion-protection: "true" + name: orkestra-gateway-orkestra-system +rules: +- apiGroups: + - admissionregistration.k8s.io + resources: + - validatingwebhookconfigurations + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: orkestra + app.kubernetes.io/tag: orkestra-internal + orkestra.io/deletion-protection: "true" + name: orkestra-gateway-orkestra-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: orkestra-gateway-orkestra-system +subjects: +- kind: ServiceAccount + name: orkestra-gateway + namespace: orkestra-system + + +--- +apiVersion: v1 +data: + katalog.yaml: | + apiVersion: orkestra.orkspace.io/v1 + kind: Katalog + metadata: + name: hello-website + description: Fixture katalog for chart e2e — one CRD, one Deployment, one Service. + version: 0.1.0 + spec: + crds: + website: + katalogName: hello-website + katalogNamespace: default + katalogDescription: Fixture katalog for chart e2e — one CRD, one Deployment, one Service. + katalogVersion: 0.1.0 + description: 'Website CRD, GVK: demo.orkestra.io/v1alpha1, Kind=Website' + mode: dynamic + apiTypes: + group: demo.orkestra.io + version: v1alpha1 + kind: Website + plural: websites + apiPath: /apis + workers: 3 + resync: 15s + operatorBox: + onCreate: + deployments: + - name: '{{ .metadata.name }}' + image: '{{ .spec.image }}' + replicas: '{{ .spec.replicas }}' + port: '{{ .spec.port }}' + reconcile: true + services: + - name: '{{ .metadata.name }}-svc' + port: "80" + targetPort: '{{ .spec.port }}' + reconcile: true + status: + fields: + - path: phase + value: Running + queue: + maxDepth: 100 + failureThreshold: 100 + allowedNamespaces: + - default + warnings: [] + security: + namespaceProtection: + enabled: true + cleanupOnShutdown: true + gateway: + enabled: true +kind: ConfigMap +metadata: + labels: + app.kubernetes.io/name: orkestra + app.kubernetes.io/tag: orkestra-internal + orkestra.io/deletion-protection: "true" + name: orkestra-katalog + namespace: orkestra-system + diff --git a/charts/orkestra/templates/pdb.yaml b/charts/orkestra/templates/pdb.yaml index 61611024..17d25f34 100644 --- a/charts/orkestra/templates/pdb.yaml +++ b/charts/orkestra/templates/pdb.yaml @@ -3,7 +3,7 @@ apiVersion: policy/v1 kind: PodDisruptionBudget metadata: - name: {{ include "orkestra.fullname" . }} + name: {{ include "orkestra.fullname" . }}-runtime namespace: {{ .Release.Namespace }} labels: {{- include "orkestra.labels" . | nindent 4 }} @@ -21,6 +21,31 @@ spec: {{- end }} {{- end }} +--- +# Gateway PodDisruptionBudget +{{- if and .Values.gateway.pdb.enabled .Values.gateway.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "orkestra.fullname" . }}-gateway + namespace: {{ .Release.Namespace }} + labels: + {{- include "orkestra.labels" . | nindent 4 }} + app.kubernetes.io/component: gateway +spec: + selector: + matchLabels: + {{- include "orkestra.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: gateway + {{- if .Values.gateway.pdb.minAvailable }} + minAvailable: {{ .Values.gateway.pdb.minAvailable }} + {{- end }} + {{- if .Values.gateway.pdb.maxUnavailable }} + maxUnavailable: {{ .Values.gateway.pdb.maxUnavailable }} + {{- end }} +{{- end }} + +--- # Control Center PodDisruptionBudget {{- if and .Values.controlCenter.pdb.enabled .Values.controlCenter.enabled }} apiVersion: policy/v1 diff --git a/charts/orkestra/values.yaml b/charts/orkestra/values.yaml index 8de6dbed..4a9bd055 100644 --- a/charts/orkestra/values.yaml +++ b/charts/orkestra/values.yaml @@ -181,6 +181,12 @@ gateway: type: ClusterIP annotations: {} + # PDB + pdb: + enabled: true + minAvailable: 1 + # maxUnavailable: 5 + # HPA hpa: enabled: false diff --git a/cmd/cli/run_dev_apply.go b/cmd/cli/run_dev_apply.go index f5bdb270..1c867da6 100644 --- a/cmd/cli/run_dev_apply.go +++ b/cmd/cli/run_dev_apply.go @@ -221,7 +221,7 @@ func applySetupIfNeeded(ctx context.Context, katalogPath string, m *merger.Merge } for _, setupFile := range entry.Setup.Apply { - path := setupFile + path := setupFile.Path if !filepath.IsAbs(path) && !strings.HasPrefix(path, "http") { path = filepath.Join(katalogDir, path) } diff --git a/cmd/cli/validate.go b/cmd/cli/validate.go index e3d7b9b2..cf4500fb 100644 --- a/cmd/cli/validate.go +++ b/cmd/cli/validate.go @@ -14,6 +14,7 @@ import ( "github.com/orkspace/orkestra/pkg/e2e" "github.com/orkspace/orkestra/pkg/katalog" "github.com/orkspace/orkestra/pkg/konfig" + motifpkg "github.com/orkspace/orkestra/pkg/motif" orktypes "github.com/orkspace/orkestra/pkg/types" orkutils "github.com/orkspace/orkestra/pkg/utils" "github.com/spf13/cobra" @@ -241,8 +242,8 @@ func validateE2EFile(path string) error { if doc.Spec.CRD == "" && doc.Spec.Init == nil && !isCustom { errs = append(errs, "spec.crd is required (or spec.init for example packs, spec.custom.target for custom targets, 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 doc.Spec.CR == "" && doc.Spec.Init == nil && !isCustom { + errs = append(errs, "spec.cr is required (or spec.init for example packs, spec.custom.target for custom targets, or imports)") } if len(doc.Spec.Expect) == 0 { errs = append(errs, "spec.expect must contain at least one expectation") @@ -251,8 +252,19 @@ func validateE2EFile(path string) error { 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)) + after := exp.After + if after == "" { + after = orktypes.AfterSetupComplete + } + validAfter := false + for _, v := range orktypes.ValidAfterValues { + if after == v { + validAfter = true + break + } + } + if !validAfter { + errs = append(errs, fmt.Sprintf("spec.expect[%d].after must be one of %v (got %q)", i, orktypes.ValidAfterValues, 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)) @@ -283,13 +295,22 @@ func validateE2EFile(path string) error { if isCustom { fmt.Printf(" %s\n", gray(fmt.Sprintf("mode : custom target (%s — Orkestra install skipped)", doc.Spec.Custom.Target))) } - 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 doc.Spec.Katalog != "" { + fmt.Printf(" %s\n", gray("katalog : "+doc.Spec.Katalog)) + } + if doc.Spec.CRD != "" { + fmt.Printf(" %s\n", gray("crd : "+doc.Spec.CRD)) + } + if doc.Spec.CR != "" { + fmt.Printf(" %s\n", gray("cr : "+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, ", "))) + paths := make([]string, len(s.Apply)) + for i, e := range s.Apply { + paths[i] = e.Path + } + fmt.Printf(" %s\n", gray("setup.apply : "+strings.Join(paths, ", "))) } if len(s.Helm) > 0 { fmt.Printf(" %s\n", gray(fmt.Sprintf("setup.helm : %d chart(s)", len(s.Helm)))) @@ -318,8 +339,12 @@ func validateE2EFile(path string) error { if to == "" { to = "60s" } + after := exp.After + if after == "" { + after = orktypes.AfterSetupComplete + } fmt.Printf(" %s\n", - gray(fmt.Sprintf("%-40s after: %-12s timeout: %s", exp.Name, exp.After, to))) + gray(fmt.Sprintf("%-40s after: %-12s timeout: %s", exp.Name, after, to))) } fmt.Println() fmt.Println(strings.Repeat("─", 60)) @@ -355,106 +380,96 @@ func validateSimulateFile(path string) error { baseDir := filepath.Dir(path) isAggregator := len(doc.Imports) > 0 && doc.Spec == nil - check := func(ok bool, pass, fail string) { - if ok { - fmt.Printf(" %s %s\n", successMark(), pass) - } else { - fmt.Printf(" %s %s\n", failureMark(), fail) - } - } - var errs []string if doc.Metadata.Name == "" { errs = append(errs, "metadata.name is required") - fmt.Printf(" %s metadata.name is required\n", failureMark()) - } else { - fmt.Printf(" %s metadata.name: %s\n", successMark(), doc.Metadata.Name) } - if !isAggregator && doc.Spec == nil { errs = append(errs, "spec or imports is required") - fmt.Printf(" %s spec or imports is required\n", failureMark()) } - if isAggregator { for _, f := range doc.Imports { p := f if !filepath.IsAbs(p) { p = filepath.Join(baseDir, p) } - check(fileExists(p), "imports: "+f+" (found)", "imports: "+f+" (not found)") if !fileExists(p) { errs = append(errs, "import not found: "+f) } } } - if doc.Spec != nil { if doc.Spec.Katalog == "" { errs = append(errs, "spec.katalog is required") - fmt.Printf(" %s spec.katalog is required\n", failureMark()) - } else { - p := filepath.Join(baseDir, doc.Spec.Katalog) - check(fileExists(p), "spec.katalog: "+doc.Spec.Katalog+" (found)", "spec.katalog: "+doc.Spec.Katalog+" (not found)") - if !fileExists(p) { - errs = append(errs, "spec.katalog not found: "+doc.Spec.Katalog) - } + } else if !fileExists(filepath.Join(baseDir, doc.Spec.Katalog)) { + errs = append(errs, "spec.katalog not found: "+doc.Spec.Katalog) } - if doc.Spec.CR == "" { errs = append(errs, "spec.cr is required") - fmt.Printf(" %s spec.cr is required\n", failureMark()) - } else { - p := filepath.Join(baseDir, doc.Spec.CR) - check(fileExists(p), "spec.cr: "+doc.Spec.CR+" (found)", "spec.cr: "+doc.Spec.CR+" (not found)") - if !fileExists(p) { - errs = append(errs, "spec.cr not found: "+doc.Spec.CR) - } + } else if !fileExists(filepath.Join(baseDir, doc.Spec.CR)) { + errs = append(errs, "spec.cr not found: "+doc.Spec.CR) } - - if doc.Spec.Cycles <= 0 { - fmt.Printf(" %s spec.cycles: not set — defaulting to 10\n", yellow("⚠")) - } else { - fmt.Printf(" %s spec.cycles: %d\n", successMark(), doc.Spec.Cycles) - } - if doc.Spec.Expect != nil { - fmt.Printf(" %s expect.ops: %d rule(s)\n", successMark(), len(doc.Spec.Expect.Ops)) validVerbs := map[string]bool{"create": true, "update": true, "delete": true, "patch": true} for i, rule := range doc.Spec.Expect.Ops { switch { case rule.Verb == "" || rule.Resource == "": errs = append(errs, fmt.Sprintf("expect.ops[%d]: verb and resource are required", i)) - fmt.Printf(" %s expect.ops[%d]: verb and resource are required\n", failureMark(), i) case !validVerbs[rule.Verb]: errs = append(errs, fmt.Sprintf("expect.ops[%d]: invalid verb %q (must be create, update, delete, or patch)", i, rule.Verb)) - fmt.Printf(" %s expect.ops[%d]: invalid verb %q\n", failureMark(), i, rule.Verb) } } for i, rule := range doc.Spec.Expect.Absent { switch { case rule.Verb == "" || rule.Resource == "": errs = append(errs, fmt.Sprintf("expect.absent[%d]: verb and resource are required", i)) - fmt.Printf(" %s expect.absent[%d]: verb and resource are required\n", failureMark(), i) case !validVerbs[rule.Verb]: errs = append(errs, fmt.Sprintf("expect.absent[%d]: invalid verb %q (must be create, update, delete, or patch)", i, rule.Verb)) - fmt.Printf(" %s expect.absent[%d]: invalid verb %q\n", failureMark(), i, rule.Verb) } } - if len(doc.Spec.Expect.Absent) > 0 { - fmt.Printf(" %s expect.absent: %d rule(s)\n", successMark(), len(doc.Spec.Expect.Absent)) - } } } - fmt.Println() - fmt.Println(strings.Repeat("─", 60)) - if len(errs) > 0 { + for _, e := range errs { + fmt.Printf(" %s %s\n", failureMark(), e) + } + fmt.Println() + fmt.Println(strings.Repeat("─", 60)) return fmt.Errorf("%d validation error(s) in %s", len(errs), path) } + // Success — print structured summary matching the Katalog/E2E style. + fmt.Printf("%s %s\n", healthIcon("ready"), bold(doc.Metadata.Name)) + if doc.Metadata.Description != "" { + fmt.Printf(" %s\n", gray(doc.Metadata.Description)) + } + fmt.Println() + + if isAggregator { + fmt.Printf(" %s\n", gray(fmt.Sprintf("imports : %d file(s)", len(doc.Imports)))) + for _, f := range doc.Imports { + fmt.Printf(" %s %s\n", healthIcon("ready"), gray(f)) + } + } else { + cycles := doc.Spec.Cycles + if cycles <= 0 { + cycles = 10 + } + fmt.Printf(" %s\n", gray(fmt.Sprintf("katalog : %s", doc.Spec.Katalog))) + fmt.Printf(" %s\n", gray(fmt.Sprintf("cr : %s", doc.Spec.CR))) + fmt.Printf(" %s\n", gray(fmt.Sprintf("cycles : %d", cycles))) + if doc.Spec.Expect != nil { + fmt.Printf(" %s\n", gray(fmt.Sprintf("ops : %d rule(s)", len(doc.Spec.Expect.Ops)))) + if len(doc.Spec.Expect.Absent) > 0 { + fmt.Printf(" %s\n", gray(fmt.Sprintf("absent : %d rule(s)", len(doc.Spec.Expect.Absent)))) + } + } + } + + fmt.Println() + fmt.Println(strings.Repeat("─", 60)) if isAggregator { fmt.Printf("%d import(s) valid\n", len(doc.Imports)) } else { @@ -474,18 +489,81 @@ func validateMotifFile(path string) error { fmt.Println() errs := katalog.ValidateMotif(path) - if len(errs) == 0 { - icon := healthIcon("ready") - fmt.Printf("%s %s\n", icon, bold(path)) - fmt.Printf(" %s\n", gray("valid")) - return nil + if len(errs) > 0 { + for _, e := range errs { + fmt.Printf(" %s %s\n", failureMark(), e.Error()) + } + fmt.Println() + fmt.Println(strings.Repeat("─", 60)) + return fmt.Errorf("%d validation error(s) in %s", len(errs), path) } - for _, e := range errs { - fmt.Printf(" %s %s\n", failureMark(), e.Error()) + // Load the motif to build the structured summary. + m, err := motifpkg.Load(path) + if err != nil { + // ValidateMotif already passed, so this is unexpected — degrade gracefully. + fmt.Printf("%s %s\n", healthIcon("ready"), bold(path)) + } else { + fmt.Printf("%s %s\n", healthIcon("ready"), bold(m.Metadata.Name)) + if m.Metadata.Description != "" { + fmt.Printf(" %s\n", gray(m.Metadata.Description)) + } + if m.Metadata.Version != "" { + fmt.Println() + fmt.Printf(" %s\n", gray("version : "+m.Metadata.Version)) + } + if len(m.Inputs) > 0 { + if m.Metadata.Version == "" { + fmt.Println() + } + fmt.Printf(" %s\n", gray(fmt.Sprintf("inputs : %d", len(m.Inputs)))) + } + if summary := motifResourceSummary(m); summary != "" { + fmt.Printf(" %s\n", gray("resources: "+summary)) + } } + fmt.Println() - return fmt.Errorf("%d validation error(s) in %s", len(errs), path) + fmt.Println(strings.Repeat("─", 60)) + fmt.Println("Motif is valid") + return nil +} + +// motifResourceSummary returns a compact string listing non-empty resource types +// and their counts, e.g. "deployments(1) services(1) networkPolicies(2)". +func motifResourceSummary(m *orktypes.Motif) string { + if m.Resources == nil { + return "" + } + ht := m.Resources.HookTemplates + var parts []string + add := func(kind string, n int) { + if n > 0 { + parts = append(parts, fmt.Sprintf("%s(%d)", kind, n)) + } + } + add("deployments", len(ht.Deployments)) + add("statefulSets", len(ht.StatefulSets)) + add("daemonSets", len(ht.DaemonSets)) + add("services", len(ht.Services)) + add("ingresses", len(ht.Ingresses)) + add("networkPolicies", len(ht.NetworkPolicies)) + add("jobs", len(ht.Jobs)) + add("cronJobs", len(ht.CronJobs)) + add("secrets", len(ht.Secrets)) + add("configMaps", len(ht.ConfigMaps)) + add("serviceAccounts", len(ht.ServiceAccounts)) + add("roles", len(ht.Roles)) + add("roleBindings", len(ht.RoleBindings)) + add("clusterRoles", len(ht.ClusterRoles)) + add("clusterRoleBindings", len(ht.ClusterRoleBindings)) + add("resourceQuotas", len(ht.ResourceQuotas)) + add("limitRanges", len(ht.LimitRanges)) + add("namespaces", len(ht.Namespaces)) + add("persistentVolumeClaims", len(ht.PersistentVolumeClaims)) + add("horizontalPodAutoscalers", len(ht.HorizontalPodAutoscalers)) + add("podDisruptionBudgets", len(ht.PodDisruptionBudgets)) + return strings.Join(parts, " ") } func init() { diff --git a/documentation/concepts/simulate/06-limitations.md b/documentation/concepts/simulate/06-limitations.md index fa9762a6..9c4734fd 100644 --- a/documentation/concepts/simulate/06-limitations.md +++ b/documentation/concepts/simulate/06-limitations.md @@ -10,6 +10,7 @@ The fake cluster has no external connectivity and no real API server. Some opera |-------|--------|--------------| | `external:` HTTP calls | Active by default — calls hit the real network; pass `--skip-external` to stub with empty 200 | Without `--skip-external`: real HTTP; with it: `external.*` fields are empty, note printed | | `cross:` informer reads | Active when peer CRs are in the CR file | Include all sibling CRDs' CRs separated by `---` in the CR file. Each is seeded into a fake informer so `cross.*` fields populate. Without a peer CR, `cross.*` fields are empty and a note is printed. | +| `fromNamespace` / `toNamespaces` copies | **Automatically skipped** — no real API server to read from | A `note:` line is printed for each skipped resource before the first cycle. All other resources in the same phase proceed normally. Use `ork e2e` to verify the copy against a live cluster. | The output still shows whether the declarative layer (templates, status fields, `once:`, `forEach:`) is correct given absent data. diff --git a/documentation/guides/e2e-universal.md b/documentation/guides/e2e-universal.md deleted file mode 100644 index d624ec3a..00000000 --- a/documentation/guides/e2e-universal.md +++ /dev/null @@ -1,233 +0,0 @@ -# Test Anything That Runs in Kubernetes - -`ork e2e` is a Kubernetes behavioural verification tool. It spins up a real cluster, -installs things, watches the API server, and asserts GVK state. Nothing in that -pipeline requires the workload under test to be an Orkestra operator. - -A Helm chart installs into the same API server. A `kubectl apply` produces the same -objects. The assertion layer does not care about the installation mechanism. It watches -GVKs. A `Deployment/my-app` is the same object whether it was created by an Orkestra -operator, a Helm chart, a controller-runtime reconciler, or `kubectl apply`. - ---- - -## The insight - -Today there is no way to look at a container image, a deployment manifest, or a Helm -chart and say it works. Signatures prove ownership. They say nothing about behaviour. -A signed chart with a broken init container is still signed. A signed operator that -silently drops events under load is still signed. - -`ork e2e` built the verification layer in the hardest context — operators with -long-lived state, drift correction, complex status, and partial failure modes. If you -can prove an operator works correctly before distribution, you can prove a Helm chart -works. The machinery is the same. The e2e file is the contract. The cluster is the -witness. - ---- - -## Set `custom.target: kubernetes` - -Any e2e file with `spec.custom.target: kubernetes` tells Orkestra: "you own the -cluster and the assertions, but not the workload". Bundle generation and Orkestra's -own helm install/uninstall are skipped. Everything else — cluster setup, CRD apply, -setup manifests, CR apply, the full assertion loop, and cleanup — runs unchanged. - -```yaml -apiVersion: orkestra.orkspace.io/v1 -kind: E2E -metadata: - name: my-helm-chart-e2e - -spec: - custom: - target: kubernetes - - setup: - helm: - - repo: https://charts.example.com - chart: my-app - namespace: my-app - createNamespace: true - version: v1.2.0 - wait: - - kind: Deployment - name: my-app - namespace: my-app - ready: true - timeout: 120s - - cr: ./test-resource.yaml - - expect: - - name: App is healthy - after: cr-applied - timeout: 60s - resources: - - kind: Deployment - name: my-app - namespace: my-app - ready: true - - - name: Cleanup verified - after: cr-deleted - timeout: 30s - resources: - - kind: Deployment - name: my-app - namespace: my-app - count: 0 -``` - -Run it: - -```bash -ork e2e -f e2e.yaml -``` - -An ephemeral kind cluster is created, your chart is installed into it, assertions run, -and the cluster is torn down. Your actual cluster is never touched. Pass or fail. -Minutes, not hours. - ---- - -## Use cases - -### Helm charts - -Write an `e2e.yaml` alongside your chart. Use `setup.helm` to install the chart -itself, then assert the resources it creates. - -```yaml -spec: - custom: - target: kubernetes - setup: - helm: - - chart: ./ # the chart itself - namespace: default - wait: - - kind: Deployment - name: my-app - ready: true - timeout: 120s - expect: - - name: App is healthy - after: cr-applied - timeout: 60s - resources: - - kind: Deployment - name: my-app - namespace: default -``` - -### Third-party operators - -Install an operator you did not write — cert-manager, ArgoCD, Crossplane, FluxCD — -and assert the resources it manages. - -```yaml -spec: - custom: - target: kubernetes - setup: - helm: - - repo: https://charts.jetstack.io - chart: cert-manager - namespace: cert-manager - createNamespace: true - version: v1.14.0 - values: - installCRDs: true - wait: - - kind: Deployment - name: cert-manager-webhook - namespace: cert-manager - ready: true - timeout: 120s - cr: ./certificate-cr.yaml - expect: - - name: TLS Secret issued - after: cr-applied - timeout: 60s - resources: - - kind: Secret - name: my-tls-secret - namespace: default -``` - -### Platform stacks - -Install multiple tools together and assert they interact correctly. - -```yaml -spec: - custom: - target: kubernetes - setup: - helm: - - repo: https://argoproj.github.io/argo-helm - chart: argo-cd - namespace: argocd - createNamespace: true - - repo: https://charts.jetstack.io - chart: cert-manager - namespace: cert-manager - createNamespace: true - values: - installCRDs: true - cr: ./platform-resources.yaml - expect: - - name: ArgoCD Application synced - after: cr-applied - timeout: 180s - commands: - - run: kubectl get application my-app -n argocd -o jsonpath='{.status.sync.status}' - outputContains: Synced - - name: TLS Secret issued - after: cr-applied - timeout: 60s - resources: - - kind: Secret - name: my-app-tls - namespace: default -``` - ---- - -## The GitHub Actions bridge - -Add `e2e.yaml` to any repository and one step to any workflow: - -```yaml -# .github/workflows/ci.yml -- uses: orkspace/orkestra-action@v1 # https://github.com/orkspace/orkestra-action - with: - validate: true - e2e: true # runs ork e2e before anything else - # or point at an explicit file: - e2e: ./e2e.yaml -``` - -The action installs `ork`, validates the spec, spins up an ephemeral kind cluster, -runs the assertions, and tears everything down. Your real cluster is never touched. -No other tooling required. - -The path to "every published artifact has a verification badge" is: add `e2e.yaml`, -add one step to the workflow, done. - ---- - -## What this is not - -`custom.target: kubernetes` does not add Orkestra runtime features to your workload. -Orkestra is the test harness — the cluster lifecycle, assertion polling, and cleanup. -Your workload runs exactly as it would in production. - -If you want Orkestra to manage your operator at runtime, start with -`ork init --pack from-controller-runtime`. - ---- - -→ Schema reference: [spec.custom](../reference/schema/04-e2e/05-custom-target.md) -→ Example pack: `use-cases/custom-operator` diff --git a/documentation/guides/e2e-universal/01-how-it-works.md b/documentation/guides/e2e-universal/01-how-it-works.md new file mode 100644 index 00000000..62900b0a --- /dev/null +++ b/documentation/guides/e2e-universal/01-how-it-works.md @@ -0,0 +1,69 @@ +# How It Works + +Set `spec.custom.target: kubernetes` in any e2e file. This tells Orkestra: "you own the cluster and the assertions, but not the workload." Bundle generation and Orkestra runtime install are skipped. Everything else — cluster setup, setup manifests, the full assertion loop, and cleanup — runs unchanged. + +```yaml +apiVersion: orkestra.orkspace.io/v1 +kind: E2E +metadata: + name: my-helm-chart-e2e + +spec: + custom: + target: kubernetes + + setup: + helm: + - repo: https://charts.example.com + chart: my-app + namespace: my-app + createNamespace: true + version: v1.2.0 + wait: + - kind: Deployment + name: my-app + namespace: my-app + ready: true + timeout: 120s + + cr: ./test-resource.yaml + + expect: + - name: App is healthy + after: cr-applied + timeout: 60s + resources: + - kind: Deployment + name: my-app + namespace: my-app + ready: true + + - name: Cleanup verified + after: cr-deleted + timeout: 30s + resources: + - kind: Deployment + name: my-app + namespace: my-app + count: 0 +``` + +Run it: + +```bash +ork e2e -f e2e.yaml +``` + +An ephemeral kind cluster is created, your chart is installed into it, assertions run, and the cluster is torn down. Your actual cluster is never touched. Pass or fail. Minutes, not hours. + +--- + +## What this is not + +`custom.target: kubernetes` does not add Orkestra runtime features to your workload. Orkestra is the test harness — cluster lifecycle, assertion polling, and cleanup. Your workload runs exactly as it would in production. + +If you want Orkestra to manage your operator at runtime, start with `ork init --pack beginner`. + +--- + +→ Next: [Use cases](02-use-cases.md) diff --git a/documentation/guides/e2e-universal/02-use-cases.md b/documentation/guides/e2e-universal/02-use-cases.md new file mode 100644 index 00000000..e8a13ead --- /dev/null +++ b/documentation/guides/e2e-universal/02-use-cases.md @@ -0,0 +1,181 @@ +# Use Cases + +## Helm charts + +Ship an `e2e.yaml` alongside your chart. Use `setup.helm` with `chart: ./` to install +the chart itself, add per-entry `wait:` to confirm it is ready, then assert the +resources it creates. No `crd:` or `cr:` needed — everything goes through setup. + +> **Real example:** Orkestra dogfoods this pattern for its own Helm chart — +> [`charts/orkestra/e2e.yaml`](https://github.com/orkspace/orkestra/blob/main/charts/orkestra/e2e.yaml) + +All expectations use `after: setup-complete` (or omit `after:`, which means the same +thing) since there is no CR lifecycle to wait for. + +```yaml +spec: + custom: + target: kubernetes + setup: + helm: + - chart: ./ + release: my-app + namespace: my-app + createNamespace: true + values: + replicaCount: 2 + wait: + - kind: Deployment + name: my-app + namespace: my-app + ready: true + timeout: 120s + expect: + - name: Deployment is ready + timeout: 30s + resources: + - kind: Deployment + name: my-app + namespace: my-app + ready: true + - name: Service is created + timeout: 30s + resources: + - kind: Service + name: my-app + namespace: my-app +``` + +When you need manifests applied before the chart (CRDs, namespaces, config), use +`setup.apply` with per-entry waits to confirm each step before moving on: + +```yaml +setup: + apply: + - path: ./fixtures/crd.yaml + wait: + - kind: CustomResourceDefinition + name: myresources.example.com + timeout: 30s + - path: ./fixtures/config.yaml + helm: + - chart: ./ + release: my-app + namespace: my-app + createNamespace: true + wait: + - kind: Deployment + name: my-app + namespace: my-app + ready: true + timeout: 120s +``` + +--- + +## Third-party operators + +Install an operator you did not write — cert-manager, ArgoCD, Crossplane, FluxCD — +and assert the resources it manages. Apply the CRD and CR via `setup.apply` so the +operator sees the CR on first boot, then use `after: setup-complete` for infrastructure +checks. + +```yaml +spec: + custom: + target: kubernetes + setup: + apply: + - path: ./fixtures/crd.yaml + wait: + - kind: CustomResourceDefinition + name: certificates.cert-manager.io + timeout: 30s + - ./fixtures/cr-certificate.yaml + helm: + - repo: https://charts.jetstack.io + chart: cert-manager + namespace: cert-manager + createNamespace: true + version: v1.14.0 + values: + installCRDs: true + wait: + - kind: Deployment + name: cert-manager-webhook + namespace: cert-manager + ready: true + timeout: 120s + expect: + - name: TLS Secret issued + timeout: 60s + resources: + - kind: Secret + name: my-tls-secret + namespace: default +``` + +--- + +## Platform stacks + +Install multiple tools together and assert they interact correctly. Per-entry `wait:` +on each helm install ensures the stack comes up in order. + +```yaml +spec: + custom: + target: kubernetes + setup: + helm: + - repo: https://charts.jetstack.io + chart: cert-manager + namespace: cert-manager + createNamespace: true + values: + installCRDs: true + wait: + - kind: Deployment + name: cert-manager-webhook + namespace: cert-manager + ready: true + timeout: 120s + - repo: https://argoproj.github.io/argo-helm + chart: argo-cd + namespace: argocd + createNamespace: true + wait: + - kind: Deployment + name: argocd-server + namespace: argocd + ready: true + timeout: 180s + expect: + - name: ArgoCD Application synced + timeout: 60s + commands: + - run: kubectl get application my-app -n argocd -o jsonpath='{.status.sync.status}' + outputContains: Synced + - name: TLS Secret issued + timeout: 60s + resources: + - kind: Secret + name: my-app-tls + namespace: default +``` + +--- + +## Lifecycle events quick reference + +| `after:` value | When to use | +|----------------|-------------| +| `setup-complete` (default) | Non-operator tests, infrastructure checks, anything without a CR lifecycle | +| `cr-applied` | After `spec.cr` is applied — operator reconciliation assertions | +| `cr-deleted` | After `spec.cr` is deleted — cleanup assertions | + +Omitting `after:` is the same as writing `after: setup-complete`. + +--- + +→ Next: [CI integration](03-ci.md) diff --git a/documentation/guides/e2e-universal/03-ci.md b/documentation/guides/e2e-universal/03-ci.md new file mode 100644 index 00000000..761e1e1d --- /dev/null +++ b/documentation/guides/e2e-universal/03-ci.md @@ -0,0 +1,21 @@ +# CI Integration + +Add `e2e.yaml` to any repository and one step to any workflow: + +```yaml +# .github/workflows/ci.yml +- uses: orkspace/orkestra-action@v1 + with: + validate: true + e2e: true # runs ork e2e before anything else + # or point at an explicit file: + # e2e: ./e2e.yaml +``` + +The action installs `ork`, validates the spec, spins up an ephemeral kind cluster, runs the assertions, and tears everything down. Your real cluster is never touched. No other tooling required. + +The path to "every published artifact has a verification badge" is: add `e2e.yaml`, add one step to the workflow, done. + +--- + +→ Schema reference: [spec.custom](../../reference/schema/04-e2e/05-custom-target.md) diff --git a/documentation/guides/e2e-universal/index.md b/documentation/guides/e2e-universal/index.md new file mode 100644 index 00000000..01dbfa76 --- /dev/null +++ b/documentation/guides/e2e-universal/index.md @@ -0,0 +1,27 @@ +# Test Anything That Runs in Kubernetes + +`ork e2e` is a Kubernetes behavioural verification tool. It spins up a real cluster, installs things, watches the API server, and asserts GVK state. Nothing in that pipeline requires the workload under test to be an Orkestra operator. + +A Helm chart installs into the same API server. A `kubectl apply` produces the same objects. The assertion layer does not care about the installation mechanism — it watches GVKs. A `Deployment/my-app` is the same object whether it was created by an Orkestra operator, a Helm chart, a controller-runtime reconciler, or `kubectl apply`. + +--- + +## The insight + +Today there is no way to look at a container image, a deployment manifest, or a Helm chart and say it works. Signatures prove ownership. They say nothing about behaviour. A signed chart with a broken init container is still signed. A signed operator that silently drops events under load is still signed. + +`ork e2e` built the verification layer in the hardest context — operators with long-lived state, drift correction, complex status, and partial failure modes. If you can prove an operator works correctly before distribution, you can prove a Helm chart works. The machinery is the same. The e2e file is the contract. The cluster is the witness. + +--- + +## Contents + +| Page | What it covers | +|---|---| +| [How it works](01-how-it-works.md) | `custom.target: kubernetes`, the e2e file structure, running it | +| [Use cases](02-use-cases.md) | Helm charts, third-party operators, platform stacks | +| [CI integration](03-ci.md) | GitHub Actions bridge — one step to add e2e to any workflow | + +--- + +→ Start with [How it works](01-how-it-works.md) diff --git a/documentation/guides/index.md b/documentation/guides/index.md index 0979743a..615b275a 100644 --- a/documentation/guides/index.md +++ b/documentation/guides/index.md @@ -7,9 +7,13 @@ End-to-end walkthroughs for specific goals. Each guide has a companion example p | [Registry](./registry/index.md) | `registry-guide` | Publish, version, gate, consume, and automate the full Katalog lifecycle | | [Migration](./migration/index.md) | `from-controller-runtime` | Move an existing controller-runtime operator to Orkestra — five options | | [Ecosystem Composition](./ecosystem/index.md) | `ecosystem-composition` | Wrap ArgoCD, cert-manager, Prometheus, and Crossplane with internal abstraction layers | +| [Namespace Provisioner](./namespace-provisioner/index.md) | `use-cases/namespace-provisioner` | Build a multi-tenant namespace provisioner with profiles, motifs, and cross-namespace secret distribution | +| [Test Anything](./e2e-universal/index.md) | `use-cases/custom-operator` | Use `ork e2e` to verify Helm charts, third-party operators, and platform stacks | ```bash ork init --pack registry-guide ork init --pack from-controller-runtime ork init --pack ecosystem-composition +ork init --pack use-cases/namespace-provisioner +ork init --pack use-cases/custom-operator ``` diff --git a/documentation/reference/schema/04-e2e/05-custom-target.md b/documentation/reference/schema/04-e2e/05-custom-target.md index 26b99930..44b2b459 100644 --- a/documentation/reference/schema/04-e2e/05-custom-target.md +++ b/documentation/reference/schema/04-e2e/05-custom-target.md @@ -2,8 +2,7 @@ `spec.custom.target` declares the runtime environment being tested when Orkestra is not the operator. Bundle generation and Orkestra helm install/uninstall are skipped. -Everything else runs unchanged: cluster setup, CRD apply, setup manifests, CR apply, -assertions, and cleanup. +Everything else runs unchanged: cluster setup, setup manifests, assertions, and cleanup. --- @@ -16,26 +15,125 @@ assertions, and cleanup. --- -## How to activate +## What runs, what is skipped + +| Step | Normal | `custom.target: kubernetes` | +|------|--------|-----------------------------| +| Cluster setup | ✓ | ✓ | +| Setup manifests (`spec.setup`) | ✓ | ✓ | +| Bundle generate + apply | ✓ | **skipped** | +| Orkestra helm install | ✓ | **skipped** | +| OCI import pre-pull | ✓ | **skipped** | +| CRD apply (`spec.crd`) | ✓ | optional | +| CR apply (`spec.cr`) | ✓ | optional | +| Expectations + assertions | ✓ | ✓ | +| Orkestra helm uninstall | ✓ | **skipped** | +| CRD / setup cleanup | ✓ | ✓ | + +`spec.katalog`, `spec.crd`, and `spec.cr` are all optional when `custom.target` is set. +For pure infrastructure tests (Helm charts, platform stacks), omit them entirely and +use `setup.apply` and `setup.helm` instead. + +--- + +## The two patterns + +### Pure Helm chart test (no CR) + +When testing a Helm chart that does not involve a custom resource, omit `crd:` and +`cr:`. Apply any prerequisite manifests via `setup.apply`, install the chart via +`setup.helm`, use per-entry `wait:` to confirm each step before the next, and assert +everything with `after: setup-complete`. ```yaml -apiVersion: orkestra.orkspace.io/v1 -kind: E2E -metadata: - name: my-operator-e2e +spec: + custom: + target: kubernetes + setup: + apply: + - path: ./fixtures/config.yaml + wait: + - kind: ConfigMap + name: my-config + namespace: my-app + timeout: 30s + helm: + - chart: ./ + release: my-app + namespace: my-app + createNamespace: true + values: + replicaCount: 2 + wait: + - kind: Deployment + name: my-app + namespace: my-app + ready: true + timeout: 120s + + expect: + - name: Deployment is ready + after: setup-complete + timeout: 30s + resources: + - kind: Deployment + name: my-app + namespace: my-app + ready: true + + - name: Service is created + after: setup-complete + timeout: 30s + resources: + - kind: Service + name: my-app + namespace: my-app +``` + +`after: setup-complete` is also the default when `after:` is omitted — both forms are +equivalent. + +### Operator with a CR + +When testing an operator that reconciles a custom resource, apply the CRD and CR via +`setup.apply`, then install the operator via `setup.helm`. Use `after: setup-complete` +for infrastructure assertions and `after: cr-applied` / `after: cr-deleted` for +reconciliation assertions. The CR is applied as part of setup so the operator sees it +on first boot. + +```yaml spec: custom: target: kubernetes - crd: ./crd.yaml - cr: ./cr.yaml setup: + apply: + - path: ./crd.yaml + wait: + - kind: CustomResourceDefinition + name: myresources.example.com + timeout: 30s + - ./cr.yaml helm: - repo: https://charts.example.com chart: my-operator namespace: my-operator-system createNamespace: true + wait: + - kind: Deployment + name: my-operator + namespace: my-operator-system + ready: true + timeout: 120s expect: + - name: Operator is ready + after: setup-complete + timeout: 30s + resources: + - kind: Deployment + name: my-operator + namespace: my-operator-system + ready: true - name: CR creates Deployment after: cr-applied timeout: 90s @@ -54,138 +152,109 @@ spec: count: 0 ``` ---- - -## What runs, what is skipped - -| Step | Normal | `custom.target: kubernetes` | -|------|--------|-----------------------------| -| Cluster setup | ✓ | ✓ | -| CRD apply (`spec.crd`) | ✓ | ✓ | -| Setup manifests (`spec.setup`) | ✓ | ✓ | -| Bundle generate + apply | ✓ | **skipped** | -| Orkestra helm install | ✓ | **skipped** | -| OCI import pre-pull | ✓ | **skipped** | -| CR apply | ✓ | ✓ | -| Expectations + assertions | ✓ | ✓ | -| Orkestra helm uninstall | ✓ | **skipped** | -| CRD / setup cleanup | ✓ | ✓ | - -`spec.katalog` is optional when `custom.target` is set. If omitted, only `spec.crd`, -`spec.cr`, and `spec.setup` are used. +> When `cr:` is specified in spec, the runner manages its lifecycle — applying it +> before `after: cr-applied` blocks and deleting it before `after: cr-deleted` blocks. +> When the CR is applied via `setup.apply` instead, only `after: setup-complete` +> assertions run. --- -## The setup.helm pattern +## Per-entry waits -Almost always paired with `setup.helm` to install the operator before the CR is -applied. +Both `setup.apply` and `setup.helm` entries support an inline `wait:` list. The runner +blocks after each entry until all conditions pass before moving to the next. This gives +you ordered, incremental setup instead of a single global wait at the end. ```yaml -spec: - custom: - target: kubernetes - setup: - helm: - - repo: https://charts.jetstack.io - chart: cert-manager - namespace: cert-manager - createNamespace: true - version: v1.14.0 - values: - installCRDs: true - wait: - - kind: Deployment - name: cert-manager - namespace: cert-manager - ready: true - timeout: 120s +setup: + apply: + - namespace.yaml # flat string — no wait + - path: crd.yaml + wait: + - kind: CustomResourceDefinition + name: myresources.example.com + timeout: 30s + helm: + - repo: https://charts.example.com + chart: cert-manager + namespace: cert-manager + createNamespace: true + values: + installCRDs: true + wait: + - kind: Deployment + name: cert-manager + namespace: cert-manager + ready: true + timeout: 120s + - repo: https://charts.example.com + chart: my-app + namespace: my-app + createNamespace: true + wait: + - kind: Deployment + name: my-app + namespace: my-app + ready: true + timeout: 120s ``` +A global `setup.wait` list is also supported and runs after all apply and helm steps +complete. Use per-entry waits when order matters between steps, global wait for a final +readiness check that spans multiple resources. + --- -## Use cases +## Lifecycle events -### Migration parity testing +| Value | When it fires | +|-------|--------------| +| `setup-complete` | After all setup steps finish, before any CR is applied. Default when `after:` is omitted. | +| `cr-applied` | After `spec.cr` is applied to the cluster. Requires `spec.cr` to be set. | +| `cr-deleted` | After `spec.cr` is deleted from the cluster. Requires `spec.cr` to be set. | -Migrating from a Kubebuilder operator to Orkestra? Write two e2e files — one with -Orkestra, one with `custom.target: kubernetes` and `setup.helm` pointing at your -existing binary — with identical assertions. When both pass, the migration is verified. +--- -```text -my-operator/ -├── e2e-orkestra.yaml # spec.katalog: ./katalog.yaml -└── e2e-kubebuilder.yaml # spec.custom.target: kubernetes - # setup.helm: my-old-operator-chart - # same assertions as e2e-orkestra.yaml -``` +## Use cases -### Third-party operator smoke tests +### Self-test a Helm chart -Test the behavior of operators you did not write — FluxCD, cert-manager, Crossplane, -ArgoCD. Install via `setup.helm`, apply a CR, assert what the operator creates. +Ship an `e2e.yaml` alongside the chart and run `ork e2e` in CI to verify every +release. No Orkestra operator required — the chart IS the thing under test. -```yaml -spec: - custom: - target: kubernetes - crd: ./certificate-crd.yaml - cr: ./cr-certificate.yaml - setup: - helm: - - repo: https://charts.jetstack.io - chart: cert-manager - # ... - expect: - - name: Certificate issued - after: cr-applied - timeout: 60s - resources: - - kind: Secret - name: my-tls-secret - namespace: default +```text +charts/my-chart/ +├── Chart.yaml +├── templates/ +├── values.yaml +├── e2e.yaml ← spec.custom.target: kubernetes, setup.helm: chart: ./ +└── fixtures/ + └── config.yaml ``` -### Two-operator composition +### Migration parity testing -Install two operators via `setup.helm`, apply CRs for both, assert they interact -correctly. Neither needs to be Orkestra. +Migrating from a Kubebuilder operator to Orkestra? Write two e2e files with identical +assertions — one for Orkestra, one for the legacy chart. When both pass, the migration +is verified. -```yaml -spec: - custom: - target: kubernetes - setup: - helm: - - repo: https://operator-a.example.com - chart: operator-a - - repo: https://operator-b.example.com - chart: operator-b - cr: ./cr-combined.yaml - expect: - - name: Operator A and B outputs composed - after: cr-applied - timeout: 120s - commands: - - run: kubectl get compositeresource my-resource -o jsonpath='{.status.ready}' - outputContains: "true" +```text +my-operator/ +├── e2e-orkestra.yaml # spec.katalog: ./katalog.yaml +└── e2e-legacy.yaml # spec.custom.target: kubernetes + # setup.helm: my-old-operator-chart + # same assertions ``` ---- - -## Example suite +### Third-party operator smoke tests -```bash -ork init --pack use-cases/custom-operator -cd use-cases/custom-operator -``` +Test the behavior of operators you did not write — FluxCD, cert-manager, Crossplane, +ArgoCD — and assert the resources they create. -| Example | What it shows | -|---------|---------------| -| `01-pure-custom` | `custom.target: kubernetes` with cert-manager installed via `setup.helm` | -| `02-side-by-side` | The same CR tested twice — once with Orkestra, once with a custom target — identical assertions | +### Platform stack tests -→ See also: [Test anything that runs in Kubernetes](../../guides/e2e-universal.md) +Install multiple tools together and assert they interact correctly. No single CR; all +assertions are `after: setup-complete`. --- diff --git a/examples/beginner/03-secret-copy/README.md b/examples/beginner/03-secret-copy/README.md index 4301a37a..260475b5 100644 --- a/examples/beginner/03-secret-copy/README.md +++ b/examples/beginner/03-secret-copy/README.md @@ -32,31 +32,52 @@ namespace stays in sync automatically. ## Steps -### 1. Start the runtime +### 1. Simulate (no cluster needed) + +```bash +ork simulate +``` + +Because the Secret copy requires reading the source Secret from a live cluster, Orkestra detects this automatically and skips it during simulation: + +``` +note: secrets/{{ .spec.secretName }}: cross-namespace copy skipped in simulate — requires a live cluster + + Cycle 1: + ~ status/db-creds + + ~ Max cycles reached (1) in 245ms +``` + +No errors, no cluster, no guessing. Use `ork e2e` to exercise the copy against a real cluster. + +--- + +### 2. Start the runtime ```bash ork run # add --dev if you don't have a cluster; Orkestra will create a kind cluster ``` -Orkestra reads `crdFile: ./crd.yaml`, applies the CRD, , setup file `./setup.yaml` and `cr.yaml` to the cluster, and starts the operator. +Orkestra applies the CRD, `setup.yaml` (which creates the source Secret in the `platform` namespace), and `cr.yaml`, then starts the operator. + +### 3. In a second terminal — verify and control -### 2. Verify CR and source Secret (optional) +Confirm the source Secret and CR are live: ```bash -kubectl get sd -n default +kubectl get secretdistribution db-creds kubectl get secret database-credentials -n platform ``` -### 3. Open the Control Center - -In a second terminal: +Then open the Control Center: ```bash ork control # username:password → orkestra ``` -Open [http://localhost:8081](http://localhost:8081) to see the live operator, and the resources created. +Open [http://localhost:8081](http://localhost:8081) to see the live operator and the resources created. ### 4. Verify copies exist diff --git a/examples/beginner/03-secret-copy/simulate.yaml b/examples/beginner/03-secret-copy/simulate.yaml new file mode 100644 index 00000000..d30e7a88 --- /dev/null +++ b/examples/beginner/03-secret-copy/simulate.yaml @@ -0,0 +1,17 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: secret-distribution-sim + description: > + Demonstrates that ork simulate detects cross-namespace copy operations + and skips them gracefully. The Secret copy requires a live API server to + read the source Secret — simulate prints a note and exits cleanly. + +spec: + katalog: ./katalog.yaml + cr: ./cr.yaml + cycles: 1 + + expect: + steady: false + noErrors: true diff --git a/examples/beginner/03b-configmap-copy/README.md b/examples/beginner/03b-configmap-copy/README.md index 31cb1cdd..ef281db6 100644 --- a/examples/beginner/03b-configmap-copy/README.md +++ b/examples/beginner/03b-configmap-copy/README.md @@ -27,31 +27,52 @@ Orkestra reads the source ConfigMap and creates copies in each target namespace, ## Steps -### 1. Verify CR and source ConfigMap (optional) +### 1. Simulate (no cluster needed) ```bash -kubectl get cd -n default -kubectl get configmap app-config -n platform +ork simulate ``` +Because the ConfigMap copy requires reading the source ConfigMap from a live cluster, Orkestra detects this automatically and skips it during simulation: + +```text +note: configmaps/{{ .spec.configMapName }}: cross-namespace copy skipped in simulate — requires a live cluster + + Cycle 1: + ~ status/db-creds + + ~ Max cycles reached (1) in 245ms +``` + +No errors, no cluster, no guessing. Use `ork e2e` to exercise the copy against a real cluster. + +--- + ### 2. Start the runtime ```bash ork run # add --dev if you don't have a cluster; Orkestra will create a kind cluster ``` -Orkestra reads `crdFile: ./crd.yaml`, applies the CRD, , setup file `./setup.yaml` and `cr.yaml` to the cluster, and starts the operator. +Orkestra applies the CRD, `setup.yaml` (which creates the source ConfigMap in the `platform` namespace), and `cr.yaml`, then starts the operator. + +### 3. In a second terminal — verify and control + +Confirm the source ConfigMap and CR are live: -### 3. Open the Control Center +```bash +kubectl get configmapdistribution app-config-distribution +kubectl get configmap app-config -n platform +``` -In a second terminal: +Then open the Control Center: ```bash ork control # username:password → orkestra ``` -Open [http://localhost:8081](http://localhost:8081) to see the live operator, and the resources created. +Open [http://localhost:8081](http://localhost:8081) to see the live operator and the resources created. ### 4. Verify copies exist diff --git a/examples/beginner/03b-configmap-copy/simulate.yaml b/examples/beginner/03b-configmap-copy/simulate.yaml new file mode 100644 index 00000000..fd8c5841 --- /dev/null +++ b/examples/beginner/03b-configmap-copy/simulate.yaml @@ -0,0 +1,17 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: configmap-distribution-sim + description: > + Demonstrates that ork simulate detects cross-namespace copy operations + and skips them gracefully. The ConfigMap copy requires a live API server to + read the source ConfigMap — simulate prints a note and exits cleanly. + +spec: + katalog: ./katalog.yaml + cr: ./cr.yaml + cycles: 1 + + expect: + steady: false + noErrors: true diff --git a/examples/beginner/simulate.yaml b/examples/beginner/simulate.yaml index 17f82728..51fc9312 100644 --- a/examples/beginner/simulate.yaml +++ b/examples/beginner/simulate.yaml @@ -7,3 +7,5 @@ metadata: imports: - ./02-with-serviceaccount/simulate.yaml + - ./03-secret-copy/simulate.yaml + - ./03b-configmap-copy/simulate.yaml diff --git a/pkg/e2e/runner.go b/pkg/e2e/runner.go index 5fc3cdd2..6ea27a99 100644 --- a/pkg/e2e/runner.go +++ b/pkg/e2e/runner.go @@ -148,9 +148,11 @@ func (r *Runner) resolveSource() error { r.katalogFile = r.abs(spec.Katalog) r.crFile = r.abs(spec.CR) - case spec.CR != "" && spec.Custom != nil && spec.Custom.Target != "": - // custom.target: katalog is optional — no bundle or Orkestra install. - r.crFile = r.abs(spec.CR) + case spec.Custom != nil && spec.Custom.Target != "": + // custom.target: both katalog and cr are optional. + if spec.CR != "" { + r.crFile = r.abs(spec.CR) + } case len(r.e2e.Imports) > 0: // Pure aggregator — no own katalog/CR, just orchestrates imports. @@ -165,8 +167,10 @@ func (r *Runner) resolveSource() error { return fmt.Errorf("katalog file not found: %s", r.katalogFile) } } - if _, err := os.Stat(r.crFile); err != nil { - return fmt.Errorf("CR file not found: %s", r.crFile) + if r.crFile != "" { + if _, err := os.Stat(r.crFile); err != nil { + return fmt.Errorf("CR file not found: %s", r.crFile) + } } return nil } @@ -362,29 +366,36 @@ func (r *Runner) Run(ctx context.Context) (*Result, error) { crDeleted := false for _, exp := range r.e2e.Spec.Expect { - switch exp.After { - case "cr-applied": + after := exp.After + if after == "" { + after = orktypes.AfterSetupComplete + } + switch after { + case orktypes.AfterSetupComplete: + // Infrastructure assertions — no CR lifecycle action needed. + + case orktypes.AfterCRApplied: 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") + fmt.Printf(" %s CR applied\n\n", orkutils.SuccessMark()) crApplied = true } - case "cr-deleted": + case orktypes.AfterCRDeleted: 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") + fmt.Printf(" %s CR deleted\n\n", orkutils.SuccessMark()) crDeleted = true } default: - return nil, fmt.Errorf("unknown after: %q (must be cr-applied or cr-deleted)", exp.After) + return nil, fmt.Errorf("unknown after: %q — valid values: %v", after, orktypes.ValidAfterValues) } to := exp.Timeout @@ -672,14 +683,19 @@ func (r *Runner) applySetup(ctx context.Context) ([]string, error) { var applied []string // ── Phase 1: apply ──────────────────────────────────────────────────────── - for _, path := range s.Apply { - abs := r.abs(path) - fmt.Printf("→ Applying setup %s...\n", path) + for _, entry := range s.Apply { + abs := r.abs(entry.Path) + fmt.Printf("→ Applying setup %s...\n", entry.Path) if out, err := kubectl(ctx, "apply", "-f", abs); err != nil { - return applied, fmt.Errorf("setup apply %s: %w\n%s", path, err, out) + return applied, fmt.Errorf("setup apply %s: %w\n%s", entry.Path, err, out) } - fmt.Printf(" ✓ Applied\n") + fmt.Printf(" %s Applied\n", orkutils.SuccessMark()) applied = append(applied, abs) + for _, w := range entry.Wait { + if err := runSetupWait(ctx, w); err != nil { + return applied, fmt.Errorf("setup apply %s wait: %w", entry.Path, err) + } + } } // ── Phase 2: helm ───────────────────────────────────────────────────────── @@ -687,28 +703,40 @@ func (r *Runner) applySetup(ctx context.Context) ([]string, error) { sp := orkutils.StartSpinner(fmt.Sprintf("Installing %s...", h.ReleaseName())) if err := ork.HelmInstall(ctx, h); err != nil { sp.Failure() - return applied, fmt.Errorf("setup helm %s/%s: %w", h.Repo, h.Chart, err) + return applied, fmt.Errorf("setup helm %s: %w", h.Chart, err) } sp.Success() + for _, w := range h.Wait { + if err := runSetupWait(ctx, w); err != nil { + return applied, fmt.Errorf("setup helm %s wait: %w", h.ReleaseName(), err) + } + } } // ── Phase 3: wait ───────────────────────────────────────────────────────── for _, w := range s.Wait { - loc := w.Kind + " " + w.Name - if w.Namespace != "" { - loc += " (" + w.Namespace + ")" - } - sp := orkutils.StartSpinner(fmt.Sprintf("Waiting for %s...", loc)) - if err := ork.WaitForResource(ctx, w); err != nil { - sp.Failure() + if err := runSetupWait(ctx, w); err != nil { return applied, fmt.Errorf("setup wait: %w", err) } - sp.Success() } return applied, nil } +func runSetupWait(ctx context.Context, w orktypes.SetupWait) error { + loc := w.Kind + " " + w.Name + if w.Namespace != "" { + loc += " (" + w.Namespace + ")" + } + sp := orkutils.StartSpinner(fmt.Sprintf("Waiting for %s...", loc)) + if err := ork.WaitForResource(ctx, w); err != nil { + sp.Failure() + return err + } + sp.Success() + return nil +} + func (r *Runner) deleteCluster(ctx context.Context) error { return deleteKindCluster(ctx, r.clusterName()) } @@ -729,9 +757,9 @@ func (r *Runner) provider() string { // isPureAggregator returns true when this E2E has no spec of its own — // it exists only to run imported E2E files. -// A kubernetesTarget spec with a cr but no katalog is NOT a pure aggregator. +// A kubernetesTarget spec is never a pure aggregator even when cr and katalog are omitted. func (r *Runner) isPureAggregator() bool { - return r.katalogFile == "" && r.crFile == "" + return r.katalogFile == "" && r.crFile == "" && !r.kubernetesTarget } func (r *Runner) abs(path string) string { diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index 849b7543..5b90251f 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -151,4 +151,8 @@ const ( // FinalizerOrkestra ensures cleanup runs before a CR is removed. FinalizerOrkestra = "orkestra.orkspace.io/finalizer" + + // NsCleanupFinalizer added to CR to ensure namespaces are cleared + // when CR is deleted + NsCleanupFinalizer = "orkestra.orkspace.io/namespace-cleanup" ) diff --git a/pkg/merger/file.go b/pkg/merger/file.go index 2661a667..5068d9ce 100644 --- a/pkg/merger/file.go +++ b/pkg/merger/file.go @@ -115,9 +115,9 @@ func (m *Merger) loadKatalog(path string, doc *orktypes.KatalogFile) (map[string } } if crd.Setup != nil { - for i, cf := range crd.Setup.Apply { - if !filepath.IsAbs(cf) && !strings.HasPrefix(cf, "http") { - crd.Setup.Apply[i] = filepath.Join(katalogDir, cf) + for i, entry := range crd.Setup.Apply { + if !filepath.IsAbs(entry.Path) && !strings.HasPrefix(entry.Path, "http") { + crd.Setup.Apply[i].Path = filepath.Join(katalogDir, entry.Path) } } } diff --git a/pkg/ork/setup.go b/pkg/ork/setup.go index b6fa10c7..497f6bc3 100644 --- a/pkg/ork/setup.go +++ b/pkg/ork/setup.go @@ -20,15 +20,18 @@ func HelmInstall(ctx context.Context, h orktypes.SetupHelmInstall) error { release := h.ReleaseName() namespace := h.EffectiveNamespace() - // Add and update the repo (idempotent). - repoName := release - _ = exec.CommandContext(ctx, "helm", "repo", "add", repoName, h.Repo).Run() - _ = exec.CommandContext(ctx, "helm", "repo", "update", repoName).Run() + chartRef := h.Chart + if !h.IsLocalChart() { + repoName := release + _ = exec.CommandContext(ctx, "helm", "repo", "add", repoName, h.Repo).Run() + _ = exec.CommandContext(ctx, "helm", "repo", "update", repoName).Run() + chartRef = fmt.Sprintf("%s/%s", repoName, h.Chart) + } args := []string{ "upgrade", "--install", release, - fmt.Sprintf("%s/%s", repoName, h.Chart), + chartRef, "--namespace", namespace, } if h.CreateNamespace { @@ -49,7 +52,7 @@ func HelmInstall(ctx context.Context, h orktypes.SetupHelmInstall) error { cmd := exec.CommandContext(ctx, "helm", args...) if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("helm install %s/%s: %w\n%s", h.Repo, h.Chart, err, out) + return fmt.Errorf("helm install %s: %w\n%s", chartRef, err, out) } return nil } diff --git a/pkg/reconciler/generic.go b/pkg/reconciler/generic.go index 705ab209..52736026 100644 --- a/pkg/reconciler/generic.go +++ b/pkg/reconciler/generic.go @@ -4,6 +4,7 @@ package reconciler import ( "context" "fmt" + "slices" "sync" "sync/atomic" "time" @@ -178,13 +179,25 @@ func NewGenericReconciler[PTR domain.Object]( sem := autoscaler.NewResizableSemaphore(workers) autoMet := autoscaler.NewAutoMetrics(sem) + box := crd.OperatorBox + // Auto-inject a system finalizer when namespaces are declared in any hook phase. + // Namespaces are cluster-scoped and are not garbage-collected via owner references, + // so explicit cleanup in handleDeletion is required. The finalizer ensures the + // CR is not deleted before the cleanup runs, even when the user does not declare + // any finalizers in the katalog. + if box.HasNamespaceDeclarations() { + if !slices.Contains(box.Finalizers, labels.NsCleanupFinalizer) { + box.Finalizers = append(box.Finalizers, labels.NsCleanupFinalizer) + } + } + r := &GenericReconciler[PTR]{ katalogRegistry: katalogRegistry, crdHealthRegistry: crdHealthRegistry, providerRegistry: providerRegistry, providerStats: providerStats, crd: crd, - operatorBox: crd.OperatorBox, + operatorBox: box, informer: informer, event: ev, kube: kube, @@ -670,6 +683,18 @@ func (r *GenericReconciler[PTR]) handleDeletion(ctx context.Context, resolver *o } } + // Namespaces require explicit deletion regardless of whether an onDelete block exists: + // they are cluster-scoped and the GC does not cascade through owner references on them. + // runTemplateOnDelete already handles this when OnDelete is set; run it here for all + // other cases (no onDelete block, or Go hook path). + if r.operatorBox.OnDelete == nil { + if kube, ok := kubeclient.FromContext(ctx); ok { + if err := deleteOwnedNamespaces(ctx, kube, resolver, obj, r.operatorBox); err != nil { + return fmt.Errorf("namespace cleanup: %w", err) + } + } + } + if err := r.removeFinalizers(ctx, obj); err != nil { r.event.Eventf(obj, corev1.EventTypeWarning, r.crd.APITypes.Kind+"FinalizerRemovalError", fmt.Sprintf("Failed to remove finalizers: %v", err)) diff --git a/pkg/reconciler/helper.go b/pkg/reconciler/helper.go index d5a38073..094305f6 100644 --- a/pkg/reconciler/helper.go +++ b/pkg/reconciler/helper.go @@ -72,18 +72,18 @@ func (r *GenericReconciler[PTR]) getLatestObject(ctx context.Context, namespace, // Configured finalizers: ["protection.orkestra.io/finalizer"] // After calling ensureFinalizers, the resource's metadata.finalizers will include it. func (r *GenericReconciler[PTR]) ensureFinalizers(ctx context.Context, obj PTR) error { - if len(r.crd.OperatorBox.Finalizers) == 0 { + if len(r.operatorBox.Finalizers) == 0 { return nil } logger.Debug(). Str("name", obj.GetName()). - Any("crd finalizers", r.crd.OperatorBox.Finalizers). + Any("crd finalizers", r.operatorBox.Finalizers). Msgf("checking finalizers: %v", obj.GetFinalizers()) needsUpdate := false - for _, f := range r.crd.OperatorBox.Finalizers { - if !ContainsFinalizer(obj, f) && r.crd.RemoveFinalizers { // Added for testing -> could be useful in future + for _, f := range r.operatorBox.Finalizers { + if !ContainsFinalizer(obj, f) { needsUpdate = true break } @@ -93,7 +93,7 @@ func (r *GenericReconciler[PTR]) ensureFinalizers(ctx context.Context, obj PTR) } newFinalizers := obj.GetFinalizers() - for _, f := range r.crd.OperatorBox.Finalizers { + for _, f := range r.operatorBox.Finalizers { if !ContainsFinalizer(obj, f) { newFinalizers = append(newFinalizers, f) } @@ -116,7 +116,7 @@ func (r *GenericReconciler[PTR]) removeFinalizers(ctx context.Context, obj PTR) newFinalizers := make([]string, 0, len(obj.GetFinalizers())) for _, f := range obj.GetFinalizers() { - if !slices.Contains(r.crd.OperatorBox.Finalizers, f) { + if !slices.Contains(r.operatorBox.Finalizers, f) { newFinalizers = append(newFinalizers, f) } } diff --git a/pkg/simulate/harness.go b/pkg/simulate/harness.go index 3dcfe995..2e4ccefb 100644 --- a/pkg/simulate/harness.go +++ b/pkg/simulate/harness.go @@ -90,6 +90,27 @@ func Run(ctx context.Context, kat *katalog.Katalog, crdName string, cr *unstruct return nil, fmt.Errorf("CRD %q not found in Katalog", crdName) } + // Strip cross-namespace copy resources (fromNamespace / toNamespaces) from + // all hook phases before the fake reconciler runs. These require a live API + // server to read the source object; in simulation they would error and block + // all subsequent resources in the same cycle. + // + // The removed resources are surfaced as notes in the result so the simulate + // output explains exactly what was omitted and why. + result := &Result{} + for _, phase := range []*orktypes.HookTemplates{ + crdEntry.OperatorBox.OnCreate, + crdEntry.OperatorBox.OnReconcile, + crdEntry.OperatorBox.OnDelete, + } { + if phase == nil { + continue + } + filtered, skipped := orktypes.FilterSimulatable(*phase) + *phase = filtered + result.Notes = append(result.Notes, skipped...) + } + scheme, err := kat.Scheme() if err != nil { return nil, fmt.Errorf("building scheme: %w", err) @@ -146,8 +167,6 @@ func Run(ctx context.Context, kat *katalog.Katalog, crdName string, cr *unstruct hookBinder = fn() } - result := &Result{} - // Build a peer registry so cross: declarations can read sibling CRDs' CRs // from the fake informer cache rather than returning empty results. // Each peer CR is seeded into its own static indexer and wrapped in a diff --git a/pkg/types/e2e.go b/pkg/types/e2e.go index 384a3e3c..c63a03d0 100644 --- a/pkg/types/e2e.go +++ b/pkg/types/e2e.go @@ -2,6 +2,7 @@ package types import ( "fmt" + "strings" "github.com/orkspace/orkestra/pkg/utils" "gopkg.in/yaml.v3" @@ -14,32 +15,45 @@ import ( // setup: // - ./prereqs/secret.yaml // -// Struct form: +// Struct form with per-entry waits: // // setup: // apply: -// - ./prereqs/secret.yaml +// - ./prereqs/namespace.yaml +// - path: ./prereqs/secret.yaml +// wait: +// - kind: Secret +// name: my-secret +// namespace: default +// timeout: 30s // helm: // - repo: https://charts.cert-manager.io // chart: cert-manager // version: v1.14.0 +// wait: +// - kind: Deployment +// name: cert-manager +// namespace: cert-manager +// ready: true +// timeout: 120s // wait: // - kind: Deployment -// name: cert-manager +// name: cert-manager-webhook // namespace: cert-manager // ready: true // timeout: 120s type SetupConfig struct { - // Apply is an ordered list of YAML file paths to kubectl-apply. + // Apply is an ordered list of manifests to kubectl-apply. + // Each entry is either a plain path string or a {path, wait} struct. // Applied first, before helm installs, after the CRD is installed. - Apply []string `yaml:"apply,omitempty"` + Apply []SetupApplyEntry `yaml:"apply,omitempty"` // Helm is an ordered list of Helm charts to install before Orkestra starts. // Executed as helm upgrade --install — not rendered for Katalog extraction. Helm []SetupHelmInstall `yaml:"helm,omitempty"` // Wait blocks until all listed resources exist and satisfy conditions. - // Runs last. If any wait times out, setup fails and the operator does not start. + // Runs after all apply and helm steps. Use per-entry wait for ordered checks. Wait []SetupWait `yaml:"wait,omitempty"` } @@ -51,7 +65,7 @@ func (s *SetupConfig) UnmarshalYAML(value *yaml.Node) error { if item.Kind != yaml.ScalarNode { break } - s.Apply = append(s.Apply, item.Value) + s.Apply = append(s.Apply, SetupApplyEntry{Path: item.Value}) } if len(s.Apply) > 0 { return nil @@ -65,19 +79,54 @@ func (s *SetupConfig) UnmarshalYAML(value *yaml.Node) error { return utils.StrictUnmarshal(raw, (*plain)(s)) } +// SetupApplyEntry is a single manifest to kubectl-apply during setup. +// It is either a plain path string or a struct with an optional per-entry wait. +// +// # flat form +// - ./prereqs/secret.yaml +// +// # structured form +// - path: ./prereqs/secret.yaml +// wait: +// - kind: Secret +// name: my-secret +// namespace: default +// timeout: 30s +type SetupApplyEntry struct { + // Path is the YAML file path to kubectl-apply. + Path string `yaml:"path"` + // Wait blocks after this apply until all listed resources satisfy their conditions. + Wait []SetupWait `yaml:"wait,omitempty"` +} + +// UnmarshalYAML allows a SetupApplyEntry to be written as either a plain string +// (the path) or a full {path, wait} struct. +func (e *SetupApplyEntry) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + e.Path = value.Value + return nil + } + type plain SetupApplyEntry + raw, err := yaml.Marshal(value) + if err != nil { + return err + } + return utils.StrictUnmarshal(raw, (*plain)(e)) +} + // SetupHelmInstall installs a Helm chart as a real release into the cluster. // Unlike HelmSource (which renders charts to extract Katalog documents), // this runs helm upgrade --install for a prerequisite chart. type SetupHelmInstall struct { - // Repo is the Helm repository URL. - Repo string `yaml:"repo"` - // Chart is the chart name within the repository. + // Repo is the Helm repository URL. Omit for local chart paths. + Repo string `yaml:"repo,omitempty"` + // Chart is the chart name within the repository, or a local path (e.g. ./). Chart string `yaml:"chart"` // Release is the Helm release name. Defaults to the chart name when empty. Release string `yaml:"release,omitempty"` // Namespace for the release. Defaults to "default". Namespace string `yaml:"namespace,omitempty"` - // Version pins the chart version. Leave empty for latest. + // Version pins the chart version. Leave empty for latest or local charts. Version string `yaml:"version,omitempty"` // ValueFiles is an ordered list of values files (local paths or URLs). ValueFiles []string `yaml:"valueFiles,omitempty"` @@ -85,6 +134,8 @@ type SetupHelmInstall struct { Values map[string]interface{} `yaml:"values,omitempty"` // CreateNamespace passes --create-namespace to helm. CreateNamespace bool `yaml:"createNamespace,omitempty"` + // Wait blocks after this helm install until all listed resources satisfy conditions. + Wait []SetupWait `yaml:"wait,omitempty"` } // ReleaseName returns the effective Helm release name. @@ -103,14 +154,19 @@ func (h SetupHelmInstall) EffectiveNamespace() string { return "default" } +// IsLocalChart reports whether the chart field is a local filesystem path. +func (h SetupHelmInstall) IsLocalChart() bool { + return strings.HasPrefix(h.Chart, "./") || strings.HasPrefix(h.Chart, "/") || h.Chart == "." +} + // Validate returns an error when required fields are missing. func (h SetupHelmInstall) Validate() error { - if h.Repo == "" { - return fmt.Errorf("setup.helm: repo is required") - } if h.Chart == "" { return fmt.Errorf("setup.helm: chart is required") } + if !h.IsLocalChart() && h.Repo == "" { + return fmt.Errorf("setup.helm: repo is required for remote charts") + } return nil } @@ -266,12 +322,31 @@ type E2ECluster struct { Reuse bool `yaml:"reuse"` } +// E2EAfter is the lifecycle event that triggers an expectation block. +type E2EAfter string + +const ( + // AfterSetupComplete runs the expectation after all setup steps finish, + // before the CR is applied. Use for infrastructure assertions. + AfterSetupComplete E2EAfter = "setup-complete" + + // AfterCRApplied runs the expectation after the CR is applied to the cluster. + AfterCRApplied E2EAfter = "cr-applied" + + // AfterCRDeleted runs the expectation after the CR is deleted from the cluster. + AfterCRDeleted E2EAfter = "cr-deleted" +) + +// ValidAfterValues is the set of valid values for E2EExpectation.After. +var ValidAfterValues = []E2EAfter{AfterSetupComplete, AfterCRApplied, AfterCRDeleted} + // E2EExpectation is one named assertion block. type E2EExpectation struct { // Name is printed in the results table. Name string `yaml:"name"` - // After triggers the expectation — "cr-applied" or "cr-deleted". - After string `yaml:"after"` + // After is the lifecycle event that triggers this expectation. + // Valid values: "setup-complete", "cr-applied", "cr-deleted". + After E2EAfter `yaml:"after"` // Timeout is the maximum time to wait for the expectation to pass. Timeout string `yaml:"timeout"` // e.g. "60s"