diff --git a/cmd/cli/helper.go b/cmd/cli/helper.go index 10cba811..031cbe40 100644 --- a/cmd/cli/helper.go +++ b/cmd/cli/helper.go @@ -607,10 +607,14 @@ func roleNameList(srcs []orktypes.RoleTemplateSource) []string { return out } -func clusterRoleNameList(srcs []orktypes.PlaceholderSource) []string { +func clusterRoleNameList(srcs []orktypes.ClusterRoleTemplateSource) []string { out := make([]string, len(srcs)) - for i := range srcs { - out[i] = fmt.Sprintf("", i+1) + for i, s := range srcs { + if s.Name != "" { + out[i] = s.Name + } else { + out[i] = fmt.Sprintf("", i+1) + } } return out } diff --git a/documentation/concepts/profiles/08-resourcequota-profile.md b/documentation/concepts/profiles/08-resourcequota-profile.md new file mode 100644 index 00000000..edf1c677 --- /dev/null +++ b/documentation/concepts/profiles/08-resourcequota-profile.md @@ -0,0 +1,87 @@ +# ResourceQuota Profile + +A ResourceQuota profile is a named preset that expands into a complete Kubernetes `ResourceQuota` hard limits block at reconcile time. + +You write a profile name. Orkestra writes the pod counts, CPU, and memory limits for the namespace. + +--- + +## Profiles + +| Profile | Pods | CPU | Memory | CPU Request | Memory Request | Use case | +|---------|------|-----|--------|-------------|----------------|----------| +| `small` | 10 | 2 | 4Gi | 1 | 2Gi | Development namespaces, small teams | +| `medium` | 20 | 4 | 8Gi | 2 | 4Gi | Standard team namespaces | +| `large` | 50 | 8 | 16Gi | 4 | 8Gi | Production namespaces, larger services | +| `xlarge` | 100 | 16 | 32Gi | 8 | 16Gi | High-scale production or platform namespaces | + +Each profile sets `pods`, `cpu`, `memory`, `requests.cpu`, `requests.memory`, `limits.cpu`, and `limits.memory`. + +--- + +## Usage + +Set `profile` on any `resourceQuotas` entry: + +```yaml +onCreate: + resourceQuotas: + - name: "{{ .metadata.name }}-quota" + profile: medium + reconcile: true +``` + +Or dynamically from the CR spec: + +```yaml +resourceQuotas: + - name: "{{ .metadata.name }}-quota" + profile: '{{ .spec.size | default "small" }}' + reconcile: true +``` + +This pattern is common in namespace provisioning operators where the CR carries a `size` field that controls how much of the cluster the namespace receives. + +--- + +## Rules + +**Profile or explicit — not both.** + +`profile` and `hard` cannot coexist on the same ResourceQuota entry. Orkestra ignores the profile if `hard` is also set and logs a warning. + +```yaml +# Valid +resourceQuotas: + - name: ns-quota + profile: large + +# Valid +resourceQuotas: + - name: ns-quota + hard: + pods: "30" + cpu: "6" + memory: 12Gi + +# Invalid — profile is ignored, hard wins +resourceQuotas: + - name: ns-quota + profile: large + hard: + pods: "30" +``` + +**Unknown profiles log a warning and skip the resource.** A typo does not cause a reconcile failure — Orkestra skips that ResourceQuota and logs the unknown profile name. + +--- + +## Choosing a profile + +| Situation | Profile | +|-----------|---------| +| Developer sandbox, local testing | `small` | +| Team namespace, feature environment | `medium` | +| Production service namespace | `large` | +| Platform or high-traffic production | `xlarge` | +| Fine-grained control needed | Omit profile, use `hard` directly | diff --git a/documentation/concepts/profiles/09-networkpolicy-profile.md b/documentation/concepts/profiles/09-networkpolicy-profile.md new file mode 100644 index 00000000..725f173b --- /dev/null +++ b/documentation/concepts/profiles/09-networkpolicy-profile.md @@ -0,0 +1,104 @@ +# NetworkPolicy Profile + +A NetworkPolicy profile is a named preset that expands into a complete set of ingress/egress rules and policy types at reconcile time. + +You write a profile name. Orkestra writes the rules. + +--- + +## Profiles + +| Profile | Ingress | Egress | Use case | +|---------|---------|--------|----------| +| `deny-all` | Blocked | Blocked | Baseline isolation — no traffic in or out | +| `deny-all-ingress` | Blocked | Unmanaged | Block all inbound, leave egress open | +| `deny-all-egress` | Unmanaged | Blocked | Block all outbound, leave ingress open | +| `allow-same-namespace` | Same namespace only | Unmanaged | Allow pods within the namespace to talk to each other | +| `allow-dns-egress` | Unmanaged | UDP/TCP port 53 only | Allow DNS resolution (combine with other policies) | + +--- + +## Usage + +Set `profile` on any `networkPolicies` entry: + +```yaml +onCreate: + networkPolicies: + - name: "{{ .metadata.name }}-baseline" + podSelector: {} + profile: deny-all + reconcile: true +``` + +Profiles compose by declaring multiple policies: + +```yaml +onCreate: + networkPolicies: + - name: "{{ .metadata.name }}-deny-all" + podSelector: {} + profile: deny-all + reconcile: true + - name: "{{ .metadata.name }}-allow-dns" + podSelector: {} + profile: allow-dns-egress + reconcile: true +``` + +This is the standard layered approach: start with `deny-all`, then add back only what the workload needs. + +--- + +## How `deny-all` works + +Kubernetes interprets an empty ingress or egress slice as "block all". The `deny-all` profile sets: + +```yaml +policyTypes: + - Ingress + - Egress +ingress: [] +egress: [] +``` + +The explicit `policyTypes` declaration is required — without it, an empty `egress` slice does not block egress traffic. + +--- + +## Rules + +**Profile or explicit — not both.** + +`profile` and explicit `ingress`/`egress`/`policyTypes` fields cannot coexist on the same NetworkPolicy entry. If both are set, the profile takes precedence and the explicit fields are ignored. + +**`podSelector` is independent of profile.** + +The profile only sets the traffic rules. You still control which pods the policy applies to via `podSelector`. An empty map (`{}`) selects all pods in the namespace. + +```yaml +networkPolicies: + - name: deny-all + podSelector: {} # applies to all pods + profile: deny-all + + - name: deny-worker-egress + podSelector: + role: worker # applies only to pods with role=worker + profile: deny-all-egress +``` + +**Unknown profiles log a warning and skip the resource.** A typo does not cause a reconcile failure. + +--- + +## Choosing a profile + +| Situation | Profile | +|-----------|---------| +| New namespace — start locked down | `deny-all` | +| Block inbound only, egress already managed | `deny-all-ingress` | +| Block outbound only | `deny-all-egress` | +| Services in the same namespace need to talk | `allow-same-namespace` | +| Workload needs DNS (after deny-all) | `allow-dns-egress` | +| Custom traffic rules | Omit profile, use `ingress`/`egress` directly | diff --git a/documentation/concepts/profiles/index.md b/documentation/concepts/profiles/index.md index 2e8b3e26..5a0bb681 100644 --- a/documentation/concepts/profiles/index.md +++ b/documentation/concepts/profiles/index.md @@ -62,7 +62,7 @@ Static names are validated at load time. Template expressions are validated when --- -## The seven profile families +## Profile families | Family | What it controls | Applied to | |--------|-----------------|------------| @@ -73,6 +73,8 @@ Static names are validated at load time. Template expressions are validated when | [HPA Behavior](./05-hpa-behavior-profile.md) | Kubernetes HPA scale-up/down policies | `hpa[*].behavior` | | [PDB Behavior](./06-pdb-profile.md) | PodDisruptionBudget disruption limits | `pdb[*].behavior` | | [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[*]` | --- diff --git a/documentation/contributing/contributing-resources.md b/documentation/contributing/contributing-resources.md index 0bb87446..4313f558 100644 --- a/documentation/contributing/contributing-resources.md +++ b/documentation/contributing/contributing-resources.md @@ -24,7 +24,12 @@ The registry is the library of built-in Kubernetes resource handlers Orkestra kn | `namespaces/` | `v1 Namespace` | Implemented — cleanup on delete not yet working | | `roles/` | `rbac/v1 Role` | Implemented — needs tests | | `rolebindings/` | `rbac/v1 RoleBinding` | Implemented — needs tests | +| `clusterroles/` | `rbac/v1 ClusterRole` | Implemented — needs tests | +| `clusterrolebindings/` | `rbac/v1 ClusterRoleBinding` | Implemented — needs tests | | `serviceaccounts/` | `v1 ServiceAccount` | Implemented — needs tests | +| `networkpolicies/` | `networking.k8s.io/v1 NetworkPolicy` | Implemented — needs tests | +| `resourcequotas/` | `v1 ResourceQuota` | Implemented — needs tests | +| `limitranges/` | `v1 LimitRange` | Implemented — needs tests | | `customresources/` | Dynamic CR via `dynamic` client | Implemented | | `pods/` | `v1 Pod` | Implemented | @@ -32,26 +37,17 @@ The registry is the library of built-in Kubernetes resource handlers Orkestra kn ## What needs implementing -The following resource types are declared in `pkg/types/types.go` as `PlaceholderSource` — they are accepted in YAML but not yet executed. Implementing one means building the full handler package. +The following resource types are declared in `pkg/types/types_hook_templates.go` as `PlaceholderSource` — they are accepted in YAML but not yet executed. Implementing one means building the full handler package. **Workloads:** - `DaemonSets` - `PodTemplates` -**RBAC:** -- `ClusterRoles` -- `ClusterRoleBindings` - **Scheduling:** - `PriorityClasses` - `PriorityLevelConfigurations` -- `LimitRanges` -- `ResourceQuotas` - `RuntimeClasses` -**Networking:** -- `NetworkPolicies` - **Storage:** - `StorageClasses` - `StorageLocations` diff --git a/documentation/reference/schema/02-katalog/16-resource-types.md b/documentation/reference/schema/02-katalog/16-resource-types.md new file mode 100644 index 00000000..c52db027 --- /dev/null +++ b/documentation/reference/schema/02-katalog/16-resource-types.md @@ -0,0 +1,111 @@ +# Resource types + +Every `onCreate`, `onReconcile`, and `onDelete` block accepts the resource types listed on this page. Resources are applied in the order declared within each type slice. + +--- + +## Supported + +These resource types are fully implemented — Orkestra creates, updates, and deletes them on every reconcile. + +### Workloads + +| Field | Kubernetes kind | API version | +|-------|----------------|-------------| +| `deployments` | `Deployment` | `apps/v1` | +| `replicaSets` | `ReplicaSet` | `apps/v1` | +| `statefulSets` | `StatefulSet` | `apps/v1` | +| `pods` | `Pod` | `v1` | +| `jobs` | `Job` | `batch/v1` | +| `cronJobs` | `CronJob` | `batch/v1` | + +### Networking + +| Field | Kubernetes kind | API version | +|-------|----------------|-------------| +| `services` | `Service` | `v1` | +| `ingresses` | `Ingress` | `networking.k8s.io/v1` | +| `networkPolicies` | `NetworkPolicy` | `networking.k8s.io/v1` | + +### Configuration + +| Field | Kubernetes kind | API version | +|-------|----------------|-------------| +| `configMaps` | `ConfigMap` | `v1` | +| `secrets` | `Secret` | `v1` | +| `namespaces` | `Namespace` | `v1` | + +### Storage + +| Field | Kubernetes kind | API version | +|-------|----------------|-------------| +| `persistentVolumeClaims` | `PersistentVolumeClaim` | `v1` | +| `persistentVolumes` | `PersistentVolume` | `v1` | + +### Identity + +| Field | Kubernetes kind | API version | +|-------|----------------|-------------| +| `serviceAccounts` | `ServiceAccount` | `v1` | +| `roles` | `Role` | `rbac.authorization.k8s.io/v1` | +| `roleBindings` | `RoleBinding` | `rbac.authorization.k8s.io/v1` | +| `clusterRoles` | `ClusterRole` | `rbac.authorization.k8s.io/v1` | +| `clusterRoleBindings` | `ClusterRoleBinding` | `rbac.authorization.k8s.io/v1` | + +### Policy + +| Field | Kubernetes kind | API version | +|-------|----------------|-------------| +| `hpa` | `HorizontalPodAutoscaler` | `autoscaling/v2` | +| `pdb` | `PodDisruptionBudget` | `policy/v1` | +| `resourceQuotas` | `ResourceQuota` | `v1` | +| `limitRanges` | `LimitRange` | `v1` | + +### Custom + +| Field | Description | +|-------|-------------| +| `custom` | Any CRD — applied via the dynamic client. Accepts any YAML structure. | + +--- + +## Not yet supported + +The following fields are accepted in the YAML and parsed without error, but Orkestra does not act on them at reconcile time. They are placeholders — declaring them does nothing. + +| Field | Kubernetes kind | Notes | +|-------|----------------|-------| +| `daemonSets` | `DaemonSet` | Planned | +| `podTemplates` | `PodTemplate` | Planned | +| `storageClasses` | `StorageClass` | Planned | +| `storageLocations` | Velero `BackupStorageLocation` | Planned | +| `storagePools` | Rook `CephBlockPool` | Planned | +| `storageBackups` | Velero `Backup` | Planned | +| `storageSnapshots` | `VolumeSnapshot` | Planned | +| `storageVolumes` | Longhorn `Volume` | Planned | +| `serviceMonitors` | Prometheus Operator `ServiceMonitor` | Planned — use `custom` in the meantime | +| `priorityClasses` | `PriorityClass` | Planned | +| `priorityLevelConfigurations` | `PriorityLevelConfiguration` | Planned | +| `runtimeClasses` | `RuntimeClass` | Planned | +| `podSecurityPolicies` | `PodSecurityPolicy` (deprecated) | No plan — PSP is removed in Kubernetes 1.25+ | +| `volumes` | Pod volume definitions | Future — injected into pod specs | +| `volumeMounts` | Container volume mounts | Future — injected into container specs | + +!!! tip "Using an unsupported type now" + For any resource type not in the supported list, use `custom:` with the full YAML structure. Orkestra applies it via the dynamic client against the cluster API. See [13-external.md](13-external.md) for details on combining external API calls with custom resources. + +--- + +## ClusterRole and ClusterRoleBinding notes + +`clusterRoles` and `clusterRoleBindings` are cluster-scoped. Kubernetes does not allow a namespace-scoped CR to own a cluster-scoped resource via `OwnerReferences` — setting one causes the garbage collector to treat the resource as orphaned and delete it immediately. + +Orkestra tracks ownership via the `orkestra.io/owner` label instead. This means cluster-scoped resources are **not automatically deleted** when the CR is deleted. Declare explicit cleanup in `onDelete` if needed: + +```yaml +onDelete: + clusterRoles: + - name: "{{ .metadata.name }}-cluster-role" + clusterRoleBindings: + - name: "{{ .metadata.name }}-crb" +``` diff --git a/documentation/reference/schema/02-katalog/index.md b/documentation/reference/schema/02-katalog/index.md index a192d491..c466dd3e 100644 --- a/documentation/reference/schema/02-katalog/index.md +++ b/documentation/reference/schema/02-katalog/index.md @@ -81,6 +81,7 @@ This scaffolds the simplest Katalog — a single CRD that creates a Deployment a | [11-katalog-notification.md](11-katalog-notification.md) | `notification` block | | [12-katalog-providers.md](12-katalog-providers.md) | `providers` block | | [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/pkg/children/builtins.go b/pkg/children/builtins.go index 7987d9a2..b4ba37d5 100644 --- a/pkg/children/builtins.go +++ b/pkg/children/builtins.go @@ -198,13 +198,19 @@ var builtInRegistry = map[string]BuiltInKind{ "resourcequota": { Kind: "ResourceQuota", Group: "", Version: "v1", Plural: "resourcequotas", Namespaced: true, APIPath: "/api", - SkipObservedGeneration: true, + SkipObservedGeneration: true, OrkestraInternal: true, + Detect: func(crd orktypes.CRDEntry) bool { + return detectAny(crd, func(t *orktypes.HookTemplates) []orktypes.ResourceQuotaTemplateSource { return t.ResourceQuotas }) + }, }, "limitrange": { Kind: "LimitRange", Group: "", Version: "v1", Plural: "limitranges", Namespaced: true, APIPath: "/api", - SkipObservedGeneration: true, + SkipObservedGeneration: true, OrkestraInternal: true, + Detect: func(crd orktypes.CRDEntry) bool { + return detectAny(crd, func(t *orktypes.HookTemplates) []orktypes.LimitRangeTemplateSource { return t.LimitRanges }) + }, }, "componentstatus": { @@ -300,7 +306,7 @@ var builtInRegistry = map[string]BuiltInKind{ Statusless: true, SkipStatusSubresource: true, OrkestraInternal: true, Shorthands: []string{"np"}, Detect: func(crd orktypes.CRDEntry) bool { - return detectAny(crd, func(t *orktypes.HookTemplates) []orktypes.PlaceholderSource { return t.NetworkPolicies }) + return detectAny(crd, func(t *orktypes.HookTemplates) []orktypes.NetworkPolicyTemplateSource { return t.NetworkPolicies }) }, }, @@ -348,7 +354,7 @@ var builtInRegistry = map[string]BuiltInKind{ Statusless: true, SkipStatusSubresource: true, OrkestraInternal: true, Shorthands: []string{"cr"}, Detect: func(crd orktypes.CRDEntry) bool { - return detectAny(crd, func(t *orktypes.HookTemplates) []orktypes.PlaceholderSource { return t.ClusterRoles }) + return detectAny(crd, func(t *orktypes.HookTemplates) []orktypes.ClusterRoleTemplateSource { return t.ClusterRoles }) }, }, @@ -358,7 +364,9 @@ var builtInRegistry = map[string]BuiltInKind{ Statusless: true, SkipStatusSubresource: true, OrkestraInternal: true, Shorthands: []string{"crb"}, Detect: func(crd orktypes.CRDEntry) bool { - return detectAny(crd, func(t *orktypes.HookTemplates) []orktypes.PlaceholderSource { return t.ClusterRoleBindings }) + return detectAny(crd, func(t *orktypes.HookTemplates) []orktypes.ClusterRoleBindingTemplateSource { + return t.ClusterRoleBindings + }) }, }, diff --git a/pkg/katalog/validate.go b/pkg/katalog/validate.go index 0b8514df..6fd963c4 100644 --- a/pkg/katalog/validate.go +++ b/pkg/katalog/validate.go @@ -202,6 +202,24 @@ func (k *Katalog) ValidateConfig(kfg *konfig.Konfig) (*Katalog, error) { return nil, err } + // 30. Validate NetworkPolicy Profiles + // ------------------------------------------------------------------------- + if err := k.validateNetworkPolicyProfiles(); err != nil { + return nil, err + } + + // 31. Validate ResourceQuota Profiles + // ------------------------------------------------------------------------- + if err := k.validateResourceQuotaProfiles(); err != nil { + return nil, err + } + + // 32. Validate cross-namespace copy pairs (fromNamespace ↔ toNamespaces) + // ------------------------------------------------------------------------- + if err := k.validateCrossNamespaceOps(); err != nil { + return nil, err + } + // ------------------------------------------------------------------------- // 29. Validate port protocols (Deployments, ReplicaSets, StatefulSets, Pods) // ------------------------------------------------------------------------- diff --git a/pkg/katalog/validate_cross_namespace.go b/pkg/katalog/validate_cross_namespace.go new file mode 100644 index 00000000..79dbd456 --- /dev/null +++ b/pkg/katalog/validate_cross_namespace.go @@ -0,0 +1,73 @@ +package katalog + +import ( + "fmt" + + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// validateCrossNamespaceOps checks that every resource using the cross-namespace +// copy pattern has both fromNamespace and toNamespaces set together. +// +// Background: the copy pattern requires a source object (fromNamespace) and at +// least one destination (toNamespaces). Declaring only one of the two produces a +// silently broken resource — the runner skips the copy or reads from an +// unintended location. validate catches the mismatch early so users see a clear +// error at katalog load time rather than a confusing runtime failure. +// +// This validation is resource-type-agnostic: any *TemplateSource that introduces +// fromNamespace / toNamespaces must add its slice to checkCrossNamespaceItems +// below so the check is inherited automatically. +func (k *Katalog) validateCrossNamespaceOps() error { + for crdName, crd := range k.enabledCRDs { + for _, phase := range []struct { + name string + ht *orktypes.HookTemplates + }{ + {"onCreate", crd.OperatorBox.OnCreate}, + {"onReconcile", crd.OperatorBox.OnReconcile}, + {"onDelete", crd.OperatorBox.OnDelete}, + } { + if phase.ht == nil { + continue + } + if err := checkCrossNamespaceHooks(crdName, phase.name, *phase.ht); err != nil { + return err + } + } + } + return nil +} + +func checkCrossNamespaceHooks(crdName, phase string, ht orktypes.HookTemplates) error { + if err := checkCrossNamespaceItems(crdName, phase, ht.Secrets); err != nil { + return err + } + if err := checkCrossNamespaceItems(crdName, phase, ht.ConfigMaps); err != nil { + return err + } + if err := checkCrossNamespaceItems(crdName, phase, ht.NetworkPolicies); err != nil { + return err + } + if err := checkCrossNamespaceItems(crdName, phase, ht.ResourceQuotas); err != nil { + return err + } + return checkCrossNamespaceItems(crdName, phase, ht.LimitRanges) +} + +func checkCrossNamespaceItems[T orktypes.CrossNamespaceChecker](crdName, phase string, items []T) error { + for _, item := range items { + hasFrom := item.GetFromNamespace() != "" + hasTo := len(item.GetToNamespaces()) > 0 + if hasFrom == hasTo { + continue + } + if hasFrom { + return fmt.Errorf("crd %q: %s/%s (phase %s) sets fromNamespace but not toNamespaces — both must be set together", + crdName, item.GetKind(), item.GetName(), phase) + } + return fmt.Errorf("crd %q: %s/%s (phase %s) sets toNamespaces but not fromNamespace — both must be set together", + crdName, item.GetKind(), item.GetName(), phase) + } + return nil +} diff --git a/pkg/katalog/validate_hpa_profile_test.go b/pkg/katalog/validate_hpa_profile_test.go new file mode 100644 index 00000000..fd7f0aa8 --- /dev/null +++ b/pkg/katalog/validate_hpa_profile_test.go @@ -0,0 +1,101 @@ +package katalog + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func katalogWithHPA(crdName string, hpas ...orktypes.HPATemplateSource) *Katalog { + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + crdName: { + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + HorizontalPodAutoscalers: hpas, + }, + }, + }, + }, + } +} + +func TestValidateHPABehaviorProfiles_NoCRDs(t *testing.T) { + k := &Katalog{enabledCRDs: map[string]orktypes.CRDEntry{}} + assert.NoError(t, k.validateHPABehaviorProfiles()) +} + +func TestValidateHPABehaviorProfiles_NoProfile(t *testing.T) { + k := katalogWithHPA("app", orktypes.HPATemplateSource{Name: "hpa"}) + assert.NoError(t, k.validateHPABehaviorProfiles()) +} + +func TestValidateHPABehaviorProfiles_ValidProfile_Web(t *testing.T) { + k := katalogWithHPA("app", orktypes.HPATemplateSource{ + Name: "hpa", + Behavior: &orktypes.HPABehavior{Profile: "web"}, + }) + assert.NoError(t, k.validateHPABehaviorProfiles()) +} + +func TestValidateHPABehaviorProfiles_ValidProfile_API(t *testing.T) { + k := katalogWithHPA("app", orktypes.HPATemplateSource{ + Behavior: &orktypes.HPABehavior{Profile: "api"}, + }) + assert.NoError(t, k.validateHPABehaviorProfiles()) +} + +func TestValidateHPABehaviorProfiles_ValidProfile_LatencySensitive(t *testing.T) { + k := katalogWithHPA("app", orktypes.HPATemplateSource{ + Behavior: &orktypes.HPABehavior{Profile: "latency-sensitive"}, + }) + assert.NoError(t, k.validateHPABehaviorProfiles()) +} + +func TestValidateHPABehaviorProfiles_ValidProfile_Batch(t *testing.T) { + k := katalogWithHPA("app", orktypes.HPATemplateSource{ + Behavior: &orktypes.HPABehavior{Profile: "batch"}, + }) + assert.NoError(t, k.validateHPABehaviorProfiles()) +} + +func TestValidateHPABehaviorProfiles_ValidProfile_CostOptimized(t *testing.T) { + k := katalogWithHPA("app", orktypes.HPATemplateSource{ + Behavior: &orktypes.HPABehavior{Profile: "cost-optimized"}, + }) + assert.NoError(t, k.validateHPABehaviorProfiles()) +} + +func TestValidateHPABehaviorProfiles_UnknownProfile(t *testing.T) { + k := katalogWithHPA("app", orktypes.HPATemplateSource{ + Name: "hpa", + Behavior: &orktypes.HPABehavior{Profile: "aggressive"}, + }) + err := k.validateHPABehaviorProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown behavior.profile") + assert.Contains(t, err.Error(), "aggressive") + assert.Contains(t, err.Error(), "web") +} + +func TestValidateHPABehaviorProfiles_MixedWithScaleUp(t *testing.T) { + k := katalogWithHPA("app", orktypes.HPATemplateSource{ + Name: "hpa", + Behavior: &orktypes.HPABehavior{ + Profile: "web", + ScaleUp: &orktypes.HPAScalingRules{StabilizationWindowSeconds: 30}, + }, + }) + err := k.validateHPABehaviorProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "scaleUp/scaleDown") +} + +func TestValidateHPABehaviorProfiles_TemplateExprSkipped(t *testing.T) { + k := katalogWithHPA("app", orktypes.HPATemplateSource{ + Behavior: &orktypes.HPABehavior{Profile: "{{ .Spec.HPA }}"}, + }) + assert.NoError(t, k.validateHPABehaviorProfiles()) +} diff --git a/pkg/katalog/validate_networkpolicy_profile.go b/pkg/katalog/validate_networkpolicy_profile.go new file mode 100644 index 00000000..2c7504cd --- /dev/null +++ b/pkg/katalog/validate_networkpolicy_profile.go @@ -0,0 +1,51 @@ +// NetworkPolicy Profile Validation +// +// NetworkPolicy profiles are named presets that expand into a complete set of +// ingress/egress rules and policy types at reconcile time. +// +// Validation enforces: +// +// 1. Known profile names: +// Allowed: deny-all, deny-all-ingress, deny-all-egress, +// allow-same-namespace, allow-dns-egress. +// +// 2. Profile-only usage: +// profile cannot appear alongside explicit ingress, egress, or policyTypes +// fields — profiles are atomic presets. +// +// 3. Template expressions: +// Profile values containing "{{" are skipped at load time and validated +// at reconcile time instead. + +package katalog + +import ( + "fmt" + + "github.com/orkspace/orkestra/pkg/profiles" +) + +func (k *Katalog) validateNetworkPolicyProfiles() error { + for crdName, crd := range k.enabledCRDs { + for _, e := range crd.CollectNetworkPolicyProfileEntries() { + if isTemplateExpr(e.Profile) { + continue + } + if !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", + crdName, e.ResourceName, e.Phase, e.Profile, + ) + } + if e.Mixed { + return fmt.Errorf( + "crd %q: networkPolicy %q (phase %s) declares both profile (%q) and "+ + "explicit ingress/egress/policyTypes — use one or the other, not both", + crdName, e.ResourceName, e.Phase, e.Profile, + ) + } + } + } + return nil +} diff --git a/pkg/katalog/validate_networkpolicy_profile_test.go b/pkg/katalog/validate_networkpolicy_profile_test.go new file mode 100644 index 00000000..f588c4b2 --- /dev/null +++ b/pkg/katalog/validate_networkpolicy_profile_test.go @@ -0,0 +1,112 @@ +package katalog + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func katalogWithNetworkPolicy(crdName string, nps ...orktypes.NetworkPolicyTemplateSource) *Katalog { + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + crdName: { + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + NetworkPolicies: nps, + }, + }, + }, + }, + } +} + +func TestValidateNetworkPolicyProfiles_NoCRDs(t *testing.T) { + k := &Katalog{enabledCRDs: map[string]orktypes.CRDEntry{}} + assert.NoError(t, k.validateNetworkPolicyProfiles()) +} + +func TestValidateNetworkPolicyProfiles_NoProfile(t *testing.T) { + k := katalogWithNetworkPolicy("app", orktypes.NetworkPolicyTemplateSource{Name: "np"}) + assert.NoError(t, k.validateNetworkPolicyProfiles()) +} + +func TestValidateNetworkPolicyProfiles_ValidProfile_DenyAll(t *testing.T) { + k := katalogWithNetworkPolicy("app", orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "deny-all", + }) + assert.NoError(t, k.validateNetworkPolicyProfiles()) +} + +func TestValidateNetworkPolicyProfiles_ValidProfile_AllowSameNamespace(t *testing.T) { + k := katalogWithNetworkPolicy("app", orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "allow-same-namespace", + }) + assert.NoError(t, k.validateNetworkPolicyProfiles()) +} + +func TestValidateNetworkPolicyProfiles_ValidProfile_AllowDNSEgress(t *testing.T) { + k := katalogWithNetworkPolicy("app", orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "allow-dns-egress", + }) + assert.NoError(t, k.validateNetworkPolicyProfiles()) +} + +func TestValidateNetworkPolicyProfiles_ValidProfile_DenyAllIngress(t *testing.T) { + k := katalogWithNetworkPolicy("app", orktypes.NetworkPolicyTemplateSource{ + Profile: "deny-all-ingress", + }) + assert.NoError(t, k.validateNetworkPolicyProfiles()) +} + +func TestValidateNetworkPolicyProfiles_ValidProfile_DenyAllEgress(t *testing.T) { + k := katalogWithNetworkPolicy("app", orktypes.NetworkPolicyTemplateSource{ + Profile: "deny-all-egress", + }) + assert.NoError(t, k.validateNetworkPolicyProfiles()) +} + +func TestValidateNetworkPolicyProfiles_UnknownProfile(t *testing.T) { + k := katalogWithNetworkPolicy("app", orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "allow-everything", + }) + err := k.validateNetworkPolicyProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown profile") + assert.Contains(t, err.Error(), "allow-everything") + assert.Contains(t, err.Error(), "deny-all") +} + +func TestValidateNetworkPolicyProfiles_MixedWithIngress(t *testing.T) { + k := katalogWithNetworkPolicy("app", orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "deny-all", + Ingress: []orktypes.NetworkPolicyIngressRule{{}}, + }) + err := k.validateNetworkPolicyProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "ingress/egress/policyTypes") +} + +func TestValidateNetworkPolicyProfiles_MixedWithEgress(t *testing.T) { + k := katalogWithNetworkPolicy("app", orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "deny-all", + Egress: []orktypes.NetworkPolicyEgressRule{{}}, + }) + err := k.validateNetworkPolicyProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "ingress/egress/policyTypes") +} + +func TestValidateNetworkPolicyProfiles_TemplateExprSkipped(t *testing.T) { + k := katalogWithNetworkPolicy("app", orktypes.NetworkPolicyTemplateSource{ + Profile: "{{ .Spec.NetworkProfile }}", + }) + assert.NoError(t, k.validateNetworkPolicyProfiles()) +} diff --git a/pkg/katalog/validate_pdb_profile_test.go b/pkg/katalog/validate_pdb_profile_test.go new file mode 100644 index 00000000..47dca874 --- /dev/null +++ b/pkg/katalog/validate_pdb_profile_test.go @@ -0,0 +1,100 @@ +package katalog + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func katalogWithPDB(crdName string, pdbs ...orktypes.PDBTemplateSource) *Katalog { + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + crdName: { + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + PodDisruptionBudgets: pdbs, + }, + }, + }, + }, + } +} + +func TestValidatePDBBehaviorProfiles_NoCRDs(t *testing.T) { + k := &Katalog{enabledCRDs: map[string]orktypes.CRDEntry{}} + assert.NoError(t, k.validatePDBBehaviorProfiles()) +} + +func TestValidatePDBBehaviorProfiles_NoProfile(t *testing.T) { + k := katalogWithPDB("app", orktypes.PDBTemplateSource{Name: "pdb"}) + assert.NoError(t, k.validatePDBBehaviorProfiles()) +} + +func TestValidatePDBBehaviorProfiles_ValidProfile_ZeroDowntime(t *testing.T) { + k := katalogWithPDB("app", orktypes.PDBTemplateSource{ + Name: "pdb", + Behavior: &orktypes.PDBBehavior{Profile: "zero-downtime"}, + }) + assert.NoError(t, k.validatePDBBehaviorProfiles()) +} + +func TestValidatePDBBehaviorProfiles_ValidProfile_Rolling(t *testing.T) { + k := katalogWithPDB("app", orktypes.PDBTemplateSource{ + Behavior: &orktypes.PDBBehavior{Profile: "rolling"}, + }) + assert.NoError(t, k.validatePDBBehaviorProfiles()) +} + +func TestValidatePDBBehaviorProfiles_ValidProfile_Relaxed(t *testing.T) { + k := katalogWithPDB("app", orktypes.PDBTemplateSource{ + Behavior: &orktypes.PDBBehavior{Profile: "relaxed"}, + }) + assert.NoError(t, k.validatePDBBehaviorProfiles()) +} + +func TestValidatePDBBehaviorProfiles_UnknownProfile(t *testing.T) { + k := katalogWithPDB("app", orktypes.PDBTemplateSource{ + Name: "pdb", + Behavior: &orktypes.PDBBehavior{Profile: "strict"}, + }) + err := k.validatePDBBehaviorProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown behavior.profile") + assert.Contains(t, err.Error(), "strict") + assert.Contains(t, err.Error(), "zero-downtime") +} + +func TestValidatePDBBehaviorProfiles_MixedWithMinAvailable(t *testing.T) { + k := katalogWithPDB("app", orktypes.PDBTemplateSource{ + Name: "pdb", + Behavior: &orktypes.PDBBehavior{ + Profile: "rolling", + MinAvailable: "1", + }, + }) + err := k.validatePDBBehaviorProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "minAvailable/maxUnavailable") +} + +func TestValidatePDBBehaviorProfiles_MixedWithMaxUnavailable(t *testing.T) { + k := katalogWithPDB("app", orktypes.PDBTemplateSource{ + Name: "pdb", + Behavior: &orktypes.PDBBehavior{ + Profile: "relaxed", + MaxUnavailable: "1", + }, + }) + err := k.validatePDBBehaviorProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "minAvailable/maxUnavailable") +} + +func TestValidatePDBBehaviorProfiles_TemplateExprSkipped(t *testing.T) { + k := katalogWithPDB("app", orktypes.PDBTemplateSource{ + Behavior: &orktypes.PDBBehavior{Profile: "{{ .Spec.PDBProfile }}"}, + }) + assert.NoError(t, k.validatePDBBehaviorProfiles()) +} diff --git a/pkg/katalog/validate_resourcequota_profile.go b/pkg/katalog/validate_resourcequota_profile.go new file mode 100644 index 00000000..63b801f4 --- /dev/null +++ b/pkg/katalog/validate_resourcequota_profile.go @@ -0,0 +1,50 @@ +// ResourceQuota Profile Validation +// +// ResourceQuota profiles are named presets that expand into a complete set of +// hard resource limits at reconcile time. +// +// Validation enforces: +// +// 1. Known profile names: +// Allowed: small, medium, large, xlarge. +// +// 2. Profile-only usage: +// profile cannot appear alongside an explicit hard map — profiles are +// atomic presets. +// +// 3. Template expressions: +// Profile values containing "{{" are skipped at load time and validated +// at reconcile time instead. + +package katalog + +import ( + "fmt" + + "github.com/orkspace/orkestra/pkg/profiles" +) + +func (k *Katalog) validateResourceQuotaProfiles() error { + for crdName, crd := range k.enabledCRDs { + for _, e := range crd.CollectResourceQuotaProfileEntries() { + if isTemplateExpr(e.Profile) { + continue + } + if !profiles.IsValidResourceQuotaProfile(e.Profile) { + return fmt.Errorf( + "crd %q: resourceQuota %q (phase %s) has unknown profile %q — "+ + "allowed: small, medium, large, xlarge", + crdName, e.ResourceName, e.Phase, e.Profile, + ) + } + if e.Mixed { + return fmt.Errorf( + "crd %q: resourceQuota %q (phase %s) declares both profile (%q) and "+ + "explicit hard limits — use one or the other, not both", + crdName, e.ResourceName, e.Phase, e.Profile, + ) + } + } + } + return nil +} diff --git a/pkg/katalog/validate_resourcequota_profile_test.go b/pkg/katalog/validate_resourcequota_profile_test.go new file mode 100644 index 00000000..04003509 --- /dev/null +++ b/pkg/katalog/validate_resourcequota_profile_test.go @@ -0,0 +1,102 @@ +package katalog + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func katalogWithResourceQuota(crdName string, rqs ...orktypes.ResourceQuotaTemplateSource) *Katalog { + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + crdName: { + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + ResourceQuotas: rqs, + }, + }, + }, + }, + } +} + +func TestValidateResourceQuotaProfiles_NoCRDs(t *testing.T) { + k := &Katalog{enabledCRDs: map[string]orktypes.CRDEntry{}} + assert.NoError(t, k.validateResourceQuotaProfiles()) +} + +func TestValidateResourceQuotaProfiles_NoProfile(t *testing.T) { + k := katalogWithResourceQuota("app", orktypes.ResourceQuotaTemplateSource{Name: "quota"}) + assert.NoError(t, k.validateResourceQuotaProfiles()) +} + +func TestValidateResourceQuotaProfiles_ValidProfile_Small(t *testing.T) { + k := katalogWithResourceQuota("app", orktypes.ResourceQuotaTemplateSource{ + Name: "quota", + Profile: "small", + }) + assert.NoError(t, k.validateResourceQuotaProfiles()) +} + +func TestValidateResourceQuotaProfiles_ValidProfile_Medium(t *testing.T) { + k := katalogWithResourceQuota("app", orktypes.ResourceQuotaTemplateSource{ + Profile: "medium", + }) + assert.NoError(t, k.validateResourceQuotaProfiles()) +} + +func TestValidateResourceQuotaProfiles_ValidProfile_Large(t *testing.T) { + k := katalogWithResourceQuota("app", orktypes.ResourceQuotaTemplateSource{ + Profile: "large", + }) + assert.NoError(t, k.validateResourceQuotaProfiles()) +} + +func TestValidateResourceQuotaProfiles_ValidProfile_XLarge(t *testing.T) { + k := katalogWithResourceQuota("app", orktypes.ResourceQuotaTemplateSource{ + Profile: "xlarge", + }) + assert.NoError(t, k.validateResourceQuotaProfiles()) +} + +func TestValidateResourceQuotaProfiles_UnknownProfile(t *testing.T) { + k := katalogWithResourceQuota("app", orktypes.ResourceQuotaTemplateSource{ + Name: "quota", + Profile: "massive", + }) + err := k.validateResourceQuotaProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown profile") + assert.Contains(t, err.Error(), "massive") + assert.Contains(t, err.Error(), "small, medium, large, xlarge") +} + +func TestValidateResourceQuotaProfiles_MixedWithHard(t *testing.T) { + k := katalogWithResourceQuota("app", orktypes.ResourceQuotaTemplateSource{ + Name: "quota", + Profile: "small", + Hard: map[string]string{"pods": "5"}, + }) + err := k.validateResourceQuotaProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "explicit hard limits") +} + +func TestValidateResourceQuotaProfiles_TemplateExprSkipped(t *testing.T) { + k := katalogWithResourceQuota("app", orktypes.ResourceQuotaTemplateSource{ + Profile: "{{ .Spec.QuotaProfile }}", + }) + assert.NoError(t, k.validateResourceQuotaProfiles()) +} + +func TestValidateResourceQuotaProfiles_MultipleEntries_OneInvalid(t *testing.T) { + k := katalogWithResourceQuota("app", + orktypes.ResourceQuotaTemplateSource{Name: "q1", Profile: "small"}, + orktypes.ResourceQuotaTemplateSource{Name: "q2", Profile: "unknown-tier"}, + ) + err := k.validateResourceQuotaProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown-tier") +} diff --git a/pkg/katalog/validate_rolling_update_profile_test.go b/pkg/katalog/validate_rolling_update_profile_test.go new file mode 100644 index 00000000..38065ad8 --- /dev/null +++ b/pkg/katalog/validate_rolling_update_profile_test.go @@ -0,0 +1,116 @@ +package katalog + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func katalogWithDeployment(crdName string, deps ...orktypes.DeploymentTemplateSource) *Katalog { + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + crdName: { + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + Deployments: deps, + }, + }, + }, + }, + } +} + +func TestValidateRollingUpdateProfiles_NoCRDs(t *testing.T) { + k := &Katalog{enabledCRDs: map[string]orktypes.CRDEntry{}} + assert.NoError(t, k.validateRollingUpdateProfiles()) +} + +func TestValidateRollingUpdateProfiles_NoProfile(t *testing.T) { + k := katalogWithDeployment("app", orktypes.DeploymentTemplateSource{Name: "deploy"}) + assert.NoError(t, k.validateRollingUpdateProfiles()) +} + +func TestValidateRollingUpdateProfiles_ValidProfile_Safe(t *testing.T) { + k := katalogWithDeployment("app", orktypes.DeploymentTemplateSource{ + Name: "deploy", + RollingUpdate: &orktypes.RollingUpdateBehavior{Profile: "safe"}, + }) + assert.NoError(t, k.validateRollingUpdateProfiles()) +} + +func TestValidateRollingUpdateProfiles_ValidProfile_Fast(t *testing.T) { + k := katalogWithDeployment("app", orktypes.DeploymentTemplateSource{ + RollingUpdate: &orktypes.RollingUpdateBehavior{Profile: "fast"}, + }) + assert.NoError(t, k.validateRollingUpdateProfiles()) +} + +func TestValidateRollingUpdateProfiles_ValidProfile_BlueGreen(t *testing.T) { + k := katalogWithDeployment("app", orktypes.DeploymentTemplateSource{ + RollingUpdate: &orktypes.RollingUpdateBehavior{Profile: "blue-green"}, + }) + assert.NoError(t, k.validateRollingUpdateProfiles()) +} + +func TestValidateRollingUpdateProfiles_UnknownProfile(t *testing.T) { + k := katalogWithDeployment("app", orktypes.DeploymentTemplateSource{ + Name: "deploy", + RollingUpdate: &orktypes.RollingUpdateBehavior{Profile: "canary"}, + }) + err := k.validateRollingUpdateProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown") + assert.Contains(t, err.Error(), "canary") +} + +func TestValidateRollingUpdateProfiles_MixedWithMaxSurge(t *testing.T) { + k := katalogWithDeployment("app", orktypes.DeploymentTemplateSource{ + Name: "deploy", + RollingUpdate: &orktypes.RollingUpdateBehavior{ + Profile: "safe", + MaxSurge: "1", + }, + }) + err := k.validateRollingUpdateProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "maxSurge/maxUnavailable") +} + +func TestValidateRollingUpdateProfiles_MixedWithMaxUnavailable(t *testing.T) { + k := katalogWithDeployment("app", orktypes.DeploymentTemplateSource{ + Name: "deploy", + RollingUpdate: &orktypes.RollingUpdateBehavior{ + Profile: "fast", + MaxUnavailable: "0", + }, + }) + err := k.validateRollingUpdateProfiles() + require.Error(t, err) + assert.Contains(t, err.Error(), "maxSurge/maxUnavailable") +} + +func TestValidateRollingUpdateProfiles_TemplateExprSkipped(t *testing.T) { + k := katalogWithDeployment("app", orktypes.DeploymentTemplateSource{ + RollingUpdate: &orktypes.RollingUpdateBehavior{Profile: "{{ .Spec.RollingProfile }}"}, + }) + assert.NoError(t, k.validateRollingUpdateProfiles()) +} + +func TestValidateRollingUpdateProfiles_StatefulSet(t *testing.T) { + k := &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + "app": { + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + StatefulSets: []orktypes.StatefulSetTemplateSource{ + {Name: "db", RollingUpdate: &orktypes.RollingUpdateBehavior{Profile: "safe"}}, + }, + }, + }, + }, + }, + } + assert.NoError(t, k.validateRollingUpdateProfiles()) +} diff --git a/pkg/profiles/networkpolicy.go b/pkg/profiles/networkpolicy.go new file mode 100644 index 00000000..59c4c419 --- /dev/null +++ b/pkg/profiles/networkpolicy.go @@ -0,0 +1,93 @@ +package profiles + +import ( + "fmt" + "strings" + + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// NetworkPolicyProfile is a named NetworkPolicy preset. +type NetworkPolicyProfile string + +const ( + NetworkPolicyDenyAll NetworkPolicyProfile = "deny-all" + NetworkPolicyDenyAllIngress NetworkPolicyProfile = "deny-all-ingress" + NetworkPolicyDenyAllEgress NetworkPolicyProfile = "deny-all-egress" + NetworkPolicyAllowSameNS NetworkPolicyProfile = "allow-same-namespace" + NetworkPolicyAllowDNSEgress NetworkPolicyProfile = "allow-dns-egress" +) + +// NetworkPolicyExpansion is a fully expanded NetworkPolicy spec. +type NetworkPolicyExpansion struct { + Ingress []orktypes.NetworkPolicyIngressRule + Egress []orktypes.NetworkPolicyEgressRule + PolicyTypes []string +} + +// ApplyNetworkPolicyProfile expands a named profile into ingress/egress rules and policy types. +// Returns an error for unknown profile names. +func ApplyNetworkPolicyProfile(name string) (*NetworkPolicyExpansion, error) { + switch NetworkPolicyProfile(strings.ToLower(name)) { + case NetworkPolicyDenyAll: + // Selects all pods; empty ingress and egress slices block all traffic. + return &NetworkPolicyExpansion{ + Ingress: []orktypes.NetworkPolicyIngressRule{}, + Egress: []orktypes.NetworkPolicyEgressRule{}, + PolicyTypes: []string{"Ingress", "Egress"}, + }, nil + + case NetworkPolicyDenyAllIngress: + return &NetworkPolicyExpansion{ + Ingress: []orktypes.NetworkPolicyIngressRule{}, + PolicyTypes: []string{"Ingress"}, + }, nil + + case NetworkPolicyDenyAllEgress: + return &NetworkPolicyExpansion{ + Egress: []orktypes.NetworkPolicyEgressRule{}, + PolicyTypes: []string{"Egress"}, + }, nil + + case NetworkPolicyAllowSameNS: + // Allow ingress from any pod in the same namespace. + return &NetworkPolicyExpansion{ + Ingress: []orktypes.NetworkPolicyIngressRule{ + { + From: []orktypes.NetworkPolicyPeer{ + {PodSelector: map[string]string{}}, + }, + }, + }, + PolicyTypes: []string{"Ingress"}, + }, nil + + case NetworkPolicyAllowDNSEgress: + // Allow egress on UDP/TCP port 53 to any destination (DNS resolution). + return &NetworkPolicyExpansion{ + Egress: []orktypes.NetworkPolicyEgressRule{ + { + Ports: []orktypes.NetworkPolicyPort{ + {Protocol: "UDP", Port: "53"}, + {Protocol: "TCP", Port: "53"}, + }, + }, + }, + PolicyTypes: []string{"Egress"}, + }, nil + + default: + return nil, fmt.Errorf("unknown networkpolicy profile: %q — allowed: deny-all, deny-all-ingress, deny-all-egress, allow-same-namespace, allow-dns-egress", name) + } +} + +// IsValidNetworkPolicyProfile reports whether name is a recognized NetworkPolicy profile. +func IsValidNetworkPolicyProfile(name string) bool { + switch NetworkPolicyProfile(strings.ToLower(name)) { + case NetworkPolicyDenyAll, NetworkPolicyDenyAllIngress, NetworkPolicyDenyAllEgress, + NetworkPolicyAllowSameNS, NetworkPolicyAllowDNSEgress: + return true + default: + return false + } +} diff --git a/pkg/profiles/resourcequota.go b/pkg/profiles/resourcequota.go new file mode 100644 index 00000000..9fb73094 --- /dev/null +++ b/pkg/profiles/resourcequota.go @@ -0,0 +1,80 @@ +package profiles + +import ( + "fmt" + "strings" +) + +// ResourceQuotaProfile is a named namespace resource quota preset. +type ResourceQuotaProfile string + +const ( + QuotaSmall ResourceQuotaProfile = "small" + QuotaMedium ResourceQuotaProfile = "medium" + QuotaLarge ResourceQuotaProfile = "large" + QuotaXLarge ResourceQuotaProfile = "xlarge" +) + +// ResourceQuotaLimits is a fully expanded set of hard limits. +type ResourceQuotaLimits struct { + Hard map[string]string +} + +// ApplyResourceQuotaProfile expands a named quota profile into a hard limits map. +// Returns an error for unknown profile names. +func ApplyResourceQuotaProfile(name string) (*ResourceQuotaLimits, error) { + switch ResourceQuotaProfile(strings.ToLower(name)) { + case QuotaSmall: + return &ResourceQuotaLimits{Hard: map[string]string{ + "pods": "10", + "cpu": "2", + "memory": "4Gi", + "requests.cpu": "1", + "requests.memory": "2Gi", + "limits.cpu": "2", + "limits.memory": "4Gi", + }}, nil + case QuotaMedium: + return &ResourceQuotaLimits{Hard: map[string]string{ + "pods": "20", + "cpu": "4", + "memory": "8Gi", + "requests.cpu": "2", + "requests.memory": "4Gi", + "limits.cpu": "4", + "limits.memory": "8Gi", + }}, nil + case QuotaLarge: + return &ResourceQuotaLimits{Hard: map[string]string{ + "pods": "50", + "cpu": "8", + "memory": "16Gi", + "requests.cpu": "4", + "requests.memory": "8Gi", + "limits.cpu": "8", + "limits.memory": "16Gi", + }}, nil + case QuotaXLarge: + return &ResourceQuotaLimits{Hard: map[string]string{ + "pods": "100", + "cpu": "16", + "memory": "32Gi", + "requests.cpu": "8", + "requests.memory": "16Gi", + "limits.cpu": "16", + "limits.memory": "32Gi", + }}, nil + default: + return nil, fmt.Errorf("unknown resourcequota profile: %q — allowed: small, medium, large, xlarge", name) + } +} + +// IsValidResourceQuotaProfile reports whether name is a recognized quota profile. +func IsValidResourceQuotaProfile(name string) bool { + switch ResourceQuotaProfile(strings.ToLower(name)) { + case QuotaSmall, QuotaMedium, QuotaLarge, QuotaXLarge: + return true + default: + return false + } +} diff --git a/pkg/reconciler/run_template_reconcile.go b/pkg/reconciler/run_template_reconcile.go index a6391c04..4af02014 100644 --- a/pkg/reconciler/run_template_reconcile.go +++ b/pkg/reconciler/run_template_reconcile.go @@ -159,6 +159,26 @@ func (r *GenericReconciler[PTR]) runResourceGroup( children.ExpandForEachConfigMaps(resolver, t.ConfigMaps), update, guard); err != nil { return err } + if err := runners.RunNetworkPolicies(ctx, kube, resolver, obj, + t.NetworkPolicies, update, guard); err != nil { + return err + } + if err := runners.RunResourceQuotas(ctx, kube, resolver, obj, + t.ResourceQuotas, update, guard); err != nil { + return err + } + if err := runners.RunLimitRanges(ctx, kube, resolver, obj, + t.LimitRanges, update, guard); err != nil { + return err + } + if err := runners.RunClusterRoles(ctx, kube, resolver, obj, + t.ClusterRoles, update); err != nil { + return err + } + if err := runners.RunClusterRoleBindings(ctx, kube, resolver, obj, + t.ClusterRoleBindings, update); err != nil { + return err + } if err := runners.RunServiceAccounts(ctx, kube, resolver, obj, children.ExpandForEachServiceAccounts(resolver, t.ServiceAccounts), update, guard); err != nil { return err diff --git a/pkg/resources/clusterrolebindings/clusterrolebinding.go b/pkg/resources/clusterrolebindings/clusterrolebinding.go new file mode 100644 index 00000000..8b61e5f8 --- /dev/null +++ b/pkg/resources/clusterrolebindings/clusterrolebinding.go @@ -0,0 +1,213 @@ +// pkg/resources/clusterrolebindings/clusterrolebinding.go +package clusterrolebindings + +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/resources/common" + orktypes "github.com/orkspace/orkestra/pkg/types" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ResolvedClusterRoleBindingSpec is the fully resolved ClusterRoleBinding specification. +type ResolvedClusterRoleBindingSpec struct { + Name string + Labels map[string]string + RoleRef rbacv1.RoleRef + Subjects []rbacv1.Subject + Sleep string +} + +// Create creates a ClusterRoleBinding if it does not already exist. +// Idempotent — skips if the ClusterRoleBinding already exists. +// ClusterRoleBindings are cluster-scoped; ownership is tracked via the orkestra.io/owner label. +func Create(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedClusterRoleBindingSpec) error { + if err := validateSpec(spec); err != nil { + return fmt.Errorf("clusterrolebinding.Create: invalid spec: %w", err) + } + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + _, err := kube.Clientset().RbacV1().ClusterRoleBindings().Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("clusterrolebinding.Create: checking existence of %q: %w", spec.Name, err) + } + if err == nil { + logger.Debug(). + Str("clusterrolebinding", spec.Name). + Msg("clusterrolebinding already exists — skipping create") + return nil + } + + crb := buildClusterRoleBinding(spec) + + _, err = kube.Clientset().RbacV1().ClusterRoleBindings().Create(ctx, crb, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("clusterrolebinding.Create: creating %q: %w", spec.Name, err) + } + + logger.Info(). + Str("clusterrolebinding", spec.Name). + Str("owner", owner.GetName()). + Msg("clusterrolebinding created") + + return nil +} + +// Update applies the desired subjects to an existing ClusterRoleBinding. +// RoleRef is immutable in Kubernetes — if it changed the binding is deleted and recreated. +// If it does not exist, creates it. +func Update(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedClusterRoleBindingSpec) error { + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + existing, err := kube.Clientset().RbacV1().ClusterRoleBindings().Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return Create(ctx, kube, owner, spec) + } + return fmt.Errorf("clusterrolebinding.Update: getting %q: %w", spec.Name, err) + } + + // roleRef is immutable — recreate if it changed + if existing.RoleRef.Name != spec.RoleRef.Name || existing.RoleRef.Kind != spec.RoleRef.Kind { + if delErr := kube.Clientset().RbacV1().ClusterRoleBindings().Delete(ctx, spec.Name, metav1.DeleteOptions{}); delErr != nil && !errors.IsNotFound(delErr) { + return fmt.Errorf("clusterrolebinding.Update: deleting stale binding %q: %w", spec.Name, delErr) + } + return Create(ctx, kube, owner, spec) + } + + if reflect.DeepEqual(existing.Subjects, spec.Subjects) && reflect.DeepEqual(existing.Labels, spec.Labels) { + logger.Debug(). + Str("clusterrolebinding", spec.Name). + Msg("clusterrolebinding in sync — no update needed") + return nil + } + + existing.Subjects = spec.Subjects + existing.Labels = spec.Labels + + _, err = kube.Clientset().RbacV1().ClusterRoleBindings().Update(ctx, existing, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("clusterrolebinding.Update: updating %q: %w", spec.Name, err) + } + + logger.Info(). + Str("clusterrolebinding", spec.Name). + Str("owner", owner.GetName()). + Msg("clusterrolebinding updated") + + return nil +} + +// Delete deletes the ClusterRoleBinding if it exists. +func Delete(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedClusterRoleBindingSpec) error { + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + err := kube.Clientset().RbacV1().ClusterRoleBindings().Delete(ctx, spec.Name, metav1.DeleteOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("clusterrolebinding.Delete: deleting %q: %w", spec.Name, err) + } + + logger.Info(). + Str("clusterrolebinding", spec.Name). + Str("owner", owner.GetName()). + Msg("clusterrolebinding deleted") + + return nil +} + +// DeleteIfOwned deletes the ClusterRoleBinding only if it is owned by the CR. +func DeleteIfOwned(ctx context.Context, kube kubeclient.KubeClient, + owner domain.Object, name string) error { + + existing, err := kube.Clientset().RbacV1().ClusterRoleBindings().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + if existing.Labels[labels.OrkestraOwner] != owner.GetName() { + return nil + } + return kube.Clientset().RbacV1().ClusterRoleBindings().Delete(ctx, name, metav1.DeleteOptions{}) +} + +// Resolve builds a ResolvedClusterRoleBindingSpec from a ClusterRoleBindingTemplateSource. +// Template expressions must already be evaluated by template.Resolver before calling. +func Resolve(src orktypes.ClusterRoleBindingTemplateSource, ownerName string) ResolvedClusterRoleBindingSpec { + spec := ResolvedClusterRoleBindingSpec{ + Name: src.Name, + Labels: make(map[string]string), + Sleep: src.Sleep, + } + + if spec.Name == "" { + spec.Name = ownerName + "-crb" + } + + for _, l := range src.Labels { + spec.Labels[l.Key] = l.Value + } + spec.Labels[labels.ManagedKey] = labels.ManagedValue + spec.Labels[labels.OrkestraOwner] = ownerName + + kind := src.RoleRef.Kind + if kind == "" { + kind = "ClusterRole" + } + spec.RoleRef = rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: kind, + Name: src.RoleRef.Name, + } + + for _, s := range src.Subjects { + spec.Subjects = append(spec.Subjects, rbacv1.Subject{ + Kind: s.Kind, + Name: s.Name, + Namespace: s.Namespace, + }) + } + + return spec +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +func buildClusterRoleBinding(spec ResolvedClusterRoleBindingSpec) *rbacv1.ClusterRoleBinding { + return &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.Name, + Labels: spec.Labels, + // No OwnerReference: ClusterRoleBindings are cluster-scoped; a namespace-scoped CR + // cannot own a cluster-scoped resource. Ownership is tracked via the + // OrkestraOwner label; cleanup is performed explicitly via DeleteIfOwned. + }, + RoleRef: spec.RoleRef, + Subjects: spec.Subjects, + } +} + +func validateSpec(spec ResolvedClusterRoleBindingSpec) error { + if spec.Name == "" { + return fmt.Errorf("name is required") + } + return nil +} diff --git a/pkg/resources/clusterroles/clusterrole.go b/pkg/resources/clusterroles/clusterrole.go new file mode 100644 index 00000000..384a0673 --- /dev/null +++ b/pkg/resources/clusterroles/clusterrole.go @@ -0,0 +1,193 @@ +// pkg/resources/clusterroles/clusterrole.go +package clusterroles + +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/resources/common" + orktypes "github.com/orkspace/orkestra/pkg/types" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ResolvedClusterRoleSpec is the fully resolved ClusterRole specification. +type ResolvedClusterRoleSpec struct { + Name string + Labels map[string]string + Rules []rbacv1.PolicyRule + Sleep string +} + +// Create creates a ClusterRole if it does not already exist. +// Idempotent — skips if the ClusterRole already exists. +// ClusterRoles are cluster-scoped; ownership is tracked via the orkestra.io/owner label. +func Create(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedClusterRoleSpec) error { + if err := validateSpec(spec); err != nil { + return fmt.Errorf("clusterrole.Create: invalid spec: %w", err) + } + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + _, err := kube.Clientset().RbacV1().ClusterRoles().Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("clusterrole.Create: checking existence of %q: %w", spec.Name, err) + } + if err == nil { + logger.Debug(). + Str("clusterrole", spec.Name). + Msg("clusterrole already exists — skipping create") + return nil + } + + cr := buildClusterRole(spec) + + _, err = kube.Clientset().RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("clusterrole.Create: creating %q: %w", spec.Name, err) + } + + logger.Info(). + Str("clusterrole", spec.Name). + Str("owner", owner.GetName()). + Msg("clusterrole created") + + return nil +} + +// Update applies the desired rules to an existing ClusterRole. +// If it does not exist, creates it. +func Update(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedClusterRoleSpec) error { + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + existing, err := kube.Clientset().RbacV1().ClusterRoles().Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return Create(ctx, kube, owner, spec) + } + return fmt.Errorf("clusterrole.Update: getting %q: %w", spec.Name, err) + } + + if reflect.DeepEqual(existing.Rules, spec.Rules) && reflect.DeepEqual(existing.Labels, spec.Labels) { + logger.Debug(). + Str("clusterrole", spec.Name). + Msg("clusterrole in sync — no update needed") + return nil + } + + existing.Rules = spec.Rules + existing.Labels = spec.Labels + + _, err = kube.Clientset().RbacV1().ClusterRoles().Update(ctx, existing, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("clusterrole.Update: updating %q: %w", spec.Name, err) + } + + logger.Info(). + Str("clusterrole", spec.Name). + Str("owner", owner.GetName()). + Msg("clusterrole updated") + + return nil +} + +// Delete deletes the ClusterRole if it exists. +func Delete(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedClusterRoleSpec) error { + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + err := kube.Clientset().RbacV1().ClusterRoles().Delete(ctx, spec.Name, metav1.DeleteOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("clusterrole.Delete: deleting %q: %w", spec.Name, err) + } + + logger.Info(). + Str("clusterrole", spec.Name). + Str("owner", owner.GetName()). + Msg("clusterrole deleted") + + return nil +} + +// DeleteIfOwned deletes the ClusterRole only if it is owned by the CR. +func DeleteIfOwned(ctx context.Context, kube kubeclient.KubeClient, + owner domain.Object, name string) error { + + existing, err := kube.Clientset().RbacV1().ClusterRoles().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + if existing.Labels[labels.OrkestraOwner] != owner.GetName() { + return nil + } + return kube.Clientset().RbacV1().ClusterRoles().Delete(ctx, name, metav1.DeleteOptions{}) +} + +// Resolve builds a ResolvedClusterRoleSpec from a ClusterRoleTemplateSource. +// Template expressions must already be evaluated by template.Resolver before calling. +func Resolve(src orktypes.ClusterRoleTemplateSource, ownerName string) ResolvedClusterRoleSpec { + spec := ResolvedClusterRoleSpec{ + Name: src.Name, + Labels: make(map[string]string), + Sleep: src.Sleep, + } + + if spec.Name == "" { + spec.Name = ownerName + "-cluster-role" + } + + for _, l := range src.Labels { + spec.Labels[l.Key] = l.Value + } + spec.Labels[labels.ManagedKey] = labels.ManagedValue + spec.Labels[labels.OrkestraOwner] = ownerName + + for _, r := range src.Rules { + spec.Rules = append(spec.Rules, rbacv1.PolicyRule{ + APIGroups: r.APIGroups, + Resources: r.Resources, + Verbs: r.Verbs, + ResourceNames: r.ResourceNames, + }) + } + + return spec +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +func buildClusterRole(spec ResolvedClusterRoleSpec) *rbacv1.ClusterRole { + return &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.Name, + Labels: spec.Labels, + // No OwnerReference: ClusterRoles are cluster-scoped; a namespace-scoped CR + // cannot own a cluster-scoped resource. Ownership is tracked via the + // OrkestraOwner label; cleanup is performed explicitly via DeleteIfOwned. + }, + Rules: spec.Rules, + } +} + +func validateSpec(spec ResolvedClusterRoleSpec) error { + if spec.Name == "" { + return fmt.Errorf("name is required") + } + return nil +} diff --git a/pkg/resources/limitranges/limitrange.go b/pkg/resources/limitranges/limitrange.go new file mode 100644 index 00000000..6eabaeda --- /dev/null +++ b/pkg/resources/limitranges/limitrange.go @@ -0,0 +1,353 @@ +// pkg/resources/limitranges/limitrange.go +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/resources/common" + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/orkspace/orkestra/pkg/utils" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ResolvedLimitRangeSpec is the fully resolved LimitRange specification. +type ResolvedLimitRangeSpec struct { + Name string + Namespace string + Limits []orktypes.LimitRangeItem + FromLimitRange string + FromNamespace string + Labels map[string]string + Sleep string +} + +// Create creates a LimitRange if it does not already exist. +// Idempotent — skips if it already exists. +// Owner reference set for cascade deletion. +func Create(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedLimitRangeSpec) error { + namespace := common.ResolveNamespace(owner, spec.Namespace) + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + _, err := kube.Clientset().CoreV1().LimitRanges(namespace).Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("limitrange.Create: checking existence of %q: %w", spec.Name, err) + } + if err == nil { + logger.Debug(). + Str("limitrange", spec.Name). + Str("namespace", namespace). + Msg("limitrange already exists — skipping create") + return nil + } + + limits, err := resolveLimits(ctx, kube, spec, owner) + if err != nil { + return fmt.Errorf("limitrange.Create: resolving limits: %w", err) + } + + lr := buildLimitRange(owner, spec, namespace, limits) + + _, err = kube.Clientset().CoreV1().LimitRanges(namespace).Create(ctx, lr, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("limitrange.Create: creating %q in %q: %w", spec.Name, namespace, err) + } + + logger.Info(). + Str("limitrange", spec.Name). + Str("namespace", namespace). + Str("owner", owner.GetName()). + Msg("limitrange created") + + return nil +} + +// Update reconciles an existing LimitRange to match the resolved spec. +// If it does not exist, creates it. +func Update(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedLimitRangeSpec) error { + namespace := common.ResolveNamespace(owner, spec.Namespace) + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + existing, err := kube.Clientset().CoreV1().LimitRanges(namespace).Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + logger.Info(). + Str("limitrange", spec.Name). + Str("namespace", namespace). + Msg("limitrange not found during reconcile — recreating") + return Create(ctx, kube, owner, spec) + } + return fmt.Errorf("limitrange.Update: getting %q: %w", spec.Name, err) + } + + limits, err := resolveLimits(ctx, kube, spec, owner) + if err != nil { + return fmt.Errorf("limitrange.Update: resolving limits: %w", err) + } + + desired := buildLimitRangeItems(limits) + if reflect.DeepEqual(existing.Spec.Limits, desired) { + logger.Debug(). + Str("limitrange", spec.Name). + Str("namespace", namespace). + Msg("limitrange in sync — no update needed") + return nil + } + + updated := existing.DeepCopy() + updated.Spec.Limits = desired + + _, err = kube.Clientset().CoreV1().LimitRanges(namespace).Update(ctx, updated, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("limitrange.Update: updating %q: %w", spec.Name, err) + } + + logger.Info(). + Str("limitrange", spec.Name). + Str("namespace", namespace). + Msg("limitrange updated") + + return nil +} + +// Delete deletes the LimitRange if it exists. +func Delete(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedLimitRangeSpec) error { + namespace := common.ResolveNamespace(owner, spec.Namespace) + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + err := kube.Clientset().CoreV1().LimitRanges(namespace).Delete(ctx, spec.Name, metav1.DeleteOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("limitrange.Delete: deleting %q in %q: %w", spec.Name, namespace, err) + } + + logger.Info(). + Str("limitrange", spec.Name). + Str("namespace", namespace). + Str("owner", owner.GetName()). + Msg("limitrange deleted") + + return nil +} + +// DeleteIfOwned deletes the LimitRange only if it is owned by the CR. +func DeleteIfOwned(ctx context.Context, kube kubeclient.KubeClient, + owner domain.Object, name, namespace string) error { + + existing, err := kube.Clientset().CoreV1().LimitRanges(namespace). + Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + if existing.Labels[labels.OrkestraOwner] != owner.GetName() { + return nil + } + return kube.Clientset().CoreV1().LimitRanges(namespace). + Delete(ctx, name, metav1.DeleteOptions{}) +} + +// CopyToNamespaces copies a LimitRange to multiple target namespaces. +// Reads the source once and creates copies in each namespace. +// Idempotent — skips namespaces where the LimitRange already exists. +func CopyToNamespaces( + ctx context.Context, + kube kubeclient.KubeClient, + owner domain.Object, + spec ResolvedLimitRangeSpec, + toNamespaces []string, +) error { + limits, err := resolveLimits(ctx, kube, spec, owner) + if err != nil { + return fmt.Errorf("limitrange.CopyToNamespaces: reading source: %w", err) + } + + for _, ns := range toNamespaces { + if ns == "" { + continue + } + + _, err := kube.Clientset().CoreV1().LimitRanges(ns).Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("limitrange.CopyToNamespaces: checking %q in %q: %w", spec.Name, ns, err) + } + if err == nil { + logger.Debug(). + Str("limitrange", spec.Name). + Str("namespace", ns). + Msg("limitrange already exists in namespace — skipping") + continue + } + + nsSpec := spec + nsSpec.Namespace = ns + lr := buildLimitRange(owner, nsSpec, ns, limits) + + _, err = kube.Clientset().CoreV1().LimitRanges(ns).Create(ctx, lr, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("limitrange.CopyToNamespaces: creating %q in %q: %w", spec.Name, ns, err) + } + + logger.Info(). + Str("limitrange", spec.Name). + Str("namespace", ns). + Str("owner", owner.GetName()). + Msg("limitrange copied to namespace") + } + + return nil +} + +// 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 { + spec := ResolvedLimitRangeSpec{ + Name: src.Name, + Namespace: src.Namespace, + Limits: src.Limits, + FromLimitRange: src.FromLimitRange, + FromNamespace: src.FromNamespace, + Labels: make(map[string]string), + Sleep: src.Sleep, + } + + for _, l := range src.Labels { + spec.Labels[l.Key] = l.Value + } + spec.Labels[labels.ManagedKey] = labels.ManagedValue + spec.Labels[labels.OrkestraOwner] = ownerName + + return spec +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +// resolveLimits returns the LimitRangeItems to apply. +// When FromLimitRange is set, copies limits from the source. +// Otherwise uses the declared Limits slice. +func resolveLimits( + ctx context.Context, + kube kubeclient.KubeClient, + spec ResolvedLimitRangeSpec, + owner domain.Object, +) ([]orktypes.LimitRangeItem, error) { + if spec.FromLimitRange == "" { + return spec.Limits, nil + } + + fromNS := spec.FromNamespace + if fromNS == "" { + fromNS = owner.GetNamespace() + } + if fromNS == "" { + fromNS = "default" + } + + source, err := kube.Clientset().CoreV1().LimitRanges(fromNS). + Get(ctx, spec.FromLimitRange, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("reading source limitrange %q from %q: %w", + spec.FromLimitRange, fromNS, err) + } + + items := make([]orktypes.LimitRangeItem, 0, len(source.Spec.Limits)) + for _, item := range source.Spec.Limits { + items = append(items, orktypes.LimitRangeItem{ + Type: string(item.Type), + Max: resourceListToMap(item.Max), + Min: resourceListToMap(item.Min), + Default: resourceListToMap(item.Default), + DefaultRequest: resourceListToMap(item.DefaultRequest), + MaxLimitRequestRatio: resourceListToMap(item.MaxLimitRequestRatio), + }) + } + return items, nil +} + +func buildLimitRange( + owner domain.Object, + spec ResolvedLimitRangeSpec, + namespace string, + limits []orktypes.LimitRangeItem, +) *corev1.LimitRange { + return &corev1.LimitRange{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.Name, + Namespace: namespace, + Labels: spec.Labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: owner.GetObjectKind().GroupVersionKind().GroupVersion().String(), + Kind: owner.GetObjectKind().GroupVersionKind().Kind, + Name: owner.GetName(), + UID: owner.GetUID(), + Controller: utils.BoolPtr(true), + BlockOwnerDeletion: utils.BoolPtr(true), + }, + }, + }, + Spec: corev1.LimitRangeSpec{ + Limits: buildLimitRangeItems(limits), + }, + } +} + +func buildLimitRangeItems(items []orktypes.LimitRangeItem) []corev1.LimitRangeItem { + out := make([]corev1.LimitRangeItem, 0, len(items)) + for _, item := range items { + lri := corev1.LimitRangeItem{ + Type: corev1.LimitType(item.Type), + Max: mapToResourceList(item.Max), + Min: mapToResourceList(item.Min), + Default: mapToResourceList(item.Default), + DefaultRequest: mapToResourceList(item.DefaultRequest), + MaxLimitRequestRatio: mapToResourceList(item.MaxLimitRequestRatio), + } + out = append(out, lri) + } + return out +} + +func mapToResourceList(m map[string]string) corev1.ResourceList { + if len(m) == 0 { + return nil + } + rl := make(corev1.ResourceList, len(m)) + for k, v := range m { + q, err := resource.ParseQuantity(v) + if err != nil { + continue + } + rl[corev1.ResourceName(k)] = q + } + return rl +} + +func resourceListToMap(rl corev1.ResourceList) map[string]string { + if len(rl) == 0 { + return nil + } + m := make(map[string]string, len(rl)) + for k, v := range rl { + m[string(k)] = v.String() + } + return m +} diff --git a/pkg/resources/networkpolicies/networkpolicy.go b/pkg/resources/networkpolicies/networkpolicy.go new file mode 100644 index 00000000..b4606fdb --- /dev/null +++ b/pkg/resources/networkpolicies/networkpolicy.go @@ -0,0 +1,428 @@ +// pkg/resources/networkpolicies/networkpolicy.go +package networkpolicies + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "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" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// ResolvedNetworkPolicySpec is the fully resolved NetworkPolicy specification. +type ResolvedNetworkPolicySpec struct { + Name string + Namespace string + PodSelector map[string]string + Ingress []orktypes.NetworkPolicyIngressRule + Egress []orktypes.NetworkPolicyEgressRule + PolicyTypes []string + FromNetworkPolicy string + FromNamespace string + Labels map[string]string + Sleep string +} + +// Create creates a NetworkPolicy if it does not already exist. +// Idempotent — skips if it already exists. +// Owner reference set for cascade deletion. +func Create(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedNetworkPolicySpec) error { + namespace := common.ResolveNamespace(owner, spec.Namespace) + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + _, err := kube.Clientset().NetworkingV1().NetworkPolicies(namespace).Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("networkpolicy.Create: checking existence of %q: %w", spec.Name, err) + } + if err == nil { + logger.Debug(). + Str("networkpolicy", spec.Name). + Str("namespace", namespace). + Msg("networkpolicy already exists — skipping create") + return nil + } + + np, err := buildNetworkPolicy(ctx, kube, owner, spec, namespace) + if err != nil { + return fmt.Errorf("networkpolicy.Create: building %q: %w", spec.Name, err) + } + + _, err = kube.Clientset().NetworkingV1().NetworkPolicies(namespace).Create(ctx, np, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("networkpolicy.Create: creating %q in %q: %w", spec.Name, namespace, err) + } + + logger.Info(). + Str("networkpolicy", spec.Name). + Str("namespace", namespace). + Str("owner", owner.GetName()). + Msg("networkpolicy created") + + return nil +} + +// Update reconciles an existing NetworkPolicy to match the resolved spec. +// If it does not exist, creates it. +func Update(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedNetworkPolicySpec) error { + namespace := common.ResolveNamespace(owner, spec.Namespace) + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + existing, err := kube.Clientset().NetworkingV1().NetworkPolicies(namespace).Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + logger.Info(). + Str("networkpolicy", spec.Name). + Str("namespace", namespace). + Msg("networkpolicy not found during reconcile — recreating") + return Create(ctx, kube, owner, spec) + } + return fmt.Errorf("networkpolicy.Update: getting %q: %w", spec.Name, err) + } + + desired, err := buildNetworkPolicy(ctx, kube, owner, spec, namespace) + if err != nil { + return fmt.Errorf("networkpolicy.Update: building %q: %w", spec.Name, err) + } + + if specsEqual(existing.Spec, desired.Spec) { + logger.Debug(). + Str("networkpolicy", spec.Name). + Str("namespace", namespace). + Msg("networkpolicy in sync — no update needed") + return nil + } + + updated := existing.DeepCopy() + updated.Spec = desired.Spec + + _, err = kube.Clientset().NetworkingV1().NetworkPolicies(namespace).Update(ctx, updated, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("networkpolicy.Update: updating %q: %w", spec.Name, err) + } + + logger.Info(). + Str("networkpolicy", spec.Name). + Str("namespace", namespace). + Msg("networkpolicy updated") + + return nil +} + +// Delete deletes the NetworkPolicy if it exists. +func Delete(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedNetworkPolicySpec) error { + namespace := common.ResolveNamespace(owner, spec.Namespace) + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + err := kube.Clientset().NetworkingV1().NetworkPolicies(namespace).Delete(ctx, spec.Name, metav1.DeleteOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("networkpolicy.Delete: deleting %q in %q: %w", spec.Name, namespace, err) + } + + logger.Info(). + Str("networkpolicy", spec.Name). + Str("namespace", namespace). + Str("owner", owner.GetName()). + Msg("networkpolicy deleted") + + return nil +} + +// DeleteIfOwned deletes the NetworkPolicy only if it is owned by the CR. +func DeleteIfOwned(ctx context.Context, kube kubeclient.KubeClient, + owner domain.Object, name, namespace string) error { + + existing, err := kube.Clientset().NetworkingV1().NetworkPolicies(namespace). + Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + if existing.Labels[labels.OrkestraOwner] != owner.GetName() { + return nil + } + return kube.Clientset().NetworkingV1().NetworkPolicies(namespace). + Delete(ctx, name, metav1.DeleteOptions{}) +} + +// CopyToNamespaces copies a NetworkPolicy to multiple target namespaces. +// Reads the source policy once and creates copies in each namespace. +// Idempotent — skips namespaces where the policy already exists. +func CopyToNamespaces( + ctx context.Context, + kube kubeclient.KubeClient, + owner domain.Object, + spec ResolvedNetworkPolicySpec, + toNamespaces []string, +) error { + sourceSpec, err := resolveSpec(ctx, kube, spec, owner) + if err != nil { + return fmt.Errorf("networkpolicy.CopyToNamespaces: reading source: %w", err) + } + + for _, ns := range toNamespaces { + if ns == "" { + continue + } + + _, err := kube.Clientset().NetworkingV1().NetworkPolicies(ns).Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("networkpolicy.CopyToNamespaces: checking %q in %q: %w", spec.Name, ns, err) + } + if err == nil { + logger.Debug(). + Str("networkpolicy", spec.Name). + Str("namespace", ns). + Msg("networkpolicy already exists in namespace — skipping") + continue + } + + np := buildNetworkPolicyFromSpec(owner, spec, ns, sourceSpec) + _, err = kube.Clientset().NetworkingV1().NetworkPolicies(ns).Create(ctx, np, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("networkpolicy.CopyToNamespaces: creating %q in %q: %w", spec.Name, ns, err) + } + + logger.Info(). + Str("networkpolicy", spec.Name). + Str("namespace", ns). + Str("owner", owner.GetName()). + Msg("networkpolicy copied to namespace") + } + + return nil +} + +// 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 { + ingress := src.Ingress + egress := src.Egress + policyTypes := src.PolicyTypes + + if src.Profile != "" { + if expanded, err := profiles.ApplyNetworkPolicyProfile(src.Profile); err != nil { + logger.Warn().Str("profile", src.Profile).Err(err).Msg("unknown networkpolicy profile — skipping") + } else { + ingress = expanded.Ingress + egress = expanded.Egress + policyTypes = expanded.PolicyTypes + } + } + + spec := ResolvedNetworkPolicySpec{ + Name: src.Name, + Namespace: src.Namespace, + PodSelector: src.PodSelector, + Ingress: ingress, + Egress: egress, + PolicyTypes: policyTypes, + FromNetworkPolicy: src.FromNetworkPolicy, + FromNamespace: src.FromNamespace, + Labels: make(map[string]string), + Sleep: src.Sleep, + } + + for _, l := range src.Labels { + spec.Labels[l.Key] = l.Value + } + spec.Labels[labels.ManagedKey] = labels.ManagedValue + spec.Labels[labels.OrkestraOwner] = ownerName + + return spec +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +// resolveSpec returns the k8s NetworkPolicySpec to apply. +// When FromNetworkPolicy is set, copies the source policy's spec. +// Otherwise builds spec from the declared fields. +func resolveSpec( + ctx context.Context, + kube kubeclient.KubeClient, + spec ResolvedNetworkPolicySpec, + owner domain.Object, +) (networkingv1.NetworkPolicySpec, error) { + if spec.FromNetworkPolicy != "" { + fromNS := spec.FromNamespace + if fromNS == "" { + fromNS = owner.GetNamespace() + } + if fromNS == "" { + fromNS = "default" + } + + source, err := kube.Clientset().NetworkingV1().NetworkPolicies(fromNS). + Get(ctx, spec.FromNetworkPolicy, metav1.GetOptions{}) + if err != nil { + return networkingv1.NetworkPolicySpec{}, fmt.Errorf( + "reading source networkpolicy %q from %q: %w", spec.FromNetworkPolicy, fromNS, err) + } + return source.Spec, nil + } + + return buildNetworkPolicySpec(spec), nil +} + +func buildNetworkPolicy( + ctx context.Context, + kube kubeclient.KubeClient, + owner domain.Object, + spec ResolvedNetworkPolicySpec, + namespace string, +) (*networkingv1.NetworkPolicy, error) { + npSpec, err := resolveSpec(ctx, kube, spec, owner) + if err != nil { + return nil, err + } + return buildNetworkPolicyFromSpec(owner, spec, namespace, npSpec), nil +} + +// specsEqual compares two NetworkPolicySpecs using JSON marshaling so that nil +// and empty maps/slices are treated as equal (both serialize to absent with omitempty). +func specsEqual(a, b networkingv1.NetworkPolicySpec) bool { + aj, err := json.Marshal(a) + if err != nil { + return false + } + bj, err := json.Marshal(b) + if err != nil { + return false + } + return string(aj) == string(bj) +} + +func buildNetworkPolicyFromSpec( + owner domain.Object, + spec ResolvedNetworkPolicySpec, + namespace string, + npSpec networkingv1.NetworkPolicySpec, +) *networkingv1.NetworkPolicy { + return &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.Name, + Namespace: namespace, + Labels: spec.Labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: owner.GetObjectKind().GroupVersionKind().GroupVersion().String(), + Kind: owner.GetObjectKind().GroupVersionKind().Kind, + Name: owner.GetName(), + UID: owner.GetUID(), + Controller: utils.BoolPtr(true), + BlockOwnerDeletion: utils.BoolPtr(true), + }, + }, + }, + Spec: npSpec, + } +} + +// buildNetworkPolicySpec translates our declaration types to k8s NetworkPolicySpec. +func buildNetworkPolicySpec(spec ResolvedNetworkPolicySpec) networkingv1.NetworkPolicySpec { + npSpec := networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: spec.PodSelector, + }, + } + + // Ingress rules + for _, r := range spec.Ingress { + rule := networkingv1.NetworkPolicyIngressRule{} + for _, peer := range r.From { + rule.From = append(rule.From, translatePeer(peer)) + } + for _, p := range r.Ports { + rule.Ports = append(rule.Ports, translatePort(p)) + } + npSpec.Ingress = append(npSpec.Ingress, rule) + } + + // Egress rules + for _, r := range spec.Egress { + rule := networkingv1.NetworkPolicyEgressRule{} + for _, peer := range r.To { + rule.To = append(rule.To, translatePeer(peer)) + } + for _, p := range r.Ports { + rule.Ports = append(rule.Ports, translatePort(p)) + } + npSpec.Egress = append(npSpec.Egress, rule) + } + + // PolicyTypes — explicit or auto-derived + if len(spec.PolicyTypes) > 0 { + for _, pt := range spec.PolicyTypes { + npSpec.PolicyTypes = append(npSpec.PolicyTypes, networkingv1.PolicyType(pt)) + } + } else { + if spec.Ingress != nil { + npSpec.PolicyTypes = append(npSpec.PolicyTypes, networkingv1.PolicyTypeIngress) + } + if spec.Egress != nil { + npSpec.PolicyTypes = append(npSpec.PolicyTypes, networkingv1.PolicyTypeEgress) + } + } + + return npSpec +} + +func translatePeer(peer orktypes.NetworkPolicyPeer) networkingv1.NetworkPolicyPeer { + p := networkingv1.NetworkPolicyPeer{} + if len(peer.PodSelector) > 0 || peer.PodSelector != nil { + p.PodSelector = &metav1.LabelSelector{MatchLabels: peer.PodSelector} + } + if len(peer.NamespaceSelector) > 0 { + p.NamespaceSelector = &metav1.LabelSelector{MatchLabels: peer.NamespaceSelector} + } + if peer.IPBlock != nil { + p.IPBlock = &networkingv1.IPBlock{ + CIDR: peer.IPBlock.CIDR, + Except: peer.IPBlock.Except, + } + } + return p +} + +func translatePort(p orktypes.NetworkPolicyPort) networkingv1.NetworkPolicyPort { + np := networkingv1.NetworkPolicyPort{} + if p.Protocol != "" { + proto := corev1.Protocol(p.Protocol) + np.Protocol = &proto + } + if p.Port != "" { + // Kubernetes requires string ports to be named ports (containing at least one letter). + // Numeric port values must be sent as integers. + var port intstr.IntOrString + if n, err := strconv.Atoi(p.Port); err == nil { + port = intstr.FromInt32(int32(n)) + } else { + port = intstr.FromString(p.Port) + } + np.Port = &port + } + return np +} diff --git a/pkg/resources/resourcequotas/resourcequota.go b/pkg/resources/resourcequotas/resourcequota.go new file mode 100644 index 00000000..4e2daac7 --- /dev/null +++ b/pkg/resources/resourcequotas/resourcequota.go @@ -0,0 +1,326 @@ +// pkg/resources/resourcequotas/resourcequota.go +package resourcequotas + +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" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ResolvedResourceQuotaSpec is the fully resolved ResourceQuota specification. +type ResolvedResourceQuotaSpec struct { + Name string + Namespace string + Hard map[string]string + FromResourceQuota string + FromNamespace string + Labels map[string]string + Sleep string +} + +// Create creates a ResourceQuota if it does not already exist. +// Idempotent — skips if it already exists. +// Owner reference set for cascade deletion. +func Create(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedResourceQuotaSpec) error { + namespace := common.ResolveNamespace(owner, spec.Namespace) + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + _, err := kube.Clientset().CoreV1().ResourceQuotas(namespace).Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("resourcequota.Create: checking existence of %q: %w", spec.Name, err) + } + if err == nil { + logger.Debug(). + Str("resourcequota", spec.Name). + Str("namespace", namespace). + Msg("resourcequota already exists — skipping create") + return nil + } + + hard, err := resolveHard(ctx, kube, spec, owner) + if err != nil { + return fmt.Errorf("resourcequota.Create: resolving hard limits: %w", err) + } + + rq := buildResourceQuota(owner, spec, namespace, hard) + + _, err = kube.Clientset().CoreV1().ResourceQuotas(namespace).Create(ctx, rq, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("resourcequota.Create: creating %q in %q: %w", spec.Name, namespace, err) + } + + logger.Info(). + Str("resourcequota", spec.Name). + Str("namespace", namespace). + Str("owner", owner.GetName()). + Msg("resourcequota created") + + return nil +} + +// Update reconciles an existing ResourceQuota to match the resolved spec. +// If it does not exist, creates it. +func Update(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedResourceQuotaSpec) error { + namespace := common.ResolveNamespace(owner, spec.Namespace) + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + existing, err := kube.Clientset().CoreV1().ResourceQuotas(namespace).Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + logger.Info(). + Str("resourcequota", spec.Name). + Str("namespace", namespace). + Msg("resourcequota not found during reconcile — recreating") + return Create(ctx, kube, owner, spec) + } + return fmt.Errorf("resourcequota.Update: getting %q: %w", spec.Name, err) + } + + hard, err := resolveHard(ctx, kube, spec, owner) + if err != nil { + return fmt.Errorf("resourcequota.Update: resolving hard limits: %w", err) + } + + desired := buildResourceList(hard) + if reflect.DeepEqual(existing.Spec.Hard, desired) { + logger.Debug(). + Str("resourcequota", spec.Name). + Str("namespace", namespace). + Msg("resourcequota in sync — no update needed") + return nil + } + + updated := existing.DeepCopy() + updated.Spec.Hard = desired + + _, err = kube.Clientset().CoreV1().ResourceQuotas(namespace).Update(ctx, updated, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("resourcequota.Update: updating %q: %w", spec.Name, err) + } + + logger.Info(). + Str("resourcequota", spec.Name). + Str("namespace", namespace). + Msg("resourcequota updated") + + return nil +} + +// Delete deletes the ResourceQuota if it exists. +func Delete(ctx context.Context, kube kubeclient.KubeClient, owner domain.Object, spec ResolvedResourceQuotaSpec) error { + namespace := common.ResolveNamespace(owner, spec.Namespace) + if err := common.SleepIfNeeded(spec.Sleep); err != nil { + return err + } + + err := kube.Clientset().CoreV1().ResourceQuotas(namespace).Delete(ctx, spec.Name, metav1.DeleteOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("resourcequota.Delete: deleting %q in %q: %w", spec.Name, namespace, err) + } + + logger.Info(). + Str("resourcequota", spec.Name). + Str("namespace", namespace). + Str("owner", owner.GetName()). + Msg("resourcequota deleted") + + return nil +} + +// DeleteIfOwned deletes the ResourceQuota only if it is owned by the CR. +func DeleteIfOwned(ctx context.Context, kube kubeclient.KubeClient, + owner domain.Object, name, namespace string) error { + + existing, err := kube.Clientset().CoreV1().ResourceQuotas(namespace). + Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + if existing.Labels[labels.OrkestraOwner] != owner.GetName() { + return nil + } + return kube.Clientset().CoreV1().ResourceQuotas(namespace). + Delete(ctx, name, metav1.DeleteOptions{}) +} + +// CopyToNamespaces copies a ResourceQuota to multiple target namespaces. +// Reads the source quota once and creates copies in each namespace. +// Idempotent — skips namespaces where the quota already exists. +func CopyToNamespaces( + ctx context.Context, + kube kubeclient.KubeClient, + owner domain.Object, + spec ResolvedResourceQuotaSpec, + toNamespaces []string, +) error { + hard, err := resolveHard(ctx, kube, spec, owner) + if err != nil { + return fmt.Errorf("resourcequota.CopyToNamespaces: reading source: %w", err) + } + + for _, ns := range toNamespaces { + if ns == "" { + continue + } + + _, err := kube.Clientset().CoreV1().ResourceQuotas(ns).Get(ctx, spec.Name, metav1.GetOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("resourcequota.CopyToNamespaces: checking %q in %q: %w", spec.Name, ns, err) + } + if err == nil { + logger.Debug(). + Str("resourcequota", spec.Name). + Str("namespace", ns). + Msg("resourcequota already exists in namespace — skipping") + continue + } + + nsSpec := spec + nsSpec.Namespace = ns + rq := buildResourceQuota(owner, nsSpec, ns, hard) + + _, err = kube.Clientset().CoreV1().ResourceQuotas(ns).Create(ctx, rq, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("resourcequota.CopyToNamespaces: creating %q in %q: %w", spec.Name, ns, err) + } + + logger.Info(). + Str("resourcequota", spec.Name). + Str("namespace", ns). + Str("owner", owner.GetName()). + Msg("resourcequota copied to namespace") + } + + return nil +} + +// 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 { + hard := src.Hard + if src.Profile != "" { + if expanded, err := profiles.ApplyResourceQuotaProfile(src.Profile); err != nil { + logger.Warn().Str("profile", src.Profile).Err(err).Msg("unknown resourcequota profile — skipping") + } else { + hard = expanded.Hard + } + } + + spec := ResolvedResourceQuotaSpec{ + Name: src.Name, + Namespace: src.Namespace, + Hard: hard, + FromResourceQuota: src.FromResourceQuota, + FromNamespace: src.FromNamespace, + Labels: make(map[string]string), + Sleep: src.Sleep, + } + + for _, l := range src.Labels { + spec.Labels[l.Key] = l.Value + } + spec.Labels[labels.ManagedKey] = labels.ManagedValue + spec.Labels[labels.OrkestraOwner] = ownerName + + return spec +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +// resolveHard returns the hard limits to apply. +// When FromResourceQuota is set, copies limits from the source quota. +// Otherwise uses the declared Hard map. +func resolveHard( + ctx context.Context, + kube kubeclient.KubeClient, + spec ResolvedResourceQuotaSpec, + owner domain.Object, +) (map[string]string, error) { + if spec.FromResourceQuota == "" { + return spec.Hard, nil + } + + fromNS := spec.FromNamespace + if fromNS == "" { + fromNS = owner.GetNamespace() + } + if fromNS == "" { + fromNS = "default" + } + + source, err := kube.Clientset().CoreV1().ResourceQuotas(fromNS). + Get(ctx, spec.FromResourceQuota, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("reading source resourcequota %q from %q: %w", + spec.FromResourceQuota, fromNS, err) + } + + hard := make(map[string]string, len(source.Spec.Hard)) + for k, v := range source.Spec.Hard { + hard[string(k)] = v.String() + } + return hard, nil +} + +func buildResourceList(hard map[string]string) corev1.ResourceList { + rl := make(corev1.ResourceList, len(hard)) + for k, v := range hard { + q, err := resource.ParseQuantity(v) + if err != nil { + continue + } + rl[corev1.ResourceName(k)] = q + } + return rl +} + +func buildResourceQuota( + owner domain.Object, + spec ResolvedResourceQuotaSpec, + namespace string, + hard map[string]string, +) *corev1.ResourceQuota { + return &corev1.ResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.Name, + Namespace: namespace, + Labels: spec.Labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: owner.GetObjectKind().GroupVersionKind().GroupVersion().String(), + Kind: owner.GetObjectKind().GroupVersionKind().Kind, + Name: owner.GetName(), + UID: owner.GetUID(), + Controller: utils.BoolPtr(true), + BlockOwnerDeletion: utils.BoolPtr(true), + }, + }, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: buildResourceList(hard), + }, + } +} diff --git a/pkg/resources/template/resolver.go b/pkg/resources/template/resolver.go index cf55bac0..351bed15 100644 --- a/pkg/resources/template/resolver.go +++ b/pkg/resources/template/resolver.go @@ -1476,3 +1476,316 @@ func (r *Resolver) resolveVolumeMounts(src []orktypes.VolumeMount) ([]orktypes.V } return result, nil } + +// ResolveNetworkPolicyTemplate resolves all template expressions in a NetworkPolicyTemplateSource. +func (r *Resolver) ResolveNetworkPolicyTemplate(src orktypes.NetworkPolicyTemplateSource) (orktypes.NetworkPolicyTemplateSource, error) { + resolved := orktypes.NetworkPolicyTemplateSource{ + Version: src.Version, + PolicyTypes: src.PolicyTypes, + Ingress: src.Ingress, + Egress: src.Egress, + PodSelector: src.PodSelector, + } + var err error + + if resolved.Profile, err = r.Resolve(src.Profile); err != nil { + return resolved, fmt.Errorf("networkpolicy.profile: %w", err) + } + if resolved.Name, err = r.Resolve(src.Name); err != nil { + return resolved, fmt.Errorf("networkpolicy.name: %w", err) + } + if resolved.Namespace, err = r.Resolve(src.Namespace); err != nil { + return resolved, fmt.Errorf("networkpolicy.namespace: %w", err) + } + if resolved.FromNetworkPolicy, err = r.Resolve(src.FromNetworkPolicy); err != nil { + return resolved, fmt.Errorf("networkpolicy.fromNetworkPolicy: %w", err) + } + if resolved.FromNamespace, err = r.Resolve(src.FromNamespace); err != nil { + return resolved, fmt.Errorf("networkpolicy.fromNamespace: %w", err) + } + if resolved.Sleep, err = r.Resolve(src.Sleep); err != nil { + return resolved, fmt.Errorf("networkpolicy.sleep: %w", err) + } + if resolved.Labels, err = r.ResolveLabels(src.Labels); err != nil { + return resolved, fmt.Errorf("networkpolicy.labels: %w", err) + } + + for i, v := range src.ToNamespaces { + if !strings.Contains(v, "{{") { + if v != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, v) + } + continue + } + raw := resolveRawValue(r.data, v) + switch typed := raw.(type) { + case []interface{}: + for _, item := range typed { + if s, ok := item.(string); ok && s != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, s) + } + } + case string: + if typed != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, typed) + } + default: + rv, e := r.Resolve(v) + if e != nil { + return resolved, fmt.Errorf("networkpolicy.toNamespaces[%d]: %w", i, e) + } + if rv != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, rv) + } + } + } + + return resolved, nil +} + +// ResolveResourceQuotaTemplate resolves all template expressions in a ResourceQuotaTemplateSource. +func (r *Resolver) ResolveResourceQuotaTemplate(src orktypes.ResourceQuotaTemplateSource) (orktypes.ResourceQuotaTemplateSource, error) { + resolved := orktypes.ResourceQuotaTemplateSource{ + Version: src.Version, + } + var err error + + if resolved.Profile, err = r.Resolve(src.Profile); err != nil { + return resolved, fmt.Errorf("resourcequota.profile: %w", err) + } + if resolved.Name, err = r.Resolve(src.Name); err != nil { + return resolved, fmt.Errorf("resourcequota.name: %w", err) + } + if resolved.Namespace, err = r.Resolve(src.Namespace); err != nil { + return resolved, fmt.Errorf("resourcequota.namespace: %w", err) + } + if resolved.FromResourceQuota, err = r.Resolve(src.FromResourceQuota); err != nil { + return resolved, fmt.Errorf("resourcequota.fromResourceQuota: %w", err) + } + if resolved.FromNamespace, err = r.Resolve(src.FromNamespace); err != nil { + return resolved, fmt.Errorf("resourcequota.fromNamespace: %w", err) + } + if resolved.Sleep, err = r.Resolve(src.Sleep); err != nil { + return resolved, fmt.Errorf("resourcequota.sleep: %w", err) + } + if resolved.Labels, err = r.ResolveLabels(src.Labels); err != nil { + return resolved, fmt.Errorf("resourcequota.labels: %w", err) + } + + if len(src.Hard) > 0 { + resolved.Hard = make(map[string]string, len(src.Hard)) + for k, v := range src.Hard { + rv, e := r.Resolve(v) + if e != nil { + return resolved, fmt.Errorf("resourcequota.hard[%q]: %w", k, e) + } + resolved.Hard[k] = rv + } + } + + for i, v := range src.ToNamespaces { + if !strings.Contains(v, "{{") { + if v != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, v) + } + continue + } + raw := resolveRawValue(r.data, v) + switch typed := raw.(type) { + case []interface{}: + for _, item := range typed { + if s, ok := item.(string); ok && s != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, s) + } + } + case string: + if typed != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, typed) + } + default: + rv, e := r.Resolve(v) + if e != nil { + return resolved, fmt.Errorf("resourcequota.toNamespaces[%d]: %w", i, e) + } + if rv != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, rv) + } + } + } + + return resolved, nil +} + +// ResolveLimitRangeTemplate resolves all template expressions in a LimitRangeTemplateSource. +func (r *Resolver) ResolveLimitRangeTemplate(src orktypes.LimitRangeTemplateSource) (orktypes.LimitRangeTemplateSource, error) { + resolved := orktypes.LimitRangeTemplateSource{ + Version: src.Version, + } + var err error + + if resolved.Name, err = r.Resolve(src.Name); err != nil { + return resolved, fmt.Errorf("limitrange.name: %w", err) + } + if resolved.Namespace, err = r.Resolve(src.Namespace); err != nil { + return resolved, fmt.Errorf("limitrange.namespace: %w", err) + } + if resolved.FromLimitRange, err = r.Resolve(src.FromLimitRange); err != nil { + return resolved, fmt.Errorf("limitrange.fromLimitRange: %w", err) + } + if resolved.FromNamespace, err = r.Resolve(src.FromNamespace); err != nil { + return resolved, fmt.Errorf("limitrange.fromNamespace: %w", err) + } + if resolved.Sleep, err = r.Resolve(src.Sleep); err != nil { + return resolved, fmt.Errorf("limitrange.sleep: %w", err) + } + if resolved.Labels, err = r.ResolveLabels(src.Labels); err != nil { + return resolved, fmt.Errorf("limitrange.labels: %w", err) + } + + for i, item := range src.Limits { + ri := item + ri.Max, err = r.resolveStringMap(item.Max) + if err != nil { + return resolved, fmt.Errorf("limitrange.limits[%d].max: %w", i, err) + } + ri.Min, err = r.resolveStringMap(item.Min) + if err != nil { + return resolved, fmt.Errorf("limitrange.limits[%d].min: %w", i, err) + } + ri.Default, err = r.resolveStringMap(item.Default) + if err != nil { + return resolved, fmt.Errorf("limitrange.limits[%d].default: %w", i, err) + } + ri.DefaultRequest, err = r.resolveStringMap(item.DefaultRequest) + if err != nil { + return resolved, fmt.Errorf("limitrange.limits[%d].defaultRequest: %w", i, err) + } + ri.MaxLimitRequestRatio, err = r.resolveStringMap(item.MaxLimitRequestRatio) + if err != nil { + return resolved, fmt.Errorf("limitrange.limits[%d].maxLimitRequestRatio: %w", i, err) + } + resolved.Limits = append(resolved.Limits, ri) + } + + for i, v := range src.ToNamespaces { + if !strings.Contains(v, "{{") { + if v != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, v) + } + continue + } + raw := resolveRawValue(r.data, v) + switch typed := raw.(type) { + case []interface{}: + for _, item := range typed { + if s, ok := item.(string); ok && s != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, s) + } + } + case string: + if typed != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, typed) + } + default: + rv, e := r.Resolve(v) + if e != nil { + return resolved, fmt.Errorf("limitrange.toNamespaces[%d]: %w", i, e) + } + if rv != "" { + resolved.ToNamespaces = append(resolved.ToNamespaces, rv) + } + } + } + + return resolved, nil +} + +// resolveStringMap resolves template expressions in all values of a map[string]string. +func (r *Resolver) resolveStringMap(m map[string]string) (map[string]string, error) { + if len(m) == 0 { + return nil, nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + rv, err := r.Resolve(v) + if err != nil { + return nil, fmt.Errorf("%q: %w", k, err) + } + out[k] = rv + } + return out, nil +} + +// ResolveClusterRoleTemplate resolves all template expressions in a ClusterRoleTemplateSource. +func (r *Resolver) ResolveClusterRoleTemplate(src orktypes.ClusterRoleTemplateSource) (orktypes.ClusterRoleTemplateSource, error) { + resolved := orktypes.ClusterRoleTemplateSource{ + Version: src.Version, + Reconcile: src.Reconcile, + } + var err error + + if resolved.Name, err = r.Resolve(src.Name); err != nil { + return resolved, fmt.Errorf("clusterrole.name: %w", err) + } + if resolved.Sleep, err = r.Resolve(src.Sleep); err != nil { + return resolved, fmt.Errorf("clusterrole.sleep: %w", err) + } + if resolved.Labels, err = r.ResolveLabels(src.Labels); err != nil { + return resolved, fmt.Errorf("clusterrole.labels: %w", err) + } + + for _, rule := range src.Rules { + resolvedRule := orktypes.PolicyRuleSpec{ + APIGroups: rule.APIGroups, + Resources: rule.Resources, + Verbs: rule.Verbs, + } + for _, rn := range rule.ResourceNames { + rv, resolveErr := r.Resolve(rn) + if resolveErr != nil { + return resolved, fmt.Errorf("clusterrole.rules.resourceNames: %w", resolveErr) + } + resolvedRule.ResourceNames = append(resolvedRule.ResourceNames, rv) + } + resolved.Rules = append(resolved.Rules, resolvedRule) + } + + return resolved, nil +} + +// ResolveClusterRoleBindingTemplate resolves all template expressions in a ClusterRoleBindingTemplateSource. +func (r *Resolver) ResolveClusterRoleBindingTemplate(src orktypes.ClusterRoleBindingTemplateSource) (orktypes.ClusterRoleBindingTemplateSource, error) { + resolved := orktypes.ClusterRoleBindingTemplateSource{ + Version: src.Version, + Reconcile: src.Reconcile, + } + var err error + + if resolved.Name, err = r.Resolve(src.Name); err != nil { + return resolved, fmt.Errorf("clusterrolebinding.name: %w", err) + } + if resolved.Sleep, err = r.Resolve(src.Sleep); err != nil { + return resolved, fmt.Errorf("clusterrolebinding.sleep: %w", err) + } + if resolved.Labels, err = r.ResolveLabels(src.Labels); err != nil { + return resolved, fmt.Errorf("clusterrolebinding.labels: %w", err) + } + + resolved.RoleRef.Kind = src.RoleRef.Kind + if resolved.RoleRef.Name, err = r.Resolve(src.RoleRef.Name); err != nil { + return resolved, fmt.Errorf("clusterrolebinding.roleRef.name: %w", err) + } + + for i, s := range src.Subjects { + rs := orktypes.SubjectSpec{Kind: s.Kind} + if rs.Name, err = r.Resolve(s.Name); err != nil { + return resolved, fmt.Errorf("clusterrolebinding.subjects[%d].name: %w", i, err) + } + if rs.Namespace, err = r.Resolve(s.Namespace); err != nil { + return resolved, fmt.Errorf("clusterrolebinding.subjects[%d].namespace: %w", i, err) + } + resolved.Subjects = append(resolved.Subjects, rs) + } + + return resolved, nil +} diff --git a/pkg/runners/clusterrolebindings.go b/pkg/runners/clusterrolebindings.go new file mode 100644 index 00000000..355df328 --- /dev/null +++ b/pkg/runners/clusterrolebindings.go @@ -0,0 +1,81 @@ +// pkg/runners/clusterrolebindings.go +package runners + +import ( + "context" + "fmt" + + "github.com/orkspace/orkestra/domain" + "github.com/orkspace/orkestra/pkg/kubeclient" + "github.com/orkspace/orkestra/pkg/logger" + orkcrb "github.com/orkspace/orkestra/pkg/resources/clusterrolebindings" + orktmpl "github.com/orkspace/orkestra/pkg/resources/template" + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// RunClusterRoleBindings resolves and applies ClusterRoleBinding template declarations. +// +// ClusterRoleBindings are cluster-scoped — the namespace guard is not applied. +// Ownership is tracked via the orkestra.io/owner label; auto-GC via +// OwnerReferences is not possible for cluster-scoped resources. +func RunClusterRoleBindings( + ctx context.Context, + kube kubeclient.KubeClient, + resolver *orktmpl.Resolver, + owner domain.Object, + srcs []orktypes.ClusterRoleBindingTemplateSource, + update bool, +) error { + activeNames := make(map[string]bool, len(srcs)) + for _, s := range srcs { + if !orktypes.EvaluateWhen(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + continue + } + n, _ := resolver.Resolve(s.Name) + activeNames[n] = true + } + + for i, src := range srcs { + conditionPassed := orktypes.EvaluateWhen(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + + name, _ := resolver.Resolve(src.Name) + + if !conditionPassed { + if update || src.Reconcile { + if !activeNames[name] { + if err := orkcrb.DeleteIfOwned(ctx, kube, owner, name); err != nil { + return fmt.Errorf("clusterRoleBindings[%d]: conditional cleanup: %w", i, err) + } + } + } + logger.FromContext(ctx).Debug(). + Str("resource", "ClusterRoleBinding"). + Int("index", i). + Msg("conditions not met — skipping resource") + continue + } + + resolved, err := resolver.ResolveClusterRoleBindingTemplate(src) + if err != nil { + return fmt.Errorf("clusterRoleBindings[%d]: %w", i, err) + } + + spec := orkcrb.Resolve(resolved, resolver.OwnerName()) + + if update { + if err := orkcrb.Update(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("clusterRoleBindings[%d].update: %w", i, err) + } + } else { + if err := orkcrb.Create(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("clusterRoleBindings[%d].create: %w", i, err) + } + if src.Reconcile { + if err := orkcrb.Update(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("clusterRoleBindings[%d].reconcile: %w", i, err) + } + } + } + } + return nil +} diff --git a/pkg/runners/clusterroles.go b/pkg/runners/clusterroles.go new file mode 100644 index 00000000..b64298f5 --- /dev/null +++ b/pkg/runners/clusterroles.go @@ -0,0 +1,81 @@ +// pkg/runners/clusterroles.go +package runners + +import ( + "context" + "fmt" + + "github.com/orkspace/orkestra/domain" + "github.com/orkspace/orkestra/pkg/kubeclient" + "github.com/orkspace/orkestra/pkg/logger" + orkcr "github.com/orkspace/orkestra/pkg/resources/clusterroles" + orktmpl "github.com/orkspace/orkestra/pkg/resources/template" + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// RunClusterRoles resolves and applies ClusterRole template declarations. +// +// ClusterRoles are cluster-scoped — the namespace guard is not applied. +// Ownership is tracked via the orkestra.io/owner label; auto-GC via +// OwnerReferences is not possible for cluster-scoped resources. +func RunClusterRoles( + ctx context.Context, + kube kubeclient.KubeClient, + resolver *orktmpl.Resolver, + owner domain.Object, + srcs []orktypes.ClusterRoleTemplateSource, + update bool, +) error { + activeNames := make(map[string]bool, len(srcs)) + for _, s := range srcs { + if !orktypes.EvaluateWhen(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + continue + } + n, _ := resolver.Resolve(s.Name) + activeNames[n] = true + } + + for i, src := range srcs { + conditionPassed := orktypes.EvaluateWhen(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + + name, _ := resolver.Resolve(src.Name) + + if !conditionPassed { + if update || src.Reconcile { + if !activeNames[name] { + if err := orkcr.DeleteIfOwned(ctx, kube, owner, name); err != nil { + return fmt.Errorf("clusterRoles[%d]: conditional cleanup: %w", i, err) + } + } + } + logger.FromContext(ctx).Debug(). + Str("resource", "ClusterRole"). + Int("index", i). + Msg("conditions not met — skipping resource") + continue + } + + resolved, err := resolver.ResolveClusterRoleTemplate(src) + if err != nil { + return fmt.Errorf("clusterRoles[%d]: %w", i, err) + } + + spec := orkcr.Resolve(resolved, resolver.OwnerName()) + + if update { + if err := orkcr.Update(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("clusterRoles[%d].update: %w", i, err) + } + } else { + if err := orkcr.Create(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("clusterRoles[%d].create: %w", i, err) + } + if src.Reconcile { + if err := orkcr.Update(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("clusterRoles[%d].reconcile: %w", i, err) + } + } + } + } + return nil +} diff --git a/pkg/runners/limitranges.go b/pkg/runners/limitranges.go new file mode 100644 index 00000000..eba8c254 --- /dev/null +++ b/pkg/runners/limitranges.go @@ -0,0 +1,121 @@ +// pkg/runners/limitranges.go +package runners + +import ( + "context" + "fmt" + + "github.com/orkspace/orkestra/domain" + "github.com/orkspace/orkestra/pkg/kubeclient" + "github.com/orkspace/orkestra/pkg/logger" + orklr "github.com/orkspace/orkestra/pkg/resources/limitranges" + orktmpl "github.com/orkspace/orkestra/pkg/resources/template" + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// RunLimitRanges resolves and applies LimitRange template declarations. +// +// limitRanges support fromLimitRange to copy an existing LimitRange's limits, +// and toNamespaces to distribute copies across multiple namespaces. +// +// reconcile: true — re-reads the source LimitRange on every reconcile and syncs changes. +func RunLimitRanges( + ctx context.Context, + kube kubeclient.KubeClient, + resolver *orktmpl.Resolver, + owner domain.Object, + srcs []orktypes.LimitRangeTemplateSource, + update bool, + guard func(ctx context.Context, obj domain.Object, ns string) bool, +) error { + activeNames := make(map[string]bool, len(srcs)) + for _, s := range srcs { + if !orktypes.EvaluateWhen(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + continue + } + n, _ := resolver.Resolve(s.Name) + nsp, _ := resolver.Resolve(s.Namespace) + if nsp == "" { + nsp = owner.GetNamespace() + } + activeNames[nsp+"/"+n] = true + } + + for i, src := range srcs { + conditionPassed := orktypes.EvaluateWhen(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + + name, _ := resolver.Resolve(src.Name) + ns, _ := resolver.Resolve(src.Namespace) + if ns == "" { + ns = owner.GetNamespace() + } + + if guard != nil && !guard(ctx, owner, ns) { + continue + } + + if !conditionPassed { + if update || src.Reconcile { + if !activeNames[ns+"/"+name] { + if err := orklr.DeleteIfOwned(ctx, kube, owner, name, ns); err != nil { + return fmt.Errorf("limitRanges[%d]: conditional cleanup: %w", i, err) + } + } + } + logger.FromContext(ctx).Debug(). + Str("resource", "LimitRange"). + Int("index", i). + Msg("conditions not met — skipping resource") + continue + } + + resolved, err := resolver.ResolveLimitRangeTemplate(src) + if err != nil { + return fmt.Errorf("limitRanges[%d]: %w", i, err) + } + + spec := orklr.Resolve(resolved, resolver.OwnerName()) + + if len(resolved.ToNamespaces) > 0 { + namespaces, err := resolver.ResolveStringSlice(resolved.ToNamespaces) + if err != nil { + return fmt.Errorf("limitRanges[%d].toNamespaces: %w", i, err) + } + + shouldSync := update || src.Reconcile + for _, targetNs := range namespaces { + if guard != nil && !guard(ctx, owner, targetNs) { + continue + } + nsSpec := spec + nsSpec.Namespace = targetNs + if shouldSync { + if err := orklr.Update(ctx, kube, owner, nsSpec); err != nil { + return fmt.Errorf("limitRanges[%d].update namespace=%s: %w", i, targetNs, err) + } + } else { + if err := orklr.Create(ctx, kube, owner, nsSpec); err != nil { + return fmt.Errorf("limitRanges[%d].create namespace=%s: %w", i, targetNs, err) + } + } + } + continue + } + + if update { + if err := orklr.Update(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("limitRanges[%d].update: %w", i, err) + } + } else { + if err := orklr.Create(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("limitRanges[%d].create: %w", i, err) + } + if src.Reconcile { + if err := orklr.Update(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("limitRanges[%d].reconcile: %w", i, err) + } + } + } + } + return nil +} diff --git a/pkg/runners/networkpolicies.go b/pkg/runners/networkpolicies.go new file mode 100644 index 00000000..d6a35870 --- /dev/null +++ b/pkg/runners/networkpolicies.go @@ -0,0 +1,121 @@ +// pkg/runners/networkpolicies.go +package runners + +import ( + "context" + "fmt" + + "github.com/orkspace/orkestra/domain" + "github.com/orkspace/orkestra/pkg/kubeclient" + "github.com/orkspace/orkestra/pkg/logger" + orknp "github.com/orkspace/orkestra/pkg/resources/networkpolicies" + orktmpl "github.com/orkspace/orkestra/pkg/resources/template" + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// RunNetworkPolicies resolves and applies NetworkPolicy template declarations. +// +// networkPolicies support fromNetworkPolicy to copy an existing policy's spec, +// and toNamespaces to distribute copies across multiple namespaces. +// +// reconcile: true — re-reads the source policy on every reconcile and syncs changes. +func RunNetworkPolicies( + ctx context.Context, + kube kubeclient.KubeClient, + resolver *orktmpl.Resolver, + owner domain.Object, + srcs []orktypes.NetworkPolicyTemplateSource, + update bool, + guard func(ctx context.Context, obj domain.Object, ns string) bool, +) error { + activeNames := make(map[string]bool, len(srcs)) + for _, s := range srcs { + if !orktypes.EvaluateWhen(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + continue + } + n, _ := resolver.Resolve(s.Name) + nsp, _ := resolver.Resolve(s.Namespace) + if nsp == "" { + nsp = owner.GetNamespace() + } + activeNames[nsp+"/"+n] = true + } + + for i, src := range srcs { + conditionPassed := orktypes.EvaluateWhen(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + + name, _ := resolver.Resolve(src.Name) + ns, _ := resolver.Resolve(src.Namespace) + if ns == "" { + ns = owner.GetNamespace() + } + + if guard != nil && !guard(ctx, owner, ns) { + continue + } + + if !conditionPassed { + if update || src.Reconcile { + if !activeNames[ns+"/"+name] { + if err := orknp.DeleteIfOwned(ctx, kube, owner, name, ns); err != nil { + return fmt.Errorf("networkPolicies[%d]: conditional cleanup: %w", i, err) + } + } + } + logger.FromContext(ctx).Debug(). + Str("resource", "NetworkPolicy"). + Int("index", i). + Msg("conditions not met — skipping resource") + continue + } + + resolved, err := resolver.ResolveNetworkPolicyTemplate(src) + if err != nil { + return fmt.Errorf("networkPolicies[%d]: %w", i, err) + } + + spec := orknp.Resolve(resolved, resolver.OwnerName()) + + if len(resolved.ToNamespaces) > 0 { + namespaces, err := resolver.ResolveStringSlice(resolved.ToNamespaces) + if err != nil { + return fmt.Errorf("networkPolicies[%d].toNamespaces: %w", i, err) + } + + shouldSync := update || src.Reconcile + for _, targetNs := range namespaces { + if guard != nil && !guard(ctx, owner, targetNs) { + continue + } + nsSpec := spec + nsSpec.Namespace = targetNs + if shouldSync { + if err := orknp.Update(ctx, kube, owner, nsSpec); err != nil { + return fmt.Errorf("networkPolicies[%d].update namespace=%s: %w", i, targetNs, err) + } + } else { + if err := orknp.Create(ctx, kube, owner, nsSpec); err != nil { + return fmt.Errorf("networkPolicies[%d].create namespace=%s: %w", i, targetNs, err) + } + } + } + continue + } + + if update { + if err := orknp.Update(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("networkPolicies[%d].update: %w", i, err) + } + } else { + if err := orknp.Create(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("networkPolicies[%d].create: %w", i, err) + } + if src.Reconcile { + if err := orknp.Update(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("networkPolicies[%d].reconcile: %w", i, err) + } + } + } + } + return nil +} diff --git a/pkg/runners/resourcequotas.go b/pkg/runners/resourcequotas.go new file mode 100644 index 00000000..c28982af --- /dev/null +++ b/pkg/runners/resourcequotas.go @@ -0,0 +1,121 @@ +// pkg/runners/resourcequotas.go +package runners + +import ( + "context" + "fmt" + + "github.com/orkspace/orkestra/domain" + "github.com/orkspace/orkestra/pkg/kubeclient" + "github.com/orkspace/orkestra/pkg/logger" + orkrq "github.com/orkspace/orkestra/pkg/resources/resourcequotas" + orktmpl "github.com/orkspace/orkestra/pkg/resources/template" + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// RunResourceQuotas resolves and applies ResourceQuota template declarations. +// +// resourceQuotas support fromResourceQuota to copy an existing quota's hard limits, +// and toNamespaces to distribute copies across multiple namespaces. +// +// reconcile: true — re-reads the source quota on every reconcile and syncs changes. +func RunResourceQuotas( + ctx context.Context, + kube kubeclient.KubeClient, + resolver *orktmpl.Resolver, + owner domain.Object, + srcs []orktypes.ResourceQuotaTemplateSource, + update bool, + guard func(ctx context.Context, obj domain.Object, ns string) bool, +) error { + activeNames := make(map[string]bool, len(srcs)) + for _, s := range srcs { + if !orktypes.EvaluateWhen(resolver.Data(), s.Conditions, s.AnyOf, resolver.TemplateEvaluator()) { + continue + } + n, _ := resolver.Resolve(s.Name) + nsp, _ := resolver.Resolve(s.Namespace) + if nsp == "" { + nsp = owner.GetNamespace() + } + activeNames[nsp+"/"+n] = true + } + + for i, src := range srcs { + conditionPassed := orktypes.EvaluateWhen(resolver.Data(), src.Conditions, src.AnyOf, resolver.TemplateEvaluator()) + + name, _ := resolver.Resolve(src.Name) + ns, _ := resolver.Resolve(src.Namespace) + if ns == "" { + ns = owner.GetNamespace() + } + + if guard != nil && !guard(ctx, owner, ns) { + continue + } + + if !conditionPassed { + if update || src.Reconcile { + if !activeNames[ns+"/"+name] { + if err := orkrq.DeleteIfOwned(ctx, kube, owner, name, ns); err != nil { + return fmt.Errorf("resourceQuotas[%d]: conditional cleanup: %w", i, err) + } + } + } + logger.FromContext(ctx).Debug(). + Str("resource", "ResourceQuota"). + Int("index", i). + Msg("conditions not met — skipping resource") + continue + } + + resolved, err := resolver.ResolveResourceQuotaTemplate(src) + if err != nil { + return fmt.Errorf("resourceQuotas[%d]: %w", i, err) + } + + spec := orkrq.Resolve(resolved, resolver.OwnerName()) + + if len(resolved.ToNamespaces) > 0 { + namespaces, err := resolver.ResolveStringSlice(resolved.ToNamespaces) + if err != nil { + return fmt.Errorf("resourceQuotas[%d].toNamespaces: %w", i, err) + } + + shouldSync := update || src.Reconcile + for _, targetNs := range namespaces { + if guard != nil && !guard(ctx, owner, targetNs) { + continue + } + nsSpec := spec + nsSpec.Namespace = targetNs + if shouldSync { + if err := orkrq.Update(ctx, kube, owner, nsSpec); err != nil { + return fmt.Errorf("resourceQuotas[%d].update namespace=%s: %w", i, targetNs, err) + } + } else { + if err := orkrq.Create(ctx, kube, owner, nsSpec); err != nil { + return fmt.Errorf("resourceQuotas[%d].create namespace=%s: %w", i, targetNs, err) + } + } + } + continue + } + + if update { + if err := orkrq.Update(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("resourceQuotas[%d].update: %w", i, err) + } + } else { + if err := orkrq.Create(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("resourceQuotas[%d].create: %w", i, err) + } + if src.Reconcile { + if err := orkrq.Update(ctx, kube, owner, spec); err != nil { + return fmt.Errorf("resourceQuotas[%d].reconcile: %w", i, err) + } + } + } + } + return nil +} diff --git a/pkg/types/hook_methods.go b/pkg/types/hook_methods.go index b7992fc5..d73db888 100644 --- a/pkg/types/hook_methods.go +++ b/pkg/types/hook_methods.go @@ -20,6 +20,11 @@ func (h HookTemplates) IsEmpty() bool { len(h.Namespaces) == 0 && len(h.Roles) == 0 && len(h.RoleBindings) == 0 && + len(h.ClusterRoles) == 0 && + len(h.ClusterRoleBindings) == 0 && + len(h.NetworkPolicies) == 0 && + len(h.ResourceQuotas) == 0 && + len(h.LimitRanges) == 0 && len(h.External) == 0 && len(h.CustomResource) == 0 && h.Git == nil && @@ -167,6 +172,21 @@ func (c *CRDEntry) HasAnyServiceAccounts() bool { return false } +// HasNamespaceDeclarations reports whether any hook phase declares Namespace resources. +// Used to determine whether to auto-inject a cleanup finalizer. +func (b OperatorBoxConfig) HasNamespaceDeclarations() bool { + if b.OnCreate != nil && len(b.OnCreate.Namespaces) > 0 { + return true + } + if b.OnReconcile != nil && len(b.OnReconcile.Namespaces) > 0 { + return true + } + if b.OnDelete != nil && len(b.OnDelete.Namespaces) > 0 { + return true + } + return false +} + // HasAnyIngresses reports whether this CRD defines any Ingresses // in either OnCreate or OnReconcile phases. func (c *CRDEntry) HasAnyIngresses() bool { diff --git a/pkg/types/hooks_conditions.go b/pkg/types/hooks_conditions.go index bfac9871..8f771166 100644 --- a/pkg/types/hooks_conditions.go +++ b/pkg/types/hooks_conditions.go @@ -124,6 +124,36 @@ func (h HookTemplates) FilterResources(fn func(conditions, anyOf []Condition) (k out.RoleBindings = append(out.RoleBindings, s) } } + for _, s := range h.ClusterRoles { + if keep, c, a := fn(s.Conditions, s.AnyOf); keep { + s.Conditions, s.AnyOf = c, a + out.ClusterRoles = append(out.ClusterRoles, s) + } + } + for _, s := range h.ClusterRoleBindings { + if keep, c, a := fn(s.Conditions, s.AnyOf); keep { + s.Conditions, s.AnyOf = c, a + out.ClusterRoleBindings = append(out.ClusterRoleBindings, s) + } + } + for _, s := range h.NetworkPolicies { + if keep, c, a := fn(s.Conditions, s.AnyOf); keep { + s.Conditions, s.AnyOf = c, a + out.NetworkPolicies = append(out.NetworkPolicies, s) + } + } + for _, s := range h.ResourceQuotas { + if keep, c, a := fn(s.Conditions, s.AnyOf); keep { + s.Conditions, s.AnyOf = c, a + out.ResourceQuotas = append(out.ResourceQuotas, s) + } + } + for _, s := range h.LimitRanges { + if keep, c, a := fn(s.Conditions, s.AnyOf); keep { + s.Conditions, s.AnyOf = c, a + out.LimitRanges = append(out.LimitRanges, s) + } + } for _, s := range h.CustomResource { if keep, c, a := fn(s.Conditions, s.AnyOf); keep { s.Conditions, s.AnyOf = c, a @@ -166,6 +196,9 @@ func (h *HookTemplates) MergeFrom(src *HookTemplates) { h.RoleBindings = append(h.RoleBindings, src.RoleBindings...) h.ClusterRoles = append(h.ClusterRoles, src.ClusterRoles...) h.ClusterRoleBindings = append(h.ClusterRoleBindings, src.ClusterRoleBindings...) + h.NetworkPolicies = append(h.NetworkPolicies, src.NetworkPolicies...) + h.ResourceQuotas = append(h.ResourceQuotas, src.ResourceQuotas...) + h.LimitRanges = append(h.LimitRanges, src.LimitRanges...) h.CustomResource = append(h.CustomResource, src.CustomResource...) h.External = append(h.External, src.External...) } diff --git a/pkg/types/hooks_cross_namespace.go b/pkg/types/hooks_cross_namespace.go new file mode 100644 index 00000000..6837f6d5 --- /dev/null +++ b/pkg/types/hooks_cross_namespace.go @@ -0,0 +1,95 @@ +package types + +// CrossNamespaceChecker is implemented by any resource template that copies +// itself across namespaces using fromNamespace / toNamespaces. +// +// These resources require a live API server to read the source object, so they +// cannot be executed during simulation. FilterSimulatable uses this interface to +// strip them from the hook templates before the fake reconciler runs, and returns +// human-readable skip notices so the simulate output explains what was omitted. +// +// Any future resource type that adds fromNamespace / toNamespaces fields must: +// 1. Implement CrossNamespaceChecker on its *TemplateSource type. +// 2. Add its slice to FilterSimulatable and the katalog validator. +// +// validate enforces that fromNamespace and toNamespaces must always be set +// together (see validate_cross_namespace.go), so checking either field alone +// is sufficient to detect the pattern here. +type CrossNamespaceChecker interface { + IsCrossNamespaceCopy() bool + GetName() string // already implemented on all *TemplateSource types via hooks_sleep.go + GetKind() string + GetFromNamespace() string + GetToNamespaces() []string +} + +// SecretTemplateSource: cross-namespace copy via fromSecret + fromNamespace → toNamespaces. +func (s SecretTemplateSource) IsCrossNamespaceCopy() bool { + return s.FromNamespace != "" || len(s.ToNamespaces) > 0 +} +func (s SecretTemplateSource) GetKind() string { return "secrets" } +func (s SecretTemplateSource) GetFromNamespace() string { return s.FromNamespace } +func (s SecretTemplateSource) GetToNamespaces() []string { return s.ToNamespaces } + +// ConfigMapTemplateSource: cross-namespace copy via fromConfigMap + fromNamespace → toNamespaces. +func (s ConfigMapTemplateSource) IsCrossNamespaceCopy() bool { + return s.FromNamespace != "" || len(s.ToNamespaces) > 0 +} +func (s ConfigMapTemplateSource) GetKind() string { return "configmaps" } +func (s ConfigMapTemplateSource) GetFromNamespace() string { return s.FromNamespace } +func (s ConfigMapTemplateSource) GetToNamespaces() []string { return s.ToNamespaces } + +// NetworkPolicyTemplateSource: cross-namespace copy via fromNetworkPolicy + fromNamespace → toNamespaces. +func (s NetworkPolicyTemplateSource) IsCrossNamespaceCopy() bool { + return s.FromNamespace != "" || len(s.ToNamespaces) > 0 +} +func (s NetworkPolicyTemplateSource) GetKind() string { return "networkpolicies" } +func (s NetworkPolicyTemplateSource) GetFromNamespace() string { return s.FromNamespace } +func (s NetworkPolicyTemplateSource) GetToNamespaces() []string { return s.ToNamespaces } + +// ResourceQuotaTemplateSource: cross-namespace copy via fromResourceQuota + fromNamespace → toNamespaces. +func (s ResourceQuotaTemplateSource) IsCrossNamespaceCopy() bool { + return s.FromNamespace != "" || len(s.ToNamespaces) > 0 +} +func (s ResourceQuotaTemplateSource) GetKind() string { return "resourcequotas" } +func (s ResourceQuotaTemplateSource) GetFromNamespace() string { return s.FromNamespace } +func (s ResourceQuotaTemplateSource) GetToNamespaces() []string { return s.ToNamespaces } + +// LimitRangeTemplateSource: cross-namespace copy via fromLimitRange + fromNamespace → toNamespaces. +func (s LimitRangeTemplateSource) IsCrossNamespaceCopy() bool { + return s.FromNamespace != "" || len(s.ToNamespaces) > 0 +} +func (s LimitRangeTemplateSource) GetKind() string { return "limitranges" } +func (s LimitRangeTemplateSource) GetFromNamespace() string { return s.FromNamespace } +func (s LimitRangeTemplateSource) GetToNamespaces() []string { return s.ToNamespaces } + +// filterItems removes cross-namespace copy resources from src and appends a +// skip notice for each one to skipped. +func filterItems[T CrossNamespaceChecker](src []T, skipped *[]string) []T { + var out []T + for _, item := range src { + if item.IsCrossNamespaceCopy() { + *skipped = append(*skipped, item.GetKind()+"/"+item.GetName()+": cross-namespace copy skipped in simulate — requires a live cluster") + } else { + out = append(out, item) + } + } + return out +} + +// FilterSimulatable returns a copy of h with all cross-namespace copy resources +// removed, along with a notice for each skipped resource. +// +// The simulate harness calls this on every HookTemplates phase (onCreate, +// onReconcile, onDelete) before building the fake reconciler. Resources that +// implement CrossNamespaceChecker and return true from IsCrossNamespaceCopy are +// omitted; the caller is responsible for printing the notices. +func FilterSimulatable(h HookTemplates) (filtered HookTemplates, skipped []string) { + filtered = h + filtered.Secrets = filterItems(h.Secrets, &skipped) + filtered.ConfigMaps = filterItems(h.ConfigMaps, &skipped) + filtered.NetworkPolicies = filterItems(h.NetworkPolicies, &skipped) + filtered.ResourceQuotas = filterItems(h.ResourceQuotas, &skipped) + filtered.LimitRanges = filterItems(h.LimitRanges, &skipped) + return filtered, skipped +} diff --git a/pkg/types/hooks_hpa_test.go b/pkg/types/hooks_hpa_test.go new file mode 100644 index 00000000..234fa1e0 --- /dev/null +++ b/pkg/types/hooks_hpa_test.go @@ -0,0 +1,101 @@ +package types_test + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func crdWithHPAOnCreate(hpas ...orktypes.HPATemplateSource) orktypes.CRDEntry { + return orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + HorizontalPodAutoscalers: hpas, + }, + }, + } +} + +func TestCollectHPAProfileEntries_Empty(t *testing.T) { + c := orktypes.CRDEntry{} + assert.Empty(t, c.CollectHPAProfileEntries()) +} + +func TestCollectHPAProfileEntries_NoBehavior(t *testing.T) { + c := crdWithHPAOnCreate(orktypes.HPATemplateSource{Name: "hpa"}) + assert.Empty(t, c.CollectHPAProfileEntries()) +} + +func TestCollectHPAProfileEntries_BehaviorNoProfile(t *testing.T) { + c := crdWithHPAOnCreate(orktypes.HPATemplateSource{ + Name: "hpa", + Behavior: &orktypes.HPABehavior{}, + }) + assert.Empty(t, c.CollectHPAProfileEntries()) +} + +func TestCollectHPAProfileEntries_ProfileReturned(t *testing.T) { + c := crdWithHPAOnCreate(orktypes.HPATemplateSource{ + Name: "my-hpa", + Behavior: &orktypes.HPABehavior{Profile: "web"}, + }) + entries := c.CollectHPAProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "onCreate", entries[0].Phase) + assert.Equal(t, "my-hpa", entries[0].ResourceName) + assert.Equal(t, "web", entries[0].Profile) + assert.False(t, entries[0].Mixed) +} + +func TestCollectHPAProfileEntries_Mixed_ScaleUp(t *testing.T) { + c := crdWithHPAOnCreate(orktypes.HPATemplateSource{ + Name: "hpa", + Behavior: &orktypes.HPABehavior{ + Profile: "batch", + ScaleUp: &orktypes.HPAScalingRules{StabilizationWindowSeconds: 30}, + }, + }) + entries := c.CollectHPAProfileEntries() + require.Len(t, entries, 1) + assert.True(t, entries[0].Mixed) +} + +func TestCollectHPAProfileEntries_Mixed_ScaleDown(t *testing.T) { + c := crdWithHPAOnCreate(orktypes.HPATemplateSource{ + Name: "hpa", + Behavior: &orktypes.HPABehavior{ + Profile: "cost-optimized", + ScaleDown: &orktypes.HPAScalingRules{StabilizationWindowSeconds: 300}, + }, + }) + entries := c.CollectHPAProfileEntries() + require.Len(t, entries, 1) + assert.True(t, entries[0].Mixed) +} + +func TestCollectHPAProfileEntries_TemplateExpr(t *testing.T) { + c := crdWithHPAOnCreate(orktypes.HPATemplateSource{ + Behavior: &orktypes.HPABehavior{Profile: "{{ .Spec.ScaleProfile }}"}, + }) + entries := c.CollectHPAProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "{{ .Spec.ScaleProfile }}", entries[0].Profile) +} + +func TestCollectHPAProfileEntries_OnReconcile(t *testing.T) { + c := orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnReconcile: &orktypes.HookTemplates{ + HorizontalPodAutoscalers: []orktypes.HPATemplateSource{ + {Name: "hpa", Behavior: &orktypes.HPABehavior{Profile: "api"}}, + }, + }, + }, + } + entries := c.CollectHPAProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "onReconcile", entries[0].Phase) + assert.Equal(t, "api", entries[0].Profile) +} diff --git a/pkg/types/hooks_networkpolicy_profile.go b/pkg/types/hooks_networkpolicy_profile.go new file mode 100644 index 00000000..436f0235 --- /dev/null +++ b/pkg/types/hooks_networkpolicy_profile.go @@ -0,0 +1,72 @@ +package types + +// NetworkPolicyProfileEntry describes a single profile reference found in a +// NetworkPolicyTemplateSource. Used by katalog validation to fail fast on unknown +// profiles and to enforce mutual exclusivity with explicit ingress/egress/policyTypes. +type NetworkPolicyProfileEntry struct { + Phase string // "onCreate", "onReconcile", "onDelete" + ResourceName string // NetworkPolicy name template (may be empty) + Profile string // raw profile name as written in the katalog + Mixed bool // true when profile is set alongside explicit ingress/egress/policyTypes +} + +type networkPolicyProfiled interface { + getNetworkPolicyProfile() string + networkPolicyProfileMixed() bool +} + +func (t NetworkPolicyTemplateSource) getNetworkPolicyProfile() string { return t.Profile } +func (t NetworkPolicyTemplateSource) networkPolicyProfileMixed() bool { + return len(t.Ingress) > 0 || len(t.Egress) > 0 || len(t.PolicyTypes) > 0 +} + +// CollectNetworkPolicyProfileEntries returns all profile references declared for +// this CRD's networkPolicies across OnCreate, OnReconcile, and OnDelete. +// Only entries with a non-empty Profile string are returned. +func (c *CRDEntry) CollectNetworkPolicyProfileEntries() []NetworkPolicyProfileEntry { + if !c.HasAnyHookTemplates() { + return nil + } + + var out []NetworkPolicyProfileEntry + + collect := func(phase string, ht *HookTemplates) { + if ht == nil { + return + } + ht.VisitResources(func(res interface{}) { + pp, ok := res.(networkPolicyProfiled) + if !ok { + return + } + profile := pp.getNetworkPolicyProfile() + if profile == "" { + return + } + + var rname string + if n, ok := res.(namer); ok { + rname = n.GetName() + } + + out = append(out, NetworkPolicyProfileEntry{ + Phase: phase, + ResourceName: rname, + Profile: profile, + Mixed: pp.networkPolicyProfileMixed(), + }) + }) + } + + 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/hooks_networkpolicy_profile_test.go b/pkg/types/hooks_networkpolicy_profile_test.go new file mode 100644 index 00000000..90f16ab0 --- /dev/null +++ b/pkg/types/hooks_networkpolicy_profile_test.go @@ -0,0 +1,99 @@ +package types_test + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func crdWithNetworkPolicyOnCreate(nps ...orktypes.NetworkPolicyTemplateSource) orktypes.CRDEntry { + return orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + NetworkPolicies: nps, + }, + }, + } +} + +func TestCollectNetworkPolicyProfileEntries_Empty(t *testing.T) { + c := orktypes.CRDEntry{} + assert.Empty(t, c.CollectNetworkPolicyProfileEntries()) +} + +func TestCollectNetworkPolicyProfileEntries_NoProfile(t *testing.T) { + c := crdWithNetworkPolicyOnCreate(orktypes.NetworkPolicyTemplateSource{Name: "allow"}) + assert.Empty(t, c.CollectNetworkPolicyProfileEntries()) +} + +func TestCollectNetworkPolicyProfileEntries_ProfileReturned(t *testing.T) { + c := crdWithNetworkPolicyOnCreate(orktypes.NetworkPolicyTemplateSource{ + Name: "default", + Profile: "deny-all", + }) + entries := c.CollectNetworkPolicyProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "onCreate", entries[0].Phase) + assert.Equal(t, "default", entries[0].ResourceName) + assert.Equal(t, "deny-all", entries[0].Profile) + assert.False(t, entries[0].Mixed) +} + +func TestCollectNetworkPolicyProfileEntries_Mixed_Ingress(t *testing.T) { + c := crdWithNetworkPolicyOnCreate(orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "deny-all", + Ingress: []orktypes.NetworkPolicyIngressRule{{}}, + }) + entries := c.CollectNetworkPolicyProfileEntries() + require.Len(t, entries, 1) + assert.True(t, entries[0].Mixed) +} + +func TestCollectNetworkPolicyProfileEntries_Mixed_Egress(t *testing.T) { + c := crdWithNetworkPolicyOnCreate(orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "allow-dns-egress", + Egress: []orktypes.NetworkPolicyEgressRule{{}}, + }) + entries := c.CollectNetworkPolicyProfileEntries() + require.Len(t, entries, 1) + assert.True(t, entries[0].Mixed) +} + +func TestCollectNetworkPolicyProfileEntries_Mixed_PolicyTypes(t *testing.T) { + c := crdWithNetworkPolicyOnCreate(orktypes.NetworkPolicyTemplateSource{ + Name: "np", + Profile: "deny-all", + PolicyTypes: []string{"Ingress"}, + }) + entries := c.CollectNetworkPolicyProfileEntries() + require.Len(t, entries, 1) + assert.True(t, entries[0].Mixed) +} + +func TestCollectNetworkPolicyProfileEntries_TemplateExpr(t *testing.T) { + c := crdWithNetworkPolicyOnCreate(orktypes.NetworkPolicyTemplateSource{ + Profile: "{{ .Spec.Profile }}", + }) + entries := c.CollectNetworkPolicyProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "{{ .Spec.Profile }}", entries[0].Profile) +} + +func TestCollectNetworkPolicyProfileEntries_OnReconcile(t *testing.T) { + c := orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnReconcile: &orktypes.HookTemplates{ + NetworkPolicies: []orktypes.NetworkPolicyTemplateSource{ + {Name: "np", Profile: "allow-same-namespace"}, + }, + }, + }, + } + entries := c.CollectNetworkPolicyProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "onReconcile", entries[0].Phase) +} diff --git a/pkg/types/hooks_pdb_test.go b/pkg/types/hooks_pdb_test.go new file mode 100644 index 00000000..54a535aa --- /dev/null +++ b/pkg/types/hooks_pdb_test.go @@ -0,0 +1,101 @@ +package types_test + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func crdWithPDBOnCreate(pdbs ...orktypes.PDBTemplateSource) orktypes.CRDEntry { + return orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + PodDisruptionBudgets: pdbs, + }, + }, + } +} + +func TestCollectPDBProfileEntries_Empty(t *testing.T) { + c := orktypes.CRDEntry{} + assert.Empty(t, c.CollectPDBProfileEntries()) +} + +func TestCollectPDBProfileEntries_NoBehavior(t *testing.T) { + c := crdWithPDBOnCreate(orktypes.PDBTemplateSource{Name: "pdb"}) + assert.Empty(t, c.CollectPDBProfileEntries()) +} + +func TestCollectPDBProfileEntries_BehaviorNoProfile(t *testing.T) { + c := crdWithPDBOnCreate(orktypes.PDBTemplateSource{ + Name: "pdb", + Behavior: &orktypes.PDBBehavior{}, + }) + assert.Empty(t, c.CollectPDBProfileEntries()) +} + +func TestCollectPDBProfileEntries_ProfileReturned(t *testing.T) { + c := crdWithPDBOnCreate(orktypes.PDBTemplateSource{ + Name: "my-pdb", + Behavior: &orktypes.PDBBehavior{Profile: "zero-downtime"}, + }) + entries := c.CollectPDBProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "onCreate", entries[0].Phase) + assert.Equal(t, "my-pdb", entries[0].ResourceName) + assert.Equal(t, "zero-downtime", entries[0].Profile) + assert.False(t, entries[0].Mixed) +} + +func TestCollectPDBProfileEntries_Mixed_MinAvailable(t *testing.T) { + c := crdWithPDBOnCreate(orktypes.PDBTemplateSource{ + Name: "pdb", + Behavior: &orktypes.PDBBehavior{ + Profile: "rolling", + MinAvailable: "1", + }, + }) + entries := c.CollectPDBProfileEntries() + require.Len(t, entries, 1) + assert.True(t, entries[0].Mixed) +} + +func TestCollectPDBProfileEntries_Mixed_MaxUnavailable(t *testing.T) { + c := crdWithPDBOnCreate(orktypes.PDBTemplateSource{ + Name: "pdb", + Behavior: &orktypes.PDBBehavior{ + Profile: "relaxed", + MaxUnavailable: "1", + }, + }) + entries := c.CollectPDBProfileEntries() + require.Len(t, entries, 1) + assert.True(t, entries[0].Mixed) +} + +func TestCollectPDBProfileEntries_TemplateExpr(t *testing.T) { + c := crdWithPDBOnCreate(orktypes.PDBTemplateSource{ + Behavior: &orktypes.PDBBehavior{Profile: "{{ .Spec.PDBProfile }}"}, + }) + entries := c.CollectPDBProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "{{ .Spec.PDBProfile }}", entries[0].Profile) +} + +func TestCollectPDBProfileEntries_OnReconcile(t *testing.T) { + c := orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnReconcile: &orktypes.HookTemplates{ + PodDisruptionBudgets: []orktypes.PDBTemplateSource{ + {Name: "pdb", Behavior: &orktypes.PDBBehavior{Profile: "relaxed"}}, + }, + }, + }, + } + entries := c.CollectPDBProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "onReconcile", entries[0].Phase) + assert.Equal(t, "relaxed", entries[0].Profile) +} diff --git a/pkg/types/hooks_resourcequota_profile.go b/pkg/types/hooks_resourcequota_profile.go new file mode 100644 index 00000000..b1d7b3f6 --- /dev/null +++ b/pkg/types/hooks_resourcequota_profile.go @@ -0,0 +1,70 @@ +package types + +// ResourceQuotaProfileEntry describes a single profile reference found in a +// ResourceQuotaTemplateSource. Used by katalog validation to fail fast on unknown +// profiles and to enforce mutual exclusivity with explicit hard limits. +type ResourceQuotaProfileEntry struct { + Phase string // "onCreate", "onReconcile", "onDelete" + ResourceName string // ResourceQuota 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 hard map +} + +type resourceQuotaProfiled interface { + getResourceQuotaProfile() string + resourceQuotaProfileMixed() bool +} + +func (t ResourceQuotaTemplateSource) getResourceQuotaProfile() string { return t.Profile } +func (t ResourceQuotaTemplateSource) resourceQuotaProfileMixed() bool { return len(t.Hard) > 0 } + +// CollectResourceQuotaProfileEntries returns all profile references declared for +// this CRD's resourceQuotas across OnCreate, OnReconcile, and OnDelete. +// Only entries with a non-empty Profile string are returned. +func (c *CRDEntry) CollectResourceQuotaProfileEntries() []ResourceQuotaProfileEntry { + if !c.HasAnyHookTemplates() { + return nil + } + + var out []ResourceQuotaProfileEntry + + collect := func(phase string, ht *HookTemplates) { + if ht == nil { + return + } + ht.VisitResources(func(res interface{}) { + rp, ok := res.(resourceQuotaProfiled) + if !ok { + return + } + profile := rp.getResourceQuotaProfile() + if profile == "" { + return + } + + var rname string + if n, ok := res.(namer); ok { + rname = n.GetName() + } + + out = append(out, ResourceQuotaProfileEntry{ + Phase: phase, + ResourceName: rname, + Profile: profile, + Mixed: rp.resourceQuotaProfileMixed(), + }) + }) + } + + 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/hooks_resourcequota_profile_test.go b/pkg/types/hooks_resourcequota_profile_test.go new file mode 100644 index 00000000..af57081b --- /dev/null +++ b/pkg/types/hooks_resourcequota_profile_test.go @@ -0,0 +1,96 @@ +package types_test + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func crdWithResourceQuotaOnCreate(rqs ...orktypes.ResourceQuotaTemplateSource) orktypes.CRDEntry { + return orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + ResourceQuotas: rqs, + }, + }, + } +} + +func TestCollectResourceQuotaProfileEntries_Empty(t *testing.T) { + c := orktypes.CRDEntry{} + assert.Empty(t, c.CollectResourceQuotaProfileEntries()) +} + +func TestCollectResourceQuotaProfileEntries_NoProfile(t *testing.T) { + c := crdWithResourceQuotaOnCreate(orktypes.ResourceQuotaTemplateSource{Name: "quota"}) + assert.Empty(t, c.CollectResourceQuotaProfileEntries()) +} + +func TestCollectResourceQuotaProfileEntries_ProfileReturned(t *testing.T) { + c := crdWithResourceQuotaOnCreate(orktypes.ResourceQuotaTemplateSource{ + Name: "default-quota", + Profile: "small", + }) + entries := c.CollectResourceQuotaProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "onCreate", entries[0].Phase) + assert.Equal(t, "default-quota", entries[0].ResourceName) + assert.Equal(t, "small", entries[0].Profile) + assert.False(t, entries[0].Mixed) +} + +func TestCollectResourceQuotaProfileEntries_Mixed(t *testing.T) { + c := crdWithResourceQuotaOnCreate(orktypes.ResourceQuotaTemplateSource{ + Name: "quota", + Profile: "medium", + Hard: map[string]string{"pods": "5"}, + }) + entries := c.CollectResourceQuotaProfileEntries() + require.Len(t, entries, 1) + assert.True(t, entries[0].Mixed) +} + +func TestCollectResourceQuotaProfileEntries_TemplateExpr(t *testing.T) { + c := crdWithResourceQuotaOnCreate(orktypes.ResourceQuotaTemplateSource{ + Profile: "{{ .Spec.QuotaProfile }}", + }) + entries := c.CollectResourceQuotaProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "{{ .Spec.QuotaProfile }}", entries[0].Profile) +} + +func TestCollectResourceQuotaProfileEntries_MultipleProfiles(t *testing.T) { + c := orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + ResourceQuotas: []orktypes.ResourceQuotaTemplateSource{ + {Name: "rq-small", Profile: "small"}, + {Name: "rq-no-profile"}, + {Name: "rq-large", Profile: "large"}, + }, + }, + }, + } + entries := c.CollectResourceQuotaProfileEntries() + require.Len(t, entries, 2) + assert.Equal(t, "small", entries[0].Profile) + assert.Equal(t, "large", entries[1].Profile) +} + +func TestCollectResourceQuotaProfileEntries_OnReconcile(t *testing.T) { + c := orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnReconcile: &orktypes.HookTemplates{ + ResourceQuotas: []orktypes.ResourceQuotaTemplateSource{ + {Name: "quota", Profile: "xlarge"}, + }, + }, + }, + } + entries := c.CollectResourceQuotaProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "onReconcile", entries[0].Phase) + assert.Equal(t, "xlarge", entries[0].Profile) +} diff --git a/pkg/types/hooks_rolling_update_test.go b/pkg/types/hooks_rolling_update_test.go new file mode 100644 index 00000000..cc7009d0 --- /dev/null +++ b/pkg/types/hooks_rolling_update_test.go @@ -0,0 +1,117 @@ +package types_test + +import ( + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func crdWithDeploymentOnCreate(deps ...orktypes.DeploymentTemplateSource) orktypes.CRDEntry { + return orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + Deployments: deps, + }, + }, + } +} + +func TestCollectRollingUpdateProfileEntries_Empty(t *testing.T) { + c := orktypes.CRDEntry{} + assert.Empty(t, c.CollectRollingUpdateProfileEntries()) +} + +func TestCollectRollingUpdateProfileEntries_NoRollingUpdate(t *testing.T) { + c := crdWithDeploymentOnCreate(orktypes.DeploymentTemplateSource{Name: "app"}) + assert.Empty(t, c.CollectRollingUpdateProfileEntries()) +} + +func TestCollectRollingUpdateProfileEntries_RollingUpdateNoProfile(t *testing.T) { + c := crdWithDeploymentOnCreate(orktypes.DeploymentTemplateSource{ + Name: "app", + RollingUpdate: &orktypes.RollingUpdateBehavior{}, + }) + assert.Empty(t, c.CollectRollingUpdateProfileEntries()) +} + +func TestCollectRollingUpdateProfileEntries_ProfileReturned(t *testing.T) { + c := crdWithDeploymentOnCreate(orktypes.DeploymentTemplateSource{ + Name: "app", + RollingUpdate: &orktypes.RollingUpdateBehavior{Profile: "safe"}, + }) + entries := c.CollectRollingUpdateProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "onCreate", entries[0].Phase) + assert.Equal(t, "app", entries[0].ResourceName) + assert.Equal(t, "safe", entries[0].Profile) + assert.False(t, entries[0].Mixed) +} + +func TestCollectRollingUpdateProfileEntries_Mixed_MaxSurge(t *testing.T) { + c := crdWithDeploymentOnCreate(orktypes.DeploymentTemplateSource{ + Name: "app", + RollingUpdate: &orktypes.RollingUpdateBehavior{ + Profile: "fast", + MaxSurge: "2", + }, + }) + entries := c.CollectRollingUpdateProfileEntries() + require.Len(t, entries, 1) + assert.True(t, entries[0].Mixed) +} + +func TestCollectRollingUpdateProfileEntries_Mixed_MaxUnavailable(t *testing.T) { + c := crdWithDeploymentOnCreate(orktypes.DeploymentTemplateSource{ + Name: "app", + RollingUpdate: &orktypes.RollingUpdateBehavior{ + Profile: "blue-green", + MaxUnavailable: "0", + }, + }) + entries := c.CollectRollingUpdateProfileEntries() + require.Len(t, entries, 1) + assert.True(t, entries[0].Mixed) +} + +func TestCollectRollingUpdateProfileEntries_TemplateExpr(t *testing.T) { + c := crdWithDeploymentOnCreate(orktypes.DeploymentTemplateSource{ + RollingUpdate: &orktypes.RollingUpdateBehavior{Profile: "{{ .Spec.DeployProfile }}"}, + }) + entries := c.CollectRollingUpdateProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "{{ .Spec.DeployProfile }}", entries[0].Profile) +} + +func TestCollectRollingUpdateProfileEntries_StatefulSet(t *testing.T) { + c := orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnCreate: &orktypes.HookTemplates{ + StatefulSets: []orktypes.StatefulSetTemplateSource{ + {Name: "db", RollingUpdate: &orktypes.RollingUpdateBehavior{Profile: "safe"}}, + }, + }, + }, + } + entries := c.CollectRollingUpdateProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "safe", entries[0].Profile) + assert.Equal(t, "db", entries[0].ResourceName) +} + +func TestCollectRollingUpdateProfileEntries_OnReconcile(t *testing.T) { + c := orktypes.CRDEntry{ + OperatorBox: orktypes.OperatorBoxConfig{ + OnReconcile: &orktypes.HookTemplates{ + Deployments: []orktypes.DeploymentTemplateSource{ + {Name: "app", RollingUpdate: &orktypes.RollingUpdateBehavior{Profile: "fast"}}, + }, + }, + }, + } + entries := c.CollectRollingUpdateProfileEntries() + require.Len(t, entries, 1) + assert.Equal(t, "onReconcile", entries[0].Phase) + assert.Equal(t, "fast", entries[0].Profile) +} diff --git a/pkg/types/hooks_sleep.go b/pkg/types/hooks_sleep.go index 9988c4e7..bfc70398 100644 --- a/pkg/types/hooks_sleep.go +++ b/pkg/types/hooks_sleep.go @@ -122,18 +122,20 @@ func (t PodTemplateSource) GetSleep() string { return t.Sleep } func (t ServiceTemplateSource) GetSleep() string { return t.Sleep } func (t IngressTemplateSource) GetSleep() string { return t.Sleep } -// func (t NetworkPolicyTemplateSource) GetSleep() string { return t.Sleep } +func (t NetworkPolicyTemplateSource) GetSleep() string { return t.Sleep } // Batch func (t JobTemplateSource) GetSleep() string { return t.Sleep } func (t CronJobTemplateSource) GetSleep() string { return t.Sleep } // Config & identity -func (t SecretTemplateSource) GetSleep() string { return t.Sleep } -func (t ConfigMapTemplateSource) GetSleep() string { return t.Sleep } -func (t ServiceAccountTemplateSource) GetSleep() string { return t.Sleep } -func (t RoleTemplateSource) GetSleep() string { return t.Sleep } -func (t RoleBindingTemplateSource) GetSleep() string { return t.Sleep } +func (t SecretTemplateSource) GetSleep() string { return t.Sleep } +func (t ConfigMapTemplateSource) GetSleep() string { return t.Sleep } +func (t ServiceAccountTemplateSource) GetSleep() string { return t.Sleep } +func (t RoleTemplateSource) GetSleep() string { return t.Sleep } +func (t RoleBindingTemplateSource) GetSleep() string { return t.Sleep } +func (t ClusterRoleTemplateSource) GetSleep() string { return t.Sleep } +func (t ClusterRoleBindingTemplateSource) GetSleep() string { return t.Sleep } // Storage func (t PVTemplateSource) GetSleep() string { return t.Sleep } @@ -161,8 +163,9 @@ func (t CustomResourceTemplateSource) GetSleep() string { return t.Sleep } // // Scheduling / QoS // func (t PriorityClassTemplateSource) GetSleep() string { return t.Sleep } // func (t RuntimeClassTemplateSource) GetSleep() string { return t.Sleep } -// func (t LimitRangeTemplateSource) GetSleep() string { return t.Sleep } -// func (t ResourceQuotaTemplateSource) GetSleep() string { return t.Sleep } +func (t LimitRangeTemplateSource) GetSleep() string { return t.Sleep } +func (t ResourceQuotaTemplateSource) GetSleep() string { return t.Sleep } + // func (t PriorityLevelConfigurationTemplateSource) GetSleep() string { // return t.Sleep // } @@ -182,19 +185,21 @@ func (t StatefulSetTemplateSource) GetName() string { return t.Name } func (t ServiceTemplateSource) GetName() string { return t.Name } func (t IngressTemplateSource) GetName() string { return t.Name } -// func (t NetworkPolicyTemplateSource) GetName() string { return t.Name } -func (t JobTemplateSource) GetName() string { return t.Name } -func (t CronJobTemplateSource) GetName() string { return t.Name } -func (t SecretTemplateSource) GetName() string { return t.Name } -func (t ConfigMapTemplateSource) GetName() string { return t.Name } -func (t ServiceAccountTemplateSource) GetName() string { return t.Name } -func (t RoleTemplateSource) GetName() string { return t.Name } -func (t RoleBindingTemplateSource) GetName() string { return t.Name } -func (t PVTemplateSource) GetName() string { return t.Name } -func (t PVCTemplateSource) GetName() string { return t.Name } -func (t HPATemplateSource) GetName() string { return t.Name } -func (t PDBTemplateSource) GetName() string { return t.Name } -func (t NamespaceTemplateSource) GetName() string { return t.Name } +func (t NetworkPolicyTemplateSource) GetName() string { return t.Name } +func (t JobTemplateSource) GetName() string { return t.Name } +func (t CronJobTemplateSource) GetName() string { return t.Name } +func (t SecretTemplateSource) GetName() string { return t.Name } +func (t ConfigMapTemplateSource) GetName() string { return t.Name } +func (t ServiceAccountTemplateSource) GetName() string { return t.Name } +func (t RoleTemplateSource) GetName() string { return t.Name } +func (t RoleBindingTemplateSource) GetName() string { return t.Name } +func (t ClusterRoleTemplateSource) GetName() string { return t.Name } +func (t ClusterRoleBindingTemplateSource) GetName() string { return t.Name } +func (t PVTemplateSource) GetName() string { return t.Name } +func (t PVCTemplateSource) GetName() string { return t.Name } +func (t HPATemplateSource) GetName() string { return t.Name } +func (t PDBTemplateSource) GetName() string { return t.Name } +func (t NamespaceTemplateSource) GetName() string { return t.Name } // Custom Resource func (t CustomResourceTemplateSource) GetName() string { return t.Sleep } @@ -209,8 +214,9 @@ func (t CustomResourceTemplateSource) GetName() string { return t.Sleep } // func (t StorageVolumeTemplateSource) GetName() string { return t.Name } // func (t PriorityClassTemplateSource) GetName() string { return t.Name } // func (t RuntimeClassTemplateSource) GetName() string { return t.Name } -// func (t LimitRangeTemplateSource) GetName() string { return t.Name } -// func (t ResourceQuotaTemplateSource) GetName() string { return t.Name } +func (t LimitRangeTemplateSource) GetName() string { return t.Name } +func (t ResourceQuotaTemplateSource) GetName() string { return t.Name } + // func (t PriorityLevelConfigurationTemplateSource) GetName() string { return t.Name } // func (t PodTemplatePlaceholderSource) GetName() string { return t.Name } // func (t ServiceMonitorTemplateSource) GetName() string { return t.Name } diff --git a/pkg/types/types_hook_templates.go b/pkg/types/types_hook_templates.go index 8826e70a..e10a9431 100644 --- a/pkg/types/types_hook_templates.go +++ b/pkg/types/types_hook_templates.go @@ -32,25 +32,30 @@ package types // - Jobs that must complete successfully before the CR can be considered deleted // - Notification or archival tasks that must run before deletion is finalized type HookTemplates struct { - Deployments []DeploymentTemplateSource `yaml:"deployments,omitempty" json:"deployments,omitempty" validate:"omitempty"` - ReplicaSets []ReplicaSetTemplateSource `yaml:"replicaSets,omitempty" json:"replicaSets,omitempty" validate:"omitempty"` - Services []ServiceTemplateSource `yaml:"services,omitempty" json:"services,omitempty" validate:"omitempty"` - Pods []PodTemplateSource `yaml:"pods,omitempty" json:"pods,omitempty" validate:"omitempty"` - Jobs []JobTemplateSource `yaml:"jobs,omitempty" json:"jobs,omitempty" validate:"omitempty"` - CronJobs []CronJobTemplateSource `yaml:"cronJobs,omitempty" json:"cronJobs,omitempty" validate:"omitempty"` - Secrets []SecretTemplateSource `yaml:"secrets,omitempty" json:"secrets,omitempty" validate:"omitempty"` - ConfigMaps []ConfigMapTemplateSource `yaml:"configMaps,omitempty" json:"configMaps,omitempty" validate:"omitempty"` - ServiceAccounts []ServiceAccountTemplateSource `yaml:"serviceAccounts,omitempty" json:"serviceAccounts,omitempty" validate:"omitempty"` - StatefulSets []StatefulSetTemplateSource `yaml:"statefulSets,omitempty" json:"statefulSets,omitempty" validate:"omitempty"` - Ingresses []IngressTemplateSource `yaml:"ingresses,omitempty" json:"ingresses,omitempty" validate:"omitempty"` - PersistentVolumes []PVTemplateSource `yaml:"persistentVolumes,omitempty" json:"persistentVolumes,omitempty" validate:"omitempty"` - PersistentVolumeClaims []PVCTemplateSource `yaml:"persistentVolumeClaims,omitempty" json:"persistentVolumeClaims,omitempty" validate:"omitempty"` - HorizontalPodAutoscalers []HPATemplateSource `yaml:"hpa,omitempty" json:"hpa,omitempty" validate:"omitempty"` - PodDisruptionBudgets []PDBTemplateSource `yaml:"pdb,omitempty" json:"pdb,omitempty" validate:"omitempty"` - Namespaces []NamespaceTemplateSource `yaml:"namespaces,omitempty" json:"namespaces,omitempty" validate:"omitempty"` - Roles []RoleTemplateSource `yaml:"roles,omitempty" json:"roles,omitempty" validate:"omitempty"` - RoleBindings []RoleBindingTemplateSource `yaml:"roleBindings,omitempty" json:"roleBindings,omitempty" validate:"omitempty"` - CustomResource []CustomResourceTemplateSource `yaml:"custom,omitempty" json:"custom,omitempty" validate:"omitempty"` + Deployments []DeploymentTemplateSource `yaml:"deployments,omitempty" json:"deployments,omitempty" validate:"omitempty"` + ReplicaSets []ReplicaSetTemplateSource `yaml:"replicaSets,omitempty" json:"replicaSets,omitempty" validate:"omitempty"` + Services []ServiceTemplateSource `yaml:"services,omitempty" json:"services,omitempty" validate:"omitempty"` + Pods []PodTemplateSource `yaml:"pods,omitempty" json:"pods,omitempty" validate:"omitempty"` + Jobs []JobTemplateSource `yaml:"jobs,omitempty" json:"jobs,omitempty" validate:"omitempty"` + CronJobs []CronJobTemplateSource `yaml:"cronJobs,omitempty" json:"cronJobs,omitempty" validate:"omitempty"` + Secrets []SecretTemplateSource `yaml:"secrets,omitempty" json:"secrets,omitempty" validate:"omitempty"` + ConfigMaps []ConfigMapTemplateSource `yaml:"configMaps,omitempty" json:"configMaps,omitempty" validate:"omitempty"` + ServiceAccounts []ServiceAccountTemplateSource `yaml:"serviceAccounts,omitempty" json:"serviceAccounts,omitempty" validate:"omitempty"` + StatefulSets []StatefulSetTemplateSource `yaml:"statefulSets,omitempty" json:"statefulSets,omitempty" validate:"omitempty"` + Ingresses []IngressTemplateSource `yaml:"ingresses,omitempty" json:"ingresses,omitempty" validate:"omitempty"` + PersistentVolumes []PVTemplateSource `yaml:"persistentVolumes,omitempty" json:"persistentVolumes,omitempty" validate:"omitempty"` + PersistentVolumeClaims []PVCTemplateSource `yaml:"persistentVolumeClaims,omitempty" json:"persistentVolumeClaims,omitempty" validate:"omitempty"` + HorizontalPodAutoscalers []HPATemplateSource `yaml:"hpa,omitempty" json:"hpa,omitempty" validate:"omitempty"` + PodDisruptionBudgets []PDBTemplateSource `yaml:"pdb,omitempty" json:"pdb,omitempty" validate:"omitempty"` + Namespaces []NamespaceTemplateSource `yaml:"namespaces,omitempty" json:"namespaces,omitempty" validate:"omitempty"` + Roles []RoleTemplateSource `yaml:"roles,omitempty" json:"roles,omitempty" validate:"omitempty"` + RoleBindings []RoleBindingTemplateSource `yaml:"roleBindings,omitempty" json:"roleBindings,omitempty" validate:"omitempty"` + CustomResource []CustomResourceTemplateSource `yaml:"custom,omitempty" json:"custom,omitempty" validate:"omitempty"` + ClusterRoles []ClusterRoleTemplateSource `yaml:"clusterRoles,omitempty" json:"clusterRoles,omitempty" validate:"omitempty"` + ClusterRoleBindings []ClusterRoleBindingTemplateSource `yaml:"clusterRoleBindings,omitempty" json:"clusterRoleBindings,omitempty" validate:"omitempty"` + LimitRanges []LimitRangeTemplateSource `yaml:"limitRanges,omitempty" json:"limitRanges,omitempty" validate:"omitempty"` + ResourceQuotas []ResourceQuotaTemplateSource `yaml:"resourceQuotas,omitempty" json:"resourceQuotas,omitempty" validate:"omitempty"` + NetworkPolicies []NetworkPolicyTemplateSource `yaml:"networkPolicies,omitempty" json:"networkPolicies,omitempty" validate:"omitempty"` // External declares HTTP calls to make before resource creation. // Results available as .external..status, .body, .error @@ -96,18 +101,13 @@ type HookTemplates struct { // TODO with placeholer Volumes []PlaceholderSource `yaml:"volumes,omitempty" json:"volumes,omitempty" validate:"omitempty"` VolumeMounts []PlaceholderSource `yaml:"volumeMounts,omitempty" json:"volumeMounts,omitempty" validate:"omitempty"` - ClusterRoles []PlaceholderSource `yaml:"clusterRoles,omitempty" json:"clusterRoles,omitempty" validate:"omitempty"` - ClusterRoleBindings []PlaceholderSource `yaml:"clusterRoleBindings,omitempty" json:"clusterRoleBindings,omitempty" validate:"omitempty"` ServiceMonitors []PlaceholderSource `yaml:"serviceMonitors,omitempty" json:"serviceMonitors,omitempty" validate:"omitempty"` PodSecurityPolicies []PlaceholderSource `yaml:"podSecurityPolicies,omitempty" json:"podSecurityPolicies,omitempty" validate:"omitempty"` PriorityClasses []PlaceholderSource `yaml:"priorityClasses,omitempty" json:"priorityClasses,omitempty" validate:"omitempty"` - LimitRanges []PlaceholderSource `yaml:"limitRanges,omitempty" json:"limitRanges,omitempty" validate:"omitempty"` - ResourceQuotas []PlaceholderSource `yaml:"resourceQuotas,omitempty" json:"resourceQuotas,omitempty" validate:"omitempty"` RuntimeClasses []PlaceholderSource `yaml:"runtimeClasses,omitempty" json:"runtimeClasses,omitempty" validate:"omitempty"` PriorityLevelConfigurations []PlaceholderSource `yaml:"priorityLevelConfigurations,omitempty" json:"priorityLevelConfigurations,omitempty" validate:"omitempty"` PodTemplates []PlaceholderSource `yaml:"podTemplates,omitempty" json:"podTemplates,omitempty" validate:"omitempty"` DaemonSets []PlaceholderSource `yaml:"daemonSets,omitempty" json:"daemonSets,omitempty" validate:"omitempty"` - NetworkPolicies []PlaceholderSource `yaml:"networkPolicies,omitempty" json:"networkPolicies,omitempty" validate:"omitempty"` // Storage StorageClasses []PlaceholderSource `yaml:"storageClasses,omitempty" json:"storageClasses,omitempty" validate:"omitempty"` diff --git a/pkg/types/types_limitrange.go b/pkg/types/types_limitrange.go new file mode 100644 index 00000000..2963d657 --- /dev/null +++ b/pkg/types/types_limitrange.go @@ -0,0 +1,117 @@ +// pkg/types/types_limitrange.go +package types + +// LimitRangeTemplateSource declares one LimitRange to be managed by Orkestra. +// +// Usage patterns: +// +// 1. Container defaults: +// +// onCreate: +// limitRanges: +// - name: "{{ .metadata.name }}-limits" +// limits: +// - type: Container +// default: +// cpu: 500m +// memory: 512Mi +// defaultRequest: +// cpu: 250m +// memory: 256Mi +// reconcile: true +// +// 2. Environment-sized limits with template expressions: +// +// onCreate: +// limitRanges: +// - name: "{{ .metadata.name }}-limits" +// limits: +// - type: Container +// default: +// cpu: '{{ if eq .spec.env "production" }}500m{{ else }}200m{{ end }}' +// memory: '{{ if eq .spec.env "production" }}512Mi{{ else }}256Mi{{ end }}' +// +// 3. Copy from existing LimitRange: +// +// onCreate: +// limitRanges: +// - name: team-limits +// fromLimitRange: org-default-limits +// fromNamespace: platform +// +// 4. Copy to multiple namespaces: +// +// onCreate: +// limitRanges: +// - name: team-limits +// fromLimitRange: org-default-limits +// fromNamespace: platform +// toNamespaces: +// - "{{ .metadata.namespace }}" +// - staging +type LimitRangeTemplateSource struct { + // Version — OrkestraRegistry implementation version. Omit for latest. + Version string `yaml:"version,omitempty" json:"version,omitempty"` + + // Name — LimitRange name. + Name string `yaml:"name,omitempty" json:"name,omitempty"` + + // Namespace — target namespace. + // Default: "{{ .metadata.namespace }}" + Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` + + // ToNamespaces — create one copy in each listed namespace. + // Each element supports template expressions. + ToNamespaces []string `yaml:"toNamespaces,omitempty" json:"toNamespaces,omitempty"` + + // FromLimitRange — name of an existing LimitRange to copy from. + // When set, Orkestra reads this LimitRange at reconcile time and copies its limits. + FromLimitRange string `yaml:"fromLimitRange,omitempty" json:"fromLimitRange,omitempty"` + + // FromNamespace — namespace where FromLimitRange lives. + // Default: same namespace as the CR. + FromNamespace string `yaml:"fromNamespace,omitempty" json:"fromNamespace,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"` + + // Labels — applied to LimitRange metadata. + Labels []ResourceLabel `yaml:"labels,omitempty" json:"labels,omitempty"` + + // Conditions (when:) — all must pass for this resource to be applied. + Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` + + // AnyOf — at least one must pass. + AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + + // Reconcile: true — sync on every reconcile (drift correction). + Reconcile bool `yaml:"reconcile,omitempty" json:"reconcile,omitempty"` + + // Sleep injects an artificial delay. + // Accepts extended duration units (s, m, h, d, w, mo, y). + Sleep string `yaml:"sleep,omitempty" json:"sleep,omitempty"` +} + +// LimitRangeItem sets constraints on one resource type within a namespace. +type LimitRangeItem struct { + // Type — the resource type this item applies to. + // One of: Container, Pod, PersistentVolumeClaim. + Type string `yaml:"type" json:"type"` + + // Max — maximum amount of compute resources allowed. + // Keys: cpu, memory, ephemeral-storage. + Max map[string]string `yaml:"max,omitempty" json:"max,omitempty"` + + // Min — minimum amount of compute resources required. + Min map[string]string `yaml:"min,omitempty" json:"min,omitempty"` + + // Default — default limit if not specified in the container spec. + Default map[string]string `yaml:"default,omitempty" json:"default,omitempty"` + + // DefaultRequest — default resource request if not specified in the container spec. + DefaultRequest map[string]string `yaml:"defaultRequest,omitempty" json:"defaultRequest,omitempty"` + + // MaxLimitRequestRatio — max ratio of limit to request for a resource. + MaxLimitRequestRatio map[string]string `yaml:"maxLimitRequestRatio,omitempty" json:"maxLimitRequestRatio,omitempty"` +} diff --git a/pkg/types/types_networkpolicy.go b/pkg/types/types_networkpolicy.go new file mode 100644 index 00000000..54a3f9dd --- /dev/null +++ b/pkg/types/types_networkpolicy.go @@ -0,0 +1,139 @@ +// pkg/types/types_networkpolicy.go +package types + +// NetworkPolicyTemplateSource declares one NetworkPolicy to be managed by Orkestra. +// +// Usage patterns: +// +// 1. Inline spec — deny-all ingress: +// +// onCreate: +// networkPolicies: +// - name: "{{ .metadata.name }}-deny-all" +// podSelector: {} +// ingress: [] +// reconcile: true +// +// 2. Allow same-namespace ingress: +// +// onCreate: +// networkPolicies: +// - name: "{{ .metadata.name }}-allow-same-ns" +// podSelector: {} +// ingress: +// - from: +// - podSelector: {} +// +// 3. Copy from existing NetworkPolicy: +// +// onCreate: +// networkPolicies: +// - name: baseline-policy +// fromNetworkPolicy: org-baseline-policy +// fromNamespace: platform +// +// 4. Copy to multiple namespaces: +// +// onCreate: +// networkPolicies: +// - name: deny-all +// fromNetworkPolicy: org-deny-all +// fromNamespace: platform +// toNamespaces: +// - "{{ .metadata.namespace }}" +// - staging +type NetworkPolicyTemplateSource struct { + // Version — OrkestraRegistry implementation version. Omit for latest. + Version string `yaml:"version,omitempty" json:"version,omitempty"` + + // Name — NetworkPolicy name. + Name string `yaml:"name,omitempty" json:"name,omitempty"` + + // Namespace — target namespace. + // Default: "{{ .metadata.namespace }}" + Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` + + // ToNamespaces — create one copy in each listed namespace. + // Each element supports template expressions. + ToNamespaces []string `yaml:"toNamespaces,omitempty" json:"toNamespaces,omitempty"` + + // FromNetworkPolicy — name of an existing NetworkPolicy to copy spec from. + // When set, Orkestra reads this NetworkPolicy at reconcile time and copies its spec. + FromNetworkPolicy string `yaml:"fromNetworkPolicy,omitempty" json:"fromNetworkPolicy,omitempty"` + + // FromNamespace — namespace where FromNetworkPolicy lives. + // Default: same namespace as the CR. + FromNamespace string `yaml:"fromNamespace,omitempty" json:"fromNamespace,omitempty"` + + // PodSelector — selects the pods this policy applies to. + // Empty map ({}) selects all pods in the namespace. + // Values support template expressions. + PodSelector map[string]string `yaml:"podSelector,omitempty" json:"podSelector,omitempty"` + + // Ingress — list of ingress rules. Empty slice denies all ingress traffic. + Ingress []NetworkPolicyIngressRule `yaml:"ingress,omitempty" json:"ingress,omitempty"` + + // Egress — list of egress rules. Omit to leave egress unmanaged by this policy. + Egress []NetworkPolicyEgressRule `yaml:"egress,omitempty" json:"egress,omitempty"` + + // PolicyTypes — which policy types to enforce. Auto-derived when empty: + // "Ingress" added when Ingress field is present; "Egress" added when Egress is present. + PolicyTypes []string `yaml:"policyTypes,omitempty" json:"policyTypes,omitempty"` + + // Labels — applied to NetworkPolicy metadata. + Labels []ResourceLabel `yaml:"labels,omitempty" json:"labels,omitempty"` + + // Conditions (when:) — all must pass for this resource to be applied. + Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` + + // AnyOf — at least one must pass. + AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + + // Profile — named NetworkPolicy preset. Expands into ingress/egress rules and policy types. + // Allowed values: deny-all, deny-all-ingress, deny-all-egress, allow-same-namespace, allow-dns-egress. + // Mutually exclusive with Ingress/Egress/PolicyTypes — set profile or explicit rules, not both. + Profile string `yaml:"profile,omitempty" json:"profile,omitempty"` + + // Reconcile: true — sync on every reconcile (drift correction). + Reconcile bool `yaml:"reconcile,omitempty" json:"reconcile,omitempty"` + + // Sleep injects an artificial delay. + // Accepts extended duration units (s, m, h, d, w, mo, y). + Sleep string `yaml:"sleep,omitempty" json:"sleep,omitempty"` +} + +// NetworkPolicyIngressRule describes one ingress rule. +type NetworkPolicyIngressRule struct { + From []NetworkPolicyPeer `yaml:"from,omitempty" json:"from,omitempty"` + Ports []NetworkPolicyPort `yaml:"ports,omitempty" json:"ports,omitempty"` +} + +// NetworkPolicyEgressRule describes one egress rule. +type NetworkPolicyEgressRule struct { + To []NetworkPolicyPeer `yaml:"to,omitempty" json:"to,omitempty"` + Ports []NetworkPolicyPort `yaml:"ports,omitempty" json:"ports,omitempty"` +} + +// NetworkPolicyPeer selects a set of pods or namespaces. +type NetworkPolicyPeer struct { + // PodSelector selects pods within the namespace. Empty = all pods. + PodSelector map[string]string `yaml:"podSelector,omitempty" json:"podSelector,omitempty"` + // NamespaceSelector selects namespaces. Empty = all namespaces. + NamespaceSelector map[string]string `yaml:"namespaceSelector,omitempty" json:"namespaceSelector,omitempty"` + // IPBlock selects a CIDR range. + IPBlock *NetworkPolicyIPBlock `yaml:"ipBlock,omitempty" json:"ipBlock,omitempty"` +} + +// NetworkPolicyIPBlock describes an IP CIDR range. +type NetworkPolicyIPBlock struct { + CIDR string `yaml:"cidr" json:"cidr"` + Except []string `yaml:"except,omitempty" json:"except,omitempty"` +} + +// NetworkPolicyPort describes a port allowed by a rule. +type NetworkPolicyPort struct { + // Protocol — TCP (default), UDP, or SCTP. + Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"` + // Port — port number or named port. Supports template expressions. + Port string `yaml:"port,omitempty" json:"port,omitempty"` +} diff --git a/pkg/types/types_rbac.go b/pkg/types/types_rbac.go index b9d0505f..1709ef2b 100644 --- a/pkg/types/types_rbac.go +++ b/pkg/types/types_rbac.go @@ -88,3 +88,64 @@ type RoleBindingTemplateSource struct { // Accepts extended duration units (s, m, h, d, w, mo, y). Sleep string `json:"sleep,omitempty" yaml:"sleep,omitempty"` } + +// ── ClusterRole / ClusterRoleBinding ───────────────────────────────────────── + +// ClusterRoleTemplateSource declares one cluster-scoped ClusterRole to be managed by Orkestra. +// +// ClusterRoles are cluster-scoped — no namespace field. Because Kubernetes cannot +// auto-GC cluster-scoped resources owned by namespace-scoped CRs, ownership is +// tracked via the orkestra.io/owner label. Declare cleanup in onDelete if needed. +// +// Example: +// +// onCreate: +// clusterRoles: +// - name: "{{ .metadata.name }}-cluster-role" +// rules: +// - apiGroups: [""] +// resources: ["namespaces"] +// verbs: ["get", "list", "watch"] +type ClusterRoleTemplateSource struct { + Version string `yaml:"version,omitempty" json:"version,omitempty"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Labels []ResourceLabel `yaml:"labels,omitempty" json:"labels,omitempty"` + Rules []PolicyRuleSpec `yaml:"rules,omitempty" json:"rules,omitempty"` + Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` + Reconcile bool `yaml:"reconcile,omitempty" json:"reconcile,omitempty"` + AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + + // Sleep injects an artificial delay into the reconcile of this resource. + Sleep string `json:"sleep,omitempty" yaml:"sleep,omitempty"` +} + +// ClusterRoleBindingTemplateSource declares one cluster-scoped ClusterRoleBinding to be managed by Orkestra. +// +// ClusterRoleBindings are cluster-scoped — no namespace field. +// Ownership is tracked via the orkestra.io/owner label. +// +// Example: +// +// onCreate: +// clusterRoleBindings: +// - name: "{{ .metadata.name }}-crb" +// roleRef: +// name: "{{ .metadata.name }}-cluster-role" +// kind: ClusterRole +// subjects: +// - kind: ServiceAccount +// name: "{{ .metadata.name }}-sa" +// namespace: "{{ .metadata.namespace }}" +type ClusterRoleBindingTemplateSource struct { + Version string `yaml:"version,omitempty" json:"version,omitempty"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Labels []ResourceLabel `yaml:"labels,omitempty" json:"labels,omitempty"` + RoleRef RoleRefSpec `yaml:"roleRef,omitempty" json:"roleRef,omitempty"` + Subjects []SubjectSpec `yaml:"subjects,omitempty" json:"subjects,omitempty"` + Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` + Reconcile bool `yaml:"reconcile,omitempty" json:"reconcile,omitempty"` + AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + + // Sleep injects an artificial delay into the reconcile of this resource. + Sleep string `json:"sleep,omitempty" yaml:"sleep,omitempty"` +} diff --git a/pkg/types/types_resourcequota.go b/pkg/types/types_resourcequota.go new file mode 100644 index 00000000..95bbb225 --- /dev/null +++ b/pkg/types/types_resourcequota.go @@ -0,0 +1,94 @@ +// pkg/types/types_resourcequota.go +package types + +// ResourceQuotaTemplateSource declares one ResourceQuota to be managed by Orkestra. +// +// Usage patterns: +// +// 1. Inline quota: +// +// onCreate: +// resourceQuotas: +// - name: "{{ .metadata.name }}-quota" +// hard: +// cpu: "4" +// memory: 8Gi +// pods: "20" +// reconcile: true +// +// 2. Environment-sized quota with template expressions: +// +// onCreate: +// resourceQuotas: +// - name: "{{ .metadata.name }}-quota" +// hard: +// cpu: '{{ if eq .spec.env "production" }}16{{ else }}2{{ end }}' +// memory: '{{ if eq .spec.env "production" }}32Gi{{ else }}4Gi{{ end }}' +// +// 3. Copy from existing ResourceQuota: +// +// onCreate: +// resourceQuotas: +// - name: team-quota +// fromResourceQuota: org-default-quota +// fromNamespace: platform +// +// 4. Copy to multiple namespaces: +// +// onCreate: +// resourceQuotas: +// - name: team-quota +// fromResourceQuota: org-default-quota +// fromNamespace: platform +// toNamespaces: +// - "{{ .metadata.namespace }}" +// - staging +type ResourceQuotaTemplateSource struct { + // Version — OrkestraRegistry implementation version. Omit for latest. + Version string `yaml:"version,omitempty" json:"version,omitempty"` + + // Name — ResourceQuota name. + Name string `yaml:"name,omitempty" json:"name,omitempty"` + + // Namespace — target namespace. + // Default: "{{ .metadata.namespace }}" + Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` + + // ToNamespaces — create one copy in each listed namespace. + // Each element supports template expressions. + ToNamespaces []string `yaml:"toNamespaces,omitempty" json:"toNamespaces,omitempty"` + + // FromResourceQuota — name of an existing ResourceQuota to copy from. + // When set, Orkestra reads this ResourceQuota at reconcile time and copies its hard limits. + FromResourceQuota string `yaml:"fromResourceQuota,omitempty" json:"fromResourceQuota,omitempty"` + + // FromNamespace — namespace where FromResourceQuota lives. + // Default: same namespace as the CR. + FromNamespace string `yaml:"fromNamespace,omitempty" json:"fromNamespace,omitempty"` + + // Hard — resource limits. Keys are Kubernetes resource names (cpu, memory, pods, etc.). + // Values are resource quantities. Supports template expressions. + // See: https://kubernetes.io/docs/concepts/policy/resource-quotas/#compute-resource-quota + Hard map[string]string `yaml:"hard,omitempty" json:"hard,omitempty"` + + // Labels — applied to ResourceQuota metadata. + Labels []ResourceLabel `yaml:"labels,omitempty" json:"labels,omitempty"` + + // Conditions (when:) — all must pass for this resource to be applied. + Conditions []Condition `yaml:"when,omitempty" json:"when,omitempty"` + + // AnyOf — at least one must pass. + AnyOf []Condition `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` + + // Reconcile: true — sync on every reconcile (drift correction). + Reconcile bool `yaml:"reconcile,omitempty" json:"reconcile,omitempty"` + + // Profile — named resource quota preset. Expands into hard limits. + // Allowed values: small, medium, large, xlarge. + // Mutually exclusive with Hard — set one or the other, not both. + Profile string `yaml:"profile,omitempty" json:"profile,omitempty"` + + // Sleep injects an artificial delay. + // Accepts extended duration units (s, m, h, d, w, mo, y). + Sleep string `yaml:"sleep,omitempty" json:"sleep,omitempty"` +}