From ab2b3d821ed0c367348bc22d8428b09b3f6c7689 Mon Sep 17 00:00:00 2001 From: ialexeze Date: Fri, 26 Jun 2026 16:13:13 +0000 Subject: [PATCH 1/4] feat(resources): add NetworkPolicy, ResourceQuota, LimitRange, ClusterRole, ClusterRoleBinding as first-class types Promotes five resource types from PlaceholderSource stubs to fully implemented Orkestra resources with Create/Update/Delete/CopyToNamespaces/Resolve support. ClusterRole and ClusterRoleBinding are cluster-scoped and cannot carry OwnerReferences to a namespace-scoped CR; ownership is tracked via the labels.OrkestraOwner label instead, matching the pattern used by Namespaces. Fixes IsEmpty() and FilterResources()/MergeFrom() to cover all five new types so motif expander and condition-based resource filtering handle them correctly. --- cmd/cli/helper.go | 10 +- pkg/children/builtins.go | 18 +- pkg/reconciler/run_template_reconcile.go | 20 + .../clusterrolebindings/clusterrolebinding.go | 213 +++++++++ pkg/resources/clusterroles/clusterrole.go | 193 ++++++++ pkg/resources/limitranges/limitrange.go | 353 +++++++++++++++ .../networkpolicies/networkpolicy.go | 428 ++++++++++++++++++ pkg/resources/resourcequotas/resourcequota.go | 326 +++++++++++++ pkg/resources/template/resolver.go | 313 +++++++++++++ pkg/runners/clusterrolebindings.go | 81 ++++ pkg/runners/clusterroles.go | 81 ++++ pkg/runners/limitranges.go | 121 +++++ pkg/runners/networkpolicies.go | 121 +++++ pkg/runners/resourcequotas.go | 121 +++++ pkg/types/hook_methods.go | 20 + pkg/types/hooks_conditions.go | 33 ++ pkg/types/hooks_sleep.go | 52 ++- pkg/types/types_hook_templates.go | 48 +- pkg/types/types_limitrange.go | 117 +++++ pkg/types/types_networkpolicy.go | 139 ++++++ pkg/types/types_rbac.go | 61 +++ pkg/types/types_resourcequota.go | 94 ++++ 22 files changed, 2908 insertions(+), 55 deletions(-) create mode 100644 pkg/resources/clusterrolebindings/clusterrolebinding.go create mode 100644 pkg/resources/clusterroles/clusterrole.go create mode 100644 pkg/resources/limitranges/limitrange.go create mode 100644 pkg/resources/networkpolicies/networkpolicy.go create mode 100644 pkg/resources/resourcequotas/resourcequota.go create mode 100644 pkg/runners/clusterrolebindings.go create mode 100644 pkg/runners/clusterroles.go create mode 100644 pkg/runners/limitranges.go create mode 100644 pkg/runners/networkpolicies.go create mode 100644 pkg/runners/resourcequotas.go create mode 100644 pkg/types/types_limitrange.go create mode 100644 pkg/types/types_networkpolicy.go create mode 100644 pkg/types/types_resourcequota.go 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/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/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_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"` +} From 9ee9099ecea2aa3587c4134d1a39002a3c5fae87 Mon Sep 17 00:00:00 2001 From: ialexeze Date: Fri, 26 Jun 2026 16:13:18 +0000 Subject: [PATCH 2/4] feat(profiles): add NetworkPolicy and ResourceQuota profile families NetworkPolicy profiles: deny-all, deny-all-ingress, deny-all-egress, allow-same-namespace, allow-dns-egress. ResourceQuota profiles: small (2 cpu / 4Gi), medium (4 cpu / 8Gi), large (8 cpu / 16Gi), xlarge (16 cpu / 32Gi). Profiles are applied in each resource package's Resolve() and are mutually exclusive with explicit inline fields. --- pkg/profiles/networkpolicy.go | 93 +++++++++++++++++++++++++++++++++++ pkg/profiles/resourcequota.go | 80 ++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 pkg/profiles/networkpolicy.go create mode 100644 pkg/profiles/resourcequota.go 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 + } +} From 9eb11df02b0b589481487c63082113ad73a4797f Mon Sep 17 00:00:00 2001 From: ialexeze Date: Fri, 26 Jun 2026 16:13:27 +0000 Subject: [PATCH 3/4] feat(katalog): validate NetworkPolicy and ResourceQuota profiles at load time Adds CollectNetworkPolicyProfileEntries / CollectResourceQuotaProfileEntries collectors and wires validateNetworkPolicyProfiles / validateResourceQuotaProfiles into ValidateConfig as steps 30 and 31. Unknown profile names and mixed profile+explicit-fields declarations are both rejected with actionable error messages. Template expressions ({{ ... }}) are deferred to reconcile time. Also adds validateCrossNamespaceOps (step 32) which rejects katalogs where fromNamespace is set without toNamespaces or vice versa. Unit test coverage for all six profile validator families (HPA, PDB, RollingUpdate, NetworkPolicy, ResourceQuota) and their underlying collectors. --- pkg/katalog/validate.go | 18 +++ pkg/katalog/validate_cross_namespace.go | 73 +++++++++++ pkg/katalog/validate_hpa_profile_test.go | 101 +++++++++++++++ pkg/katalog/validate_networkpolicy_profile.go | 51 ++++++++ .../validate_networkpolicy_profile_test.go | 112 +++++++++++++++++ pkg/katalog/validate_pdb_profile_test.go | 100 +++++++++++++++ pkg/katalog/validate_resourcequota_profile.go | 50 ++++++++ .../validate_resourcequota_profile_test.go | 102 +++++++++++++++ .../validate_rolling_update_profile_test.go | 116 +++++++++++++++++ pkg/types/hooks_cross_namespace.go | 95 ++++++++++++++ pkg/types/hooks_hpa_test.go | 101 +++++++++++++++ pkg/types/hooks_networkpolicy_profile.go | 72 +++++++++++ pkg/types/hooks_networkpolicy_profile_test.go | 99 +++++++++++++++ pkg/types/hooks_pdb_test.go | 101 +++++++++++++++ pkg/types/hooks_resourcequota_profile.go | 70 +++++++++++ pkg/types/hooks_resourcequota_profile_test.go | 96 ++++++++++++++ pkg/types/hooks_rolling_update_test.go | 117 ++++++++++++++++++ 17 files changed, 1474 insertions(+) create mode 100644 pkg/katalog/validate_cross_namespace.go create mode 100644 pkg/katalog/validate_hpa_profile_test.go create mode 100644 pkg/katalog/validate_networkpolicy_profile.go create mode 100644 pkg/katalog/validate_networkpolicy_profile_test.go create mode 100644 pkg/katalog/validate_pdb_profile_test.go create mode 100644 pkg/katalog/validate_resourcequota_profile.go create mode 100644 pkg/katalog/validate_resourcequota_profile_test.go create mode 100644 pkg/katalog/validate_rolling_update_profile_test.go create mode 100644 pkg/types/hooks_cross_namespace.go create mode 100644 pkg/types/hooks_hpa_test.go create mode 100644 pkg/types/hooks_networkpolicy_profile.go create mode 100644 pkg/types/hooks_networkpolicy_profile_test.go create mode 100644 pkg/types/hooks_pdb_test.go create mode 100644 pkg/types/hooks_resourcequota_profile.go create mode 100644 pkg/types/hooks_resourcequota_profile_test.go create mode 100644 pkg/types/hooks_rolling_update_test.go 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/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) +} From a19bb3055e078e57a40d74be07f1731bbf9a23b8 Mon Sep 17 00:00:00 2001 From: ialexeze Date: Fri, 26 Jun 2026 16:13:35 +0000 Subject: [PATCH 4/4] docs: add resource-types reference page and ResourceQuota/NetworkPolicy profile docs - documentation/reference/schema/02-katalog/16-resource-types.md: supported and not-yet-supported resource types, ClusterRole ownership note - documentation/concepts/profiles/08-resourcequota-profile.md: small/medium/ large/xlarge presets, mutual exclusivity rules, dynamic profile selection - documentation/concepts/profiles/09-networkpolicy-profile.md: all five profiles, layered composition example, how deny-all works - contributing-resources.md: updated tables to reflect the five newly implemented types --- .../profiles/08-resourcequota-profile.md | 87 ++++++++++++++ .../profiles/09-networkpolicy-profile.md | 104 ++++++++++++++++ documentation/concepts/profiles/index.md | 4 +- .../contributing/contributing-resources.md | 16 +-- .../schema/02-katalog/16-resource-types.md | 111 ++++++++++++++++++ .../reference/schema/02-katalog/index.md | 1 + 6 files changed, 312 insertions(+), 11 deletions(-) create mode 100644 documentation/concepts/profiles/08-resourcequota-profile.md create mode 100644 documentation/concepts/profiles/09-networkpolicy-profile.md create mode 100644 documentation/reference/schema/02-katalog/16-resource-types.md 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 | ---