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