diff --git a/pkg/module_manager/models/hooks/dependency.go b/pkg/module_manager/models/hooks/dependency.go index ae272a83c..41b638fab 100644 --- a/pkg/module_manager/models/hooks/dependency.go +++ b/pkg/module_manager/models/hooks/dependency.go @@ -10,6 +10,7 @@ import ( gohook "github.com/flant/addon-operator/pkg/module_manager/go_hook" "github.com/flant/addon-operator/pkg/module_manager/models/hooks/kind" "github.com/flant/addon-operator/pkg/utils" + "github.com/flant/addon-operator/pkg/values/validation/defaults" bindingcontext "github.com/flant/shell-operator/pkg/hook/binding_context" "github.com/flant/shell-operator/pkg/hook/config" "github.com/flant/shell-operator/pkg/hook/controller" @@ -32,6 +33,10 @@ type kubeObjectPatcher interface { ExecuteOperations([]sdkpkg.PatchCollectorOperation) error } +type defaultsOverrideApplier interface { + ApplyDefaultsOverride(overrides []defaults.Override) +} + type globalValuesGetter interface { GetValues(bool) utils.Values GetConfigValues(bool) utils.Values @@ -39,12 +44,13 @@ type globalValuesGetter interface { // HookExecutionDependencyContainer container for all hook execution dependencies type HookExecutionDependencyContainer struct { - HookMetricsStorage hooksMetricsStorage - KubeConfigManager kubeConfigManager - KubeObjectPatcher kubeObjectPatcher - MetricStorage metricStorage - GlobalValuesGetter globalValuesGetter - EnvironmentManager *environmentmanager.Manager + HookMetricsStorage hooksMetricsStorage + KubeConfigManager kubeConfigManager + KubeObjectPatcher kubeObjectPatcher + MetricStorage metricStorage + GlobalValuesGetter globalValuesGetter + DefaultsOverrideApplier defaultsOverrideApplier + EnvironmentManager *environmentmanager.Manager } type executableHook interface { diff --git a/pkg/module_manager/models/modules/basic.go b/pkg/module_manager/models/modules/basic.go index 5cbbfcf07..fabf47035 100644 --- a/pkg/module_manager/models/modules/basic.go +++ b/pkg/module_manager/models/modules/basic.go @@ -30,6 +30,7 @@ import ( "github.com/flant/addon-operator/pkg/module_manager/models/hooks/kind" "github.com/flant/addon-operator/pkg/utils" "github.com/flant/addon-operator/pkg/values/validation" + "github.com/flant/addon-operator/pkg/values/validation/defaults" "github.com/flant/addon-operator/sdk" shapp "github.com/flant/shell-operator/pkg/app" "github.com/flant/shell-operator/pkg/executor" @@ -117,6 +118,10 @@ func (bm *BasicModule) WithLogger(logger *log.Logger) { bm.logger = logger } +func (bm *BasicModule) SetDefaultsOverrideContracts(contracts []defaults.OverrideContract) { + bm.valuesStorage.SetDefaultsOverrideContracts(contracts) +} + func (bm *BasicModule) SetCritical(value bool) { bm.critical = value } @@ -1344,6 +1349,11 @@ func (bm *BasicModule) GetSchemaStorage() *validation.SchemaStorage { return bm.valuesStorage.schemaStorage } +// ApplyDefaultsOverride overrides values schema openAPI spec defaults +func (bm *BasicModule) ApplyDefaultsOverride(override defaults.Override) { + bm.valuesStorage.ApplyDefaultsOverride(override) +} + // ApplyNewSchemaStorage updates schema storage of the basic module func (bm *BasicModule) ApplyNewSchemaStorage(schema *validation.SchemaStorage) error { return bm.valuesStorage.applyNewSchemaStorage(schema) diff --git a/pkg/module_manager/models/modules/global.go b/pkg/module_manager/models/modules/global.go index e1e7dfbb4..14b5f2f5d 100644 --- a/pkg/module_manager/models/modules/global.go +++ b/pkg/module_manager/models/modules/global.go @@ -21,6 +21,7 @@ import ( "github.com/flant/addon-operator/pkg/module_manager/models/hooks/kind" "github.com/flant/addon-operator/pkg/utils" "github.com/flant/addon-operator/pkg/values/validation" + "github.com/flant/addon-operator/pkg/values/validation/defaults" "github.com/flant/addon-operator/sdk" bindingcontext "github.com/flant/shell-operator/pkg/hook/binding_context" sh_op_types "github.com/flant/shell-operator/pkg/hook/types" @@ -306,6 +307,8 @@ func (gm *GlobalModule) executeHook(ctx context.Context, h *hooks.GlobalHook, bi if err != nil { return fmt.Errorf("apply enabled patches from global values patch: %v", err) } + + gm.dc.DefaultsOverrideApplier.ApplyDefaultsOverride(defaults.GetOverridesByPatch(*valuesPatch)) } return nil diff --git a/pkg/module_manager/models/modules/values_defaulting_transformers.go b/pkg/module_manager/models/modules/values_defaulting_transformers.go index 652084024..7376bb96a 100644 --- a/pkg/module_manager/models/modules/values_defaulting_transformers.go +++ b/pkg/module_manager/models/modules/values_defaulting_transformers.go @@ -5,6 +5,7 @@ import ( "github.com/flant/addon-operator/pkg/utils" "github.com/flant/addon-operator/pkg/values/validation" + "github.com/flant/addon-operator/pkg/values/validation/defaults" ) type transformer interface { @@ -27,7 +28,7 @@ func (a *applyDefaults) Transform(values utils.Values) utils.Values { } res := values.Copy() - validation.ApplyDefaults(res, s) + defaults.ApplyDefaults(res, s) return res } diff --git a/pkg/module_manager/models/modules/values_storage.go b/pkg/module_manager/models/modules/values_storage.go index 0219dc2fa..6518c41f2 100644 --- a/pkg/module_manager/models/modules/values_storage.go +++ b/pkg/module_manager/models/modules/values_storage.go @@ -6,6 +6,7 @@ import ( "github.com/flant/addon-operator/pkg/utils" "github.com/flant/addon-operator/pkg/values/validation" + "github.com/flant/addon-operator/pkg/values/validation/defaults" ) /* @@ -27,6 +28,8 @@ type ValuesStorage struct { schemaStorage *validation.SchemaStorage moduleName string + overridePolicy *defaults.OverridePolicy + // we are locking the whole storage on any concurrent operation // because it could be called from concurrent hooks (goroutines) and we will have a deadlock on RW mutex lock sync.Mutex @@ -66,14 +69,18 @@ func NewValuesStorage(moduleName string, staticValues utils.Values, configBytes, schemaStorage: schemaStorage, moduleName: moduleName, } - err = vs.calculateResultValues() - if err != nil { + + if err = vs.calculateResultValues(); err != nil { return nil, fmt.Errorf("critical error occurred with calculating values for %q: %w", moduleName, err) } return vs, nil } +func (vs *ValuesStorage) SetDefaultsOverrideContracts(contracts []defaults.OverrideContract) { + vs.overridePolicy = defaults.BuildOverridePolicy(contracts...) +} + func (vs *ValuesStorage) openapiDefaultsTransformer(schemaType validation.SchemaType) transformer { return &applyDefaults{ SchemaType: schemaType, @@ -268,3 +275,14 @@ func (vs *ValuesStorage) getValuesPatches() []utils.ValuesPatch { func (vs *ValuesStorage) GetSchemaStorage() *validation.SchemaStorage { return vs.schemaStorage } + +func (vs *ValuesStorage) ApplyDefaultsOverride(override defaults.Override) { + vs.lock.Lock() + defer vs.lock.Unlock() + + scheme := vs.schemaStorage.Schemas[validation.ValuesSchema] + vs.overridePolicy.ApplyOverride(scheme, override) + + // error could be only if values patch fails to apply + _ = vs.calculateResultValues() +} diff --git a/pkg/module_manager/module_manager.go b/pkg/module_manager/module_manager.go index f294dbb2c..aad31127d 100644 --- a/pkg/module_manager/module_manager.go +++ b/pkg/module_manager/module_manager.go @@ -13,6 +13,7 @@ import ( "github.com/deckhouse/deckhouse/pkg/log" metricsstorage "github.com/deckhouse/deckhouse/pkg/metrics-storage" + "github.com/flant/addon-operator/pkg/values/validation/defaults" "github.com/hashicorp/go-multierror" "go.opentelemetry.io/otel" @@ -1525,6 +1526,17 @@ func (mm *ModuleManager) EnvironmentManagerEnabled() bool { return mm.environmentManager != nil } +func (mm *ModuleManager) ApplyDefaultsOverride(overrides []defaults.Override) { + for _, override := range overrides { + basic := mm.GetModule(override.Target) + if basic == nil { + return + } + + basic.ApplyDefaultsOverride(override) + } +} + // queueHasPendingModuleRunTaskWithStartup returns true if queue has pending tasks // with the type "ModuleRun" related to the module "moduleName" and DoModuleStartup is set to true. func queueHasPendingModuleRunTaskWithStartup(q *queue.TaskQueue, moduleName string) bool { diff --git a/pkg/module_manager/module_manager_hooks.go b/pkg/module_manager/module_manager_hooks.go index 93dfc0510..c02decdef 100644 --- a/pkg/module_manager/module_manager_hooks.go +++ b/pkg/module_manager/module_manager_hooks.go @@ -85,10 +85,11 @@ func (mm *ModuleManager) loadGlobalValues() (*globalValues, error) { func (mm *ModuleManager) registerGlobalModule(globalValues utils.Values, configBytes, valuesBytes []byte) error { // load and registry global hooks dep := hooks.HookExecutionDependencyContainer{ - HookMetricsStorage: mm.dependencies.HookMetricStorage, - KubeConfigManager: mm.dependencies.KubeConfigManager, - KubeObjectPatcher: mm.dependencies.KubeObjectPatcher, - MetricStorage: mm.dependencies.MetricStorage, + HookMetricsStorage: mm.dependencies.HookMetricStorage, + KubeConfigManager: mm.dependencies.KubeConfigManager, + KubeObjectPatcher: mm.dependencies.KubeObjectPatcher, + MetricStorage: mm.dependencies.MetricStorage, + DefaultsOverrideApplier: mm, } gm, err := modules.NewGlobalModule(mm.GlobalHooksDir, globalValues, &dep, configBytes, valuesBytes, shapp.DebugKeepTmpFiles, modules.WithLogger(mm.logger.Named("global-module"))) diff --git a/pkg/values/validation/defaulting.go b/pkg/values/validation/defaults/defaults.go similarity index 98% rename from pkg/values/validation/defaulting.go rename to pkg/values/validation/defaults/defaults.go index 831fb8631..67ae327cd 100644 --- a/pkg/values/validation/defaulting.go +++ b/pkg/values/validation/defaults/defaults.go @@ -1,4 +1,4 @@ -package validation +package defaults import ( "github.com/go-openapi/spec" diff --git a/pkg/values/validation/defaulting_test.go b/pkg/values/validation/defaults/defaults_test.go similarity index 95% rename from pkg/values/validation/defaulting_test.go rename to pkg/values/validation/defaults/defaults_test.go index 741b34289..b1b318b45 100644 --- a/pkg/values/validation/defaulting_test.go +++ b/pkg/values/validation/defaults/defaults_test.go @@ -1,4 +1,4 @@ -package validation_test +package defaults_test import ( "testing" @@ -11,6 +11,7 @@ import ( "github.com/flant/addon-operator/pkg/module_manager/models/modules" "github.com/flant/addon-operator/pkg/utils" "github.com/flant/addon-operator/pkg/values/validation" + "github.com/flant/addon-operator/pkg/values/validation/defaults" ) func Test_ApplyDefaults(t *testing.T) { @@ -72,7 +73,7 @@ properties: s := valueStorage.GetSchemaStorage().Schemas[validation.ConfigValuesSchema] - changed := validation.ApplyDefaults(moduleValues["moduleName"], s) + changed := defaults.ApplyDefaults(moduleValues["moduleName"], s) g.Expect(changed).Should(BeTrue()) g.Expect(moduleValues["moduleName"]).Should(HaveKey("param2")) @@ -162,7 +163,7 @@ properties: s := valueStorage.GetSchemaStorage().Schemas[validation.ConfigValuesSchema] - changed := validation.ApplyDefaults(moduleValues["moduleName"], s) + changed := defaults.ApplyDefaults(moduleValues["moduleName"], s) validator := validate.NewSchemaValidator(s, nil, "", strfmt.Default) diff --git a/pkg/values/validation/defaults/override.go b/pkg/values/validation/defaults/override.go new file mode 100644 index 000000000..211312f01 --- /dev/null +++ b/pkg/values/validation/defaults/override.go @@ -0,0 +1,205 @@ +package defaults + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/go-openapi/spec" + "sigs.k8s.io/yaml" + + "github.com/flant/addon-operator/pkg/utils" +) + +// contractsFile is the default filename for override contracts. +const contractsFile = "override.yaml" + +// OverrideContract defines the rules governing which schema fields may be overridden. +// It is loaded from a YAML file and acts as a guard: only patches targeting +// paths listed in Paths are permitted. +// +// Example YAML (override.yaml): +// +// purpose: "cloud-provider" +// allowedModules: +// - cloud-provider-aws +// - cloud-provider-gcp +// paths: +// - network.podSubnet +// - network.serviceSubnet +type OverrideContract struct { + // Purpose describes the intent of this override contract. + Purpose string `json:"purpose"` + // Allowed lists the module names that may use this contract. + Allowed []string `json:"allowed"` + // Paths maps dot-separated property paths to the list of modules + // that are allowed to override those fields. + Paths []string `json:"paths"` +} + +// OverridePolicy is the resolved, flattened representation of all contracts. +// It maps each property path to its access policy, used at runtime +// to decide which patches are permitted. +type OverridePolicy struct { + // paths maps dot-separated property paths to their access policies. + paths map[string][]string +} + +// Override holds a collection of patches that override +// default values in a schema, submitted by a specific source module. +// +// Example YAML: +// +// source: cloud-provider-aws +// patches: +// - path: network.podSubnet +// value: "10.244.0.0/16" +// - path: network.serviceSubnet +// value: "10.96.0.0/12" +type Override struct { + // Source is the module name that schema should be overridden + Target string + // Patches is an ordered list of default-value overrides to apply. + Patches []Patch `json:"patches"` + // Source is the module name requesting the overrides. + // It is checked against PathPolicy.allowed to determine permission. + Source string `json:"source"` +} + +// Patch defines a single default value override for a schema property. +// Path uses "." as a separator (e.g. "spec.replicas"). +type Patch struct { + // Path is the dot-separated property path within the schema. + Path string `json:"path"` + // Value is the new default value to set on the target property. + Value string `json:"value"` +} + +// ParseOverrideContractsFromDir reads the contracts file (override.yaml) from the given +// directory and unmarshals it into contracts. +func ParseOverrideContractsFromDir(dirPath string) ([]OverrideContract, error) { + raw, err := os.ReadFile(filepath.Join(dirPath, contractsFile)) + if err != nil { + return nil, fmt.Errorf("read contracts file: %w", err) + } + + var c []OverrideContract + if err = yaml.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("unmarshal contracts file: %w", err) + } + + return c, nil +} + +// BuildOverridePolicy flattens the given contracts into a single OverridePolicy. +// Each contract's Allowed modules are associated with every path it declares, +// and multiple contracts contributing the same path merge their allowed lists. +func BuildOverridePolicy(contracts ...OverrideContract) *OverridePolicy { + p := &OverridePolicy{ + paths: make(map[string][]string), + } + + for _, c := range contracts { + for _, path := range c.Paths { + p.paths[path] = append(p.paths[path], c.Allowed...) + } + } + + return p +} + +// GetOverridesByPatch extracts Override entries from a ValuesPatch. +// It scans each operation for a JSON-Patch path whose second segment is +// "override" (e.g. "/override/target") and unmarshals the operation's +// value as a list of Override structs. +func GetOverridesByPatch(valuesPatch utils.ValuesPatch) []Override { + var overrides []Override + + for _, op := range valuesPatch.Operations { + pathParts := strings.Split(op.Path, "/") + if len(pathParts) == 3 { + if pathParts[1] == "override" { + var override Override + if err := yaml.Unmarshal(op.Value, &override.Patches); err != nil { + continue + } + + override.Target = pathParts[2] + + overrides = append(overrides, override) + } + } + } + + return overrides +} + +// ApplyOverride applies permitted default overrides directly to the schema. +// Each patch is checked against the policy: only patches whose paths exist +// in the policy and whose source module is in the path's allowed list are +// applied. Non-matching patches are silently skipped. +func (p *OverridePolicy) ApplyOverride(s *spec.Schema, override Override) { + if p == nil { + return + } + + if len(override.Patches) == 0 || s == nil { + return + } + + if len(s.Properties) == 0 { + s.Properties = make(map[string]spec.Schema) + } + + var allowed Override + for _, patch := range override.Patches { + policy, ok := p.paths[patch.Path] + if !ok { + continue + } + + if slices.Contains(policy, override.Source) { + allowed.Patches = append(allowed.Patches, patch) + } + } + + allowed.transform(s) +} + +// transform walks over every patch and applies it to the schema by +// setting default values on the matching properties. +func (t *Override) transform(s *spec.Schema) { + if s == nil || len(t.Patches) == 0 { + return + } + + for _, override := range t.Patches { + segments := strings.Split(override.Path, ".") + setDefault(s, segments, override.Value) + } +} + +// setDefault walks the schema tree through Properties and sets +// the Default field on the leaf property. +func setDefault(s *spec.Schema, segments []string, value any) { + if len(segments) == 0 || s == nil { + return + } + + propName := segments[0] + prop, exists := s.Properties[propName] + if !exists { + return + } + + if len(segments) == 1 { + prop.Default = value + s.Properties[propName] = prop + return + } + + setDefault(&prop, segments[1:], value) + s.Properties[propName] = prop +} diff --git a/pkg/values/validation/defaults/override_test.go b/pkg/values/validation/defaults/override_test.go new file mode 100644 index 000000000..98855abc7 --- /dev/null +++ b/pkg/values/validation/defaults/override_test.go @@ -0,0 +1,348 @@ +package defaults_test + +import ( + "encoding/json" + "testing" + + sdkutils "github.com/deckhouse/module-sdk/pkg/utils" + "github.com/go-openapi/spec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/flant/addon-operator/pkg/utils" + "github.com/flant/addon-operator/pkg/values/validation/defaults" +) + +func TestParsePolicyByContracts(t *testing.T) { + t.Run("single contract", func(t *testing.T) { + c := defaults.OverrideContract{ + Purpose: "cloud-provider", + Allowed: []string{"cloud-provider-aws"}, + Paths: []string{"network.podSubnet", "network.serviceSubnet"}, + } + + p := defaults.BuildOverridePolicy(c) + require.NotNil(t, p) + }) + + t.Run("no contracts produces valid policy", func(t *testing.T) { + p := defaults.BuildOverridePolicy() + require.NotNil(t, p) + }) +} + +func TestApplyOverride(t *testing.T) { + t.Run("applies permitted patch", func(t *testing.T) { + p := buildPolicy(t, defaults.OverrideContract{ + Allowed: []string{"my-module"}, + Paths: []string{"replicas"}, + }) + s := schemaWithProps("replicas") + + p.ApplyOverride(s, defaults.Override{ + Source: "my-module", + Patches: []defaults.Patch{{Path: "replicas", Value: "3"}}, + }) + + assert.Equal(t, "3", s.Properties["replicas"].Default) + }) + + t.Run("skips patch for disallowed source", func(t *testing.T) { + p := buildPolicy(t, defaults.OverrideContract{ + Allowed: []string{"allowed-module"}, + Paths: []string{"replicas"}, + }) + s := schemaWithProps("replicas") + + p.ApplyOverride(s, defaults.Override{ + Source: "other-module", + Patches: []defaults.Patch{{Path: "replicas", Value: "3"}}, + }) + + assert.Nil(t, s.Properties["replicas"].Default) + }) + + t.Run("skips patch for path not in policy", func(t *testing.T) { + p := buildPolicy(t, defaults.OverrideContract{ + Allowed: []string{"my-module"}, + Paths: []string{"replicas"}, + }) + s := schemaWithProps("replicas", "image") + + p.ApplyOverride(s, defaults.Override{ + Source: "my-module", + Patches: []defaults.Patch{{Path: "image", Value: "nginx"}}, + }) + + assert.Nil(t, s.Properties["image"].Default) + }) + + t.Run("no-op on empty patches", func(t *testing.T) { + p := buildPolicy(t) + s := &spec.Schema{} + + p.ApplyOverride(s, defaults.Override{Source: "m"}) + }) + + t.Run("no-op on nil schema", func(t *testing.T) { + p := buildPolicy(t, defaults.OverrideContract{ + Allowed: []string{"m"}, + Paths: []string{"replicas"}, + }) + + p.ApplyOverride(nil, defaults.Override{ + Source: "m", + Patches: []defaults.Patch{{Path: "replicas", Value: "1"}}, + }) + }) + + t.Run("mixed allowed and disallowed patches", func(t *testing.T) { + p := buildPolicy(t, + defaults.OverrideContract{ + Allowed: []string{"my-module"}, + Paths: []string{"replicas"}, + }, + defaults.OverrideContract{ + Allowed: []string{"other-module"}, + Paths: []string{"image"}, + }, + ) + s := schemaWithProps("replicas", "image") + + p.ApplyOverride(s, defaults.Override{ + Source: "my-module", + Patches: []defaults.Patch{ + {Path: "replicas", Value: "5"}, + {Path: "image", Value: "nginx"}, + }, + }) + + assert.Equal(t, "5", s.Properties["replicas"].Default) + assert.Nil(t, s.Properties["image"].Default) + }) + + t.Run("multiple contracts merge permissions for same path", func(t *testing.T) { + p := buildPolicy(t, + defaults.OverrideContract{ + Allowed: []string{"module-a"}, + Paths: []string{"replicas"}, + }, + defaults.OverrideContract{ + Allowed: []string{"module-b"}, + Paths: []string{"replicas"}, + }, + ) + s := schemaWithProps("replicas") + + p.ApplyOverride(s, defaults.Override{ + Source: "module-b", + Patches: []defaults.Patch{{Path: "replicas", Value: "7"}}, + }) + + assert.Equal(t, "7", s.Properties["replicas"].Default) + }) + + t.Run("applies nested property path", func(t *testing.T) { + p := buildPolicy(t, defaults.OverrideContract{ + Allowed: []string{"my-module"}, + Paths: []string{"network.podSubnet"}, + }) + s := &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "network": { + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "podSubnet": {}, + }, + }, + }, + }, + }, + } + + p.ApplyOverride(s, defaults.Override{ + Source: "my-module", + Patches: []defaults.Patch{{Path: "network.podSubnet", Value: "10.244.0.0/16"}}, + }) + + assert.Equal(t, "10.244.0.0/16", s.Properties["network"].Properties["podSubnet"].Default) + }) + + t.Run("applies deeply nested property path", func(t *testing.T) { + p := buildPolicy(t, defaults.OverrideContract{ + Allowed: []string{"m"}, + Paths: []string{"a.b.c"}, + }) + s := &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "a": { + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "b": { + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "c": {}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + p.ApplyOverride(s, defaults.Override{ + Source: "m", + Patches: []defaults.Patch{{Path: "a.b.c", Value: "deep"}}, + }) + + assert.Equal(t, "deep", s.Properties["a"].Properties["b"].Properties["c"].Default) + }) + + t.Run("skips patch when schema property does not exist", func(t *testing.T) { + p := buildPolicy(t, defaults.OverrideContract{ + Allowed: []string{"m"}, + Paths: []string{"missing"}, + }) + s := schemaWithProps("replicas") + + p.ApplyOverride(s, defaults.Override{ + Source: "m", + Patches: []defaults.Patch{{Path: "missing", Value: "val"}}, + }) + + _, exists := s.Properties["missing"] + assert.False(t, exists) + }) + + t.Run("contract with multiple allowed modules", func(t *testing.T) { + p := buildPolicy(t, defaults.OverrideContract{ + Allowed: []string{"module-a", "module-b"}, + Paths: []string{"replicas"}, + }) + s := schemaWithProps("replicas") + + p.ApplyOverride(s, defaults.Override{ + Source: "module-b", + Patches: []defaults.Patch{{Path: "replicas", Value: "2"}}, + }) + + assert.Equal(t, "2", s.Properties["replicas"].Default) + }) +} + +func TestOverridesByValuesPatch(t *testing.T) { + t.Run("extracts overrides from operations with /override/target path", func(t *testing.T) { + patches := []defaults.Patch{ + {Path: "network.podSubnet", Value: "10.244.0.0/16"}, + } + raw, err := json.Marshal(patches) + require.NoError(t, err) + + vp := utils.ValuesPatch{ + Operations: []*sdkutils.ValuesPatchOperation{ + { + Op: "add", + Path: "/override/global", + Value: raw, + }, + }, + } + + result := defaults.GetOverridesByPatch(vp) + require.Len(t, result, 1) + assert.Equal(t, "cloud-provider-aws", result[0].Source) + assert.Equal(t, "global", result[0].Target) + require.Len(t, result[0].Patches, 1) + assert.Equal(t, "network.podSubnet", result[0].Patches[0].Path) + assert.Equal(t, "10.244.0.0/16", result[0].Patches[0].Value) + }) + + t.Run("skips operations without override path segment", func(t *testing.T) { + vp := utils.ValuesPatch{ + Operations: []*sdkutils.ValuesPatchOperation{ + { + Op: "add", + Path: "/global/someKey", + Value: json.RawMessage(`"value"`), + }, + }, + } + + result := defaults.GetOverridesByPatch(vp) + assert.Empty(t, result) + }) + + t.Run("skips operations with too short path", func(t *testing.T) { + vp := utils.ValuesPatch{ + Operations: []*sdkutils.ValuesPatchOperation{ + { + Op: "add", + Path: "/override", + Value: json.RawMessage(`[]`), + }, + }, + } + + result := defaults.GetOverridesByPatch(vp) + assert.Empty(t, result) + }) + + t.Run("skips operations with too long path", func(t *testing.T) { + vp := utils.ValuesPatch{ + Operations: []*sdkutils.ValuesPatchOperation{ + { + Op: "add", + Path: "/override/target/extra", + Value: json.RawMessage(`[]`), + }, + }, + } + + result := defaults.GetOverridesByPatch(vp) + assert.Empty(t, result) + }) + + t.Run("skips operations with invalid yaml value", func(t *testing.T) { + vp := utils.ValuesPatch{ + Operations: []*sdkutils.ValuesPatchOperation{ + { + Op: "add", + Path: "/override/target", + Value: json.RawMessage(`{invalid`), + }, + }, + } + + result := defaults.GetOverridesByPatch(vp) + assert.Empty(t, result) + }) + + t.Run("returns empty for empty values patch", func(t *testing.T) { + vp := utils.ValuesPatch{} + result := defaults.GetOverridesByPatch(vp) + assert.Empty(t, result) + }) +} + +// buildPolicy is a test helper that creates a OverridePolicy from the given contracts. +func buildPolicy(t *testing.T, contracts ...defaults.OverrideContract) *defaults.OverridePolicy { + t.Helper() + return defaults.BuildOverridePolicy(contracts...) +} + +// schemaWithProps creates a schema with the given top-level property names. +func schemaWithProps(names ...string) *spec.Schema { + props := make(map[string]spec.Schema, len(names)) + for _, n := range names { + props[n] = spec.Schema{} + } + return &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Properties: props, + }, + } +}