diff --git a/Makefile b/Makefile index a7822ec0..429ff4cd 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,7 @@ ork: generate-notes cd $(ORKESTRA_DIR) && gofmt -w . cd $(ORKESTRA_DIR) && go build -ldflags "$(ORK_LDFLAGS)" -o $(OUTPUT_DIR)/ork ./cmd/orkestra @echo "✅ Orkestra built successfully" + @python3 scripts/fix-bare-fences.py documentation/ orkcc: @echo "Building Orkestra Control Center..." diff --git a/cmd/cli/validate.go b/cmd/cli/validate.go index cf4500fb..985132da 100644 --- a/cmd/cli/validate.go +++ b/cmd/cli/validate.go @@ -521,6 +521,9 @@ func validateMotifFile(path string) error { if summary := motifResourceSummary(m); summary != "" { fmt.Printf(" %s\n", gray("resources: "+summary)) } + if summary := motifProfileSummary(m); summary != "" { + fmt.Printf(" %s\n", gray("profiles : "+summary)) + } } fmt.Println() @@ -529,6 +532,28 @@ func validateMotifFile(path string) error { return nil } +// motifProfileSummary returns a compact string listing non-empty profile classes +// and their counts, e.g. "networkPolicies(2) resourceQuotas(1)". +func motifProfileSummary(m *orktypes.Motif) string { + reg := m.Profiles + if reg.IsEmpty() { + return "" + } + var parts []string + add := func(kind string, n int) { + if n > 0 { + parts = append(parts, fmt.Sprintf("%s(%d)", kind, n)) + } + } + add("networkPolicies", len(reg.NetworkPolicies)) + add("resourceQuotas", len(reg.ResourceQuotas)) + add("limitRanges", len(reg.LimitRanges)) + add("hpa", len(reg.HPA)) + add("pdb", len(reg.PDB)) + add("rollingUpdate", len(reg.RollingUpdate)) + return strings.Join(parts, " ") +} + // 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 { diff --git a/documentation/concepts/profiles/10-user-defined-profiles.md b/documentation/concepts/profiles/10-user-defined-profiles.md new file mode 100644 index 00000000..b3573d95 --- /dev/null +++ b/documentation/concepts/profiles/10-user-defined-profiles.md @@ -0,0 +1,174 @@ +# User-Defined Profiles + +Built-in Orkestra profiles cover the common cases. User-defined profiles let your team define the cases specific to you — your network topology, your compliance requirements, your capacity tiers — and name them so that intent travels through every Katalog and Motif that imports them. + +--- + +## Declaring profiles + +Profiles are declared at the root of a Katalog or Motif alongside `spec:` and `security:`: + +```yaml +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: platform-operator + +profiles: + networkPolicies: + - name: allow-monitoring + description: Allow ingress from the platform monitoring namespace + ingress: + - from: + - namespaceSelector: + team: platform + policyTypes: [Ingress] + + resourceQuotas: + - name: team-medium + description: Standard allocation for a medium-sized team namespace + hard: + pods: "30" + cpu: "6" + memory: "12Gi" + requests.cpu: "3" + requests.memory: "6Gi" + +spec: + crds: + namespace: + ... + operatorBox: + onCreate: + networkPolicies: + - name: "{{ .metadata.name }}-monitoring" + profile: allow-monitoring + resourceQuotas: + - name: "{{ .metadata.name }}-quota" + profile: team-medium +``` + +--- + +## Supported profile classes + +| Class | YAML key | Expands into | +|-------|----------|--------------| +| NetworkPolicy | `profiles.networkPolicies` | ingress/egress rules, policyTypes | +| ResourceQuota | `profiles.resourceQuotas` | hard limits map | +| LimitRange | `profiles.limitRanges` | limit items | +| HPA | `profiles.hpa` | minReplicas, maxReplicas, CPU target, behavior | +| PDB | `profiles.pdb` | minAvailable or maxUnavailable | +| Rolling Update | `profiles.rollingUpdate` | maxSurge, maxUnavailable | + +--- + +## Template expressions in profile fields + +Profile field values support template expressions. They are resolved at reconcile time against the live CR: + +```yaml +profiles: + resourceQuotas: + - name: cr-sized + hard: + pods: "{{ .spec.maxPods }}" + cpu: "{{ .spec.cpuLimit }}" + memory: "{{ .spec.memLimit }}" + + hpa: + - name: cr-scaled + minReplicas: "{{ .spec.minReplicas | default \"2\" }}" + maxReplicas: "{{ .spec.maxReplicas }}" + targetCPUUtilizationPercentage: "70" +``` + +At `ork validate` time, fields containing `{{` are skipped — they cannot be validated statically. At reconcile time, the expression is expanded before the profile is applied. + +--- + +## Validation + +`ork validate` enforces three rules on the `profiles:` block: + +1. **Non-empty name** — every profile entry must declare a `name`. +2. **Unique within class** — two `networkPolicies` entries with the same name is an error. Two entries with the same name in different classes (`resourceQuotas` and `hpa`) is fine — class is the scope boundary. +3. **Shadowing built-ins** — allowed but warned. If you declare a `networkPolicies` profile named `deny-all`, your version is used instead of Orkestra's built-in. A warning is printed at validate time so the shadowing is explicit. + +--- + +## Resolution order + +When a resource declares `profile: some-name`, Orkestra resolves it in this order: + +1. User-defined profiles in the katalog `profiles:` block +2. User-defined profiles merged from imported Motifs +3. Built-in Orkestra profiles + +The first match wins. Built-ins are only consulted when the name is not found in any user registry. + +--- + +## Profiles in Motifs + +A Motif can declare its own `profiles:` block. When a Katalog imports the Motif, its profiles are merged into the Katalog's registry: + +```yaml +# tenant-isolation.motif.yaml +apiVersion: orkestra.orkspace.io/v1 +kind: Motif +metadata: + name: tenant-isolation + version: v0.2.0 + +profiles: + networkPolicies: + - name: allow-monitoring + ingress: + - from: + - namespaceSelector: + team: platform + policyTypes: [Ingress] + - name: allow-internal + ingress: + - from: + - namespaceSelector: + scope: internal + policyTypes: [Ingress] + +resources: + networkPolicies: + - name: "{{ .metadata.name }}-deny-all" + profile: deny-all + - name: "{{ .metadata.name }}-monitoring" + profile: allow-monitoring +``` + +### Conflict detection + +If the same profile name appears in the same class in both the Katalog and an imported Motif — or in two imported Motifs — it is a **hard error** at load time: + +```text +profile conflict: networkPolicies "allow-monitoring" defined in both motif "tenant-isolation" and the katalog +``` + +The same name in different classes is not a conflict — `resourceQuotas.medium` and `hpa.medium` are independent. + +--- + +## ork validate output + +When a Motif declares profiles, `ork validate` shows them alongside resources: + +```text +● tenant-isolation + Reusable network isolation motif + version : v0.2.0 + inputs : 2 + resources: networkPolicies(1) + profiles : networkPolicies(2) +``` + +--- + +→ Back: [09 — NetworkPolicy Profile](09-networkpolicy-profile.md) | [Profiles index](index.md) diff --git a/documentation/concepts/profiles/index.md b/documentation/concepts/profiles/index.md index 5a0bb681..8be22fc7 100644 --- a/documentation/concepts/profiles/index.md +++ b/documentation/concepts/profiles/index.md @@ -62,6 +62,46 @@ Static names are validated at load time. Template expressions are validated when --- +## Built-ins versus user-defined profiles + +Orkestra ships with built-in profiles — `deny-all`, `small/medium/large/xlarge`, `safe`, `zero-downtime` — so the feature works immediately without any extra YAML. They cover common Kubernetes patterns. + +But the feature is designed for the profiles you write yourself. + +A team writing `profile: org-medium` is expressing an organizational contract: this namespace gets the capacity we agreed on for medium-sized teams. The numbers follow from that decision. Change the `org-medium` definition once and it propagates to every Katalog and Motif that references it — no grep, no PR chain, no drift. + +That is what built-ins cannot do. `medium` is Orkestra's guess at what medium means. `org-medium` is your team's actual answer. + +User-defined profiles are declared in a `profiles:` block at the root of any Katalog or Motif: + +```yaml +profiles: + resourceQuotas: + - name: org-medium + description: Standard allocation for a medium-sized team namespace + hard: + pods: "25" + cpu: "4" + memory: "8Gi" + + networkPolicies: + - name: org-deny-all + description: Block all traffic — start here and add policies for what you need + policyTypes: [Ingress, Egress] + + rollingUpdate: + - name: org-safe + description: Never reduces capacity during a rollout + maxSurge: "1" + maxUnavailable: "0" +``` + +They resolve before built-ins. A profile named `deny-all` in your `profiles:` block shadows the built-in. A conflict between two imported Motifs declaring the same profile name in the same class is a hard error at load time. + +See [User-defined profiles](./10-user-defined-profiles.md) for the full reference. + +--- + ## Profile families | Family | What it controls | Applied to | @@ -75,6 +115,7 @@ Static names are validated at load time. Template expressions are validated when | [Rolling Update](./07-rolling-update-profile.md) | Deployment/StatefulSet/ReplicaSet rollout strategy | `deployments[*].rollingUpdate`, `statefulSets[*].rollingUpdate`, `replicaSets[*].rollingUpdate` | | [ResourceQuota](./08-resourcequota-profile.md) | Namespace resource limits (pods, CPU, memory) | `resourceQuotas[*]` | | [NetworkPolicy](./09-networkpolicy-profile.md) | Traffic allow/deny rules for pods | `networkPolicies[*]` | +| [User-defined](./10-user-defined-profiles.md) | Custom named profiles declared in your Katalog or Motif | All profile-supporting fields | --- @@ -84,3 +125,4 @@ Static names are validated at load time. Template expressions are validated when - **Fail-fast** — an unknown profile name is a Katalog load error, not a runtime error. - **No mixing** — a profile and explicit fields of the same type cannot coexist on the same resource. - **Template-safe** — profile names can be template expressions. Static names are validated immediately; template expressions are validated at reconcile time. +- **User-defined** — teams can declare custom named profiles in a Katalog or Motif `profiles:` block. They resolve before built-ins. See [User-defined profiles](./10-user-defined-profiles.md). diff --git a/documentation/reference/schema/02-katalog/index.md b/documentation/reference/schema/02-katalog/index.md index c466dd3e..048abc50 100644 --- a/documentation/reference/schema/02-katalog/index.md +++ b/documentation/reference/schema/02-katalog/index.md @@ -40,6 +40,14 @@ spec: imports: # Motif imports ... +profiles: # optional — user-defined named profiles + networkPolicies: + - name: allow-monitoring + ... + resourceQuotas: + - name: team-medium + ... + security: # optional ... @@ -80,6 +88,8 @@ This scaffolds the simplest Katalog — a single CRD that creates a Deployment a | [10-katalog-security.md](10-katalog-security.md) | `security` block | | [11-katalog-notification.md](11-katalog-notification.md) | `notification` block | | [12-katalog-providers.md](12-katalog-providers.md) | `providers` block | +| [16-resource-types.md](16-resource-types.md) | Supported Kubernetes resource types | +| [Profiles concept](../../../concepts/profiles/10-user-defined-profiles.md) | `profiles:` — user-defined named profiles | | [15-enrich.md](15-enrich.md) | `enrich` — post-reconcile enrichment | | [16-resource-types.md](16-resource-types.md) | Supported resource types and placeholder fields | diff --git a/examples/beginner/03-secret-copy/README.md b/examples/beginner/03-secret-copy/README.md index 260475b5..c6ed3311 100644 --- a/examples/beginner/03-secret-copy/README.md +++ b/examples/beginner/03-secret-copy/README.md @@ -40,7 +40,7 @@ ork simulate Because the Secret copy requires reading the source Secret from a live cluster, Orkestra detects this automatically and skips it during simulation: -``` +```text note: secrets/{{ .spec.secretName }}: cross-namespace copy skipped in simulate — requires a live cluster Cycle 1: diff --git a/examples/use-cases/namespace-provisioner/03-user-defined-profiles/cr.yaml b/examples/use-cases/namespace-provisioner/03-user-defined-profiles/cr.yaml new file mode 100644 index 00000000..8d049e1a --- /dev/null +++ b/examples/use-cases/namespace-provisioner/03-user-defined-profiles/cr.yaml @@ -0,0 +1,10 @@ +apiVersion: provisioner.orkestra.io/v1 +kind: NamespaceClaim +metadata: + name: team-gamma-claim +spec: + targetNamespace: team-gamma + team: gamma + owner: gamma-operator + ownerNamespace: default + tier: medium diff --git a/examples/use-cases/namespace-provisioner/03-user-defined-profiles/katalog.yaml b/examples/use-cases/namespace-provisioner/03-user-defined-profiles/katalog.yaml new file mode 100644 index 00000000..07dcd663 --- /dev/null +++ b/examples/use-cases/namespace-provisioner/03-user-defined-profiles/katalog.yaml @@ -0,0 +1,341 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: namespace-provisioner-user-profiles + version: v1.0.0 + description: > + Namespace Provisioner 03 — all six user-defined profile classes in one place. + Demonstrates that every profile class (networkPolicies, resourceQuotas, + limitRanges, hpa, pdb, rollingUpdate) can be declared and resolved from the + katalog's own profiles: block instead of using Orkestra built-ins. + + The RBAC layer comes from the shared tenant-rbac motif. + Network isolation is defined here as user profiles, not built-ins. + author: orkspace + tags: + - namespace + - networkpolicy + - resourcequota + - limitrange + - hpa + - pdb + - rollingupdate + - user-defined-profiles + - multi-tenancy + +# ── User-defined profiles ───────────────────────────────────────────────────── +# All six classes declared here. None of these names collide with Orkestra +# built-ins — they use an org- prefix to make that explicit. +profiles: + + # NetworkPolicy: org-scoped isolation rules + networkPolicies: + - name: org-deny-all + description: Block all ingress and egress (policyTypes makes it explicit) + policyTypes: [Ingress, Egress] + + - name: org-allow-dns-egress + description: Allow UDP/TCP port 53 so pods can resolve DNS + egress: + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + policyTypes: [Egress] + + - name: org-allow-monitoring + description: Allow ingress from the platform monitoring namespace + ingress: + - from: + - namespaceSelector: + team: platform + policyTypes: [Ingress] + + # ResourceQuota: org-defined capacity tiers (different names from built-ins) + resourceQuotas: + - name: org-small + description: Minimal allocation for prototype or CI namespaces + hard: + pods: "10" + cpu: "1" + memory: "2Gi" + requests.cpu: "500m" + requests.memory: "1Gi" + limits.cpu: "1" + limits.memory: "2Gi" + + - name: org-medium + description: Standard team namespace allocation + hard: + pods: "25" + cpu: "4" + memory: "8Gi" + requests.cpu: "2" + requests.memory: "4Gi" + limits.cpu: "4" + limits.memory: "8Gi" + + - name: org-large + description: High-traffic or data-intensive team namespaces + hard: + pods: "60" + cpu: "12" + memory: "24Gi" + requests.cpu: "6" + requests.memory: "12Gi" + limits.cpu: "12" + limits.memory: "24Gi" + + # LimitRange: container defaults so unset workloads still have sensible bounds + limitRanges: + - name: org-container-defaults + description: Default CPU/memory requests and limits for containers that do not declare them + limits: + - type: Container + default: + cpu: 500m + memory: 512Mi + defaultRequest: + cpu: 100m + memory: 128Mi + max: + cpu: "4" + memory: 8Gi + + # HPA: org autoscaling presets — each profile sets a CPU target and scale-down window + hpa: + - name: org-conservative + description: Conservative scale-down for stateful or slow-starting services + targetCPUUtilizationPercentage: "70" + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Pods + value: 1 + periodSeconds: 60 + + - name: org-burst + description: Faster scale-down for stateless services that recover quickly + targetCPUUtilizationPercentage: "60" + behavior: + scaleDown: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 20 + periodSeconds: 30 + + # PDB: availability guarantees during voluntary disruptions + pdb: + - name: org-at-least-one + description: Always keep at least one pod available during drains or rollouts + minAvailable: "1" + + - name: org-majority + description: Keep the majority of pods available (suitable for quorum workloads) + minAvailable: "51%" + + # RollingUpdate: rollout strategies + rollingUpdate: + - name: org-safe + description: Zero-downtime rollout — adds new pod before removing the old one + maxSurge: "1" + maxUnavailable: "0" + + - name: org-fast + description: Fast rollout — tolerates some disruption to replace pods quickly + maxSurge: "25%" + maxUnavailable: "25%" + +# ── CRD definition ───────────────────────────────────────────────────────────── +spec: + crds: + namespaceclaim: + crdFile: ../crd.yaml + crFiles: + - cr.yaml + setup: + apply: + - ../setup.yaml + wait: + - kind: Secret + name: database-credentials + namespace: platform + timeout: 15s + + workers: 2 + resync: 1m + + imports: + - motif: ../motifs/tenant-rbac/motif.yaml + with: + team: "{{ .spec.team }}" + targetNamespace: "{{ .spec.targetNamespace }}" + owner: "{{ .spec.owner }}" + ownerNamespace: "{{ .spec.ownerNamespace }}" + + operatorBox: + status: + fields: + - path: phase + value: Provisioning + when: + - field: status.phase + operator: notExists + + - path: phase + value: Ready + when: + - field: "{{ resourceExists .children.namespaces }}" + equals: "true" + + - path: namespace + value: "{{ .spec.targetNamespace }}" + + - path: team + value: "{{ .spec.team }}" + + onCreate: + namespaces: + - name: "{{ .spec.targetNamespace }}" + labels: + - key: team + value: "{{ .spec.team }}" + - key: managed-by + value: orkestra + reconcile: true + + serviceAccounts: + - name: "{{ .spec.owner }}" + namespace: "{{ .spec.targetNamespace }}" + labels: + - key: team + value: "{{ .spec.team }}" + - key: managed-by + value: orkestra + reconcile: true + + # ── networkPolicies: user-defined org-* profiles ──────────────── + networkPolicies: + - name: "{{ .spec.team }}-deny-all" + namespace: "{{ .spec.targetNamespace }}" + podSelector: {} + profile: org-deny-all + labels: + - key: team + value: "{{ .spec.team }}" + - key: managed-by + value: orkestra + reconcile: true + + - name: "{{ .spec.team }}-allow-dns" + namespace: "{{ .spec.targetNamespace }}" + podSelector: {} + profile: org-allow-dns-egress + labels: + - key: team + value: "{{ .spec.team }}" + - key: managed-by + value: orkestra + reconcile: true + + - name: "{{ .spec.team }}-allow-monitoring" + namespace: "{{ .spec.targetNamespace }}" + podSelector: {} + profile: org-allow-monitoring + labels: + - key: team + value: "{{ .spec.team }}" + - key: managed-by + value: orkestra + reconcile: true + + # ── resourceQuotas: user-defined org-* tier ──────────────────── + # The template resolves spec.tier ("small"|"medium"|"large") to the + # org- prefixed profile name at reconcile time. + resourceQuotas: + - name: "{{ .spec.team }}-quota" + namespace: "{{ .spec.targetNamespace }}" + profile: "{{ printf \"org-%s\" .spec.tier }}" + labels: + - key: team + value: "{{ .spec.team }}" + - key: managed-by + value: orkestra + reconcile: true + + # ── limitRanges: user-defined container defaults ───────────────── + limitRanges: + - name: "{{ .spec.team }}-container-limits" + namespace: "{{ .spec.targetNamespace }}" + profile: org-container-defaults + labels: + - key: team + value: "{{ .spec.team }}" + - key: managed-by + value: orkestra + reconcile: true + + # ── Namespace agent deployment ──────────────────────────────────── + # A lightweight agent that runs in each provisioned namespace. + # Uses org-safe rolling update and org-conservative HPA/PDB profiles. + deployments: + - name: "{{ .spec.team }}-ns-agent" + namespace: "{{ .spec.targetNamespace }}" + image: orkspace/ns-agent:v1 + replicas: "2" + rollingUpdate: + profile: org-safe + labels: + - key: team + value: "{{ .spec.team }}" + - key: app + value: ns-agent + - key: managed-by + value: orkestra + reconcile: true + + # ── hpa: user-defined org-conservative preset ──────────────────── + hpa: + - name: "{{ .spec.team }}-ns-agent-hpa" + namespace: "{{ .spec.targetNamespace }}" + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: "{{ .spec.team }}-ns-agent" + minReplicas: "2" + maxReplicas: "8" + behavior: + profile: org-conservative + labels: + - key: team + value: "{{ .spec.team }}" + - key: managed-by + value: orkestra + reconcile: true + + # ── pdb: user-defined org-at-least-one preset ──────────────────── + pdb: + - name: "{{ .spec.team }}-ns-agent-pdb" + namespace: "{{ .spec.targetNamespace }}" + selector: + app: ns-agent + team: "{{ .spec.team }}" + behavior: + profile: org-at-least-one + labels: + - key: team + value: "{{ .spec.team }}" + - key: managed-by + value: orkestra + reconcile: true + + secrets: + - name: registry-credentials + fromSecret: registry-credentials + fromNamespace: platform + toNamespaces: + - "{{ .spec.targetNamespace }}" + reconcile: true diff --git a/examples/use-cases/namespace-provisioner/03-user-defined-profiles/simulate.yaml b/examples/use-cases/namespace-provisioner/03-user-defined-profiles/simulate.yaml new file mode 100644 index 00000000..c738cf06 --- /dev/null +++ b/examples/use-cases/namespace-provisioner/03-user-defined-profiles/simulate.yaml @@ -0,0 +1,76 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: namespace-provisioner-user-profiles-sim + description: > + Verify that a NamespaceClaim provisions all resources in cycle 1 using + user-defined profiles for all six profile classes (networkPolicies, + resourceQuotas, limitRanges, hpa, pdb, rollingUpdate). + +spec: + katalog: ./katalog.yaml + cr: ./cr.yaml + cycles: 3 + + expect: + steady: true + ops: + - cycle: 1 + verb: create + resource: namespaces + name: team-gamma + + - cycle: 1 + verb: create + resource: serviceaccounts + name: gamma-operator + + - cycle: 1 + verb: create + resource: networkpolicies + name: gamma-deny-all + + - cycle: 1 + verb: create + resource: networkpolicies + name: gamma-allow-dns + + - cycle: 1 + verb: create + resource: networkpolicies + name: gamma-allow-monitoring + + - cycle: 1 + verb: create + resource: resourcequotas + name: gamma-quota + + - cycle: 1 + verb: create + resource: limitranges + name: gamma-container-limits + + - cycle: 1 + verb: create + resource: deployments + name: gamma-ns-agent + + - cycle: 1 + verb: create + resource: horizontalpodautoscalers + name: gamma-ns-agent-hpa + + - cycle: 1 + verb: create + resource: poddisruptionbudgets + name: gamma-ns-agent-pdb + + - cycle: 1 + verb: create + resource: clusterroles + name: gamma-ns-admin + + - cycle: 1 + verb: create + resource: clusterrolebindings + name: gamma-ns-admin-binding diff --git a/examples/use-cases/profiles/01-resource/README.md b/examples/use-cases/profiles/01-resource/README.md index 42d58477..d96dda23 100644 --- a/examples/use-cases/profiles/01-resource/README.md +++ b/examples/use-cases/profiles/01-resource/README.md @@ -29,7 +29,15 @@ ork validate --- -## Step 2 — Start the runtime +## Step 2 — Simulate + +```bash +ork simulate +``` + +--- + +## Step 3 — Start the runtime ```bash ork run @@ -37,7 +45,7 @@ ork run --- -## Step 3 — Open the Control Center +## Step 4 — Open the Control Center In a **separate terminal**: @@ -50,7 +58,7 @@ Open [http://localhost:8081](http://localhost:8081). Select **service-resource-p --- -## Step 4 — Apply the CR +## Step 5 — Apply the CR ```bash kubectl apply -f ../cr.yaml diff --git a/examples/use-cases/profiles/01-resource/simulate.yaml b/examples/use-cases/profiles/01-resource/simulate.yaml new file mode 100644 index 00000000..e811c32e --- /dev/null +++ b/examples/use-cases/profiles/01-resource/simulate.yaml @@ -0,0 +1,55 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: profiles-resource-sim + description: > + Eight resource profiles — tiny through memory-heavy. All eight Deployments + are created in cycle 1 from a single CR. + +spec: + katalog: ./katalog.yaml + cr: ../cr.yaml + cycles: 3 + + expect: + steady: true + ops: + - cycle: 1 + verb: create + resource: deployments + name: my-service-tiny + + - cycle: 1 + verb: create + resource: deployments + name: my-service-small + + - cycle: 1 + verb: create + resource: deployments + name: my-service-medium + + - cycle: 1 + verb: create + resource: deployments + name: my-service-large + + - cycle: 1 + verb: create + resource: deployments + name: my-service-burst + + - cycle: 1 + verb: create + resource: deployments + name: my-service-steady + + - cycle: 1 + verb: create + resource: deployments + name: my-service-compute-heavy + + - cycle: 1 + verb: create + resource: deployments + name: my-service-memory-heavy diff --git a/examples/use-cases/profiles/02-security/README.md b/examples/use-cases/profiles/02-security/README.md index dfdfe8ba..45c866a2 100644 --- a/examples/use-cases/profiles/02-security/README.md +++ b/examples/use-cases/profiles/02-security/README.md @@ -36,7 +36,15 @@ ork validate --- -## Step 2 — Start the runtime +## Step 2 — Simulate + +```bash +ork simulate +``` + +--- + +## Step 3 — Start the runtime ```bash ork run @@ -44,7 +52,7 @@ ork run --- -## Step 3 — Open the Control Center +## Step 4 — Open the Control Center In a **separate terminal**: @@ -57,7 +65,7 @@ Open [http://localhost:8081](http://localhost:8081). Select **service-security-p --- -## Step 4 — Apply the CR +## Step 5 — Apply the CR ```bash kubectl apply -f ../cr.yaml diff --git a/examples/use-cases/profiles/02-security/simulate.yaml b/examples/use-cases/profiles/02-security/simulate.yaml new file mode 100644 index 00000000..dda88af9 --- /dev/null +++ b/examples/use-cases/profiles/02-security/simulate.yaml @@ -0,0 +1,30 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: profiles-security-sim + description: > + Three security profiles — baseline, restricted, hardened. All three + Deployments are created in cycle 1 from a single CR. + +spec: + katalog: ./katalog.yaml + cr: ../cr.yaml + cycles: 3 + + expect: + steady: true + ops: + - cycle: 1 + verb: create + resource: deployments + name: my-service-baseline + + - cycle: 1 + verb: create + resource: deployments + name: my-service-restricted + + - cycle: 1 + verb: create + resource: deployments + name: my-service-hardened diff --git a/examples/use-cases/profiles/03-probes/README.md b/examples/use-cases/profiles/03-probes/README.md index 0fa8f850..ab2c9a93 100644 --- a/examples/use-cases/profiles/03-probes/README.md +++ b/examples/use-cases/profiles/03-probes/README.md @@ -27,7 +27,15 @@ ork validate --- -## Step 2 — Start the runtime +## Step 2 — Simulate + +```bash +ork simulate +``` + +--- + +## Step 3 — Start the runtime ```bash ork run @@ -35,7 +43,7 @@ ork run --- -## Step 3 — Open the Control Center +## Step 4 — Open the Control Center In a **separate terminal**: @@ -48,7 +56,7 @@ Open [http://localhost:8081](http://localhost:8081). Select **service-probe-prof --- -## Step 4 — Apply the CR +## Step 5 — Apply the CR ```bash kubectl apply -f ../cr.yaml diff --git a/examples/use-cases/profiles/03-probes/simulate.yaml b/examples/use-cases/profiles/03-probes/simulate.yaml new file mode 100644 index 00000000..c6dc4448 --- /dev/null +++ b/examples/use-cases/profiles/03-probes/simulate.yaml @@ -0,0 +1,35 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: profiles-probes-sim + description: > + Four probe profiles — fast, standard, patient, slow-start. All four + Deployments are created in cycle 1 from a single CR. + +spec: + katalog: ./katalog.yaml + cr: ../cr.yaml + cycles: 3 + + expect: + steady: true + ops: + - cycle: 1 + verb: create + resource: deployments + name: my-service-fast + + - cycle: 1 + verb: create + resource: deployments + name: my-service-standard + + - cycle: 1 + verb: create + resource: deployments + name: my-service-patient + + - cycle: 1 + verb: create + resource: deployments + name: my-service-slow-start diff --git a/examples/use-cases/profiles/04-rolling-update/README.md b/examples/use-cases/profiles/04-rolling-update/README.md index ea129159..340ac940 100644 --- a/examples/use-cases/profiles/04-rolling-update/README.md +++ b/examples/use-cases/profiles/04-rolling-update/README.md @@ -24,7 +24,15 @@ ork validate --- -## Step 2 — Start the runtime +## Step 2 — Simulate + +```bash +ork simulate +``` + +--- + +## Step 3 — Start the runtime ```bash ork run @@ -32,7 +40,7 @@ ork run --- -## Step 3 — Open the Control Center +## Step 4 — Open the Control Center In a **separate terminal**: @@ -45,7 +53,7 @@ Open [http://localhost:8081](http://localhost:8081). Select **service-rolling-pr --- -## Step 4 — Apply the CR +## Step 5 — Apply the CR ```bash kubectl apply -f ../cr.yaml @@ -68,7 +76,7 @@ my-service-bg 100% 0 --- -## Step 5 — Trigger a rollout and observe the difference +## Step 6 — Trigger a rollout and observe the difference Patch the image to start a rolling update across all three: diff --git a/examples/use-cases/profiles/04-rolling-update/simulate.yaml b/examples/use-cases/profiles/04-rolling-update/simulate.yaml new file mode 100644 index 00000000..4ec00b4e --- /dev/null +++ b/examples/use-cases/profiles/04-rolling-update/simulate.yaml @@ -0,0 +1,30 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: profiles-rolling-update-sim + description: > + Three rolling update profiles — safe, fast, blue-green. All three + Deployments are created in cycle 1 from a single CR. + +spec: + katalog: ./katalog.yaml + cr: ../cr.yaml + cycles: 3 + + expect: + steady: true + ops: + - cycle: 1 + verb: create + resource: deployments + name: my-service-safe + + - cycle: 1 + verb: create + resource: deployments + name: my-service-fast + + - cycle: 1 + verb: create + resource: deployments + name: my-service-bg diff --git a/examples/use-cases/profiles/05-pdb/README.md b/examples/use-cases/profiles/05-pdb/README.md index c9f5fccc..10c90805 100644 --- a/examples/use-cases/profiles/05-pdb/README.md +++ b/examples/use-cases/profiles/05-pdb/README.md @@ -24,7 +24,15 @@ ork validate --- -## Step 2 — Start the runtime +## Step 2 — Simulate + +```bash +ork simulate +``` + +--- + +## Step 3 — Start the runtime ```bash ork run @@ -32,7 +40,7 @@ ork run --- -## Step 3 — Open the Control Center +## Step 4 — Open the Control Center In a **separate terminal**: @@ -45,7 +53,7 @@ Open [http://localhost:8081](http://localhost:8081). Select **service-pdb-profil --- -## Step 4 — Apply the CR +## Step 5 — Apply the CR ```bash kubectl apply -f ../cr.yaml diff --git a/examples/use-cases/profiles/05-pdb/simulate.yaml b/examples/use-cases/profiles/05-pdb/simulate.yaml new file mode 100644 index 00000000..d88100a6 --- /dev/null +++ b/examples/use-cases/profiles/05-pdb/simulate.yaml @@ -0,0 +1,45 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: profiles-pdb-sim + description: > + Three PDB profiles — zero-downtime, rolling, relaxed. Three Deployments + and three PodDisruptionBudgets are created in cycle 1 from a single CR. + +spec: + katalog: ./katalog.yaml + cr: ../cr.yaml + cycles: 3 + + expect: + steady: true + ops: + - cycle: 1 + verb: create + resource: deployments + name: my-service-zero + + - cycle: 1 + verb: create + resource: deployments + name: my-service-rolling + + - cycle: 1 + verb: create + resource: deployments + name: my-service-relaxed + + - cycle: 1 + verb: create + resource: poddisruptionbudgets + name: my-service-zero-pdb + + - cycle: 1 + verb: create + resource: poddisruptionbudgets + name: my-service-rolling-pdb + + - cycle: 1 + verb: create + resource: poddisruptionbudgets + name: my-service-relaxed-pdb diff --git a/examples/use-cases/profiles/06-networkpolicy/README.md b/examples/use-cases/profiles/06-networkpolicy/README.md new file mode 100644 index 00000000..174e3443 --- /dev/null +++ b/examples/use-cases/profiles/06-networkpolicy/README.md @@ -0,0 +1,105 @@ +# Profiles 06 — NetworkPolicy + +One CR. Five NetworkPolicies. Each uses a different built-in profile — no ingress or egress rules to write. + +**What you learn:** `networkPolicies.profile`, what each preset expands to, and how to layer policies by applying multiple profiles from a single CR. + +--- + +## Profiles at a glance + +| Profile | policyTypes | Rules | +|---|---|---| +| `deny-all` | Ingress, Egress | Empty ingress and egress — blocks all traffic | +| `deny-all-ingress` | Ingress | Empty ingress — blocks all inbound, egress unrestricted | +| `deny-all-egress` | Egress | Empty egress — blocks all outbound, ingress unrestricted | +| `allow-same-namespace` | Ingress | Ingress from any pod in the same namespace | +| `allow-dns-egress` | Egress | Egress to UDP/TCP 53 — DNS resolution only | + +--- + +## Step 1 — Validate + +```bash +ork validate +``` + +## Step 2 — Simulate + +```bash +ork simulate +``` + +--- + +## Step 3 — Start the runtime + +```bash +ork run +``` + +--- + +## Step 4 — Apply the CR + +In a separate terminal: + +```bash +kubectl apply -f ../cr.yaml +``` + +Verify the policies: + +```bash +kubectl get networkpolicies +``` + +Expected: +```text +NAME POD-SELECTOR AGE +my-service-deny-all 5s +my-service-deny-ingress 5s +my-service-deny-egress 5s +my-service-allow-same-ns 5s +my-service-allow-dns 5s +``` + +Inspect the expanded rules for any policy: + +```bash +kubectl get networkpolicy my-service-allow-dns -o jsonpath='{.spec}' | jq +``` + +--- + +## Using a profile in your own Katalog + +```yaml +networkPolicies: + - name: "{{ .metadata.name }}-deny-all" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: deny-all + reconcile: true + - name: "{{ .metadata.name }}-allow-dns" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: allow-dns-egress + reconcile: true +``` + +--- + +## E2E + +```bash +ork e2e +``` + +--- + +## Cleanup + +```bash +chmod +x cleanup.sh && ./cleanup.sh +``` diff --git a/examples/use-cases/profiles/06-networkpolicy/cleanup.sh b/examples/use-cases/profiles/06-networkpolicy/cleanup.sh new file mode 100755 index 00000000..5c806bd3 --- /dev/null +++ b/examples/use-cases/profiles/06-networkpolicy/cleanup.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Cleaning up profiles/06-networkpolicy..." +kubectl delete -f ../cr.yaml --ignore-not-found +kubectl delete -f ../crd.yaml --ignore-not-found +echo "✓ Done." diff --git a/examples/use-cases/profiles/06-networkpolicy/e2e.yaml b/examples/use-cases/profiles/06-networkpolicy/e2e.yaml new file mode 100644 index 00000000..02cbf929 --- /dev/null +++ b/examples/use-cases/profiles/06-networkpolicy/e2e.yaml @@ -0,0 +1,55 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: E2E +metadata: + name: profiles-networkpolicy-e2e + description: > + networkpolicy.profile — one CR produces five NetworkPolicies each using + a different built-in profile. Verifies all five are created and removed + on deletion. + +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: Five NetworkPolicies created + after: cr-applied + timeout: 60s + resources: + - kind: NetworkPolicy + name: my-service-deny-all + namespace: default + - kind: NetworkPolicy + name: my-service-deny-ingress + namespace: default + - kind: NetworkPolicy + name: my-service-deny-egress + namespace: default + - kind: NetworkPolicy + name: my-service-allow-same-ns + namespace: default + - kind: NetworkPolicy + name: my-service-allow-dns + namespace: default + + - name: Cleanup verified + after: cr-deleted + timeout: 60s + resources: + - kind: NetworkPolicy + name: my-service-deny-all + namespace: default + count: 0 diff --git a/examples/use-cases/profiles/06-networkpolicy/katalog.yaml b/examples/use-cases/profiles/06-networkpolicy/katalog.yaml new file mode 100644 index 00000000..d44153e1 --- /dev/null +++ b/examples/use-cases/profiles/06-networkpolicy/katalog.yaml @@ -0,0 +1,69 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: service-networkpolicy-profiles + description: > + Profiles 06 — NetworkPolicy profiles. + Creates five NetworkPolicies from a single CR, each using a different + built-in profile. The profile expands at Katalog load time into ingress + and egress rules; no rule syntax to write. + +spec: + crds: + service: + crdFile: ../crd.yaml + + workers: 2 + resync: 30s + + operatorBox: + + status: + fields: + - path: phase + value: "Pending" + when: + - field: status.phase + operator: notExists + - path: phase + value: "Ready" + when: + - field: "{{ resourceExists .children.networkPolicies }}" + equals: "true" + + onCreate: + networkPolicies: + # deny-all — blocks all ingress and egress for the pods in this namespace + - name: "{{ .metadata.name }}-deny-all" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: deny-all + reconcile: true + + # deny-all-ingress — blocks inbound traffic only; egress is unrestricted + - name: "{{ .metadata.name }}-deny-ingress" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: deny-all-ingress + reconcile: true + + # deny-all-egress — blocks outbound traffic only; ingress is unrestricted + - name: "{{ .metadata.name }}-deny-egress" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: deny-all-egress + reconcile: true + + # allow-same-namespace — allows ingress from any pod in the same namespace + - name: "{{ .metadata.name }}-allow-same-ns" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: allow-same-namespace + reconcile: true + + # allow-dns-egress — opens UDP/TCP 53 so pods can resolve DNS + - name: "{{ .metadata.name }}-allow-dns" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: allow-dns-egress + reconcile: true diff --git a/examples/use-cases/profiles/06-networkpolicy/simulate.yaml b/examples/use-cases/profiles/06-networkpolicy/simulate.yaml new file mode 100644 index 00000000..f47dbfa1 --- /dev/null +++ b/examples/use-cases/profiles/06-networkpolicy/simulate.yaml @@ -0,0 +1,40 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: profiles-networkpolicy-sim + description: > + Five NetworkPolicy profiles — deny-all, deny-all-ingress, deny-all-egress, + allow-same-namespace, allow-dns-egress. All five are created in cycle 1. + +spec: + katalog: ./katalog.yaml + cr: ../cr.yaml + cycles: 3 + + expect: + steady: true + ops: + - cycle: 1 + verb: create + resource: networkpolicies + name: my-service-deny-all + + - cycle: 1 + verb: create + resource: networkpolicies + name: my-service-deny-ingress + + - cycle: 1 + verb: create + resource: networkpolicies + name: my-service-deny-egress + + - cycle: 1 + verb: create + resource: networkpolicies + name: my-service-allow-same-ns + + - cycle: 1 + verb: create + resource: networkpolicies + name: my-service-allow-dns diff --git a/examples/use-cases/profiles/07-resourcequota/README.md b/examples/use-cases/profiles/07-resourcequota/README.md new file mode 100644 index 00000000..b289179c --- /dev/null +++ b/examples/use-cases/profiles/07-resourcequota/README.md @@ -0,0 +1,91 @@ +# Profiles 07 — ResourceQuota + +One CR. Four ResourceQuotas. Each uses a different tier profile — no pod counts or CPU values to configure. + +**What you learn:** `resourceQuotas.profile`, what each tier expands to, and how to apply multiple quota presets side-by-side. + +--- + +## Profiles at a glance + +| Profile | Pods | CPU | Memory | +|---|---|---|---| +| `small` | 10 | 2 | 4Gi | +| `medium` | 20 | 4 | 8Gi | +| `large` | 50 | 8 | 16Gi | +| `xlarge` | 100 | 16 | 32Gi | + +Each tier sets both `requests.*` and `limits.*` at a 1:2 ratio. + +--- + +## Step 1 — Validate + +```bash +ork validate +``` + +## Step 2 — Simulate + +```bash +ork simulate +``` + +--- + +## Step 3 — Start the runtime + +```bash +ork run +``` + +--- + +## Step 4 — Apply the CR + +In a separate terminal: + +```bash +kubectl apply -f ../cr.yaml +``` + +Verify the quotas and inspect the expanded hard limits: + +```bash +kubectl get resourcequota +kubectl describe resourcequota my-service-quota-medium +``` + +--- + +## Using a profile in your own Katalog + +```yaml +resourceQuotas: + - name: "{{ .metadata.name }}-quota" + namespace: "{{ .metadata.namespace }}" + profile: medium # pods: 20, cpu: 4, memory: 8Gi + reconcile: true +``` + +Or drive the tier from the CR: + +```yaml +profile: "{{ .spec.tier }}" +``` + +--- + +## E2E + +```bash +ork e2e +``` + +--- + +## Cleanup + +```bash +chmod +x cleanup.sh && ./cleanup.sh +``` diff --git a/examples/use-cases/profiles/07-resourcequota/cleanup.sh b/examples/use-cases/profiles/07-resourcequota/cleanup.sh new file mode 100644 index 00000000..e5920736 --- /dev/null +++ b/examples/use-cases/profiles/07-resourcequota/cleanup.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Cleaning up profiles/07-resourcequota..." +kubectl delete -f ../cr.yaml --ignore-not-found +kubectl delete -f ../crd.yaml --ignore-not-found +echo "✓ Done." diff --git a/examples/use-cases/profiles/07-resourcequota/e2e.yaml b/examples/use-cases/profiles/07-resourcequota/e2e.yaml new file mode 100644 index 00000000..1ba07975 --- /dev/null +++ b/examples/use-cases/profiles/07-resourcequota/e2e.yaml @@ -0,0 +1,51 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: E2E +metadata: + name: profiles-resourcequota-e2e + description: > + resourcequota.profile — one CR produces four ResourceQuotas each using + a different tier profile. Verifies all four are created and removed on deletion. + +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 ResourceQuotas created + after: cr-applied + timeout: 60s + resources: + - kind: ResourceQuota + name: my-service-quota-small + namespace: default + - kind: ResourceQuota + name: my-service-quota-medium + namespace: default + - kind: ResourceQuota + name: my-service-quota-large + namespace: default + - kind: ResourceQuota + name: my-service-quota-xlarge + namespace: default + + - name: Cleanup verified + after: cr-deleted + timeout: 60s + resources: + - kind: ResourceQuota + name: my-service-quota-small + namespace: default + count: 0 diff --git a/examples/use-cases/profiles/07-resourcequota/katalog.yaml b/examples/use-cases/profiles/07-resourcequota/katalog.yaml new file mode 100644 index 00000000..5f864cce --- /dev/null +++ b/examples/use-cases/profiles/07-resourcequota/katalog.yaml @@ -0,0 +1,58 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: service-resourcequota-profiles + description: > + Profiles 07 — ResourceQuota profiles. + Creates four ResourceQuotas from a single CR, each using a different + built-in tier profile. The profile expands at Katalog load time into a + fully-formed hard limits map; no pod counts or CPU values to write. + +spec: + crds: + service: + crdFile: ../crd.yaml + + workers: 2 + resync: 30s + + operatorBox: + + status: + fields: + - path: phase + value: "Pending" + when: + - field: status.phase + operator: notExists + - path: phase + value: "Ready" + when: + - field: "{{ resourceExists .children.resourceQuotas }}" + equals: "true" + + onCreate: + resourceQuotas: + # small — prototype and CI workloads + - name: "{{ .metadata.name }}-quota-small" + namespace: "{{ .metadata.namespace }}" + profile: small + reconcile: true + + # medium — standard team namespace + - name: "{{ .metadata.name }}-quota-medium" + namespace: "{{ .metadata.namespace }}" + profile: medium + reconcile: true + + # large — high-traffic or data-intensive namespaces + - name: "{{ .metadata.name }}-quota-large" + namespace: "{{ .metadata.namespace }}" + profile: large + reconcile: true + + # xlarge — very large or shared platform namespaces + - name: "{{ .metadata.name }}-quota-xlarge" + namespace: "{{ .metadata.namespace }}" + profile: xlarge + reconcile: true diff --git a/examples/use-cases/profiles/07-resourcequota/simulate.yaml b/examples/use-cases/profiles/07-resourcequota/simulate.yaml new file mode 100644 index 00000000..38aa962b --- /dev/null +++ b/examples/use-cases/profiles/07-resourcequota/simulate.yaml @@ -0,0 +1,35 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: profiles-resourcequota-sim + description: > + Four ResourceQuota profiles — small, medium, large, xlarge. + All four are created in cycle 1 from a single CR. + +spec: + katalog: ./katalog.yaml + cr: ../cr.yaml + cycles: 3 + + expect: + steady: true + ops: + - cycle: 1 + verb: create + resource: resourcequotas + name: my-service-quota-small + + - cycle: 1 + verb: create + resource: resourcequotas + name: my-service-quota-medium + + - cycle: 1 + verb: create + resource: resourcequotas + name: my-service-quota-large + + - cycle: 1 + verb: create + resource: resourcequotas + name: my-service-quota-xlarge diff --git a/examples/use-cases/profiles/08-limitrange/README.md b/examples/use-cases/profiles/08-limitrange/README.md new file mode 100644 index 00000000..c6b4e9e4 --- /dev/null +++ b/examples/use-cases/profiles/08-limitrange/README.md @@ -0,0 +1,99 @@ +# Profiles 08 — LimitRange + +One CR. Three LimitRanges. Each uses a profile declared in the Katalog's `profiles:` block — LimitRange has no built-in presets, so the `profiles:` block is the only source. + +**What you learn:** `limitRanges.profile`, how to declare user-defined LimitRange profiles, and that `ork validate` enforces references against your own registry. + +--- + +## Profiles at a glance + +| Profile | Default CPU | Default memory | Max CPU | Max memory | +|---|---|---|---|---| +| `minimal` | 200m | 128Mi | 1 | 512Mi | +| `standard` | 500m | 512Mi | 2 | 4Gi | +| `generous` | 1 | 2Gi | 4 | 8Gi | + +All three set both `default` (applied when a container omits `resources.limits`) and `defaultRequest` (applied when a container omits `resources.requests`). + +--- + +## Step 1 — Validate + +```bash +ork validate +``` + +## Step 2 — Simulate + +```bash +ork simulate +``` + +--- + +## Step 3 — Start the runtime + +```bash +ork run +``` + +--- + +## Step 4 — Apply the CR + +In a separate terminal: + +```bash +kubectl apply -f ../cr.yaml +``` + +Verify the limit ranges and inspect an expanded profile: + +```bash +kubectl get limitrange +kubectl describe limitrange my-service-limits-standard +``` + +--- + +## Using a profile in your own Katalog + +Declare the profile in the `profiles:` block, then reference it from `operatorBox`: + +```yaml +profiles: + limitRanges: + - name: standard + limits: + - type: Container + default: { cpu: 500m, memory: 512Mi } + defaultRequest: { cpu: 100m, memory: 128Mi } + +spec: + crds: + mycrd: + operatorBox: + onCreate: + limitRanges: + - name: "{{ .metadata.name }}-limits" + namespace: "{{ .metadata.namespace }}" + profile: standard + reconcile: true +``` + +--- + +## E2E + +```bash +ork e2e +``` + +--- + +## Cleanup + +```bash +chmod +x cleanup.sh && ./cleanup.sh +``` diff --git a/examples/use-cases/profiles/08-limitrange/cleanup.sh b/examples/use-cases/profiles/08-limitrange/cleanup.sh new file mode 100644 index 00000000..5989cec1 --- /dev/null +++ b/examples/use-cases/profiles/08-limitrange/cleanup.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Cleaning up profiles/08-limitrange..." +kubectl delete -f ../cr.yaml --ignore-not-found +kubectl delete -f ../crd.yaml --ignore-not-found +echo "✓ Done." diff --git a/examples/use-cases/profiles/08-limitrange/e2e.yaml b/examples/use-cases/profiles/08-limitrange/e2e.yaml new file mode 100644 index 00000000..97279f2e --- /dev/null +++ b/examples/use-cases/profiles/08-limitrange/e2e.yaml @@ -0,0 +1,49 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: E2E +metadata: + name: profiles-limitrange-e2e + description: > + limitrange.profile (user-defined) — one CR produces three LimitRanges each + using a profile declared in the Katalog's profiles: block. Verifies all three + are created and removed on deletion. + +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 LimitRanges created + after: cr-applied + timeout: 60s + resources: + - kind: LimitRange + name: my-service-limits-minimal + namespace: default + - kind: LimitRange + name: my-service-limits-standard + namespace: default + - kind: LimitRange + name: my-service-limits-generous + namespace: default + + - name: Cleanup verified + after: cr-deleted + timeout: 60s + resources: + - kind: LimitRange + name: my-service-limits-minimal + namespace: default + count: 0 diff --git a/examples/use-cases/profiles/08-limitrange/katalog.yaml b/examples/use-cases/profiles/08-limitrange/katalog.yaml new file mode 100644 index 00000000..9678e6e9 --- /dev/null +++ b/examples/use-cases/profiles/08-limitrange/katalog.yaml @@ -0,0 +1,96 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: service-limitrange-profiles + description: > + Profiles 08 — LimitRange profiles (user-defined). + LimitRange has no built-in presets — the profiles: block is how you name + your container default policies. Declares three presets and creates one + LimitRange per profile from a single CR. + +profiles: + limitRanges: + - name: minimal + description: Low defaults for CI or ephemeral namespaces + limits: + - type: Container + default: + cpu: 200m + memory: 128Mi + defaultRequest: + cpu: 50m + memory: 64Mi + max: + cpu: "1" + memory: 512Mi + + - name: standard + description: General-purpose defaults for most application namespaces + limits: + - type: Container + default: + cpu: 500m + memory: 512Mi + defaultRequest: + cpu: 100m + memory: 128Mi + max: + cpu: "2" + memory: 4Gi + + - name: generous + description: Higher defaults for workloads with heavier baseline resource needs + limits: + - type: Container + default: + cpu: "1" + memory: 2Gi + defaultRequest: + cpu: 250m + memory: 512Mi + max: + cpu: "4" + memory: 8Gi + +spec: + crds: + service: + crdFile: ../crd.yaml + + workers: 2 + resync: 30s + + operatorBox: + + status: + fields: + - path: phase + value: "Pending" + when: + - field: status.phase + operator: notExists + - path: phase + value: "Ready" + when: + - field: "{{ resourceExists .children.limitRanges }}" + equals: "true" + + onCreate: + limitRanges: + # minimal — CI and ephemeral namespaces + - name: "{{ .metadata.name }}-limits-minimal" + namespace: "{{ .metadata.namespace }}" + profile: minimal + reconcile: true + + # standard — general application namespaces + - name: "{{ .metadata.name }}-limits-standard" + namespace: "{{ .metadata.namespace }}" + profile: standard + reconcile: true + + # generous — workloads with heavy baseline needs + - name: "{{ .metadata.name }}-limits-generous" + namespace: "{{ .metadata.namespace }}" + profile: generous + reconcile: true diff --git a/examples/use-cases/profiles/08-limitrange/simulate.yaml b/examples/use-cases/profiles/08-limitrange/simulate.yaml new file mode 100644 index 00000000..bfef11ac --- /dev/null +++ b/examples/use-cases/profiles/08-limitrange/simulate.yaml @@ -0,0 +1,31 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: profiles-limitrange-sim + description: > + Three user-defined LimitRange profiles — minimal, standard, generous. + All three are created in cycle 1. LimitRange has no built-in profiles; + the profiles: block in the Katalog is the only source. + +spec: + katalog: ./katalog.yaml + cr: ../cr.yaml + cycles: 3 + + expect: + steady: true + ops: + - cycle: 1 + verb: create + resource: limitranges + name: my-service-limits-minimal + + - cycle: 1 + verb: create + resource: limitranges + name: my-service-limits-standard + + - cycle: 1 + verb: create + resource: limitranges + name: my-service-limits-generous diff --git a/examples/use-cases/profiles/09-user-defined/README.md b/examples/use-cases/profiles/09-user-defined/README.md new file mode 100644 index 00000000..e8d1baa2 --- /dev/null +++ b/examples/use-cases/profiles/09-user-defined/README.md @@ -0,0 +1,113 @@ +# Profiles 09 — User-Defined + +One CR. One Deployment. Three NetworkPolicies. Two HPAs. All profile names are declared in the Katalog's `profiles:` block — none are Orkestra built-ins. + +**What you learn:** how to declare user-defined profile classes; that `ork validate` enforces references against your registry; that profile names are scoped to the Katalog and can be anything meaningful to your team. + +**Contrast with 06 and HPA built-ins:** those examples use names Orkestra ships. This example owns the names — `team-conservative` and `team-allow-internal` mean exactly what this team defines, and a future reader of the Katalog finds the definition right at the top. + +--- + +## Profiles declared in this Katalog + +| Class | Name | Purpose | +|---|---|---| +| `networkPolicies` | `team-deny-all` | Block all ingress and egress | +| `networkPolicies` | `team-allow-internal` | Allow ingress from pods in the same namespace | +| `networkPolicies` | `team-allow-dns` | Allow outbound UDP/TCP 53 for DNS | +| `hpa` | `team-conservative` | 70% CPU, scale-down one pod per minute | +| `hpa` | `team-responsive` | 50% CPU, scale-down 25% per 15 seconds | + +--- + +## Step 1 — Validate + +```bash +ork validate +``` + +## Step 2 — Simulate + +```bash +ork simulate +``` + +--- + +## Step 3 — Start the runtime + +```bash +ork run +``` + +--- + +## Step 4 — Apply the CR + +In a separate terminal: + +```bash +kubectl apply -f ../cr.yaml +``` + +Verify the resources: + +```bash +kubectl get networkpolicies,hpa +``` + +--- + +## Using user-defined profiles in your own Katalog + +```yaml +profiles: + networkPolicies: + - name: team-deny-all + policyTypes: [Ingress, Egress] + + hpa: + - name: team-conservative + targetCPUUtilizationPercentage: "70" + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + +spec: + crds: + mycrd: + operatorBox: + onCreate: + networkPolicies: + - name: "{{ .metadata.name }}-deny-all" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: team-deny-all + hpa: + - name: "{{ .metadata.name }}-hpa" + namespace: "{{ .metadata.namespace }}" + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: "{{ .metadata.name }}" + minReplicas: "2" + maxReplicas: "6" + behavior: + profile: team-conservative +``` + +--- + +## E2E + +```bash +ork e2e +``` + +--- + +## Cleanup + +```bash +chmod +x cleanup.sh && ./cleanup.sh +``` diff --git a/examples/use-cases/profiles/09-user-defined/cleanup.sh b/examples/use-cases/profiles/09-user-defined/cleanup.sh new file mode 100755 index 00000000..61b48eb1 --- /dev/null +++ b/examples/use-cases/profiles/09-user-defined/cleanup.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Cleaning up profiles/09-user-defined..." +kubectl delete -f ../cr.yaml --ignore-not-found +kubectl delete -f ../crd.yaml --ignore-not-found +echo "✓ Done." diff --git a/examples/use-cases/profiles/09-user-defined/e2e.yaml b/examples/use-cases/profiles/09-user-defined/e2e.yaml new file mode 100644 index 00000000..9a477611 --- /dev/null +++ b/examples/use-cases/profiles/09-user-defined/e2e.yaml @@ -0,0 +1,72 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: E2E +metadata: + name: profiles-user-defined-e2e + description: > + User-defined profiles (networkPolicies + hpa) — one CR produces one Deployment, + three NetworkPolicies, and two HPAs all using team-owned profile names. + Verifies all six resources are created and removed on deletion. + +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: Deployment created + after: cr-applied + timeout: 90s + resources: + - kind: Deployment + name: my-service + namespace: default + + - name: Three NetworkPolicies created + after: cr-applied + timeout: 60s + resources: + - kind: NetworkPolicy + name: my-service-deny-all + namespace: default + - kind: NetworkPolicy + name: my-service-allow-internal + namespace: default + - kind: NetworkPolicy + name: my-service-allow-dns + namespace: default + + - name: Two HPAs created + after: cr-applied + timeout: 60s + resources: + - kind: HorizontalPodAutoscaler + name: my-service-hpa-conservative + namespace: default + - kind: HorizontalPodAutoscaler + name: my-service-hpa-responsive + namespace: default + + - name: Cleanup verified + after: cr-deleted + timeout: 60s + resources: + - kind: Deployment + name: my-service + namespace: default + count: 0 + - kind: NetworkPolicy + name: my-service-deny-all + namespace: default + count: 0 diff --git a/examples/use-cases/profiles/09-user-defined/katalog.yaml b/examples/use-cases/profiles/09-user-defined/katalog.yaml new file mode 100644 index 00000000..f98dd9e8 --- /dev/null +++ b/examples/use-cases/profiles/09-user-defined/katalog.yaml @@ -0,0 +1,134 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: service-user-defined-profiles + description: > + Profiles 09 — user-defined profiles. + Declares two profile classes in the profiles: block and references them + from operatorBox. None of the names used below are Orkestra built-ins — + every definition is owned by this Katalog. ork validate enforces that + every reference resolves to a definition declared here. + +profiles: + networkPolicies: + - name: team-deny-all + description: Block all ingress and egress — baseline isolation + policyTypes: [Ingress, Egress] + + - name: team-allow-internal + description: Allow ingress from pods in the same namespace + ingress: + - from: + - podSelector: {} + policyTypes: [Ingress] + + - name: team-allow-dns + description: Allow outbound DNS resolution + egress: + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + policyTypes: [Egress] + + hpa: + - name: team-conservative + description: 70% CPU target, slow scale-down + targetCPUUtilizationPercentage: "70" + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Pods + value: 1 + periodSeconds: 60 + + - name: team-responsive + description: 50% CPU target, fast scale-down + targetCPUUtilizationPercentage: "50" + behavior: + scaleDown: + stabilizationWindowSeconds: 30 + policies: + - type: Percent + value: 25 + periodSeconds: 15 + +spec: + crds: + service: + crdFile: ../crd.yaml + + workers: 2 + resync: 30s + + operatorBox: + + status: + fields: + - path: phase + value: "Pending" + when: + - field: status.phase + operator: notExists + - path: phase + value: "Ready" + when: + - field: "{{ allReplicasReady .children.deployments }}" + equals: "true" + + onCreate: + deployments: + - name: "{{ .metadata.name }}" + image: "{{ .spec.image }}" + port: "{{ .spec.port }}" + replicas: "2" + reconcile: true + + networkPolicies: + # User-defined — not a built-in name + - name: "{{ .metadata.name }}-deny-all" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: team-deny-all + reconcile: true + + - name: "{{ .metadata.name }}-allow-internal" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: team-allow-internal + reconcile: true + + - name: "{{ .metadata.name }}-allow-dns" + namespace: "{{ .metadata.namespace }}" + podSelector: {} + profile: team-allow-dns + reconcile: true + + hpa: + # conservative — scales down one pod per minute + - name: "{{ .metadata.name }}-hpa-conservative" + namespace: "{{ .metadata.namespace }}" + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: "{{ .metadata.name }}" + minReplicas: "2" + maxReplicas: "6" + behavior: + profile: team-conservative + reconcile: true + + # responsive — reacts faster to load changes + - name: "{{ .metadata.name }}-hpa-responsive" + namespace: "{{ .metadata.namespace }}" + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: "{{ .metadata.name }}" + minReplicas: "2" + maxReplicas: "10" + behavior: + profile: team-responsive + reconcile: true diff --git a/examples/use-cases/profiles/09-user-defined/simulate.yaml b/examples/use-cases/profiles/09-user-defined/simulate.yaml new file mode 100644 index 00000000..b6b60179 --- /dev/null +++ b/examples/use-cases/profiles/09-user-defined/simulate.yaml @@ -0,0 +1,46 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: profiles-user-defined-sim + description: > + Two user-defined profile classes — networkPolicies and hpa. + All five resources (1 Deployment, 3 NetworkPolicies, 2 HPAs) are + created in cycle 1. Names are team-prefixed; none are Orkestra built-ins. + +spec: + katalog: ./katalog.yaml + cr: ../cr.yaml + cycles: 3 + + expect: + steady: true + ops: + - cycle: 1 + verb: create + resource: deployments + name: my-service + + - cycle: 1 + verb: create + resource: networkpolicies + name: my-service-deny-all + + - cycle: 1 + verb: create + resource: networkpolicies + name: my-service-allow-internal + + - cycle: 1 + verb: create + resource: networkpolicies + name: my-service-allow-dns + + - cycle: 1 + verb: create + resource: horizontalpodautoscalers + name: my-service-hpa-conservative + + - cycle: 1 + verb: create + resource: horizontalpodautoscalers + name: my-service-hpa-responsive diff --git a/examples/use-cases/profiles/README.md b/examples/use-cases/profiles/README.md index 371668df..712679c0 100644 --- a/examples/use-cases/profiles/README.md +++ b/examples/use-cases/profiles/README.md @@ -1,6 +1,6 @@ # Profiles Examples -Five examples showing Orkestra's named presets. Each profile expands at Katalog load time into fully-formed resource, security, probe, or rollout configuration — the runtime never sees a profile name. +Nine examples showing Orkestra's named presets and user-defined profiles. Each profile expands at Katalog load time into fully-formed configuration — the runtime never sees a profile name. | Example | What it teaches | |---|---| @@ -9,8 +9,12 @@ Five examples showing Orkestra's named presets. Each profile expands at Katalog | [03 — Probes](03-probes/README.md) | `probes.liveness.profile` — `fast`, `standard`, `patient`, `slow-start` timing presets | | [04 — Rolling Update](04-rolling-update/README.md) | `rollingUpdate.profile` — `safe`, `fast`, `blue-green` rollout strategies | | [05 — PDB](05-pdb/README.md) | `pdb.behavior.profile` — `zero-downtime`, `rolling`, `relaxed` disruption budgets | +| [06 — NetworkPolicy](06-networkpolicy/README.md) | `networkPolicies.profile` — `deny-all`, `deny-all-ingress`, `allow-dns-egress` and more | +| [07 — ResourceQuota](07-resourcequota/README.md) | `resourceQuotas.profile` — `small`, `medium`, `large`, `xlarge` tier presets | +| [08 — LimitRange](08-limitrange/README.md) | `limitRanges.profile` — user-defined presets; LimitRange has no built-ins | +| [09 — User-Defined](09-user-defined/README.md) | `profiles:` block — declare your own names for any class; `ork validate` enforces every reference | -All five share one CRD (`crd.yaml`) and one CR (`cr.yaml`) at this directory level. +All nine share one CRD (`crd.yaml`) and one CR (`cr.yaml`) at this directory level. For autoscale profiles (`autoscale.profile`): @@ -25,9 +29,25 @@ cd 12-autoscale --- +## Simulate (no cluster needed) + +```bash +ork simulate +``` + +This runs [simulate.yaml](./simulate.yaml), which chains all nine sub-examples. + +To run a single example: + +```bash +cd 06-networkpolicy && ork simulate +``` + +--- + ## E2E -Run the full suite — all five profile examples in one command: +Run the full suite — all nine profile examples in one command: ```bash ork e2e -f e2e.yaml diff --git a/examples/use-cases/profiles/e2e.yaml b/examples/use-cases/profiles/e2e.yaml index 3ba7f1c7..e29c44e2 100644 --- a/examples/use-cases/profiles/e2e.yaml +++ b/examples/use-cases/profiles/e2e.yaml @@ -4,7 +4,8 @@ metadata: name: profiles-suite description: > Suite for the profiles use-case track — resource, security, probes, - rolling-update, and PDB profiles. Runs all five sub-examples in the same cluster. + rolling-update, PDB, networkpolicy, resourcequota, limitrange, and + user-defined profiles. Runs all nine sub-examples in the same cluster. imports: - ./01-resource/e2e.yaml @@ -12,3 +13,7 @@ imports: - ./03-probes/e2e.yaml - ./04-rolling-update/e2e.yaml - ./05-pdb/e2e.yaml + - ./06-networkpolicy/e2e.yaml + - ./07-resourcequota/e2e.yaml + - ./08-limitrange/e2e.yaml + - ./09-user-defined/e2e.yaml diff --git a/examples/use-cases/profiles/simulate.yaml b/examples/use-cases/profiles/simulate.yaml new file mode 100644 index 00000000..28b0ffa0 --- /dev/null +++ b/examples/use-cases/profiles/simulate.yaml @@ -0,0 +1,18 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Simulate +metadata: + name: profiles-suite-sim + description: > + Full profiles suite — all eight sub-examples run in sequence. + No cluster required. + +imports: + - ./01-resource/simulate.yaml + - ./02-security/simulate.yaml + - ./03-probes/simulate.yaml + - ./04-rolling-update/simulate.yaml + - ./05-pdb/simulate.yaml + - ./06-networkpolicy/simulate.yaml + - ./07-resourcequota/simulate.yaml + - ./08-limitrange/simulate.yaml + - ./09-user-defined/simulate.yaml diff --git a/pkg/katalog/generate_rbac.go b/pkg/katalog/generate_rbac.go index a8ea12f0..78e81023 100644 --- a/pkg/katalog/generate_rbac.go +++ b/pkg/katalog/generate_rbac.go @@ -14,6 +14,27 @@ var defaultVerbs = []string{ "get", "list", "watch", "create", "update", "patch", "delete", } +// rbacVerbsFor returns the appropriate verb set for a built-in resource. +// +// Roles and ClusterRoles require two extra verbs beyond standard CRUD: +// - "escalate": allows creating/updating a Role or ClusterRole that grants +// permissions the Orkestra SA does not already hold. Without it Kubernetes +// blocks any attempt to provision a role with a broader permission set. +// - "bind": allows creating a RoleBinding or ClusterRoleBinding that +// references a Role/ClusterRole whose permissions the SA doesn't hold. +// Without it the binding creation is blocked even after the role exists. +// +// Both verbs are required whenever the operator provisions RBAC on behalf of +// tenant service accounts (e.g. via clusterRoles:/roles: in onCreate). +// They are absent from the generated bundle when no Roles or ClusterRoles are +// detected in the Katalog, preserving least-privilege for all other operators. +func rbacVerbsFor(group, plural string) []string { + if group == "rbac.authorization.k8s.io" && (plural == "roles" || plural == "clusterroles") { + return append(defaultVerbs, "escalate", "bind") + } + return defaultVerbs +} + func (k *Katalog) GenerateRBACRules() []rbacv1.PolicyRule { var rules []rbacv1.PolicyRule @@ -161,7 +182,7 @@ func (k *Katalog) GenerateRBACRules() []rbacv1.PolicyRule { rules = append(rules, rbacv1.PolicyRule{ APIGroups: []string{b.Group}, Resources: []string{b.Plural}, - Verbs: defaultVerbs, + Verbs: rbacVerbsFor(b.Group, b.Plural), }) } } @@ -297,7 +318,7 @@ func (k *Katalog) GenerateRuntimeRBACRules() []rbacv1.PolicyRule { rules = append(rules, rbacv1.PolicyRule{ APIGroups: []string{b.Group}, Resources: []string{b.Plural}, - Verbs: defaultVerbs, + Verbs: rbacVerbsFor(b.Group, b.Plural), }) } } diff --git a/pkg/katalog/motif_imports.go b/pkg/katalog/motif_imports.go index d46ddea3..49a6fe6e 100644 --- a/pkg/katalog/motif_imports.go +++ b/pkg/katalog/motif_imports.go @@ -88,6 +88,16 @@ func (k *Katalog) mergeExpandedMotif(entry *orktypes.CRDEntry, expanded *motif.E } } + // Merge user-defined profiles from the motif into the katalog registry. + // Conflict (same class, same name in both) is a hard error. + if !expanded.Profiles.IsEmpty() { + merged, err := k.Profiles.Merge(expanded.Profiles, fmt.Sprintf("motif %q", expanded.Name)) + if err != nil { + return err + } + k.Profiles = merged + } + // Merge admission (validation + mutation) rules – these are at CRD level, not operatorBox if expanded.HasAdmission() { // Merge validation rules diff --git a/pkg/katalog/parser.go b/pkg/katalog/parser.go index 7d1c64fe..2a699ec2 100644 --- a/pkg/katalog/parser.go +++ b/pkg/katalog/parser.go @@ -91,6 +91,7 @@ func (k *Katalog) KomposeRuntimeKatalog( k.Gateway = m.ToGateway() k.Notification = m.ToNotification() k.Providers = m.ToProviders() + k.Profiles = m.ToProfiles() k.projectInfo = m.ToProjectInfo() k.enabledCRDs = m.Enabled() // Enabled CRDs for all operations k.metadata = m.APIMetadata().Metadata // Metadata for CLI and health endpoints diff --git a/pkg/katalog/serialize.go b/pkg/katalog/serialize.go index 818e9ac3..70ee720e 100644 --- a/pkg/katalog/serialize.go +++ b/pkg/katalog/serialize.go @@ -34,6 +34,7 @@ func (k *Katalog) SerializeExpanded() ([]byte, error) { Gateway: k.Gateway, Notification: k.Notification, Providers: k.Providers, + Profiles: k.Profiles, } out, err := yaml.Marshal(kf) diff --git a/pkg/katalog/type.go b/pkg/katalog/type.go index 4d632894..cccd3b8c 100644 --- a/pkg/katalog/type.go +++ b/pkg/katalog/type.go @@ -24,6 +24,7 @@ type Katalog struct { Kind string `yaml:"kind"` Spec orktypes.KatalogSpec `yaml:"spec"` Security orktypes.KatalogSecurity `yaml:"security"` + Profiles orktypes.ProfileRegistry `yaml:"profiles,omitempty"` Gateway *orktypes.GatewayConfig `yaml:"gateway,omitempty"` Notification *orktypes.KatalogNotification `yaml:"notification,omitempty"` Providers []orktypes.KatalogProviderRequirement `yaml:"providers,omitempty"` diff --git a/pkg/katalog/validate.go b/pkg/katalog/validate.go index 6fd963c4..c6599e17 100644 --- a/pkg/katalog/validate.go +++ b/pkg/katalog/validate.go @@ -184,7 +184,13 @@ func (k *Katalog) ValidateConfig(kfg *konfig.Konfig) (*Katalog, error) { return nil, err } - // 26. Validate HPA Behavior Profiles + // 26. Validate user-defined profiles (uniqueness, shadowing warnings) + // ------------------------------------------------------------------------- + if err := k.validateUserProfiles(); err != nil { + return nil, err + } + + // 26b. Validate HPA Behavior Profiles // ------------------------------------------------------------------------- if err := k.validateHPABehaviorProfiles(); err != nil { return nil, err @@ -214,6 +220,10 @@ func (k *Katalog) ValidateConfig(kfg *konfig.Konfig) (*Katalog, error) { return nil, err } + if err := k.validateLimitRangeProfiles(); err != nil { + return nil, err + } + // 32. Validate cross-namespace copy pairs (fromNamespace ↔ toNamespaces) // ------------------------------------------------------------------------- if err := k.validateCrossNamespaceOps(); err != nil { diff --git a/pkg/katalog/validate_hpa_profile.go b/pkg/katalog/validate_hpa_profile.go index 8e08b171..7a39cb4b 100644 --- a/pkg/katalog/validate_hpa_profile.go +++ b/pkg/katalog/validate_hpa_profile.go @@ -31,7 +31,7 @@ func (k *Katalog) validateHPABehaviorProfiles() error { if isTemplateExpr(e.Profile) { continue } - if !profiles.IsValidHPAProfile(e.Profile) { + if !k.isUserHPAProfile(e.Profile) && !profiles.IsValidHPAProfile(e.Profile) { return fmt.Errorf( "crd %q: HPA %q (phase %s) has unknown behavior.profile %q — "+ "allowed: web, api, latency-sensitive, batch, cost-optimized", diff --git a/pkg/katalog/validate_limitrange_profile.go b/pkg/katalog/validate_limitrange_profile.go new file mode 100644 index 00000000..fd57c6db --- /dev/null +++ b/pkg/katalog/validate_limitrange_profile.go @@ -0,0 +1,47 @@ +// LimitRange Profile Validation +// +// LimitRange profiles are user-defined named presets that expand into a +// list of LimitRangeItems at reconcile time. There are no built-in presets — +// every limitRange profile must be declared in the katalog or an imported motif. +// +// Validation enforces: +// +// 1. Known profile names: +// Profile must appear in profiles.limitRanges or an imported motif. +// +// 2. Profile-only usage: +// profile cannot appear alongside an explicit limits list. +// +// 3. Template expressions: +// Profile values containing "{{" are skipped at load time. + +package katalog + +import ( + "fmt" +) + +func (k *Katalog) validateLimitRangeProfiles() error { + for crdName, crd := range k.enabledCRDs { + for _, e := range crd.CollectLimitRangeProfileEntries() { + if isTemplateExpr(e.Profile) { + continue + } + if !k.isUserLimitRangeProfile(e.Profile) { + return fmt.Errorf( + "crd %q: LimitRange %q (phase %s) has unknown profile %q — "+ + "define it in profiles.limitRanges", + crdName, e.ResourceName, e.Phase, e.Profile, + ) + } + if e.Mixed { + return fmt.Errorf( + "crd %q: LimitRange %q (phase %s) declares both profile (%q) and "+ + "explicit limits — use one or the other, not both", + crdName, e.ResourceName, e.Phase, e.Profile, + ) + } + } + } + return nil +} diff --git a/pkg/katalog/validate_networkpolicy_profile.go b/pkg/katalog/validate_networkpolicy_profile.go index 2c7504cd..294287af 100644 --- a/pkg/katalog/validate_networkpolicy_profile.go +++ b/pkg/katalog/validate_networkpolicy_profile.go @@ -31,7 +31,7 @@ func (k *Katalog) validateNetworkPolicyProfiles() error { if isTemplateExpr(e.Profile) { continue } - if !profiles.IsValidNetworkPolicyProfile(e.Profile) { + if !k.isUserNetworkPolicyProfile(e.Profile) && !profiles.IsValidNetworkPolicyProfile(e.Profile) { return fmt.Errorf( "crd %q: networkPolicy %q (phase %s) has unknown profile %q — "+ "allowed: deny-all, deny-all-ingress, deny-all-egress, allow-same-namespace, allow-dns-egress", diff --git a/pkg/katalog/validate_pdb_profile.go b/pkg/katalog/validate_pdb_profile.go index 86c21161..93459035 100644 --- a/pkg/katalog/validate_pdb_profile.go +++ b/pkg/katalog/validate_pdb_profile.go @@ -28,7 +28,7 @@ func (k *Katalog) validatePDBBehaviorProfiles() error { if isTemplateExpr(e.Profile) { continue } - if !profiles.IsValidPDBProfile(e.Profile) { + if !k.isUserPDBProfile(e.Profile) && !profiles.IsValidPDBProfile(e.Profile) { return fmt.Errorf( "crd %q: PDB %q (phase %s) has unknown behavior.profile %q — "+ "allowed: zero-downtime, rolling, relaxed", diff --git a/pkg/katalog/validate_resourcequota_profile.go b/pkg/katalog/validate_resourcequota_profile.go index 63b801f4..8e98bfb3 100644 --- a/pkg/katalog/validate_resourcequota_profile.go +++ b/pkg/katalog/validate_resourcequota_profile.go @@ -30,7 +30,7 @@ func (k *Katalog) validateResourceQuotaProfiles() error { if isTemplateExpr(e.Profile) { continue } - if !profiles.IsValidResourceQuotaProfile(e.Profile) { + if !k.isUserResourceQuotaProfile(e.Profile) && !profiles.IsValidResourceQuotaProfile(e.Profile) { return fmt.Errorf( "crd %q: resourceQuota %q (phase %s) has unknown profile %q — "+ "allowed: small, medium, large, xlarge", diff --git a/pkg/katalog/validate_rolling_update_profile.go b/pkg/katalog/validate_rolling_update_profile.go index 27ead0c1..423b999b 100644 --- a/pkg/katalog/validate_rolling_update_profile.go +++ b/pkg/katalog/validate_rolling_update_profile.go @@ -28,7 +28,7 @@ func (k *Katalog) validateRollingUpdateProfiles() error { if isTemplateExpr(e.Profile) { continue } - if !profiles.IsValidRollingUpdateProfile(e.Profile) { + if !k.isUserRollingUpdateProfile(e.Profile) && !profiles.IsValidRollingUpdateProfile(e.Profile) { return fmt.Errorf( "crd %q: Deployment %q (phase %s) has unknown rollingUpdate.profile %q — "+ "allowed: safe, fast, blue-green", diff --git a/pkg/katalog/validate_user_profiles.go b/pkg/katalog/validate_user_profiles.go new file mode 100644 index 00000000..20463850 --- /dev/null +++ b/pkg/katalog/validate_user_profiles.go @@ -0,0 +1,150 @@ +package katalog + +import ( + "fmt" + + "github.com/orkspace/orkestra/pkg/logger" + "github.com/orkspace/orkestra/pkg/profiles" + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// validateUserProfiles checks the profiles: block declared on the katalog. +// +// Enforces: +// 1. No duplicate names within a class. +// 2. Warns (does not error) when a user profile shadows a built-in name. +// 3. Each profile entry must have a non-empty name. +func (k *Katalog) validateUserProfiles() error { + reg := k.Profiles + if reg.IsEmpty() { + return nil + } + + type check struct { + class string + names []string + isBuiltin func(string) bool + } + + checks := []check{ + { + class: "networkPolicies", + names: npDefNames(reg.NetworkPolicies), + isBuiltin: profiles.IsValidNetworkPolicyProfile, + }, + { + class: "resourceQuotas", + names: rqDefNames(reg.ResourceQuotas), + isBuiltin: profiles.IsValidResourceQuotaProfile, + }, + { + class: "limitRanges", + names: lrDefNames(reg.LimitRanges), + isBuiltin: nil, + }, + { + class: "hpa", + names: hpaDefNames(reg.HPA), + isBuiltin: profiles.IsValidHPAProfile, + }, + { + class: "pdb", + names: pdbDefNames(reg.PDB), + isBuiltin: profiles.IsValidPDBProfile, + }, + { + class: "rollingUpdate", + names: ruDefNames(reg.RollingUpdate), + isBuiltin: profiles.IsValidRollingUpdateProfile, + }, + } + + for _, c := range checks { + seen := make(map[string]bool, len(c.names)) + for _, name := range c.names { + if name == "" { + return fmt.Errorf("profiles.%s: profile entry is missing a name", c.class) + } + if seen[name] { + return fmt.Errorf("profiles.%s: duplicate profile name %q — names must be unique within a class", c.class, name) + } + seen[name] = true + if c.isBuiltin != nil && c.isBuiltin(name) { + logger.Warn().Msgf( + "profiles.%s %q shadows a built-in Orkestra profile — the user-defined version will be used instead", + c.class, name, + ) + } + } + } + return nil +} + +// isUserNetworkPolicyProfile reports whether name is in the katalog's user registry. +func (k *Katalog) isUserNetworkPolicyProfile(name string) bool { + _, found := k.Profiles.LookupNetworkPolicy(name) + return found +} +func (k *Katalog) isUserResourceQuotaProfile(name string) bool { + _, found := k.Profiles.LookupResourceQuota(name) + return found +} +func (k *Katalog) isUserLimitRangeProfile(name string) bool { + _, found := k.Profiles.LookupLimitRange(name) + return found +} +func (k *Katalog) isUserHPAProfile(name string) bool { + _, found := k.Profiles.LookupHPA(name) + return found +} +func (k *Katalog) isUserPDBProfile(name string) bool { + _, found := k.Profiles.LookupPDB(name) + return found +} +func (k *Katalog) isUserRollingUpdateProfile(name string) bool { + _, found := k.Profiles.LookupRollingUpdate(name) + return found +} + +func npDefNames(defs []orktypes.NetworkPolicyProfileDef) []string { + out := make([]string, len(defs)) + for i, d := range defs { + out[i] = d.Name + } + return out +} +func rqDefNames(defs []orktypes.ResourceQuotaProfileDef) []string { + out := make([]string, len(defs)) + for i, d := range defs { + out[i] = d.Name + } + return out +} +func lrDefNames(defs []orktypes.LimitRangeProfileDef) []string { + out := make([]string, len(defs)) + for i, d := range defs { + out[i] = d.Name + } + return out +} +func hpaDefNames(defs []orktypes.HPAProfileDef) []string { + out := make([]string, len(defs)) + for i, d := range defs { + out[i] = d.Name + } + return out +} +func pdbDefNames(defs []orktypes.PDBProfileDef) []string { + out := make([]string, len(defs)) + for i, d := range defs { + out[i] = d.Name + } + return out +} +func ruDefNames(defs []orktypes.RollingUpdateProfileDef) []string { + out := make([]string, len(defs)) + for i, d := range defs { + out[i] = d.Name + } + return out +} diff --git a/pkg/katalog/validate_user_profiles_test.go b/pkg/katalog/validate_user_profiles_test.go new file mode 100644 index 00000000..a53d6945 --- /dev/null +++ b/pkg/katalog/validate_user_profiles_test.go @@ -0,0 +1,217 @@ +package katalog + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// helpers + +func katalogWithProfiles(reg orktypes.ProfileRegistry) *Katalog { + return &Katalog{ + Profiles: reg, + enabledCRDs: map[string]orktypes.CRDEntry{}, + } +} + +func katalogWithProfilesAndNP(reg orktypes.ProfileRegistry, crdName string, nps ...orktypes.NetworkPolicyTemplateSource) *Katalog { + return &Katalog{ + Profiles: reg, + enabledCRDs: map[string]orktypes.CRDEntry{ + crdName: { + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + NetworkPolicies: nps, + }, + }, + }, + }, + } +} + +func katalogWithProfilesAndRQ(reg orktypes.ProfileRegistry, crdName string, rqs ...orktypes.ResourceQuotaTemplateSource) *Katalog { + return &Katalog{ + Profiles: reg, + enabledCRDs: map[string]orktypes.CRDEntry{ + crdName: { + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + ResourceQuotas: rqs, + }, + }, + }, + }, + } +} + +// ── validateUserProfiles ────────────────────────────────────────────────────── + +func TestValidateUserProfiles_Empty(t *testing.T) { + k := katalogWithProfiles(orktypes.ProfileRegistry{}) + assert.NoError(t, k.validateUserProfiles()) +} + +func TestValidateUserProfiles_ValidNetworkPolicy(t *testing.T) { + k := katalogWithProfiles(orktypes.ProfileRegistry{ + NetworkPolicies: []orktypes.NetworkPolicyProfileDef{ + {Name: "allow-monitoring", PolicyTypes: []string{"Ingress"}}, + }, + }) + assert.NoError(t, k.validateUserProfiles()) +} + +func TestValidateUserProfiles_ValidResourceQuota(t *testing.T) { + k := katalogWithProfiles(orktypes.ProfileRegistry{ + ResourceQuotas: []orktypes.ResourceQuotaProfileDef{ + {Name: "team-medium", Hard: map[string]string{"pods": "30", "cpu": "6"}}, + }, + }) + assert.NoError(t, k.validateUserProfiles()) +} + +func TestValidateUserProfiles_ValidHPA(t *testing.T) { + k := katalogWithProfiles(orktypes.ProfileRegistry{ + HPA: []orktypes.HPAProfileDef{ + {Name: "aggressive-scale", MinReplicas: "2", MaxReplicas: "20"}, + }, + }) + assert.NoError(t, k.validateUserProfiles()) +} + +func TestValidateUserProfiles_ValidPDB(t *testing.T) { + k := katalogWithProfiles(orktypes.ProfileRegistry{ + PDB: []orktypes.PDBProfileDef{ + {Name: "strict", MinAvailable: "2"}, + }, + }) + assert.NoError(t, k.validateUserProfiles()) +} + +func TestValidateUserProfiles_ValidRollingUpdate(t *testing.T) { + k := katalogWithProfiles(orktypes.ProfileRegistry{ + RollingUpdate: []orktypes.RollingUpdateProfileDef{ + {Name: "canary", MaxSurge: "1", MaxUnavailable: "0"}, + }, + }) + assert.NoError(t, k.validateUserProfiles()) +} + +func TestValidateUserProfiles_TemplateExpressionInHard(t *testing.T) { + k := katalogWithProfiles(orktypes.ProfileRegistry{ + ResourceQuotas: []orktypes.ResourceQuotaProfileDef{ + {Name: "dynamic", Hard: map[string]string{"pods": "{{ .spec.maxPods }}"}}, + }, + }) + assert.NoError(t, k.validateUserProfiles()) +} + +func TestValidateUserProfiles_DuplicateNetworkPolicyName(t *testing.T) { + k := katalogWithProfiles(orktypes.ProfileRegistry{ + NetworkPolicies: []orktypes.NetworkPolicyProfileDef{ + {Name: "allow-monitoring"}, + {Name: "allow-monitoring"}, + }, + }) + err := k.validateUserProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate profile name") + assert.Contains(t, err.Error(), "allow-monitoring") +} + +func TestValidateUserProfiles_DuplicateResourceQuotaName(t *testing.T) { + k := katalogWithProfiles(orktypes.ProfileRegistry{ + ResourceQuotas: []orktypes.ResourceQuotaProfileDef{ + {Name: "team-medium", Hard: map[string]string{"pods": "10"}}, + {Name: "team-medium", Hard: map[string]string{"pods": "20"}}, + }, + }) + err := k.validateUserProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate profile name") +} + +func TestValidateUserProfiles_MissingName(t *testing.T) { + k := katalogWithProfiles(orktypes.ProfileRegistry{ + PDB: []orktypes.PDBProfileDef{ + {MinAvailable: "1"}, + }, + }) + err := k.validateUserProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "missing a name") +} + +func TestValidateUserProfiles_ShadowingBuiltinIsAllowed(t *testing.T) { + // Shadowing a built-in produces a warning but is not an error. + k := katalogWithProfiles(orktypes.ProfileRegistry{ + NetworkPolicies: []orktypes.NetworkPolicyProfileDef{ + {Name: "deny-all", PolicyTypes: []string{"Ingress", "Egress"}}, + }, + }) + assert.NoError(t, k.validateUserProfiles()) +} + +// ── user profile used in networkPolicy reference ────────────────────────────── + +func TestValidateNetworkPolicyProfiles_UserDefinedProfileAccepted(t *testing.T) { + reg := orktypes.ProfileRegistry{ + NetworkPolicies: []orktypes.NetworkPolicyProfileDef{ + {Name: "allow-monitoring", PolicyTypes: []string{"Ingress"}}, + }, + } + k := katalogWithProfilesAndNP(reg, "app", orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "allow-monitoring", + }) + require.NoError(t, k.validateUserProfiles()) + assert.NoError(t, k.validateNetworkPolicyProfiles()) +} + +func TestValidateNetworkPolicyProfiles_UnknownProfileStillRejected(t *testing.T) { + k := katalogWithProfilesAndNP(orktypes.ProfileRegistry{}, "app", orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "custom-unknown", + }) + assert.Error(t, k.validateNetworkPolicyProfiles()) +} + +func TestValidateNetworkPolicyProfiles_UserProfileShadowsBuiltin(t *testing.T) { + reg := orktypes.ProfileRegistry{ + NetworkPolicies: []orktypes.NetworkPolicyProfileDef{ + {Name: "deny-all", PolicyTypes: []string{"Ingress"}}, + }, + } + k := katalogWithProfilesAndNP(reg, "app", orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "deny-all", + }) + require.NoError(t, k.validateUserProfiles()) + assert.NoError(t, k.validateNetworkPolicyProfiles()) +} + +// ── user profile used in resourceQuota reference ────────────────────────────── + +func TestValidateResourceQuotaProfiles_UserDefinedProfileAccepted(t *testing.T) { + reg := orktypes.ProfileRegistry{ + ResourceQuotas: []orktypes.ResourceQuotaProfileDef{ + {Name: "team-medium", Hard: map[string]string{"pods": "30"}}, + }, + } + k := katalogWithProfilesAndRQ(reg, "app", orktypes.ResourceQuotaTemplateSource{ + Name: "rq", + Profile: "team-medium", + }) + require.NoError(t, k.validateUserProfiles()) + assert.NoError(t, k.validateResourceQuotaProfiles()) +} + +func TestValidateResourceQuotaProfiles_UnknownProfileStillRejected(t *testing.T) { + k := katalogWithProfilesAndRQ(orktypes.ProfileRegistry{}, "app", orktypes.ResourceQuotaTemplateSource{ + Name: "rq", + Profile: "custom-unknown", + }) + assert.Error(t, k.validateResourceQuotaProfiles()) +} diff --git a/pkg/merger/file.go b/pkg/merger/file.go index 5068d9ce..ca340b97 100644 --- a/pkg/merger/file.go +++ b/pkg/merger/file.go @@ -183,6 +183,7 @@ func (m *Merger) loadKatalog(path string, doc *orktypes.KatalogFile) (map[string m.notification = doc.Notification m.providers = doc.Providers m.gateway = doc.Gateway + m.profiles = doc.Profiles return result, nil } @@ -199,13 +200,14 @@ func (m *Merger) loadKomposer(path string, doc *orktypes.KatalogFile) (map[strin localSeen := map[string]string{} allCRDs := make(map[string]orktypes.CRDEntry) - // accSecurity, accNotification, and accProviders accumulate top-level settings - // from all imported Katalogs. Each import that calls loadKatalog sets these + // accSecurity, accNotification, accProviders, and accProfiles accumulate top-level + // settings from all imported Katalogs. Each import that calls loadKatalog sets these // as side-effects on m; we capture and merge here so they are not discarded // when the Komposer's own (possibly empty) block is applied at the end. var accSecurity orktypes.KatalogSecurity var accNotification *orktypes.KatalogNotification var accProviders []orktypes.KatalogProviderRequirement + var accProfiles orktypes.ProfileRegistry // ── Step 1: registry imports ───────────────────────────────────────────── if doc.Imports != nil { @@ -227,10 +229,11 @@ func (m *Merger) loadKomposer(path string, doc *orktypes.KatalogFile) (map[strin allCRDs[name] = crd } - // Accumulate security, notification, and providers from registry source Katalog. + // Accumulate security, notification, providers, and profiles from registry source Katalog. accSecurity = mergeKatalogSecurity(accSecurity, m.security) accNotification = mergeKatalogNotification(accNotification, m.notification) accProviders = append(accProviders, m.providers...) + accProfiles, _ = accProfiles.Merge(m.profiles, fmt.Sprintf("registry:%d", i)) logger.Debug(). Str("import", fmt.Sprintf("registry:%d", i)). Msg("merger: accumulated security, notification, and providers from registry import") @@ -273,10 +276,11 @@ func (m *Merger) loadKomposer(path string, doc *orktypes.KatalogFile) (map[strin allCRDs[name] = crd } - // Accumulate security, notification, and providers from this Katalog file import. + // Accumulate security, notification, providers, and profiles from this Katalog file import. accSecurity = mergeKatalogSecurity(accSecurity, m.security) accNotification = mergeKatalogNotification(accNotification, m.notification) accProviders = append(accProviders, m.providers...) + accProfiles, _ = accProfiles.Merge(m.profiles, "file:"+resolved) logger.Debug(). Str("import", "file:"+resolved). Msg("merger: accumulated security, notification, and providers from file import") @@ -297,10 +301,11 @@ func (m *Merger) loadKomposer(path string, doc *orktypes.KatalogFile) (map[strin allCRDs[name] = crd } - // Accumulate security, notification, and providers from this Helm import. + // Accumulate security, notification, providers, and profiles from this Helm import. accSecurity = mergeKatalogSecurity(accSecurity, m.security) accNotification = mergeKatalogNotification(accNotification, m.notification) accProviders = append(accProviders, m.providers...) + accProfiles, _ = accProfiles.Merge(m.profiles, srcName) logger.Debug(). Str("import", srcName). Msg("merger: accumulated security, notification, and providers from helm import") @@ -400,6 +405,9 @@ func (m *Merger) loadKomposer(path string, doc *orktypes.KatalogFile) (map[strin m.gateway = doc.Gateway } + merged, _ := accProfiles.Merge(doc.Profiles, path) + m.profiles = merged + logger.Debug(). Str("path", path). Msg("merger: Komposer security, notification, and providers merged from imports and inline") diff --git a/pkg/merger/merger.go b/pkg/merger/merger.go index da0dbcc0..755fe719 100644 --- a/pkg/merger/merger.go +++ b/pkg/merger/merger.go @@ -53,6 +53,9 @@ type Merger struct { // gateway holds the gateway deployment config of the final katalog gateway *orktypes.GatewayConfig + // profiles holds the merged user-defined profile registry of the final katalog + profiles orktypes.ProfileRegistry + // projects holds the merged projectInfo configuration of the final katalog projects map[string]interface{} @@ -358,6 +361,13 @@ func (m *Merger) ToGateway() *orktypes.GatewayConfig { return m.gateway } +// ToProfiles returns the merged user-defined profile registry of the merged result. +// Used by KomposeRuntimeKatalog to populate Katalog.Profiles. +func (m *Merger) ToProfiles() orktypes.ProfileRegistry { + m.mustBeMerged() + return m.profiles +} + // ToProjectInfo returns merged project information of the merged result // This is used by KomposeRuntimeKatalog to populate Katalog.ProjectInfo. func (m *Merger) ToProjectInfo() interface{} { diff --git a/pkg/motif/expander.go b/pkg/motif/expander.go index b03c0da1..7d86d47e 100644 --- a/pkg/motif/expander.go +++ b/pkg/motif/expander.go @@ -28,12 +28,17 @@ import ( // ExpandedMotif holds the result of expanding a motif. type ExpandedMotif struct { + // Name is the motif's metadata.name, used in conflict error messages. + Name string // OnCreate contains resources from resources.onCreate: — merged into the CRD's OnCreate phase. OnCreate *orktypes.HookTemplates // OnReconcile contains resources from the flat resources: fields — merged into OnReconcile. OnReconcile *orktypes.HookTemplates Status *orktypes.StatusConfig Admission *orktypes.Admission + // Profiles carries user-defined profiles declared in the motif. + // Merged into the katalog's ProfileRegistry during expandMotifImports. + Profiles orktypes.ProfileRegistry } // HasResources returns true when the motif produced any resource templates. @@ -132,10 +137,12 @@ func Expand(m *orktypes.Motif, bindings map[string]string) (*ExpandedMotif, erro } return &ExpandedMotif{ + Name: m.Metadata.Name, OnCreate: onCreate, OnReconcile: onReconcile, Status: status, Admission: admission, + Profiles: m.Profiles, }, nil } diff --git a/pkg/profiles/docs/03-adding-a-profile.md b/pkg/profiles/docs/03-adding-a-profile.md index 5983a424..d180a5d6 100644 --- a/pkg/profiles/docs/03-adding-a-profile.md +++ b/pkg/profiles/docs/03-adding-a-profile.md @@ -1,103 +1,233 @@ # 03 — Adding a profile -## Adding a new name to an existing profile kind +There are three ways to add a profile, in order of which you should reach for first. -Example: adding `xlarge` to resource profiles. +--- + +## 1. User-defined profiles (recommended — no Go code required) + +For org-specific presets, declare the profile in the `profiles:` block of your Katalog or Motif. No Pull Request. No code review. No binary update. + +```yaml +profiles: + resourceQuotas: + - name: org-medium + description: Standard allocation for a team namespace + hard: + pods: "25" + cpu: "4" + memory: "8Gi" + requests.cpu: "2" + requests.memory: "4Gi" + + networkPolicies: + - name: org-allow-monitoring + description: Allow ingress from the platform monitoring namespace + ingress: + - from: + - namespaceSelector: + team: platform + policyTypes: [Ingress] + + limitRanges: + - name: org-container-defaults + limits: + - type: Container + default: + cpu: 500m + memory: 512Mi + defaultRequest: + cpu: 100m + memory: 128Mi + + hpa: + - name: org-conservative + targetCPUUtilizationPercentage: "70" + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + + pdb: + - name: org-at-least-one + minAvailable: "1" + + rollingUpdate: + - name: org-safe + maxSurge: "1" + maxUnavailable: "0" +``` + +Reference the profile by name in your spec: + +```yaml +spec: + crds: + namespaceclaim: + operatorBox: + onCreate: + resourceQuotas: + - name: "{{ .metadata.name }}-quota" + profile: org-medium + networkPolicies: + - name: "{{ .metadata.name }}-baseline" + podSelector: {} + profile: org-allow-monitoring + limitRanges: + - name: "{{ .metadata.name }}-limits" + profile: org-container-defaults + deployments: + - name: "{{ .metadata.name }}-agent" + image: myorg/agent:v1 + rollingUpdate: + profile: org-safe + hpa: + - name: "{{ .metadata.name }}-agent-hpa" + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: "{{ .metadata.name }}-agent" + minReplicas: "1" + maxReplicas: "5" + behavior: + profile: org-conservative + pdb: + - name: "{{ .metadata.name }}-agent-pdb" + selector: + app: agent + behavior: + profile: org-at-least-one +``` + +Run `ork validate` to confirm all profile references resolve: + +```text +ork validate -f katalog.yaml +``` + +**Profile field placement by class:** + +| Class | Profile field location | +|-------|----------------------| +| `networkPolicies` | `profile:` on the entry (top-level) | +| `resourceQuotas` | `profile:` on the entry (top-level) | +| `limitRanges` | `profile:` on the entry (top-level) | +| `hpa` | `behavior.profile:` on the HPA entry | +| `pdb` | `behavior.profile:` on the PDB entry | +| `rollingUpdate` | `rollingUpdate.profile:` on the Deployment/StatefulSet entry | + +**Template expressions are supported in profile field values.** Fields containing `{{` are resolved at reconcile time and skipped at `ork validate` time. + +**Profile names are validated at load time.** An unknown static name is a hard error. An unknown template expression is validated at reconcile time. + +See [User-defined profiles](../../../documentation/concepts/profiles/10-user-defined-profiles.md) for the full reference. + +--- -**1. Add the constant** in `pkg/profiles/resource.go`: +## 2. Adding a name to an existing built-in class + +Only relevant for contributors adding presets to the Orkestra binary itself. If your preset is org-specific, use path 1 instead. + +**Example: adding `xlarge` to resource profiles.** + +**1.** Add the constant in `pkg/profiles/resource.go`: ```go -const ( - // ...existing constants... - ResourceXLarge ResourceProfile = "xlarge" -) +ResourceXLarge ResourceProfile = "xlarge" ``` -**2. Add the expansion case** in `ApplyResourceProfile`: +**2.** Add the expansion case in `ApplyResourceProfile`: ```go case ResourceXLarge: return &orktypes.ResourceRequirements{ - Requests: map[string]string{"cpu": "1", "memory": "1Gi"}, - Limits: map[string]string{"cpu": "4", "memory": "4Gi"}, + Requests: map[string]string{"cpu": "2", "memory": "4Gi"}, + Limits: map[string]string{"cpu": "8", "memory": "8Gi"}, }, nil ``` -**3. Add to `IsValidResourceProfile`**: +**3.** Add to `IsValidResourceProfile`: ```go case ResourceTiny, ..., ResourceXLarge: return true ``` -**4. Update the error message** in `ApplyResourceProfile` to include `"xlarge"` in the allowed list. +**4.** Update the error message in `ApplyResourceProfile` to list `"xlarge"` in the allowed names. -**5. Add a test case** in `resource_test.go`: +**5.** Add a test in `resource_test.go`. -```go -{"xlarge", "xlarge", false, "1", "1Gi", "4", "4Gi"}, -``` +**6.** Add a fixture entry in `pkg/profiles/fixture/katalog-resource.yaml`. -**6. Add the profile to the fixture** in `pkg/profiles/fixture/katalog-resource.yaml` — add a deployment using `resources.profile: xlarge` and run `ork run` to verify it creates the Deployment with the correct resource requests. +**7.** Update the reference table in `docs/01-profiles.md`. -**7. Add a deployment to the use-case example** in `examples/use-cases/profiles/01-resource/katalog.yaml` — add a deployment with `resources.profile: xlarge` alongside the existing ones, and add a row to the README table. +--- -**8. Update the reference table** in `docs/01-profiles.md`. +## 3. Adding a new profile class (new resource type) -**9. Update the concept doc** in `documentation/concepts/operatorbox/06-profiles/index.md` — add a row to the resource profiles table. +Only needed when Orkestra adds support for a resource type that has no profile class yet. The LimitRange class is the most recent example. ---- +**1. Add the `*ProfileDef` type** to `pkg/types/types_profiles.go` — name, description, and the fields that the profile expands into: -## Adding a new profile kind +```go +type LimitRangeProfileDef struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty"` + Limits []LimitRangeItem `yaml:"limits" json:"limits"` +} +``` -For a complete reference implementation, see `pkg/profiles/hpa.go` (HPA behavior profiles). The steps below use a hypothetical PDB profile kind as the example. +**2. Add the class** to `ProfileRegistry` and wire `IsEmpty`, `Lookup*`, and `Merge`: -**1. Create `pkg/profiles/pdb.go`** following the same structure as the existing files: +```go +type ProfileRegistry struct { + // ...existing fields... + LimitRanges []LimitRangeProfileDef `yaml:"limitRanges,omitempty"` +} +``` + +**3. Add a `Profile` field** to the template source type in `pkg/types/types_.go`: ```go -package profiles +Profile string `yaml:"profile,omitempty" json:"profile,omitempty"` +``` -import ( - "fmt" - "strings" - orktypes "github.com/orkspace/orkestra/pkg/types" -) +**4. Add a profile entry collector** in `pkg/types/hooks__profile.go` — define `ProfileEntry`, add `GetProfile()` interface and implement it on the template source, and add `CollectProfileEntries()` on `*CRDEntry` using `VisitResources`. -type PDBProfile string +**5. Create `pkg/profiles/.go`** — `ApplyProfile(name, reg)` looks up user registry first, then built-ins (or returns an error if there are no built-ins for the class). Also export `IsValidProfile`. -const ( - PDBStrict PDBProfile = "strict" - PDBRelaxed PDBProfile = "relaxed" -) +**6. Update `pkg/resources//.go`** — add `reg orktypes.ProfileRegistry` as a third parameter to `Resolve()` and apply the profile when `src.Profile != ""`. -func ApplyPDBProfile(name string) (orktypes.PDBBehavior, error) { ... } -func IsValidPDBProfile(name string) bool { ... } -``` +**7. Update `pkg/runners/.go`** — pass `resolver.Profiles()` to `Resolve()`. -Export only `Apply*Profile` and `IsValid*Profile`. Keep internal config types unexported. +**8. Add `pkg/katalog/validate__profile.go`** — `validateProfiles()` using `CollectProfileEntries()`. Call it from `ValidateConfig()` in `validate.go`. -**2. Add the type** to `pkg/types/` (e.g., `pdb_behavior.go`) and add a `Behavior *PDBBehavior` field to `PDBTemplateSource` in `types.go`. +**9. Update `pkg/katalog/validate_user_profiles.go`** — three additions: -**3. Add a `hooks_pdb.go`** to `pkg/types/` following the pattern in `hooks_hpa.go` — define `PDBProfileEntry`, implement `GetPDBBehavior()` on `PDBTemplateSource`, and add `CollectPDBProfileEntries()` to `CRDEntry`. +- Add a `DefNames()` helper that extracts the `Name` field from a `[]ProfileDef` slice (same pattern as `npDefNames`, `rqDefNames`, etc.). +- Add an entry to the `checks` slice inside `validateUserProfiles()` for the new class, pointing to the new helper and (if the class has built-ins) `profiles.IsValidProfile` as the `isBuiltin` func. Use `nil` for `isBuiltin` if the class has no built-ins (like LimitRange). +- Add `isUserProfile()` as a method on `*Katalog` that calls `k.Profiles.Lookup(name)` — used by `validate__profile.go` to check user profiles before rejecting an unknown name. -**4. Wire validation** into `pkg/katalog` — add `validate_pdb_profile.go` with a `validatePDBBehaviorProfiles()` method on `*Katalog`, call it from `ValidateConfig()`. +**10. Wire the new class through the merger** so that `profiles:` declared in a Katalog YAML actually reaches `Katalog.Profiles` at validate and reconcile time. Without this step, all profile references will fail validation even when the name is correctly declared. -**5. Wire resolution** into `pkg/resources/pdbs/` — expand the profile in `Resolve()`, convert to the Kubernetes type in the builder. +- `pkg/merger/merger.go` — add the class to the `profiles` field (it's a `ProfileRegistry`, so no change needed there — just make sure `ToProfiles()` exists). +- `pkg/merger/file.go`, `loadKatalog` — no change needed; `m.profiles = doc.Profiles` already covers all classes via the shared `ProfileRegistry`. +- `pkg/merger/file.go`, `loadKomposer` — add `accProfiles, _ = accProfiles.Merge(m.profiles, source)` alongside the existing `accSecurity`/`accProviders` lines at each import step, and `merged, _ := accProfiles.Merge(doc.Profiles, path); m.profiles = merged` at the final merge block. +- `pkg/katalog/parser.go`, `KomposeRuntimeKatalog` — add `k.Profiles = m.ToProfiles()` alongside the existing `k.Security = m.ToSecurity()` calls. -**6. Add tests** in `pkg/profiles/pdb_test.go`. +This step is only needed when adding the *first* new class to `ProfileRegistry`. If `ProfileRegistry` already has a `ToProfiles()` path (it does as of LimitRange), the new class field is carried automatically. -**7. Add to the fixture** in `pkg/profiles/fixture/katalog-pdb.yaml`. +**11. Rebuild and validate** — `make ork && ork validate -f your-example.yaml`. -**8. Add a use-case example** in `examples/use-cases/profiles/` — create a new numbered directory following the existing pattern (katalog.yaml, README.md, cleanup.sh), add it to `examples/use-cases/profiles/README.md`, and add a Try it block to `documentation/concepts/operatorbox/06-profiles/index.md`. +**12. Add a test fixture** in `pkg/profiles/fixture/`. -**9. Document** in `docs/01-profiles.md` and `documentation/concepts/operatorbox/06-profiles/index.md`. +**13. Add to the use-case examples** under `examples/use-cases/profiles/` or `examples/use-cases/namespace-provisioner/`. --- ## Rules -- `Apply*Profile` must return an error for unknown names, never silently fall back. -- `IsValid*Profile` must stay in sync with the `Apply*Profile` switch — add to both at the same time. -- Profile names are case-insensitive: normalize with `strings.ToLower` at the top of the switch. -- Template expressions (`{{`) are always skipped at load time — do not add validation for them in `pkg/profiles`. -- Profile and explicit fields are mutually exclusive. Enforce this in `pkg/katalog` validation, not here. +- `Apply*Profile` must check the user `ProfileRegistry` first, then fall back to built-ins. Return an error for unknown names — never silently fall back. +- `IsValid*Profile` for built-in classes must stay in sync with the `Apply*Profile` switch. For user-only classes (like LimitRange), `IsValid*Profile` takes a registry argument. +- Profile names are case-insensitive for built-in names: normalize with `strings.ToLower`. User-defined names are matched exactly. +- Template expressions (`{{`) are always skipped at load time — do not add static validation for them in `pkg/profiles`. +- Profile and explicit fields are mutually exclusive. Enforce this in `pkg/katalog` validation, not in `pkg/profiles` or `pkg/resources`. diff --git a/pkg/profiles/hpa.go b/pkg/profiles/hpa.go index 6409e6e7..c68954ad 100644 --- a/pkg/profiles/hpa.go +++ b/pkg/profiles/hpa.go @@ -37,8 +37,23 @@ type HPAProfileResult struct { } // ApplyHPAProfile expands a named HPA profile into a CPUTarget and behavior block. +// User-defined profiles in reg are checked first; falls back to built-ins. // Returns an error for unknown profile names. -func ApplyHPAProfile(name string) (HPAProfileResult, error) { +func ApplyHPAProfile(name string, reg orktypes.ProfileRegistry) (HPAProfileResult, error) { + if def, found := reg.LookupHPA(name); found { + r := HPAProfileResult{} + if def.TargetCPUUtilizationPercentage != "" { + // static value only — template expressions resolved before this call + var cpu int32 + if _, err := fmt.Sscanf(def.TargetCPUUtilizationPercentage, "%d", &cpu); err == nil { + r.CPUTarget = cpu + } + } + if def.Behavior != nil { + r.Behavior = *def.Behavior + } + return r, nil + } switch HPAProfile(strings.ToLower(name)) { case HPAWeb: return HPAProfileResult{ diff --git a/pkg/profiles/hpa_test.go b/pkg/profiles/hpa_test.go index 67758bba..205328a4 100644 --- a/pkg/profiles/hpa_test.go +++ b/pkg/profiles/hpa_test.go @@ -72,7 +72,7 @@ func TestHPAProfiles(t *testing.T) { for _, tc := range cases { t.Run(tc.profile, func(t *testing.T) { - result, err := profiles.ApplyHPAProfile(tc.profile) + result, err := profiles.ApplyHPAProfile(tc.profile, orktypes.ProfileRegistry{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -110,7 +110,7 @@ func assertRules(t *testing.T, label string, r *orktypes.HPAScalingRules, wantWi func TestHPAProfileCaseInsensitive(t *testing.T) { for _, name := range []string{"WEB", "Web", "API", "Latency-Sensitive", "BATCH", "Cost-Optimized"} { - _, err := profiles.ApplyHPAProfile(name) + _, err := profiles.ApplyHPAProfile(name, orktypes.ProfileRegistry{}) if err != nil { t.Errorf("profile %q: unexpected error: %v", name, err) } @@ -118,7 +118,7 @@ func TestHPAProfileCaseInsensitive(t *testing.T) { } func TestHPAProfileUnknown(t *testing.T) { - _, err := profiles.ApplyHPAProfile("unknown-profile") + _, err := profiles.ApplyHPAProfile("unknown-profile", orktypes.ProfileRegistry{}) if err == nil { t.Error("expected error for unknown profile, got nil") } @@ -142,7 +142,7 @@ func TestIsValidHPAProfile(t *testing.T) { func TestHPAProfilePoliciesNonZero(t *testing.T) { for _, name := range []string{"web", "api", "latency-sensitive", "batch", "cost-optimized"} { - result, _ := profiles.ApplyHPAProfile(name) + result, _ := profiles.ApplyHPAProfile(name, orktypes.ProfileRegistry{}) for i, p := range result.Behavior.ScaleUp.Policies { if p.Value == 0 { t.Errorf("profile %q scaleUp policy[%d]: Value is 0", name, i) diff --git a/pkg/profiles/limitrange.go b/pkg/profiles/limitrange.go new file mode 100644 index 00000000..d6f3d541 --- /dev/null +++ b/pkg/profiles/limitrange.go @@ -0,0 +1,24 @@ +package profiles + +import ( + "fmt" + + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// ApplyLimitRangeProfile expands a named LimitRange profile into a list of LimitRangeItems. +// User-defined profiles in reg are checked first; there are no built-in LimitRange profiles. +// Returns an error for unknown profile names. +func ApplyLimitRangeProfile(name string, reg orktypes.ProfileRegistry) ([]orktypes.LimitRangeItem, error) { + if def, found := reg.LookupLimitRange(name); found { + return def.Limits, nil + } + return nil, fmt.Errorf("unknown limitrange profile: %q — define it in profiles.limitRanges", name) +} + +// IsValidLimitRangeProfile reports whether name is a recognized LimitRange profile. +// LimitRange profiles are always user-defined — there are no built-in presets. +func IsValidLimitRangeProfile(name string, reg orktypes.ProfileRegistry) bool { + _, found := reg.LookupLimitRange(name) + return found +} diff --git a/pkg/profiles/networkpolicy.go b/pkg/profiles/networkpolicy.go index 59c4c419..bead555c 100644 --- a/pkg/profiles/networkpolicy.go +++ b/pkg/profiles/networkpolicy.go @@ -26,8 +26,16 @@ type NetworkPolicyExpansion struct { } // ApplyNetworkPolicyProfile expands a named profile into ingress/egress rules and policy types. +// User-defined profiles in reg are checked first; falls back to built-ins. // Returns an error for unknown profile names. -func ApplyNetworkPolicyProfile(name string) (*NetworkPolicyExpansion, error) { +func ApplyNetworkPolicyProfile(name string, reg orktypes.ProfileRegistry) (*NetworkPolicyExpansion, error) { + if def, found := reg.LookupNetworkPolicy(name); found { + return &NetworkPolicyExpansion{ + Ingress: def.Ingress, + Egress: def.Egress, + PolicyTypes: def.PolicyTypes, + }, nil + } switch NetworkPolicyProfile(strings.ToLower(name)) { case NetworkPolicyDenyAll: // Selects all pods; empty ingress and egress slices block all traffic. diff --git a/pkg/profiles/pdb.go b/pkg/profiles/pdb.go index f045866f..287e90a1 100644 --- a/pkg/profiles/pdb.go +++ b/pkg/profiles/pdb.go @@ -3,6 +3,8 @@ package profiles import ( "fmt" "strings" + + orktypes "github.com/orkspace/orkestra/pkg/types" ) // PDBProfile is a named PodDisruptionBudget disruption limit preset. @@ -32,8 +34,12 @@ type PDBProfileResult struct { } // ApplyPDBProfile expands a named PDB profile into disruption limit values. +// User-defined profiles in reg are checked first; falls back to built-ins. // Returns an error for unknown profile names. -func ApplyPDBProfile(name string) (PDBProfileResult, error) { +func ApplyPDBProfile(name string, reg orktypes.ProfileRegistry) (PDBProfileResult, error) { + if def, found := reg.LookupPDB(name); found { + return PDBProfileResult{MinAvailable: def.MinAvailable, MaxUnavailable: def.MaxUnavailable}, nil + } switch PDBProfile(strings.ToLower(name)) { case PDBZeroDowntime: return PDBProfileResult{MinAvailable: "100%"}, nil diff --git a/pkg/profiles/pdb_test.go b/pkg/profiles/pdb_test.go index 5193e086..2f8ba0ac 100644 --- a/pkg/profiles/pdb_test.go +++ b/pkg/profiles/pdb_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/orkspace/orkestra/pkg/profiles" + orktypes "github.com/orkspace/orkestra/pkg/types" ) func TestPDBProfiles(t *testing.T) { @@ -19,7 +20,7 @@ func TestPDBProfiles(t *testing.T) { for _, tc := range cases { t.Run(tc.profile, func(t *testing.T) { - result, err := profiles.ApplyPDBProfile(tc.profile) + result, err := profiles.ApplyPDBProfile(tc.profile, orktypes.ProfileRegistry{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -35,7 +36,7 @@ func TestPDBProfiles(t *testing.T) { func TestPDBProfileMutualExclusivity(t *testing.T) { for _, name := range []string{"zero-downtime", "rolling", "relaxed"} { - result, _ := profiles.ApplyPDBProfile(name) + result, _ := profiles.ApplyPDBProfile(name, orktypes.ProfileRegistry{}) if result.MinAvailable != "" && result.MaxUnavailable != "" { t.Errorf("profile %q sets both MinAvailable and MaxUnavailable", name) } @@ -44,7 +45,7 @@ func TestPDBProfileMutualExclusivity(t *testing.T) { func TestPDBProfileCaseInsensitive(t *testing.T) { for _, name := range []string{"ZERO-DOWNTIME", "Zero-Downtime", "ROLLING", "RELAXED"} { - _, err := profiles.ApplyPDBProfile(name) + _, err := profiles.ApplyPDBProfile(name, orktypes.ProfileRegistry{}) if err != nil { t.Errorf("profile %q: unexpected error: %v", name, err) } @@ -52,7 +53,7 @@ func TestPDBProfileCaseInsensitive(t *testing.T) { } func TestPDBProfileUnknown(t *testing.T) { - _, err := profiles.ApplyPDBProfile("unknown") + _, err := profiles.ApplyPDBProfile("unknown", orktypes.ProfileRegistry{}) if err == nil { t.Error("expected error for unknown profile, got nil") } diff --git a/pkg/profiles/resourcequota.go b/pkg/profiles/resourcequota.go index 9fb73094..cf59ded8 100644 --- a/pkg/profiles/resourcequota.go +++ b/pkg/profiles/resourcequota.go @@ -3,6 +3,8 @@ package profiles import ( "fmt" "strings" + + orktypes "github.com/orkspace/orkestra/pkg/types" ) // ResourceQuotaProfile is a named namespace resource quota preset. @@ -21,8 +23,12 @@ type ResourceQuotaLimits struct { } // ApplyResourceQuotaProfile expands a named quota profile into a hard limits map. +// User-defined profiles in reg are checked first; falls back to built-ins. // Returns an error for unknown profile names. -func ApplyResourceQuotaProfile(name string) (*ResourceQuotaLimits, error) { +func ApplyResourceQuotaProfile(name string, reg orktypes.ProfileRegistry) (*ResourceQuotaLimits, error) { + if def, found := reg.LookupResourceQuota(name); found { + return &ResourceQuotaLimits{Hard: def.Hard}, nil + } switch ResourceQuotaProfile(strings.ToLower(name)) { case QuotaSmall: return &ResourceQuotaLimits{Hard: map[string]string{ diff --git a/pkg/profiles/rolling_update.go b/pkg/profiles/rolling_update.go index 5b496216..cb23a528 100644 --- a/pkg/profiles/rolling_update.go +++ b/pkg/profiles/rolling_update.go @@ -3,6 +3,8 @@ package profiles import ( "fmt" "strings" + + orktypes "github.com/orkspace/orkestra/pkg/types" ) // RollingUpdateProfile is a named Deployment rolling update strategy preset. @@ -33,8 +35,12 @@ type RollingUpdateProfileResult struct { } // ApplyRollingUpdateProfile expands a named rolling update profile into MaxSurge and MaxUnavailable. +// User-defined profiles in reg are checked first; falls back to built-ins. // Returns an error for unknown profile names. -func ApplyRollingUpdateProfile(name string) (RollingUpdateProfileResult, error) { +func ApplyRollingUpdateProfile(name string, reg orktypes.ProfileRegistry) (RollingUpdateProfileResult, error) { + if def, found := reg.LookupRollingUpdate(name); found { + return RollingUpdateProfileResult{MaxSurge: def.MaxSurge, MaxUnavailable: def.MaxUnavailable}, nil + } switch RollingUpdateProfile(strings.ToLower(name)) { case RollingUpdateSafe: return RollingUpdateProfileResult{MaxSurge: "1", MaxUnavailable: "0"}, nil diff --git a/pkg/profiles/rolling_update_test.go b/pkg/profiles/rolling_update_test.go index 3096be24..6a127815 100644 --- a/pkg/profiles/rolling_update_test.go +++ b/pkg/profiles/rolling_update_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/orkspace/orkestra/pkg/profiles" + orktypes "github.com/orkspace/orkestra/pkg/types" ) func TestRollingUpdateProfiles(t *testing.T) { @@ -19,7 +20,7 @@ func TestRollingUpdateProfiles(t *testing.T) { for _, tc := range cases { t.Run(tc.profile, func(t *testing.T) { - result, err := profiles.ApplyRollingUpdateProfile(tc.profile) + result, err := profiles.ApplyRollingUpdateProfile(tc.profile, orktypes.ProfileRegistry{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -34,14 +35,14 @@ func TestRollingUpdateProfiles(t *testing.T) { } func TestRollingUpdateSafeHasZeroUnavailable(t *testing.T) { - result, _ := profiles.ApplyRollingUpdateProfile("safe") + result, _ := profiles.ApplyRollingUpdateProfile("safe", orktypes.ProfileRegistry{}) if result.MaxUnavailable != "0" { t.Errorf("safe profile must have maxUnavailable=0 to guarantee zero capacity drop; got %q", result.MaxUnavailable) } } func TestRollingUpdateBlueGreenHasFullSurge(t *testing.T) { - result, _ := profiles.ApplyRollingUpdateProfile("blue-green") + result, _ := profiles.ApplyRollingUpdateProfile("blue-green", orktypes.ProfileRegistry{}) if result.MaxSurge != "100%" { t.Errorf("blue-green profile must have maxSurge=100%%; got %q", result.MaxSurge) } @@ -52,7 +53,7 @@ func TestRollingUpdateBlueGreenHasFullSurge(t *testing.T) { func TestRollingUpdateProfileCaseInsensitive(t *testing.T) { for _, name := range []string{"SAFE", "Safe", "FAST", "BLUE-GREEN", "Blue-Green"} { - _, err := profiles.ApplyRollingUpdateProfile(name) + _, err := profiles.ApplyRollingUpdateProfile(name, orktypes.ProfileRegistry{}) if err != nil { t.Errorf("profile %q: unexpected error: %v", name, err) } @@ -60,7 +61,7 @@ func TestRollingUpdateProfileCaseInsensitive(t *testing.T) { } func TestRollingUpdateProfileUnknown(t *testing.T) { - _, err := profiles.ApplyRollingUpdateProfile("canary") + _, err := profiles.ApplyRollingUpdateProfile("canary", orktypes.ProfileRegistry{}) if err == nil { t.Error("expected error for unknown profile, got nil") } diff --git a/pkg/reconciler/generic.go b/pkg/reconciler/generic.go index 52736026..e1ea33b3 100644 --- a/pkg/reconciler/generic.go +++ b/pkg/reconciler/generic.go @@ -304,6 +304,9 @@ func (r *GenericReconciler[PTR]) reconcileCore(ctx context.Context, key string) if len(normalizeChanges) > 0 { resolver = resolver.WithNormalizeChanges(normalizeChanges) } + if r.kat != nil && !r.kat.Profiles.IsEmpty() { + resolver = resolver.WithProfiles(r.kat.Profiles) + } // ────────────────────────────────────────────────────────────────────────────── // GVK FIX: typed objects from the informer cache may arrive without a valid diff --git a/pkg/reconciler/run_status.go b/pkg/reconciler/run_status.go index 7f28e7ac..0d9574a0 100644 --- a/pkg/reconciler/run_status.go +++ b/pkg/reconciler/run_status.go @@ -37,6 +37,7 @@ import ( "github.com/orkspace/orkestra/pkg/children" "github.com/orkspace/orkestra/pkg/logger" orktmpl "github.com/orkspace/orkestra/pkg/resources/template" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // patchStatusWithChildren is the top-level status entry point called from @@ -136,7 +137,80 @@ func runStatusPatch[PTR domain.Object]( } } - return r.kube.PatchStatus(ctx, obj, patch) + // Skip the API call entirely when nothing has semantically changed. + // PatchStatus always increments resourceVersion, which generates a watch + // event on the CR — immediately re-queuing a reconcile and defeating the + // configured resync interval. + if statusPatchNeeded(obj, patch) { + return r.kube.PatchStatus(ctx, obj, patch) + } + return nil +} + +// statusPatchNeeded returns true when the patch carries at least one +// meaningful change vs. the object's current status. lastTransitionTime is +// excluded from the comparison — only type/status/reason/message are checked +// for conditions, and the scalar fields (observedGeneration, any layer-2 +// values) are compared directly. +func statusPatchNeeded(obj domain.Object, patch map[string]interface{}) bool { + u, ok := any(obj).(*unstructured.Unstructured) + if !ok { + // Can't read existing status — patch to be safe. + return true + } + + // observedGeneration + if desiredGen, ok := patch["observedGeneration"].(int64); ok { + existingGen, _, _ := unstructured.NestedInt64(u.Object, "status", "observedGeneration") + if existingGen != desiredGen { + return true + } + } + + // conditions — compare by type, ignoring lastTransitionTime + existing, _, _ := unstructured.NestedSlice(u.Object, "status", "conditions") + byType := make(map[string]map[string]interface{}, len(existing)) + for _, c := range existing { + cm, ok := c.(map[string]interface{}) + if !ok { + continue + } + if t, _ := cm["type"].(string); t != "" { + byType[t] = cm + } + } + + desired, _ := patch["conditions"].([]interface{}) + if len(desired) != len(byType) { + return true + } + for _, d := range desired { + dm, ok := d.(map[string]interface{}) + if !ok { + return true + } + t, _ := dm["type"].(string) + ex, found := byType[t] + if !found { + return true + } + if ex["status"] != dm["status"] || ex["reason"] != dm["reason"] || ex["message"] != dm["message"] { + return true + } + } + + // layer-2 scalar fields (any key beyond conditions/observedGeneration) + for k, v := range patch { + if k == "conditions" || k == "observedGeneration" { + continue + } + existing, ok, _ := unstructured.NestedFieldNoCopy(u.Object, "status", k) + if !ok || existing != v { + return true + } + } + + return false } // buildReadyCondition constructs the standard Kubernetes Ready condition map. diff --git a/pkg/resources/deployments/deployment.go b/pkg/resources/deployments/deployment.go index 62e398aa..9caeb120 100644 --- a/pkg/resources/deployments/deployment.go +++ b/pkg/resources/deployments/deployment.go @@ -200,7 +200,7 @@ func DeleteIfOwned(ctx context.Context, kube kubeclient.KubeClient, // Use pkg/orkestra-registry/template.Resolver to evaluate expressions first. // // The resolver already evaluated template expressions — here we just merge. -func Resolve(src orktypes.DeploymentTemplateSource, ownerName string) ResolvedDeploymentSpec { +func Resolve(src orktypes.DeploymentTemplateSource, ownerName string, reg orktypes.ProfileRegistry) ResolvedDeploymentSpec { spec := ResolvedDeploymentSpec{ Name: src.Name, Image: src.Image, @@ -242,7 +242,7 @@ func Resolve(src orktypes.DeploymentTemplateSource, ownerName string) ResolvedDe spec.Env = []orktypes.EnvVar(src.Env) if src.RollingUpdate != nil && src.RollingUpdate.Profile != "" { - expansion, err := profiles.ApplyRollingUpdateProfile(src.RollingUpdate.Profile) + expansion, err := profiles.ApplyRollingUpdateProfile(src.RollingUpdate.Profile, reg) if err != nil { logger.Warn().Str("profile", src.RollingUpdate.Profile).Err(err).Msg("unknown rolling update profile — skipping") } else { diff --git a/pkg/resources/hpas/hpa.go b/pkg/resources/hpas/hpa.go index f2187afe..4b68de58 100644 --- a/pkg/resources/hpas/hpa.go +++ b/pkg/resources/hpas/hpa.go @@ -179,7 +179,7 @@ func DeleteIfOwned(ctx context.Context, kube kubeclient.KubeClient, // Resolve builds a ResolvedHPASpec from an HPATemplateSource. // All template expressions must be evaluated before calling here. -func Resolve(src orktypes.HPATemplateSource, ownerName string) ResolvedHPASpec { +func Resolve(src orktypes.HPATemplateSource, ownerName string, reg orktypes.ProfileRegistry) ResolvedHPASpec { spec := ResolvedHPASpec{ Name: src.Name, Namespace: src.Namespace, @@ -214,7 +214,7 @@ func Resolve(src orktypes.HPATemplateSource, ownerName string) ResolvedHPASpec { } if src.Behavior != nil && src.Behavior.Profile != "" { - expansion, err := profiles.ApplyHPAProfile(src.Behavior.Profile) + expansion, err := profiles.ApplyHPAProfile(src.Behavior.Profile, reg) if err != nil { logger.Warn().Str("profile", src.Behavior.Profile).Err(err).Msg("unknown hpa behavior profile — skipping") } else { @@ -342,10 +342,22 @@ func behaviorEqual(a, b *autoscalingv2.HorizontalPodAutoscalerBehavior) bool { if a == nil && b == nil { return true } - if a == nil || b == nil { + if b == nil { + // No desired behavior — nothing to enforce, not a drift. + return true + } + if a == nil { + return false + } + // Only compare a side if we have an explicit desired spec for it. + // Kubernetes injects defaults for the unset side; ignore those. + if b.ScaleUp != nil && !scalingRulesEqual(a.ScaleUp, b.ScaleUp) { return false } - return scalingRulesEqual(a.ScaleUp, b.ScaleUp) && scalingRulesEqual(a.ScaleDown, b.ScaleDown) + if b.ScaleDown != nil && !scalingRulesEqual(a.ScaleDown, b.ScaleDown) { + return false + } + return true } func scalingRulesEqual(a, b *autoscalingv2.HPAScalingRules) bool { @@ -366,24 +378,27 @@ func scalingRulesEqual(a, b *autoscalingv2.HPAScalingRules) bool { if swA != swB { return false } - spA := autoscalingv2.ScalingPolicySelect("") - if a.SelectPolicy != nil { - spA = *a.SelectPolicy - } - spB := autoscalingv2.ScalingPolicySelect("") + // Only compare SelectPolicy when we explicitly set one — Kubernetes injects + // a default ("Max") when the field is omitted, which would otherwise loop. if b.SelectPolicy != nil { - spB = *b.SelectPolicy - } - if spA != spB { - return false - } - if len(a.Policies) != len(b.Policies) { - return false + spA := autoscalingv2.ScalingPolicySelect("") + if a.SelectPolicy != nil { + spA = *a.SelectPolicy + } + if spA != *b.SelectPolicy { + return false + } } - for i := range a.Policies { - if a.Policies[i] != b.Policies[i] { + // Only compare Policies when we declared any — same default-injection risk. + if len(b.Policies) > 0 { + if len(a.Policies) != len(b.Policies) { return false } + for i := range b.Policies { + if a.Policies[i] != b.Policies[i] { + return false + } + } } return true } diff --git a/pkg/resources/limitranges/limitrange.go b/pkg/resources/limitranges/limitrange.go index 6eabaeda..6477e3ae 100644 --- a/pkg/resources/limitranges/limitrange.go +++ b/pkg/resources/limitranges/limitrange.go @@ -4,12 +4,11 @@ package limitranges import ( "context" "fmt" - "reflect" - "github.com/orkspace/orkestra/domain" "github.com/orkspace/orkestra/pkg/kubeclient" "github.com/orkspace/orkestra/pkg/labels" "github.com/orkspace/orkestra/pkg/logger" + "github.com/orkspace/orkestra/pkg/profiles" "github.com/orkspace/orkestra/pkg/resources/common" orktypes "github.com/orkspace/orkestra/pkg/types" "github.com/orkspace/orkestra/pkg/utils" @@ -98,7 +97,7 @@ func Update(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object } desired := buildLimitRangeItems(limits) - if reflect.DeepEqual(existing.Spec.Limits, desired) { + if limitRangeItemsEqual(existing.Spec.Limits, desired) { logger.Debug(). Str("limitrange", spec.Name). Str("namespace", namespace). @@ -218,11 +217,18 @@ func CopyToNamespaces( // Resolve builds a ResolvedLimitRangeSpec from a LimitRangeTemplateSource. // Template expressions must already be evaluated by template.Resolver before calling. -func Resolve(src orktypes.LimitRangeTemplateSource, ownerName string) ResolvedLimitRangeSpec { +func Resolve(src orktypes.LimitRangeTemplateSource, ownerName string, reg orktypes.ProfileRegistry) ResolvedLimitRangeSpec { + limits := src.Limits + if src.Profile != "" && len(limits) == 0 { + if expanded, err := profiles.ApplyLimitRangeProfile(src.Profile, reg); err == nil { + limits = expanded + } + } + spec := ResolvedLimitRangeSpec{ Name: src.Name, Namespace: src.Namespace, - Limits: src.Limits, + Limits: limits, FromLimitRange: src.FromLimitRange, FromNamespace: src.FromNamespace, Labels: make(map[string]string), @@ -326,6 +332,41 @@ func buildLimitRangeItems(items []orktypes.LimitRangeItem) []corev1.LimitRangeIt return out } +func limitRangeItemsEqual(a, b []corev1.LimitRangeItem) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Type != b[i].Type { + return false + } + if !resourceListEqual(a[i].Max, b[i].Max) || + !resourceListEqual(a[i].Min, b[i].Min) || + !resourceListEqual(a[i].Default, b[i].Default) || + !resourceListEqual(a[i].DefaultRequest, b[i].DefaultRequest) || + !resourceListEqual(a[i].MaxLimitRequestRatio, b[i].MaxLimitRequestRatio) { + return false + } + } + return true +} + +func resourceListEqual(a, b corev1.ResourceList) bool { + if len(a) != len(b) { + return false + } + for k, qa := range a { + qb, ok := b[k] + if !ok { + return false + } + if qa.Cmp(qb) != 0 { + return false + } + } + return true +} + func mapToResourceList(m map[string]string) corev1.ResourceList { if len(m) == 0 { return nil diff --git a/pkg/resources/networkpolicies/networkpolicy.go b/pkg/resources/networkpolicies/networkpolicy.go index b4606fdb..8a180a25 100644 --- a/pkg/resources/networkpolicies/networkpolicy.go +++ b/pkg/resources/networkpolicies/networkpolicy.go @@ -218,13 +218,13 @@ func CopyToNamespaces( // Resolve builds a ResolvedNetworkPolicySpec from a NetworkPolicyTemplateSource. // Template expressions must already be evaluated by template.Resolver before calling. -func Resolve(src orktypes.NetworkPolicyTemplateSource, ownerName string) ResolvedNetworkPolicySpec { +func Resolve(src orktypes.NetworkPolicyTemplateSource, ownerName string, reg orktypes.ProfileRegistry) ResolvedNetworkPolicySpec { ingress := src.Ingress egress := src.Egress policyTypes := src.PolicyTypes if src.Profile != "" { - if expanded, err := profiles.ApplyNetworkPolicyProfile(src.Profile); err != nil { + if expanded, err := profiles.ApplyNetworkPolicyProfile(src.Profile, reg); err != nil { logger.Warn().Str("profile", src.Profile).Err(err).Msg("unknown networkpolicy profile — skipping") } else { ingress = expanded.Ingress @@ -392,13 +392,23 @@ func buildNetworkPolicySpec(spec ResolvedNetworkPolicySpec) networkingv1.Network func translatePeer(peer orktypes.NetworkPolicyPeer) networkingv1.NetworkPolicyPeer { p := networkingv1.NetworkPolicyPeer{} - if len(peer.PodSelector) > 0 || peer.PodSelector != nil { + + hasNS := len(peer.NamespaceSelector) > 0 + hasIP := peer.IPBlock != nil + + if peer.PodSelector != nil { p.PodSelector = &metav1.LabelSelector{MatchLabels: peer.PodSelector} - } - if len(peer.NamespaceSelector) > 0 { + } else if !hasNS && !hasIP { + // podSelector: {} was declared but its empty map was dropped by omitempty + // during the bundle serialization round-trip. An all-nil peer is never valid + // in Kubernetes, so the only sensible interpretation is "select all pods in + // the namespace" — which is exactly what an empty LabelSelector means. + p.PodSelector = &metav1.LabelSelector{} + } + if hasNS { p.NamespaceSelector = &metav1.LabelSelector{MatchLabels: peer.NamespaceSelector} } - if peer.IPBlock != nil { + if hasIP { p.IPBlock = &networkingv1.IPBlock{ CIDR: peer.IPBlock.CIDR, Except: peer.IPBlock.Except, diff --git a/pkg/resources/pdbs/pdb.go b/pkg/resources/pdbs/pdb.go index acf26244..955ee4a7 100644 --- a/pkg/resources/pdbs/pdb.go +++ b/pkg/resources/pdbs/pdb.go @@ -169,7 +169,7 @@ func DeleteIfOwned(ctx context.Context, kube kubeclient.KubeClient, // Resolve builds a ResolvedPDBSpec from a PDBTemplateSource. // All template expressions must be evaluated before calling here. -func Resolve(src orktypes.PDBTemplateSource, ownerName string) ResolvedPDBSpec { +func Resolve(src orktypes.PDBTemplateSource, ownerName string, reg orktypes.ProfileRegistry) ResolvedPDBSpec { spec := ResolvedPDBSpec{ Name: src.Name, Namespace: src.Namespace, @@ -185,7 +185,7 @@ func Resolve(src orktypes.PDBTemplateSource, ownerName string) ResolvedPDBSpec { } if src.Behavior != nil && src.Behavior.Profile != "" { - expansion, err := profiles.ApplyPDBProfile(src.Behavior.Profile) + expansion, err := profiles.ApplyPDBProfile(src.Behavior.Profile, reg) if err != nil { logger.Warn().Str("profile", src.Behavior.Profile).Err(err).Msg("unknown pdb behavior profile — skipping") } else { diff --git a/pkg/resources/replicasets/replicaset.go b/pkg/resources/replicasets/replicaset.go index b3351e0c..b24c8029 100644 --- a/pkg/resources/replicasets/replicaset.go +++ b/pkg/resources/replicasets/replicaset.go @@ -192,7 +192,7 @@ func DeleteIfOwned(ctx context.Context, kube kubeclient.KubeClient, } // Resolve builds a ResolvedReplicaSetSpec from a ReplicaSetTemplateSource. -func Resolve(src orktypes.ReplicaSetTemplateSource, ownerName string) ResolvedReplicaSetSpec { +func Resolve(src orktypes.ReplicaSetTemplateSource, ownerName string, reg orktypes.ProfileRegistry) ResolvedReplicaSetSpec { spec := ResolvedReplicaSetSpec{ Name: src.Name, Image: src.Image, @@ -235,7 +235,7 @@ func Resolve(src orktypes.ReplicaSetTemplateSource, ownerName string) ResolvedRe spec.Env = []orktypes.EnvVar(src.Env) if src.RollingUpdate != nil && src.RollingUpdate.Profile != "" { - expansion, err := profiles.ApplyRollingUpdateProfile(src.RollingUpdate.Profile) + expansion, err := profiles.ApplyRollingUpdateProfile(src.RollingUpdate.Profile, reg) if err != nil { logger.Warn().Str("profile", src.RollingUpdate.Profile).Err(err).Msg("unknown rolling update profile — skipping") } else { diff --git a/pkg/resources/resourcequotas/resourcequota.go b/pkg/resources/resourcequotas/resourcequota.go index 4e2daac7..d881e94a 100644 --- a/pkg/resources/resourcequotas/resourcequota.go +++ b/pkg/resources/resourcequotas/resourcequota.go @@ -219,10 +219,10 @@ func CopyToNamespaces( // Resolve builds a ResolvedResourceQuotaSpec from a ResourceQuotaTemplateSource. // Template expressions must already be evaluated by template.Resolver before calling. -func Resolve(src orktypes.ResourceQuotaTemplateSource, ownerName string) ResolvedResourceQuotaSpec { +func Resolve(src orktypes.ResourceQuotaTemplateSource, ownerName string, reg orktypes.ProfileRegistry) ResolvedResourceQuotaSpec { hard := src.Hard if src.Profile != "" { - if expanded, err := profiles.ApplyResourceQuotaProfile(src.Profile); err != nil { + if expanded, err := profiles.ApplyResourceQuotaProfile(src.Profile, reg); err != nil { logger.Warn().Str("profile", src.Profile).Err(err).Msg("unknown resourcequota profile — skipping") } else { hard = expanded.Hard diff --git a/pkg/resources/statefulsets/statefulset.go b/pkg/resources/statefulsets/statefulset.go index a1eb14e5..64c689c5 100644 --- a/pkg/resources/statefulsets/statefulset.go +++ b/pkg/resources/statefulsets/statefulset.go @@ -159,7 +159,7 @@ func DeleteIfOwned(ctx context.Context, kube kubeclient.KubeClient, owner domain } // Resolve builds a ResolvedStatefulSetSpec from a StatefulSetTemplateSource. -func Resolve(src orktypes.StatefulSetTemplateSource, ownerName string) ResolvedStatefulSetSpec { +func Resolve(src orktypes.StatefulSetTemplateSource, ownerName string, reg orktypes.ProfileRegistry) ResolvedStatefulSetSpec { spec := ResolvedStatefulSetSpec{ Name: src.Name, Namespace: src.Namespace, @@ -222,7 +222,7 @@ func Resolve(src orktypes.StatefulSetTemplateSource, ownerName string) ResolvedS spec.Labels[labels.OrkestraOwner] = ownerName if src.RollingUpdate != nil && src.RollingUpdate.Profile != "" { - expansion, err := profiles.ApplyRollingUpdateProfile(src.RollingUpdate.Profile) + expansion, err := profiles.ApplyRollingUpdateProfile(src.RollingUpdate.Profile, reg) if err != nil { logger.Warn().Str("profile", src.RollingUpdate.Profile).Err(err).Msg("unknown rolling update profile — skipping") } else { diff --git a/pkg/resources/template/resolver.go b/pkg/resources/template/resolver.go index 351bed15..3f7e0608 100644 --- a/pkg/resources/template/resolver.go +++ b/pkg/resources/template/resolver.go @@ -36,6 +36,19 @@ type Resolver struct { data map[string]interface{} ownerName string ownerNamespace string + profiles orktypes.ProfileRegistry +} + +// WithProfiles attaches a user-defined profile registry to the resolver. +// Call this after NewResolver when the katalog declares a profiles: block. +func (r *Resolver) WithProfiles(reg orktypes.ProfileRegistry) *Resolver { + r.profiles = reg + return r +} + +// Profiles returns the user-defined profile registry attached to this resolver. +func (r *Resolver) Profiles() orktypes.ProfileRegistry { + return r.profiles } // NewResolver creates a Resolver from any domain.Object. diff --git a/pkg/runners/deployments.go b/pkg/runners/deployments.go index 23f12118..20b8a8c5 100644 --- a/pkg/runners/deployments.go +++ b/pkg/runners/deployments.go @@ -87,7 +87,7 @@ func RunDeployments( return fmt.Errorf("deployments[%d]: %w", i, err) } - spec := orkdeploy.Resolve(resolved, resolver.OwnerName()) + spec := orkdeploy.Resolve(resolved, resolver.OwnerName(), resolver.Profiles()) if update { if err := orkdeploy.Update(ctx, kube, owner, spec); err != nil { diff --git a/pkg/runners/hpas.go b/pkg/runners/hpas.go index 8f3b327d..930cddc3 100644 --- a/pkg/runners/hpas.go +++ b/pkg/runners/hpas.go @@ -69,7 +69,7 @@ func RunHPAs( return fmt.Errorf("hpas[%d]: %w", i, err) } - spec := orkhpa.Resolve(resolved, resolver.OwnerName()) + spec := orkhpa.Resolve(resolved, resolver.OwnerName(), resolver.Profiles()) if update { if err := orkhpa.Update(ctx, kube, owner, spec); err != nil { diff --git a/pkg/runners/limitranges.go b/pkg/runners/limitranges.go index eba8c254..f7aa3571 100644 --- a/pkg/runners/limitranges.go +++ b/pkg/runners/limitranges.go @@ -74,7 +74,7 @@ func RunLimitRanges( return fmt.Errorf("limitRanges[%d]: %w", i, err) } - spec := orklr.Resolve(resolved, resolver.OwnerName()) + spec := orklr.Resolve(resolved, resolver.OwnerName(), resolver.Profiles()) if len(resolved.ToNamespaces) > 0 { namespaces, err := resolver.ResolveStringSlice(resolved.ToNamespaces) diff --git a/pkg/runners/networkpolicies.go b/pkg/runners/networkpolicies.go index d6a35870..faacc599 100644 --- a/pkg/runners/networkpolicies.go +++ b/pkg/runners/networkpolicies.go @@ -74,7 +74,7 @@ func RunNetworkPolicies( return fmt.Errorf("networkPolicies[%d]: %w", i, err) } - spec := orknp.Resolve(resolved, resolver.OwnerName()) + spec := orknp.Resolve(resolved, resolver.OwnerName(), resolver.Profiles()) if len(resolved.ToNamespaces) > 0 { namespaces, err := resolver.ResolveStringSlice(resolved.ToNamespaces) diff --git a/pkg/runners/pdbs.go b/pkg/runners/pdbs.go index 8f2350f2..2347b052 100644 --- a/pkg/runners/pdbs.go +++ b/pkg/runners/pdbs.go @@ -69,7 +69,7 @@ func RunPDBs( return fmt.Errorf("pdbs[%d]: %w", i, err) } - spec := orkpdb.Resolve(resolved, resolver.OwnerName()) + spec := orkpdb.Resolve(resolved, resolver.OwnerName(), resolver.Profiles()) if update { if err := orkpdb.Update(ctx, kube, owner, spec); err != nil { diff --git a/pkg/runners/replicasets.go b/pkg/runners/replicasets.go index 6f233308..8433592b 100644 --- a/pkg/runners/replicasets.go +++ b/pkg/runners/replicasets.go @@ -89,7 +89,7 @@ func RunReplicaSets( return fmt.Errorf("replicasets[%d]: %w", i, err) } - spec := orkreplicaset.Resolve(resolved, resolver.OwnerName()) + spec := orkreplicaset.Resolve(resolved, resolver.OwnerName(), resolver.Profiles()) if update { if err := orkreplicaset.Update(ctx, kube, owner, spec); err != nil { diff --git a/pkg/runners/resourcequotas.go b/pkg/runners/resourcequotas.go index c28982af..0f3b712c 100644 --- a/pkg/runners/resourcequotas.go +++ b/pkg/runners/resourcequotas.go @@ -74,7 +74,7 @@ func RunResourceQuotas( return fmt.Errorf("resourceQuotas[%d]: %w", i, err) } - spec := orkrq.Resolve(resolved, resolver.OwnerName()) + spec := orkrq.Resolve(resolved, resolver.OwnerName(), resolver.Profiles()) if len(resolved.ToNamespaces) > 0 { namespaces, err := resolver.ResolveStringSlice(resolved.ToNamespaces) diff --git a/pkg/runners/statefulsets.go b/pkg/runners/statefulsets.go index 047db991..aa5db5f5 100644 --- a/pkg/runners/statefulsets.go +++ b/pkg/runners/statefulsets.go @@ -69,7 +69,7 @@ func RunStatefulSets( return fmt.Errorf("statefulsets[%d]: %w", i, err) } - spec := orksts.Resolve(resolved, resolver.OwnerName()) + spec := orksts.Resolve(resolved, resolver.OwnerName(), resolver.Profiles()) if update { if err := orksts.Update(ctx, kube, owner, spec); err != nil { diff --git a/pkg/types/hooks_limitrange_profile.go b/pkg/types/hooks_limitrange_profile.go new file mode 100644 index 00000000..a7bbe169 --- /dev/null +++ b/pkg/types/hooks_limitrange_profile.go @@ -0,0 +1,70 @@ +package types + +// LimitRangeProfileEntry describes a single profile reference found in a +// LimitRangeTemplateSource. Used by katalog validation to fail fast on unknown +// profiles and to enforce mutual exclusivity with explicit limits. +type LimitRangeProfileEntry struct { + Phase string // "onCreate", "onReconcile", "onDelete" + ResourceName string // LimitRange name template (may be empty) + Profile string // raw profile name as written in the katalog + Mixed bool // true when profile is set alongside an explicit Limits list +} + +type limitRangeProfiled interface { + getLimitRangeProfile() string + limitRangeProfileMixed() bool +} + +func (t LimitRangeTemplateSource) getLimitRangeProfile() string { return t.Profile } +func (t LimitRangeTemplateSource) limitRangeProfileMixed() bool { return len(t.Limits) > 0 } + +// CollectLimitRangeProfileEntries returns all profile references declared for +// this CRD's limitRanges across OnCreate, OnReconcile, and OnDelete. +// Only entries with a non-empty Profile string are returned. +func (c *CRDEntry) CollectLimitRangeProfileEntries() []LimitRangeProfileEntry { + if !c.HasAnyHookTemplates() { + return nil + } + + var out []LimitRangeProfileEntry + + collect := func(phase string, ht *HookTemplates) { + if ht == nil { + return + } + ht.VisitResources(func(res interface{}) { + rp, ok := res.(limitRangeProfiled) + if !ok { + return + } + profile := rp.getLimitRangeProfile() + if profile == "" { + return + } + + var rname string + if n, ok := res.(namer); ok { + rname = n.GetName() + } + + out = append(out, LimitRangeProfileEntry{ + Phase: phase, + ResourceName: rname, + Profile: profile, + Mixed: rp.limitRangeProfileMixed(), + }) + }) + } + + if c.HasOnCreate() { + collect("onCreate", c.OperatorBox.OnCreate) + } + if c.HasOnReconcile() { + collect("onReconcile", c.OperatorBox.OnReconcile) + } + if c.HasOnDelete() { + collect("onDelete", c.OperatorBox.OnDelete) + } + + return out +} diff --git a/pkg/types/katalog.go b/pkg/types/katalog.go index 7daa6010..530b9536 100644 --- a/pkg/types/katalog.go +++ b/pkg/types/katalog.go @@ -58,6 +58,12 @@ type KatalogFile struct { // and the Komposer's own teams win on name conflict. Notification *KatalogNotification `yaml:"notification,omitempty"` + // Profiles declares named profiles available to all CRDs in this Katalog. + // Profiles are resolved before built-in Orkestra profiles at both validate + // and reconcile time. Template expressions in profile field values are + // resolved at reconcile time; validation skips fields that contain {{ }}. + Profiles ProfileRegistry `yaml:"profiles,omitempty"` + // Providers declares which external provider libraries this Katalog requires. // Top-level alongside spec: and security: — providers represent a distinct // operational concern (infrastructure dependencies) separate from CRD definitions. diff --git a/pkg/types/motif.go b/pkg/types/motif.go index 19c9e4b9..4b6ec6dc 100644 --- a/pkg/types/motif.go +++ b/pkg/types/motif.go @@ -31,6 +31,7 @@ type Motif struct { Kind string `yaml:"kind" json:"kind"` Metadata MotifMeta `yaml:"metadata" json:"metadata"` Inputs []MotifInput `yaml:"inputs,omitempty" json:"inputs,omitempty"` + Profiles ProfileRegistry `yaml:"profiles,omitempty" json:"profiles,omitempty"` Resources *MotifResources `yaml:"resources,omitempty" json:"resources,omitempty"` Status *StatusConfig `yaml:"status,omitempty" json:"status,omitempty"` Admission *Admission `yaml:"admission,omitempty" json:"admission,omitempty"` diff --git a/pkg/types/types_limitrange.go b/pkg/types/types_limitrange.go index 2963d657..40ace238 100644 --- a/pkg/types/types_limitrange.go +++ b/pkg/types/types_limitrange.go @@ -72,6 +72,10 @@ type LimitRangeTemplateSource struct { // Default: same namespace as the CR. FromNamespace string `yaml:"fromNamespace,omitempty" json:"fromNamespace,omitempty"` + // Profile — named LimitRange preset. Expands into a Limits list at reconcile time. + // Mutually exclusive with Limits — set one or the other, not both. + Profile string `yaml:"profile,omitempty" json:"profile,omitempty"` + // Limits — the list of limit range items. // Each item applies to one type: Container, Pod, or PersistentVolumeClaim. Limits []LimitRangeItem `yaml:"limits,omitempty" json:"limits,omitempty"` diff --git a/pkg/types/types_profiles.go b/pkg/types/types_profiles.go new file mode 100644 index 00000000..2a9053c1 --- /dev/null +++ b/pkg/types/types_profiles.go @@ -0,0 +1,179 @@ +package types + +import "fmt" + +// ProfileRegistry holds all user-defined profiles declared in a Katalog or Motif. +// Profiles are resolved before built-ins at validate and reconcile time. +// Template expressions in profile field values are allowed and resolved at reconcile time. +type ProfileRegistry struct { + NetworkPolicies []NetworkPolicyProfileDef `yaml:"networkPolicies,omitempty" json:"networkPolicies,omitempty"` + ResourceQuotas []ResourceQuotaProfileDef `yaml:"resourceQuotas,omitempty" json:"resourceQuotas,omitempty"` + LimitRanges []LimitRangeProfileDef `yaml:"limitRanges,omitempty" json:"limitRanges,omitempty"` + HPA []HPAProfileDef `yaml:"hpa,omitempty" json:"hpa,omitempty"` + PDB []PDBProfileDef `yaml:"pdb,omitempty" json:"pdb,omitempty"` + RollingUpdate []RollingUpdateProfileDef `yaml:"rollingUpdate,omitempty" json:"rollingUpdate,omitempty"` +} + +func (r ProfileRegistry) IsEmpty() bool { + return len(r.NetworkPolicies) == 0 && + len(r.ResourceQuotas) == 0 && + len(r.LimitRanges) == 0 && + len(r.HPA) == 0 && + len(r.PDB) == 0 && + len(r.RollingUpdate) == 0 +} + +func (r ProfileRegistry) LookupNetworkPolicy(name string) (NetworkPolicyProfileDef, bool) { + for _, e := range r.NetworkPolicies { + if e.Name == name { + return e, true + } + } + return NetworkPolicyProfileDef{}, false +} + +func (r ProfileRegistry) LookupResourceQuota(name string) (ResourceQuotaProfileDef, bool) { + for _, e := range r.ResourceQuotas { + if e.Name == name { + return e, true + } + } + return ResourceQuotaProfileDef{}, false +} + +func (r ProfileRegistry) LookupLimitRange(name string) (LimitRangeProfileDef, bool) { + for _, e := range r.LimitRanges { + if e.Name == name { + return e, true + } + } + return LimitRangeProfileDef{}, false +} + +func (r ProfileRegistry) LookupHPA(name string) (HPAProfileDef, bool) { + for _, e := range r.HPA { + if e.Name == name { + return e, true + } + } + return HPAProfileDef{}, false +} + +func (r ProfileRegistry) LookupPDB(name string) (PDBProfileDef, bool) { + for _, e := range r.PDB { + if e.Name == name { + return e, true + } + } + return PDBProfileDef{}, false +} + +func (r ProfileRegistry) LookupRollingUpdate(name string) (RollingUpdateProfileDef, bool) { + for _, e := range r.RollingUpdate { + if e.Name == name { + return e, true + } + } + return RollingUpdateProfileDef{}, false +} + +// Merge combines other into r, returning a conflict error if the same name +// appears in the same class in both registries. +func (r ProfileRegistry) Merge(other ProfileRegistry, otherSource string) (ProfileRegistry, error) { + merged := r + for _, e := range other.NetworkPolicies { + if _, found := r.LookupNetworkPolicy(e.Name); found { + return ProfileRegistry{}, profileConflictError("networkPolicies", e.Name, otherSource) + } + merged.NetworkPolicies = append(merged.NetworkPolicies, e) + } + for _, e := range other.ResourceQuotas { + if _, found := r.LookupResourceQuota(e.Name); found { + return ProfileRegistry{}, profileConflictError("resourceQuotas", e.Name, otherSource) + } + merged.ResourceQuotas = append(merged.ResourceQuotas, e) + } + for _, e := range other.LimitRanges { + if _, found := r.LookupLimitRange(e.Name); found { + return ProfileRegistry{}, profileConflictError("limitRanges", e.Name, otherSource) + } + merged.LimitRanges = append(merged.LimitRanges, e) + } + for _, e := range other.HPA { + if _, found := r.LookupHPA(e.Name); found { + return ProfileRegistry{}, profileConflictError("hpa", e.Name, otherSource) + } + merged.HPA = append(merged.HPA, e) + } + for _, e := range other.PDB { + if _, found := r.LookupPDB(e.Name); found { + return ProfileRegistry{}, profileConflictError("pdb", e.Name, otherSource) + } + merged.PDB = append(merged.PDB, e) + } + for _, e := range other.RollingUpdate { + if _, found := r.LookupRollingUpdate(e.Name); found { + return ProfileRegistry{}, profileConflictError("rollingUpdate", e.Name, otherSource) + } + merged.RollingUpdate = append(merged.RollingUpdate, e) + } + return merged, nil +} + +func profileConflictError(class, name, source string) error { + return fmt.Errorf("profile conflict: %s %q defined in both %s and the katalog", class, name, source) +} + +// NetworkPolicyProfileDef defines a named NetworkPolicy profile. +// Fields mirror NetworkPolicyTemplateSource minus declaration-level concerns. +// Template expressions are allowed and resolved at reconcile time. +type NetworkPolicyProfileDef struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + PodSelector map[string]interface{} `yaml:"podSelector,omitempty" json:"podSelector,omitempty"` + Ingress []NetworkPolicyIngressRule `yaml:"ingress,omitempty" json:"ingress,omitempty"` + Egress []NetworkPolicyEgressRule `yaml:"egress,omitempty" json:"egress,omitempty"` + PolicyTypes []string `yaml:"policyTypes,omitempty" json:"policyTypes,omitempty"` +} + +// ResourceQuotaProfileDef defines a named ResourceQuota profile. +type ResourceQuotaProfileDef struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Hard map[string]string `yaml:"hard" json:"hard"` +} + +// LimitRangeProfileDef defines a named LimitRange profile. +type LimitRangeProfileDef struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Limits []LimitRangeItem `yaml:"limits" json:"limits"` +} + +// HPAProfileDef defines a named HPA profile. +// Template expressions in MinReplicas, MaxReplicas, and TargetCPUUtilizationPercentage +// are resolved at reconcile time. +type HPAProfileDef struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + MinReplicas string `yaml:"minReplicas,omitempty" json:"minReplicas,omitempty"` + MaxReplicas string `yaml:"maxReplicas,omitempty" json:"maxReplicas,omitempty"` + TargetCPUUtilizationPercentage string `yaml:"targetCPUUtilizationPercentage,omitempty" json:"targetCPUUtilizationPercentage,omitempty"` + Behavior *HPABehavior `yaml:"behavior,omitempty" json:"behavior,omitempty"` +} + +// PDBProfileDef defines a named PodDisruptionBudget profile. +type PDBProfileDef struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + MinAvailable string `yaml:"minAvailable,omitempty" json:"minAvailable,omitempty"` + MaxUnavailable string `yaml:"maxUnavailable,omitempty" json:"maxUnavailable,omitempty"` +} + +// RollingUpdateProfileDef defines a named rolling update profile. +type RollingUpdateProfileDef struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + MaxSurge string `yaml:"maxSurge,omitempty" json:"maxSurge,omitempty"` + MaxUnavailable string `yaml:"maxUnavailable,omitempty" json:"maxUnavailable,omitempty"` +} diff --git a/pkg/types/types_profiles_test.go b/pkg/types/types_profiles_test.go new file mode 100644 index 00000000..bb564df6 --- /dev/null +++ b/pkg/types/types_profiles_test.go @@ -0,0 +1,312 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProfileRegistry_IsEmpty(t *testing.T) { + assert.True(t, ProfileRegistry{}.IsEmpty()) + assert.False(t, ProfileRegistry{ + NetworkPolicies: []NetworkPolicyProfileDef{{Name: "x"}}, + }.IsEmpty()) +} + +func TestProfileRegistry_LookupNetworkPolicy(t *testing.T) { + reg := ProfileRegistry{ + NetworkPolicies: []NetworkPolicyProfileDef{ + {Name: "allow-monitoring", PolicyTypes: []string{"Ingress"}}, + }, + } + got, found := reg.LookupNetworkPolicy("allow-monitoring") + require.True(t, found) + assert.Equal(t, "allow-monitoring", got.Name) + + _, found = reg.LookupNetworkPolicy("missing") + assert.False(t, found) +} + +func TestProfileRegistry_LookupResourceQuota(t *testing.T) { + reg := ProfileRegistry{ + ResourceQuotas: []ResourceQuotaProfileDef{ + {Name: "team-medium", Hard: map[string]string{"pods": "30"}}, + }, + } + got, found := reg.LookupResourceQuota("team-medium") + require.True(t, found) + assert.Equal(t, "30", got.Hard["pods"]) + + _, found = reg.LookupResourceQuota("missing") + assert.False(t, found) +} + +func TestProfileRegistry_LookupHPA(t *testing.T) { + reg := ProfileRegistry{ + HPA: []HPAProfileDef{ + {Name: "aggressive-scale", MaxReplicas: "20"}, + }, + } + got, found := reg.LookupHPA("aggressive-scale") + require.True(t, found) + assert.Equal(t, "20", got.MaxReplicas) + + _, found = reg.LookupHPA("missing") + assert.False(t, found) +} + +func TestProfileRegistry_LookupPDB(t *testing.T) { + reg := ProfileRegistry{ + PDB: []PDBProfileDef{ + {Name: "strict", MinAvailable: "2"}, + }, + } + got, found := reg.LookupPDB("strict") + require.True(t, found) + assert.Equal(t, "2", got.MinAvailable) +} + +func TestProfileRegistry_LookupRollingUpdate(t *testing.T) { + reg := ProfileRegistry{ + RollingUpdate: []RollingUpdateProfileDef{ + {Name: "canary", MaxSurge: "1", MaxUnavailable: "0"}, + }, + } + got, found := reg.LookupRollingUpdate("canary") + require.True(t, found) + assert.Equal(t, "1", got.MaxSurge) +} + +func TestProfileRegistry_Merge_NoConflict(t *testing.T) { + base := ProfileRegistry{ + NetworkPolicies: []NetworkPolicyProfileDef{{Name: "base-policy"}}, + } + other := ProfileRegistry{ + NetworkPolicies: []NetworkPolicyProfileDef{{Name: "motif-policy"}}, + ResourceQuotas: []ResourceQuotaProfileDef{{Name: "motif-quota", Hard: map[string]string{"pods": "10"}}}, + } + merged, err := base.Merge(other, "motif \"tenant-isolation\"") + require.NoError(t, err) + assert.Len(t, merged.NetworkPolicies, 2) + assert.Len(t, merged.ResourceQuotas, 1) +} + +func TestProfileRegistry_Merge_ConflictNetworkPolicy(t *testing.T) { + base := ProfileRegistry{ + NetworkPolicies: []NetworkPolicyProfileDef{{Name: "shared-policy"}}, + } + other := ProfileRegistry{ + NetworkPolicies: []NetworkPolicyProfileDef{{Name: "shared-policy"}}, + } + _, err := base.Merge(other, "motif \"tenant-isolation\"") + require.Error(t, err) + assert.Contains(t, err.Error(), "profile conflict") + assert.Contains(t, err.Error(), "shared-policy") +} + +func TestProfileRegistry_Merge_ConflictResourceQuota(t *testing.T) { + base := ProfileRegistry{ + ResourceQuotas: []ResourceQuotaProfileDef{{Name: "shared", Hard: map[string]string{}}}, + } + other := ProfileRegistry{ + ResourceQuotas: []ResourceQuotaProfileDef{{Name: "shared", Hard: map[string]string{}}}, + } + _, err := base.Merge(other, "motif \"quotas\"") + require.Error(t, err) + assert.Contains(t, err.Error(), "resourceQuotas") +} + +func TestProfileRegistry_Merge_SameNameDifferentClass_Allowed(t *testing.T) { + // "medium" in resourceQuotas and "medium" in hpa are independent — no conflict. + base := ProfileRegistry{ + ResourceQuotas: []ResourceQuotaProfileDef{{Name: "medium", Hard: map[string]string{}}}, + } + other := ProfileRegistry{ + HPA: []HPAProfileDef{{Name: "medium", MaxReplicas: "10"}}, + } + merged, err := base.Merge(other, "motif \"sizing\"") + require.NoError(t, err) + assert.Len(t, merged.ResourceQuotas, 1) + assert.Len(t, merged.HPA, 1) +} + +// ── IsEmpty: each class ─────────────────────────────────────────────────────── + +func TestProfileRegistry_IsEmpty_EachClass(t *testing.T) { + cases := []struct { + name string + reg ProfileRegistry + }{ + {"ResourceQuotas", ProfileRegistry{ResourceQuotas: []ResourceQuotaProfileDef{{Name: "x"}}}}, + {"LimitRanges", ProfileRegistry{LimitRanges: []LimitRangeProfileDef{{Name: "x"}}}}, + {"HPA", ProfileRegistry{HPA: []HPAProfileDef{{Name: "x"}}}}, + {"PDB", ProfileRegistry{PDB: []PDBProfileDef{{Name: "x"}}}}, + {"RollingUpdate", ProfileRegistry{RollingUpdate: []RollingUpdateProfileDef{{Name: "x"}}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.False(t, tc.reg.IsEmpty()) + }) + } +} + +// ── Lookup: multiple entries, returns correct one ───────────────────────────── + +func TestProfileRegistry_LookupNetworkPolicy_MultipleEntries(t *testing.T) { + reg := ProfileRegistry{ + NetworkPolicies: []NetworkPolicyProfileDef{ + {Name: "first", PolicyTypes: []string{"Ingress"}}, + {Name: "second", PolicyTypes: []string{"Egress"}}, + {Name: "third", PolicyTypes: []string{"Ingress", "Egress"}}, + }, + } + got, found := reg.LookupNetworkPolicy("second") + require.True(t, found) + assert.Equal(t, []string{"Egress"}, got.PolicyTypes) +} + +func TestProfileRegistry_LookupLimitRange(t *testing.T) { + reg := ProfileRegistry{ + LimitRanges: []LimitRangeProfileDef{ + { + Name: "default-limits", + Limits: []LimitRangeItem{ + {Type: "Container", Default: map[string]string{"cpu": "500m", "memory": "256Mi"}}, + }, + }, + }, + } + got, found := reg.LookupLimitRange("default-limits") + require.True(t, found) + assert.Len(t, got.Limits, 1) + assert.Equal(t, "500m", got.Limits[0].Default["cpu"]) + + _, found = reg.LookupLimitRange("missing") + assert.False(t, found) +} + +func TestProfileRegistry_LookupHPA_WithMinReplicas(t *testing.T) { + reg := ProfileRegistry{ + HPA: []HPAProfileDef{ + {Name: "scaled", MinReplicas: "2", MaxReplicas: "{{ .spec.maxReplicas }}", TargetCPUUtilizationPercentage: "70"}, + }, + } + got, found := reg.LookupHPA("scaled") + require.True(t, found) + assert.Equal(t, "2", got.MinReplicas) + assert.Equal(t, "{{ .spec.maxReplicas }}", got.MaxReplicas) + assert.Equal(t, "70", got.TargetCPUUtilizationPercentage) +} + +func TestProfileRegistry_LookupPDB_MaxUnavailable(t *testing.T) { + reg := ProfileRegistry{ + PDB: []PDBProfileDef{ + {Name: "relaxed", MaxUnavailable: "{{ .spec.maxUnavailable }}"}, + }, + } + got, found := reg.LookupPDB("relaxed") + require.True(t, found) + assert.Equal(t, "{{ .spec.maxUnavailable }}", got.MaxUnavailable) +} + +func TestProfileRegistry_LookupRollingUpdate_BothFields(t *testing.T) { + reg := ProfileRegistry{ + RollingUpdate: []RollingUpdateProfileDef{ + {Name: "gradual", MaxSurge: "{{ .spec.surge }}", MaxUnavailable: "0"}, + }, + } + got, found := reg.LookupRollingUpdate("gradual") + require.True(t, found) + assert.Equal(t, "{{ .spec.surge }}", got.MaxSurge) + assert.Equal(t, "0", got.MaxUnavailable) +} + +// ── Merge: conflict for every class ────────────────────────────────────────── + +func TestProfileRegistry_Merge_ConflictHPA(t *testing.T) { + base := ProfileRegistry{HPA: []HPAProfileDef{{Name: "clash"}}} + other := ProfileRegistry{HPA: []HPAProfileDef{{Name: "clash"}}} + _, err := base.Merge(other, "motif \"scaling\"") + require.Error(t, err) + assert.Contains(t, err.Error(), "hpa") + assert.Contains(t, err.Error(), "clash") +} + +func TestProfileRegistry_Merge_ConflictPDB(t *testing.T) { + base := ProfileRegistry{PDB: []PDBProfileDef{{Name: "clash"}}} + other := ProfileRegistry{PDB: []PDBProfileDef{{Name: "clash"}}} + _, err := base.Merge(other, "motif \"disruption\"") + require.Error(t, err) + assert.Contains(t, err.Error(), "pdb") +} + +func TestProfileRegistry_Merge_ConflictRollingUpdate(t *testing.T) { + base := ProfileRegistry{RollingUpdate: []RollingUpdateProfileDef{{Name: "clash"}}} + other := ProfileRegistry{RollingUpdate: []RollingUpdateProfileDef{{Name: "clash"}}} + _, err := base.Merge(other, "motif \"rollout\"") + require.Error(t, err) + assert.Contains(t, err.Error(), "rollingUpdate") +} + +func TestProfileRegistry_Merge_ConflictLimitRange(t *testing.T) { + base := ProfileRegistry{LimitRanges: []LimitRangeProfileDef{{Name: "clash"}}} + other := ProfileRegistry{LimitRanges: []LimitRangeProfileDef{{Name: "clash"}}} + _, err := base.Merge(other, "motif \"limits\"") + require.Error(t, err) + assert.Contains(t, err.Error(), "limitRanges") +} + +func TestProfileRegistry_Merge_EmptyBase(t *testing.T) { + other := ProfileRegistry{ + NetworkPolicies: []NetworkPolicyProfileDef{{Name: "np"}}, + HPA: []HPAProfileDef{{Name: "hpa", MaxReplicas: "5"}}, + } + merged, err := ProfileRegistry{}.Merge(other, "motif \"full\"") + require.NoError(t, err) + assert.Len(t, merged.NetworkPolicies, 1) + assert.Len(t, merged.HPA, 1) +} + +func TestProfileRegistry_Merge_EmptyOther(t *testing.T) { + base := ProfileRegistry{ + PDB: []PDBProfileDef{{Name: "strict", MinAvailable: "2"}}, + } + merged, err := base.Merge(ProfileRegistry{}, "motif \"empty\"") + require.NoError(t, err) + assert.Len(t, merged.PDB, 1) +} + +// ── NetworkPolicyProfileDef fields ─────────────────────────────────────────── + +func TestNetworkPolicyProfileDef_FullFields(t *testing.T) { + def := NetworkPolicyProfileDef{ + Name: "allow-monitoring", + Description: "Allows ingress from the platform monitoring namespace", + PodSelector: map[string]interface{}{"app": "my-app"}, + Ingress: []NetworkPolicyIngressRule{ + {From: []NetworkPolicyPeer{{NamespaceSelector: map[string]string{"team": "platform"}}}}, + }, + PolicyTypes: []string{"Ingress"}, + } + assert.Equal(t, "allow-monitoring", def.Name) + assert.Len(t, def.Ingress, 1) + assert.Equal(t, []string{"Ingress"}, def.PolicyTypes) +} + +// ── ResourceQuotaProfileDef: template expressions ──────────────────────────── + +func TestResourceQuotaProfileDef_TemplateExpressions(t *testing.T) { + def := ResourceQuotaProfileDef{ + Name: "dynamic", + Hard: map[string]string{ + "pods": "{{ .spec.maxPods }}", + "cpu": "{{ .spec.cpuLimit }}", + "memory": "{{ .spec.memLimit }}", + }, + } + reg := ProfileRegistry{ResourceQuotas: []ResourceQuotaProfileDef{def}} + got, found := reg.LookupResourceQuota("dynamic") + require.True(t, found) + assert.Equal(t, "{{ .spec.maxPods }}", got.Hard["pods"]) +} diff --git a/scripts/fix-bare-fences.py b/scripts/fix-bare-fences.py new file mode 100755 index 00000000..8aa8da94 --- /dev/null +++ b/scripts/fix-bare-fences.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +fix-bare-fences.py — add language tags to bare opening code fences in markdown files. + +Scans the given files (or all .md files under the given directories) and replaces +any opening ``` fence that has no language tag with ```text. + +Closing fences are always bare ``` and are left unchanged. + +Usage: + python3 scripts/fix-bare-fences.py [paths...] + +Examples: + # Fix all markdown in documentation/ and examples/ + python3 scripts/fix-bare-fences.py documentation/ examples/ + + # Fix specific files + python3 scripts/fix-bare-fences.py docs/guide.md pkg/profiles/docs/ + + # Fix all markdown in the repo + python3 scripts/fix-bare-fences.py . +""" + +import os +import re +import sys + + +def fix_file(path: str) -> bool: + with open(path) as f: + lines = f.readlines() + + in_block = False + new_lines = [] + changed = False + + for line in lines: + stripped = line.rstrip() + if stripped == "```": + if not in_block: + new_lines.append("```text\n") + in_block = True + changed = True + else: + new_lines.append(line) + in_block = False + elif re.match(r"^```\w", stripped): + new_lines.append(line) + in_block = True + else: + new_lines.append(line) + + if changed: + with open(path, "w") as f: + f.writelines(new_lines) + + return changed + + +def collect_markdown(paths: list[str]) -> list[str]: + files = [] + for p in paths: + if os.path.isfile(p) and p.endswith(".md"): + files.append(p) + elif os.path.isdir(p): + for root, _, fnames in os.walk(p): + for name in fnames: + if name.endswith(".md"): + files.append(os.path.join(root, name)) + return sorted(files) + + +def main() -> None: + targets = sys.argv[1:] if len(sys.argv) > 1 else ["."] + files = collect_markdown(targets) + + if not files: + print("No markdown files found.") + return + + fixed = 0 + for path in files: + if fix_file(path): + print(f"fixed: {path}") + fixed += 1 + + total = len(files) + print(f"\n{fixed} file(s) changed, {total - fixed} already clean ({total} scanned).") + + +if __name__ == "__main__": + main()