diff --git a/cmd/inspect/inspect.go b/cmd/inspect/inspect.go index 7b59610a7..bfafcaa21 100644 --- a/cmd/inspect/inspect.go +++ b/cmd/inspect/inspect.go @@ -26,6 +26,7 @@ func init() { InspectCmd = NewInspectCmd() InspectCmd.AddCommand(inspectPolicyCmd()) InspectCmd.AddCommand(inspectPolicyDataCmd()) + InspectCmd.AddCommand(inspectECPCmd()) } func NewInspectCmd() *cobra.Command { diff --git a/cmd/inspect/inspect_ecp.go b/cmd/inspect/inspect_ecp.go new file mode 100644 index 000000000..d45152877 --- /dev/null +++ b/cmd/inspect/inspect_ecp.go @@ -0,0 +1,116 @@ +// Copyright The Conforma Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +// Define the `ec inspect ecp` command +package inspect + +import ( + "fmt" + + hd "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + validate_utils "github.com/conforma/cli/internal/validate" +) + +func inspectECPCmd() *cobra.Command { + var ( + policyConfiguration string + policyOverlays []string + ) + + cmd := &cobra.Command{ + Use: "ecp --policy ", + Short: "Inspect and display the effective EnterpriseContractPolicy configuration", + + Long: hd.Doc(` + Load a base EnterpriseContractPolicy configuration and optionally merge it with + overlay files, then display the resulting effective configuration. + + This is useful for debugging and understanding how policy overlays are merged + with the base policy. The output shows the final configuration that would be + used during validation. + `), + + Example: hd.Doc(` + Display a single policy configuration: + + ec inspect ecp --policy policy.yaml + + Display the merged result of a base policy and overlays: + + ec inspect ecp --policy standard.yaml --policy-overlay team.yaml + + Display the result of multiple overlays (applied in order): + + ec inspect ecp --policy standard.yaml \ + --policy-overlay team.yaml \ + --policy-overlay hotfix.yaml + `), + + Args: cobra.NoArgs, + + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Load base policy configuration + policyConfig, err := validate_utils.GetPolicyConfig(ctx, policyConfiguration) + if err != nil { + return fmt.Errorf("failed to load policy: %w", err) + } + + // Merge overlays if provided + if len(policyOverlays) > 0 { + var overlays []string + for _, overlayFile := range policyOverlays { + overlay, err := validate_utils.GetPolicyConfig(ctx, overlayFile) + if err != nil { + return fmt.Errorf("failed to load policy overlay %s: %w", overlayFile, err) + } + overlays = append(overlays, overlay) + } + + mergedConfig, err := validate_utils.MergePolicyConfigs(ctx, policyConfig, overlays) + if err != nil { + return fmt.Errorf("failed to merge policy configs: %w", err) + } + policyConfig = mergedConfig + } + + // Output the effective configuration + fmt.Println(policyConfig) + return nil + }, + } + + cmd.Flags().StringVarP(&policyConfiguration, "policy", "p", policyConfiguration, hd.Doc(` + Policy configuration as: + * Kubernetes reference ([/]) + * file (policy.yaml) + * git reference (github.com/user/repo//default?ref=main), or + * inline JSON ('{sources: {...}, identity: {...}}')`)) + + cmd.Flags().StringSliceVar(&policyOverlays, "policy-overlay", policyOverlays, hd.Doc(` + Policy overlay files to merge with the base policy. Can be specified multiple times. + Overlays are applied in order. Maps are deeply merged, arrays are concatenated. + Supports the same formats as --policy (files, git references, inline JSON/YAML).`)) + + if err := cmd.MarkFlagRequired("policy"); err != nil { + panic(err) + } + + return cmd +} diff --git a/cmd/validate/image.go b/cmd/validate/image.go index 336f1080d..9262afc54 100644 --- a/cmd/validate/image.go +++ b/cmd/validate/image.go @@ -201,6 +201,27 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { } data.policyConfiguration = policyConfiguration + // Merge policy overlays if provided + if len(data.policyOverlays) > 0 { + var overlays []string + for _, overlayFile := range data.policyOverlays { + overlay, err := validate_utils.GetPolicyConfig(ctx, overlayFile) + if err != nil { + allErrors = errors.Join(allErrors, fmt.Errorf("failed to load policy overlay %s: %w", overlayFile, err)) + return + } + overlays = append(overlays, overlay) + } + + mergedConfig, err := validate_utils.MergePolicyConfigs(ctx, data.policyConfiguration, overlays) + if err != nil { + allErrors = errors.Join(allErrors, fmt.Errorf("failed to merge policy configs: %w", err)) + return + } + data.policyConfiguration = mergedConfig + log.Debugf("Merged base policy with %d overlay(s)", len(overlays)) + } + policyOptions := policy.Options{ EffectiveTime: data.effectiveTime, Identity: cosign.Identity{ @@ -484,6 +505,11 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { * git reference (github.com/user/repo//default?ref=main), or * inline JSON ('{sources: {...}, identity: {...}}')")`)) + cmd.Flags().StringSliceVar(&data.policyOverlays, "policy-overlay", data.policyOverlays, hd.Doc(` + Policy overlay files to merge with the base policy. Can be specified multiple times. + Overlays are applied in order. Maps are deeply merged, arrays are concatenated. + Supports the same formats as --policy (files, git references, inline JSON/YAML).`)) + cmd.Flags().StringVarP(&data.imageRef, "image", "i", data.imageRef, "OCI image reference") cmd.Flags().StringVarP(&data.publicKey, "public-key", "k", data.publicKey, @@ -640,6 +666,7 @@ type imageData struct { outputFile string policy policy.Policy policyConfiguration string + policyOverlays []string policySource string publicKey string rekorURL string diff --git a/docs/modules/ROOT/pages/ec_inspect_ecp.adoc b/docs/modules/ROOT/pages/ec_inspect_ecp.adoc new file mode 100644 index 000000000..636aa746f --- /dev/null +++ b/docs/modules/ROOT/pages/ec_inspect_ecp.adoc @@ -0,0 +1,63 @@ += ec inspect ecp + +Inspect and display the effective EnterpriseContractPolicy configuration + +== Synopsis + +Load a base EnterpriseContractPolicy configuration and optionally merge it with +overlay files, then display the resulting effective configuration. + +This is useful for debugging and understanding how policy overlays are merged +with the base policy. The output shows the final configuration that would be +used during validation. + +[source,shell] +---- +ec inspect ecp --policy [flags] +---- + +== Examples +Display a single policy configuration: + + ec inspect ecp --policy policy.yaml + +Display the merged result of a base policy and overlays: + + ec inspect ecp --policy standard.yaml --policy-overlay team.yaml + +Display the result of multiple overlays (applied in order): + + ec inspect ecp --policy standard.yaml \ + --policy-overlay team.yaml \ + --policy-overlay hotfix.yaml + +== Options + +-h, --help:: help for ecp (Default: false) +-p, --policy:: Policy configuration as: + * Kubernetes reference ([/]) + * file (policy.yaml) + * git reference (github.com/user/repo//default?ref=main), or + * inline JSON ('{sources: {...}, identity: {...}}') +--policy-overlay:: Policy overlay files to merge with the base policy. Can be specified multiple times. +Overlays are applied in order. Maps are deeply merged, arrays are concatenated. +Supports the same formats as --policy (files, git references, inline JSON/YAML). (Default: []) + +== Options inherited from parent commands + +--debug:: same as verbose but also show function names and line numbers (Default: false) +--kubeconfig:: path to the Kubernetes config file to use +--logfile:: file to write the logging output. If not specified logging output will be written to stderr +--quiet:: less verbose output (Default: false) +--retry-duration:: base duration for exponential backoff calculation (Default: 1s) +--retry-factor:: exponential backoff multiplier (Default: 2) +--retry-jitter:: randomness factor for backoff calculation (0.0-1.0) (Default: 0.1) +--retry-max-retry:: maximum number of retry attempts (Default: 3) +--retry-max-wait:: maximum wait time between retries (Default: 3s) +--timeout:: max overall execution duration (Default: 5m0s) +--trace:: enable trace logging, set one or more comma separated values: none,all,perf,cpu,mem,opa,log (Default: none) +--verbose:: more verbose output (Default: false) + +== See also + + * xref:ec_inspect.adoc[ec inspect - Inspect policy rules] diff --git a/docs/modules/ROOT/pages/ec_validate_image.adoc b/docs/modules/ROOT/pages/ec_validate_image.adoc index d2d67263f..93faad9bc 100644 --- a/docs/modules/ROOT/pages/ec_validate_image.adoc +++ b/docs/modules/ROOT/pages/ec_validate_image.adoc @@ -149,6 +149,9 @@ mark (?) sign, for example: --output text=output.txt?show-successes=false * file (policy.yaml) * git reference (github.com/user/repo//default?ref=main), or * inline JSON ('{sources: {...}, identity: {...}}')") +--policy-overlay:: Policy overlay files to merge with the base policy. Can be specified multiple times. +Overlays are applied in order. Maps are deeply merged, arrays are concatenated. +Supports the same formats as --policy (files, git references, inline JSON/YAML). (Default: []) -k, --public-key:: path to the public key. Overrides publicKey from EnterpriseContractPolicy -r, --rekor-url:: Rekor URL. Overrides rekorURL from EnterpriseContractPolicy --skip-image-sig-check:: Skip image signature validation checks. (Default: false) diff --git a/docs/modules/ROOT/partials/cli_nav.adoc b/docs/modules/ROOT/partials/cli_nav.adoc index 144c517a0..6bfb8c8f7 100644 --- a/docs/modules/ROOT/partials/cli_nav.adoc +++ b/docs/modules/ROOT/partials/cli_nav.adoc @@ -6,6 +6,7 @@ ** xref:ec_init.adoc[ec init] ** xref:ec_init_policies.adoc[ec init policies] ** xref:ec_inspect.adoc[ec inspect] +** xref:ec_inspect_ecp.adoc[ec inspect ecp] ** xref:ec_inspect_policy.adoc[ec inspect policy] ** xref:ec_inspect_policy-data.adoc[ec inspect policy-data] ** xref:ec_opa.adoc[ec opa] diff --git a/features/__snapshots__/inspect_ecp.snap b/features/__snapshots__/inspect_ecp.snap new file mode 100755 index 000000000..e8b0d459b --- /dev/null +++ b/features/__snapshots__/inspect_ecp.snap @@ -0,0 +1,104 @@ + +[inspect policy with multiple overlays:stdout - 1] +exclude: +- overlay1_exclude +- overlay2_exclude +publicKey: k8s://openshift-pipelines/public-key +ruleData: + key1: value1 + key2: value2 +sources: +- name: Default + policy: + - oci::quay.io/enterprise-contract/ec-release-policy:latest + + +--- + +[overlay values override base values:stdout - 1] +ruleData: + baseKey: baseValue + overlayKey: overlayValue + sharedKey: fromOverlay +sources: +- name: Default + policy: + - oci::quay.io/example/policy:v1 + + +--- + +[inspect policy with multiple overlays:stderr - 1] + +--- + +[overlay values override base values:stderr - 1] + +--- + +[inspect policy with single overlay:stdout - 1] +exclude: +- test.rule_data_provided +- attestation_task_bundle.disallowed_task_reference +publicKey: k8s://openshift-pipelines/public-key +ruleData: + customThreshold: 0.95 + teamName: platform-team +sources: +- name: Default + policy: + - oci::quay.io/enterprise-contract/ec-release-policy:latest + + +--- + +[inspect policy with single overlay:stderr - 1] + +--- + +[deep merge of nested ruleData:stdout - 1] +ruleData: + baseKey: baseValue + overlayKey: overlayValue + sharedKey: fromOverlay +sources: +- name: Default + policy: + - oci::quay.io/example/policy:v1 + + +--- + +[deep merge of nested ruleData:stderr - 1] + +--- + +[arrays are concatenated in overlays:stdout - 1] +ruleData: + baseKey: baseValue + overlayKey: overlayValue + sharedKey: fromOverlay +sources: +- name: Default + policy: + - oci::quay.io/example/policy:v1 + + +--- + +[arrays are concatenated in overlays:stderr - 1] + +--- + +[inspect single policy file:stdout - 1] +sources: + - name: Default + policy: + - "oci::quay.io/enterprise-contract/ec-release-policy:latest" +publicKey: "k8s://openshift-pipelines/public-key" + +--- + +[inspect single policy file:stderr - 1] + +--- diff --git a/features/inspect_ecp.feature b/features/inspect_ecp.feature new file mode 100644 index 000000000..dc3f1cdb3 --- /dev/null +++ b/features/inspect_ecp.feature @@ -0,0 +1,132 @@ +Feature: inspect ecp + The ec command line should be able to inspect and merge EnterpriseContractPolicy configurations + + Scenario: inspect single policy file + Given a file named "base-policy.yaml" containing + """ + sources: + - name: Default + policy: + - "oci::quay.io/enterprise-contract/ec-release-policy:latest" + publicKey: "k8s://openshift-pipelines/public-key" + """ + When ec command is run with "inspect ecp --policy base-policy.yaml" + Then the exit status should be 0 + Then the output should match the snapshot + + Scenario: inspect policy with single overlay + Given a file named "base-policy.yaml" containing + """ + sources: + - name: Default + policy: + - "oci::quay.io/enterprise-contract/ec-release-policy:latest" + publicKey: "k8s://openshift-pipelines/public-key" + """ + Given a file named "team-overlay.yaml" containing + """ + exclude: + - test.rule_data_provided + - attestation_task_bundle.disallowed_task_reference + ruleData: + teamName: platform-team + customThreshold: 0.95 + """ + When ec command is run with "inspect ecp --policy base-policy.yaml --policy-overlay team-overlay.yaml" + Then the exit status should be 0 + Then the output should match the snapshot + + Scenario: inspect policy with multiple overlays + Given a file named "base-policy.yaml" containing + """ + sources: + - name: Default + policy: + - "oci::quay.io/enterprise-contract/ec-release-policy:latest" + exclude: + - base_exclude_1 + """ + Given a file named "overlay1.yaml" containing + """ + exclude: + - overlay1_exclude + ruleData: + key1: value1 + """ + Given a file named "overlay2.yaml" containing + """ + exclude: + - overlay2_exclude + ruleData: + key2: value2 + """ + When ec command is run with "inspect ecp --policy base-policy.yaml --policy-overlay overlay1.yaml --policy-overlay overlay2.yaml" + Then the exit status should be 0 + Then the output should match the snapshot + + Scenario: arrays are concatenated in overlays + Given a file named "base.yaml" containing + """ + sources: + - name: Default + policy: + - "oci::quay.io/example/policy:v1" + exclude: + - rule1 + - rule2 + """ + Given a file named "overlay.yaml" containing + """ + exclude: + - rule3 + - rule4 + """ + When ec command is run with "inspect ecp --policy base.yaml --policy-overlay overlay.yaml" + Then the exit status should be 0 + Then the output should match the snapshot + + Scenario: overlay values override base values + Given a file named "base.yaml" containing + """ + sources: + - name: Default + policy: + - "oci::quay.io/example/policy:v1" + ruleData: + baseKey: baseValue + sharedKey: fromBase + """ + Given a file named "overlay.yaml" containing + """ + ruleData: + overlayKey: overlayValue + sharedKey: fromOverlay + """ + When ec command is run with "inspect ecp --policy base.yaml --policy-overlay overlay.yaml" + Then the exit status should be 0 + Then the output should match the snapshot + + Scenario: deep merge of nested ruleData + Given a file named "base.yaml" containing + """ + sources: + - name: Default + policy: + - "oci::quay.io/example/policy:v1" + ruleData: + level1: + level2: + baseKey: baseValue + sharedKey: fromBase + """ + Given a file named "overlay.yaml" containing + """ + ruleData: + level1: + level2: + overlayKey: overlayValue + sharedKey: fromOverlay + """ + When ec command is run with "inspect ecp --policy base.yaml --policy-overlay overlay.yaml" + Then the exit status should be 0 + Then the output should match the snapshot diff --git a/internal/validate/policy_merge.go b/internal/validate/policy_merge.go new file mode 100644 index 000000000..23c35f543 --- /dev/null +++ b/internal/validate/policy_merge.go @@ -0,0 +1,106 @@ +// Copyright The Conforma Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package validate + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + "sigs.k8s.io/yaml" +) + +// MergePolicyConfigs performs a deep merge of multiple policy configurations. +// The base config is merged with each overlay in order, with later overlays +// taking precedence. Arrays are concatenated and maps are deeply merged. +func MergePolicyConfigs(ctx context.Context, base string, overlays []string) (string, error) { + if len(overlays) == 0 { + return base, nil + } + + log.Debugf("Merging base policy with %d overlay(s)", len(overlays)) + + // Parse base config + var baseData map[string]interface{} + if err := yaml.Unmarshal([]byte(base), &baseData); err != nil { + return "", fmt.Errorf("failed to parse base policy config: %w", err) + } + + // Merge each overlay in order + for i, overlay := range overlays { + var overlayData map[string]interface{} + if err := yaml.Unmarshal([]byte(overlay), &overlayData); err != nil { + return "", fmt.Errorf("failed to parse policy overlay %d: %w", i, err) + } + + log.Debugf("Applying policy overlay %d", i) + baseData = deepMerge(baseData, overlayData) + } + + // Marshal back to YAML + result, err := yaml.Marshal(baseData) + if err != nil { + return "", fmt.Errorf("failed to marshal merged policy config: %w", err) + } + + return string(result), nil +} + +// deepMerge performs a deep merge of two maps. +// - For maps: recursively merge keys, with overlay taking precedence +// - For arrays: concatenate base and overlay +// - For other types: overlay value replaces base value +func deepMerge(base, overlay map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + + // Copy all base values + for k, v := range base { + result[k] = v + } + + // Merge overlay values + for k, overlayVal := range overlay { + baseVal, exists := result[k] + if !exists { + // Key doesn't exist in base, just add it + result[k] = overlayVal + continue + } + + // Both base and overlay have this key - need to merge + baseMap, baseIsMap := baseVal.(map[string]interface{}) + overlayMap, overlayIsMap := overlayVal.(map[string]interface{}) + + if baseIsMap && overlayIsMap { + // Both are maps - recursively merge + result[k] = deepMerge(baseMap, overlayMap) + } else if baseSlice, baseIsSlice := baseVal.([]interface{}); baseIsSlice { + if overlaySlice, overlayIsSlice := overlayVal.([]interface{}); overlayIsSlice { + // Both are slices - concatenate + result[k] = append(baseSlice, overlaySlice...) + } else { + // Type mismatch - overlay wins + result[k] = overlayVal + } + } else { + // Scalar value or type mismatch - overlay wins + result[k] = overlayVal + } + } + + return result +} diff --git a/internal/validate/policy_merge_test.go b/internal/validate/policy_merge_test.go new file mode 100644 index 000000000..2d18709f4 --- /dev/null +++ b/internal/validate/policy_merge_test.go @@ -0,0 +1,214 @@ +// Copyright The Conforma Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build unit + +package validate + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +func TestMergePolicyConfigs_NoOverlays(t *testing.T) { + base := ` +sources: + - policy: + - "git::https://example.com/policy" +` + + result, err := MergePolicyConfigs(context.Background(), base, nil) + require.NoError(t, err) + assert.Equal(t, base, result) +} + +func TestMergePolicyConfigs_SimpleOverlay(t *testing.T) { + base := ` +sources: + - policy: + - "git::https://example.com/policy" +` + + overlay := ` +exclude: + - rule1 + - rule2 +` + + result, err := MergePolicyConfigs(context.Background(), base, []string{overlay}) + require.NoError(t, err) + + var resultData map[string]interface{} + err = yaml.Unmarshal([]byte(result), &resultData) + require.NoError(t, err) + + // Should have both sources and exclude + assert.Contains(t, resultData, "sources") + assert.Contains(t, resultData, "exclude") + + // Exclude should have both rules + exclude := resultData["exclude"].([]interface{}) + assert.Len(t, exclude, 2) +} + +func TestMergePolicyConfigs_MergeRuleData(t *testing.T) { + base := ` +ruleData: + baseKey: baseValue + sharedKey: baseSharedValue +` + + overlay := ` +ruleData: + overlayKey: overlayValue + sharedKey: overlaySharedValue +` + + result, err := MergePolicyConfigs(context.Background(), base, []string{overlay}) + require.NoError(t, err) + + var resultData map[string]interface{} + err = yaml.Unmarshal([]byte(result), &resultData) + require.NoError(t, err) + + ruleData := resultData["ruleData"].(map[string]interface{}) + + // Should have all three keys + assert.Equal(t, "baseValue", ruleData["baseKey"]) + assert.Equal(t, "overlayValue", ruleData["overlayKey"]) + // Shared key should be overridden by overlay + assert.Equal(t, "overlaySharedValue", ruleData["sharedKey"]) +} + +func TestMergePolicyConfigs_ConcatenateArrays(t *testing.T) { + base := ` +exclude: + - rule1 + - rule2 +` + + overlay := ` +exclude: + - rule3 + - rule4 +` + + result, err := MergePolicyConfigs(context.Background(), base, []string{overlay}) + require.NoError(t, err) + + var resultData map[string]interface{} + err = yaml.Unmarshal([]byte(result), &resultData) + require.NoError(t, err) + + exclude := resultData["exclude"].([]interface{}) + assert.Len(t, exclude, 4) + assert.Equal(t, "rule1", exclude[0]) + assert.Equal(t, "rule2", exclude[1]) + assert.Equal(t, "rule3", exclude[2]) + assert.Equal(t, "rule4", exclude[3]) +} + +func TestMergePolicyConfigs_MultipleOverlays(t *testing.T) { + base := ` +sources: + - policy: + - "git::https://example.com/policy" +exclude: + - rule1 +` + + overlay1 := ` +exclude: + - rule2 +ruleData: + key1: value1 +` + + overlay2 := ` +exclude: + - rule3 +ruleData: + key2: value2 +` + + result, err := MergePolicyConfigs(context.Background(), base, []string{overlay1, overlay2}) + require.NoError(t, err) + + var resultData map[string]interface{} + err = yaml.Unmarshal([]byte(result), &resultData) + require.NoError(t, err) + + // Should have sources + assert.Contains(t, resultData, "sources") + + // Should have all three excluded rules + exclude := resultData["exclude"].([]interface{}) + assert.Len(t, exclude, 3) + + // Should have both ruleData keys + ruleData := resultData["ruleData"].(map[string]interface{}) + assert.Equal(t, "value1", ruleData["key1"]) + assert.Equal(t, "value2", ruleData["key2"]) +} + +func TestMergePolicyConfigs_InvalidBase(t *testing.T) { + base := `{invalid yaml` + overlay := `exclude: [rule1]` + + _, err := MergePolicyConfigs(context.Background(), base, []string{overlay}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse base policy config") +} + +func TestMergePolicyConfigs_InvalidOverlay(t *testing.T) { + base := `sources: []` + overlay := `{invalid yaml` + + _, err := MergePolicyConfigs(context.Background(), base, []string{overlay}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse policy overlay") +} + +func TestDeepMerge_NestedMaps(t *testing.T) { + base := map[string]interface{}{ + "level1": map[string]interface{}{ + "level2": map[string]interface{}{ + "key1": "base1", + "key2": "base2", + }, + }, + } + + overlay := map[string]interface{}{ + "level1": map[string]interface{}{ + "level2": map[string]interface{}{ + "key2": "overlay2", + "key3": "overlay3", + }, + }, + } + + result := deepMerge(base, overlay) + + level2 := result["level1"].(map[string]interface{})["level2"].(map[string]interface{}) + assert.Equal(t, "base1", level2["key1"]) + assert.Equal(t, "overlay2", level2["key2"]) + assert.Equal(t, "overlay3", level2["key3"]) +}