diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..59f6941b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,232 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this +repository. + +## Overview + +The Configuration Policy Controller is a Kubernetes controller that enforces and evaluates +ConfigurationPolicy resources in Open Cluster Management. It monitors objects on managed clusters, +checks compliance against policy templates, and can automatically remediate non-compliant resources +when set to enforce mode. + +## Build, Test, and Run Commands + +### Building + +```bash +# Build the controller binary +make build + +# Build the dryrun CLI tool +make build-cmd + +# Build container image (configurable with REGISTRY, IMG, TAG env vars) +make build-images +``` + +### Testing + +```bash +# Run unit tests +make test + +# Run unit tests with coverage +make test-coverage + +# Run E2E tests (requires KinD cluster) +make e2e-test + +# Run specific E2E tests +TESTARGS="--focus=" make e2e-test + +# Setup KinD cluster for development +make kind-bootstrap-cluster-dev + +# Deploy controller to KinD and run E2E tests +make kind-tests +``` + +### Running Locally + +```bash +# Run controller locally (must set WATCH_NAMESPACE) +export WATCH_NAMESPACE= +make run + +# The controller requires a Kubernetes cluster configured via kubectl +``` + +### Linting and Formatting + +```bash +# Format code +make fmt +``` + +### Generate Manifests + +```bash +# Generate CRDs and RBAC manifests +make manifests + +# Generate DeepCopy implementations +make generate +``` + +## Architecture + +### Core Components + +**ConfigurationPolicyReconciler** (`controllers/configurationpolicy_controller.go`) + +- Main reconciler that evaluates ConfigurationPolicy resources +- Handles both `inform` (report-only) and `enforce` (remediate) modes +- Uses dynamic client to work with any Kubernetes resource type +- Supports templating with Go templates and sprig functions +- Implements watch-based evaluation for efficient resource monitoring + +**OperatorPolicyReconciler** (`controllers/operatorpolicy_controller.go`) + +- Manages OperatorPolicy resources for OLM operator lifecycle management +- Controls operator subscriptions, CSV status, and upgrade behavior + +**Evaluation Flow**: + +1. Policy is reconciled based on evaluation interval or watch events +2. Templates are resolved (if present) using go-template-utils library +3. Namespace selector determines target namespaces +4. For each object template: + - Determine desired objects (resolving selectors if needed) + - Compare with existing cluster state + - If enforce mode: create, update, or delete objects as needed + - If inform mode: report compliance status only +5. Status is updated with compliance details and related objects +6. Events are emitted to parent Policy resource + +### Key Packages + +**pkg/common** - Shared utilities: + +- `namespace_selection.go`: NamespaceSelector reconciler for efficient namespace filtering +- `common.go`: Helper functions for environment detection + +**pkg/dryrun** - CLI tool for testing policies without a cluster: + +- Simulates policy evaluation using fake clients +- Supports reading cluster resources or using local YAML files +- Provides diff output and compliance message reporting + +**pkg/mappings** - API resource mappings for the dryrun CLI + +**pkg/triggeruninstall** - Handles controller uninstallation cleanup + +### Template Processing + +The controller supports Go templating in object definitions with these special features: + +- Hub templates: can reference objects from the hub cluster (when configured) +- Context variables: `.Object`, `.ObjectName`, `.ObjectNamespace` for dynamic templating per + namespace/object +- Template functions: `fromSecret`, `fromConfigMap`, `fromClusterClaim`, `lookup`, plus sprig + functions +- `skipObject` function: allows conditional object creation based on template logic +- Encryption support: templates can include encrypted values using AES encryption + +### Compliance Types + +- **musthave**: Object must exist and match the specified fields (partial match) +- **mustnothave**: Object must not exist +- **mustonlyhave**: Object must exist and match exactly (no extra fields) + +### Watch vs Polling + +The controller supports two evaluation modes: + +- **Watch mode** (default): Uses Kubernetes watches for efficient real-time evaluation +- **Polling mode**: Periodically evaluates policies based on evaluationInterval + +The dynamic watcher (kubernetes-dependency-watches) automatically manages watches on related +objects. + +### Hosted Mode + +The controller can run in "hosted mode" where: + +- Controller runs on hub cluster +- Evaluates/enforces policies on a separate managed cluster +- Configured via `--target-kubeconfig-path` flag +- Uses separate managers for hub and managed cluster clients + +## Important Patterns + +### Object Comparison + +The comparison logic in `handleSingleKey` and `mergeSpecsHelper` is critical: + +- Merges template values into existing object to avoid false negatives +- Handles arrays specially (preserves duplicates, matches by "name" field) +- Dry-run updates verify actual API behavior before enforcement +- `zeroValueEqualsNil` parameter controls empty value handling + +### Caching and Evaluation Optimization + +- `processedPolicyCache`: Tracks evaluated objects by resourceVersion to avoid redundant comparisons +- `lastEvaluatedCache`: Prevents race conditions with controller-runtime cache staleness +- Evaluation backoff (`--evaluation-backoff`): Throttles frequent policy evaluations + +### Pruning Behavior + +When `pruneObjectBehavior` is set: + +- **DeleteIfCreated**: Removes objects created by the policy +- **DeleteAll**: Removes all objects matching the template +- Tracked via finalizers and object UIDs in status.relatedObjects + +### Dry Run CLI + +The `dryrun` command provides policy testing without cluster modification: + +```bash +# Test policy against local resources +build/_output/bin/dryrun -p policy.yaml resource1.yaml resource2.yaml + +# Test policy against live cluster (read-only) +build/_output/bin/dryrun -p policy.yaml --from-cluster + +# Compare status against expected +build/_output/bin/dryrun -p policy.yaml resources.yaml --desired-status expected-status.yaml +``` + +## Testing Guidelines + +### E2E Test Structure + +- Tests are in `test/e2e/` with descriptive case names +- Use Ginkgo/Gomega framework +- Tests can filter by label: `--label-filter='!hosted-mode'` +- Helper functions in `test/utils/utils.go` for common operations + +### Writing Tests + +- Use `utils.GetWithTimeout` for eventually-consistent checks +- Clean up resources in AfterEach blocks +- Use unique names to avoid test conflicts +- Test both inform and enforce modes where applicable + +## Configuration + +### Controller Flags + +Key flags when running the controller: + +- `--evaluation-concurrency`: Max concurrent policy evaluations (default: 2) +- `--evaluation-backoff`: Seconds before re-evaluation in watch mode (default: 10) +- `--enable-operator-policy`: Enable OperatorPolicy support +- `--target-kubeconfig-path`: Path to managed cluster kubeconfig (hosted mode) +- `--standalone-hub-templates-kubeconfig-path`: Hub cluster for template resolution + +### Environment Variables + +- `WATCH_NAMESPACE`: Namespace to monitor for policies (required when running locally) +- `POD_NAME`: Used to detect controller pod name diff --git a/build/common/Makefile.common.mk b/build/common/Makefile.common.mk index 16909ad9..1b0491c8 100755 --- a/build/common/Makefile.common.mk +++ b/build/common/Makefile.common.mk @@ -3,21 +3,26 @@ ## CLI versions (with links to the latest releases) # https://github.com/kubernetes-sigs/controller-tools/releases/latest -CONTROLLER_GEN_VERSION := v0.16.3 +CONTROLLER_GEN_VERSION := v0.19.0 # https://github.com/kubernetes-sigs/kustomize/releases/latest -KUSTOMIZE_VERSION := v5.6.0 +KUSTOMIZE_VERSION := v5.7.1 # https://github.com/golangci/golangci-lint/releases/latest GOLANGCI_VERSION := v1.64.8 # https://github.com/mvdan/gofumpt/releases/latest -GOFUMPT_VERSION := v0.7.0 +GOFUMPT_VERSION := v0.9.1 # https://github.com/daixiang0/gci/releases/latest -GCI_VERSION := v0.13.5 +GCI_VERSION := v0.13.7 # https://github.com/securego/gosec/releases/latest -GOSEC_VERSION := v2.22.2 +GOSEC_VERSION := v2.22.9 # https://github.com/kubernetes-sigs/kubebuilder/releases/latest -KBVERSION := 3.15.1 -# https://github.com/kubernetes/kubernetes/releases/latest -ENVTEST_K8S_VERSION := 1.30.x +KBVERSION := 4.9.0 +# https://github.com/alexfalkowski/gocovmerge/releases/latest +GOCOVMERGE_VERSION := v2.16.0 +# ref: https://book.kubebuilder.io/reference/envtest.html?highlight=setup-envtest#installation +# Parse the controller-runtime version from go.mod and parse to its release-X.Y git branch +ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}') +# Parse the Kubernetes API version from go.mod (which is v0.Y.Z) and convert to the corresponding v1.Y.Z format +ENVTEST_K8S_VERSION := $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') LOCAL_BIN ?= $(error LOCAL_BIN is not set.) ifneq ($(findstring $(LOCAL_BIN), $(PATH)), $(LOCAL_BIN)) @@ -112,7 +117,8 @@ kubebuilder: .PHONY: envtest envtest: - $(call go-get-tool,sigs.k8s.io/controller-runtime/tools/setup-envtest@latest) + # Installing setup-envtest using the release-X.Y branch from the version specified in go.mod + $(call go-get-tool,sigs.k8s.io/controller-runtime/tools/setup-envtest@$(ENVTEST_VERSION)) .PHONY: gosec gosec: @@ -180,4 +186,4 @@ e2e-dependencies: GOCOVMERGE = $(LOCAL_BIN)/gocovmerge .PHONY: coverage-dependencies coverage-dependencies: - $(call go-get-tool,github.com/wadey/gocovmerge@v0.0.0-20160331181800-b5bfa59ec0ad) + $(call go-get-tool,github.com/alexfalkowski/gocovmerge/v2@$(GOCOVMERGE_VERSION)) diff --git a/controllers/configurationpolicy_controller.go b/controllers/configurationpolicy_controller.go index 04885605..fb7c44f4 100644 --- a/controllers/configurationpolicy_controller.go +++ b/controllers/configurationpolicy_controller.go @@ -1700,7 +1700,7 @@ func (r *ConfigurationPolicyReconciler) determineDesiredObjects( "expected one optional boolean argument but received %d arguments", len(skips)) } - return + return empty, err }, } @@ -2356,7 +2356,7 @@ func (r *ConfigurationPolicyReconciler) handleSingleObj( } } - return + return result, objectProperties } if exists && !obj.shouldExist { @@ -2373,7 +2373,7 @@ func (r *ConfigurationPolicyReconciler) handleSingleObj( result.events = append(result.events, objectTmplEvalEvent{false, reasonWantNotFoundExists, ""}) } - return + return result, objectProperties } if !exists && !obj.shouldExist { @@ -2381,7 +2381,7 @@ func (r *ConfigurationPolicyReconciler) handleSingleObj( // it is a must not have and it does not exist, so it is compliant result.events = append(result.events, objectTmplEvalEvent{true, reasonWantNotFoundDNE, ""}) - return + return result, objectProperties } // object exists and the template requires it, so we need to check specific fields to see if we have a match @@ -2458,7 +2458,7 @@ func (r *ConfigurationPolicyReconciler) handleSingleObj( } } - return + return result, objectProperties } // getMapping takes in a raw object, decodes it, and maps it to an existing group/kind @@ -3565,7 +3565,7 @@ func handleKeys( } } - return + return throwSpecViolation, message, updateNeeded, statusMismatch, missingKey } func removeFieldsForComparison(obj *unstructured.Unstructured) { diff --git a/controllers/configurationpolicy_utils.go b/controllers/configurationpolicy_utils.go index 7d69f9f8..bae606e2 100644 --- a/controllers/configurationpolicy_utils.go +++ b/controllers/configurationpolicy_utils.go @@ -617,7 +617,7 @@ func createStatus( compliancyDetailsMsg = getCombinedCompliancyDetailsMsg(msgMap, resourceName, compliancyDetailsMsg) } - return + return compliant, compliancyDetailsReason, compliancyDetailsMsg } func setCompliancyDetailsMsgEnd(compliancyDetailsMsg string) string { diff --git a/deploy/crds/kustomize_configurationpolicy/policy.open-cluster-management.io_configurationpolicies.yaml b/deploy/crds/kustomize_configurationpolicy/policy.open-cluster-management.io_configurationpolicies.yaml index 315e3a86..66556c37 100644 --- a/deploy/crds/kustomize_configurationpolicy/policy.open-cluster-management.io_configurationpolicies.yaml +++ b/deploy/crds/kustomize_configurationpolicy/policy.open-cluster-management.io_configurationpolicies.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.19.0 name: configurationpolicies.policy.open-cluster-management.io spec: group: policy.open-cluster-management.io diff --git a/deploy/crds/kustomize_operatorpolicy/policy.open-cluster-management.io_operatorpolicies.yaml b/deploy/crds/kustomize_operatorpolicy/policy.open-cluster-management.io_operatorpolicies.yaml index f842ae70..e02f4a76 100644 --- a/deploy/crds/kustomize_operatorpolicy/policy.open-cluster-management.io_operatorpolicies.yaml +++ b/deploy/crds/kustomize_operatorpolicy/policy.open-cluster-management.io_operatorpolicies.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.19.0 name: operatorpolicies.policy.open-cluster-management.io spec: group: policy.open-cluster-management.io diff --git a/deploy/crds/policy.open-cluster-management.io_configurationpolicies.yaml b/deploy/crds/policy.open-cluster-management.io_configurationpolicies.yaml index 7b6e1a43..f37bc10f 100644 --- a/deploy/crds/policy.open-cluster-management.io_configurationpolicies.yaml +++ b/deploy/crds/policy.open-cluster-management.io_configurationpolicies.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.19.0 labels: policy.open-cluster-management.io/policy-type: template name: configurationpolicies.policy.open-cluster-management.io diff --git a/deploy/crds/policy.open-cluster-management.io_operatorpolicies.yaml b/deploy/crds/policy.open-cluster-management.io_operatorpolicies.yaml index 7026a80f..b56875cf 100644 --- a/deploy/crds/policy.open-cluster-management.io_operatorpolicies.yaml +++ b/deploy/crds/policy.open-cluster-management.io_operatorpolicies.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.19.0 labels: policy.open-cluster-management.io/policy-type: template name: operatorpolicies.policy.open-cluster-management.io diff --git a/pkg/dryrun/cmd.go b/pkg/dryrun/cmd.go index 2d41a1b0..c357c707 100644 --- a/pkg/dryrun/cmd.go +++ b/pkg/dryrun/cmd.go @@ -21,6 +21,7 @@ type DryRunner struct { logPath string noColors bool fullDiffs bool + fromCluster bool } var ErrNonCompliant = errors.New("policy is NonCompliant") @@ -105,6 +106,18 @@ func (d *DryRunner) GetCmd() *cobra.Command { "the DRYRUN_MAPPINGS_FILE environment variable.", ) + fromCluster := os.Getenv("DRYRUN_FROM_CLUSTER") == "true" // false if not set + + cmd.Flags().BoolVar( + &d.fromCluster, + "from-cluster", + fromCluster, + "Read the current state of resources from the currently configured Kubernetes cluster instead of "+ + "from input files. Uses the default kubeconfig or KUBECONFIG environment variable. "+ + "Any input files representing the cluster state are ignored. "+ + "Can also be set via the DRYRUN_FROM_CLUSTER environment variable.", + ) + cmd.AddCommand(&cobra.Command{ Use: "generate", Short: "Generate an API Mappings file", diff --git a/pkg/dryrun/dryrun.go b/pkg/dryrun/dryrun.go index 58ef48a9..59172948 100644 --- a/pkg/dryrun/dryrun.go +++ b/pkg/dryrun/dryrun.go @@ -30,8 +30,10 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" dynfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes" clientsetfake "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/record" klog "k8s.io/klog/v2" parentpolicyv1 "open-cluster-management.io/governance-policy-propagator/api/v1" @@ -57,11 +59,6 @@ func (d *DryRunner) dryRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("unable to read input policy: %w", err) } - inputObjects, err := d.readInputResources(cmd, args) - if err != nil { - return fmt.Errorf("unable to read input resources: %w", err) - } - if err := d.setupLogs(); err != nil { return fmt.Errorf("unable to setup the logging configuration: %w", err) } @@ -74,43 +71,15 @@ func (d *DryRunner) dryRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("unable to setup the dryrun reconciler: %w", err) } - // Apply the user's resources to the fake cluster - for _, obj := range inputObjects { - gvk := obj.GroupVersionKind() - - scopedGVR, err := rec.DynamicWatcher.GVKToGVR(gvk) + if !d.fromCluster { + inputObjects, err := d.readInputResources(cmd, args) if err != nil { - if errors.Is(err, depclient.ErrNoVersionedResource) { - return fmt.Errorf("%w for kind %v: if this is a custom resource, it may need an "+ - "entry in the mappings file", err, gvk.Kind) - } - - return fmt.Errorf("unable to apply an input resource: %w", err) + return fmt.Errorf("unable to read input resources: %w", err) } - var resInt dynamic.ResourceInterface - - if scopedGVR.Namespaced { - if obj.GetNamespace() == "" { - obj.SetNamespace("default") - } - - resInt = rec.TargetK8sDynamicClient.Resource(scopedGVR.GroupVersionResource).Namespace(obj.GetNamespace()) - } else { - resInt = rec.TargetK8sDynamicClient.Resource(scopedGVR.GroupVersionResource) - } - - sanitizeForCreation(obj) - - if _, err := resInt.Create(ctx, obj, metav1.CreateOptions{}); err != nil && - !k8serrors.IsAlreadyExists(err) { - return fmt.Errorf("unable to apply an input resource: %w", err) - } - - // Manually convert resources from the dynamic client to the runtime client - err = rec.Client.Create(ctx, obj) - if err != nil && !k8serrors.IsAlreadyExists(err) { - return err + err = d.applyInputResources(ctx, rec, inputObjects) + if err != nil { + return fmt.Errorf("unable to apply input resources: %w", err) } } @@ -367,6 +336,54 @@ func (d *DryRunner) readInputResources(cmd *cobra.Command, args []string) ( return rawInputs, nil } +// applyInputResources applies the user's resources to the fake cluster +func (d *DryRunner) applyInputResources( + ctx context.Context, + rec *ctrl.ConfigurationPolicyReconciler, + inputObjects []*unstructured.Unstructured, +) error { + for _, obj := range inputObjects { + gvk := obj.GroupVersionKind() + + scopedGVR, err := rec.DynamicWatcher.GVKToGVR(gvk) + if err != nil { + if errors.Is(err, depclient.ErrNoVersionedResource) { + return fmt.Errorf("%w for kind %v: if this is a custom resource, it may need an "+ + "entry in the mappings file", err, gvk.Kind) + } + + return err + } + + var resInt dynamic.ResourceInterface + + if scopedGVR.Namespaced { + if obj.GetNamespace() == "" { + obj.SetNamespace("default") + } + + resInt = rec.TargetK8sDynamicClient.Resource(scopedGVR.GroupVersionResource).Namespace(obj.GetNamespace()) + } else { + resInt = rec.TargetK8sDynamicClient.Resource(scopedGVR.GroupVersionResource) + } + + sanitizeForCreation(obj) + + if _, err := resInt.Create(ctx, obj, metav1.CreateOptions{}); err != nil && + !k8serrors.IsAlreadyExists(err) { + return err + } + + // Manually convert resources from the dynamic client to the runtime client + err = rec.Client.Create(ctx, obj) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return err + } + } + + return nil +} + // setupLogs configures klog and the controller-runtime logger to send logs to the // path defined in the configuration. If that option is empty, logs will be discarded. func (d *DryRunner) setupLogs() error { @@ -417,10 +434,34 @@ func (d *DryRunner) setupReconciler( return nil, err } - dynamicClient := dynfake.NewSimpleDynamicClient(scheme.Scheme) - clientset := clientsetfake.NewSimpleClientset() - watcherReconciler, _ := depclient.NewControllerRuntimeSource() + runtimeClient := clientfake.NewClientBuilder(). + WithScheme(scheme.Scheme). + WithObjects(configPolCRD, cfgPolicy). + WithStatusSubresource(cfgPolicy). + Build() + nsSelUpdatesChan := make(chan event.GenericEvent, 20) + + var clientset kubernetes.Interface + var dynamicClient dynamic.Interface + var nsSelReconciler common.NamespaceSelectorReconciler + + if d.fromCluster { + var nsSelClient client.Client + var err error + + clientset, dynamicClient, nsSelClient, err = setupClusterClients() + if err != nil { + return nil, err + } + + nsSelReconciler = common.NewNamespaceSelectorReconciler(nsSelClient, nsSelUpdatesChan) + } else { + dynamicClient = dynfake.NewSimpleDynamicClient(scheme.Scheme) + clientset = clientsetfake.NewSimpleClientset() + nsSelReconciler = common.NewNamespaceSelectorReconciler(runtimeClient, nsSelUpdatesChan) + } + watcherReconciler, _ := depclient.NewControllerRuntimeSource() dynamicWatcher := depclient.NewWithClients( dynamicClient, clientset.Discovery(), @@ -435,14 +476,28 @@ func (d *DryRunner) setupReconciler( } }() - runtimeClient := clientfake.NewClientBuilder(). - WithScheme(scheme.Scheme). - WithObjects(configPolCRD, cfgPolicy). - WithStatusSubresource(cfgPolicy). - Build() + rec := ctrl.ConfigurationPolicyReconciler{ + Client: runtimeClient, + DecryptionConcurrency: 1, + DynamicWatcher: dynamicWatcher, + Scheme: scheme.Scheme, + Recorder: record.NewFakeRecorder(8), + InstanceName: "policy-cli", + TargetK8sClient: clientset, + TargetK8sDynamicClient: dynamicClient, + SelectorReconciler: &nsSelReconciler, + EnableMetrics: false, + UninstallMode: false, + EvalBackoffSeconds: 5, + FullDiffs: d.fullDiffs, + } - nsSelUpdatesChan := make(chan event.GenericEvent, 20) - nsSelReconciler := common.NewNamespaceSelectorReconciler(runtimeClient, nsSelUpdatesChan) + // wait for dynamic watcher to have started + <-rec.DynamicWatcher.Started() + + if d.fromCluster { + return &rec, nil + } defaultNs := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -467,21 +522,7 @@ func (d *DryRunner) setupReconciler( return nil, err } - rec := ctrl.ConfigurationPolicyReconciler{ - Client: runtimeClient, - DecryptionConcurrency: 1, - DynamicWatcher: dynamicWatcher, - Scheme: scheme.Scheme, - Recorder: record.NewFakeRecorder(8), - InstanceName: "policy-cli", - TargetK8sClient: clientset, - TargetK8sDynamicClient: dynamicClient, - SelectorReconciler: &nsSelReconciler, - EnableMetrics: false, - UninstallMode: false, - EvalBackoffSeconds: 5, - FullDiffs: d.fullDiffs, - } + fakeClientset := clientset.(*clientsetfake.Clientset) if d.mappingsPath != "" { mFile, err := os.ReadFile(d.mappingsPath) @@ -494,19 +535,16 @@ func (d *DryRunner) setupReconciler( return nil, err } - clientset.Resources = mappings.ResourceLists(apiMappings) + fakeClientset.Resources = mappings.ResourceLists(apiMappings) } else { - clientset.Resources, err = mappings.DefaultResourceLists() + fakeClientset.Resources, err = mappings.DefaultResourceLists() if err != nil { return nil, err } } // Add open-cluster-management policy CRD - addSupportedResources(clientset) - - // wait for dynamic watcher to have started - <-rec.DynamicWatcher.Started() + addSupportedResources(fakeClientset) return &rec, nil } @@ -622,6 +660,40 @@ func sanitizeForCreation(obj *unstructured.Unstructured) { delete(obj.Object["metadata"].(map[string]interface{}), "uid") } +func setupClusterClients() (kubernetes.Interface, dynamic.Interface, client.Client, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, &clientcmd.ConfigOverrides{}, + ) + + kubeConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, nil, nil, err + } + + clientset, err := kubernetes.NewForConfig(kubeConfig) + if err != nil { + return nil, nil, nil, err + } + + dynamicClient, err := dynamic.NewForConfig(kubeConfig) + if err != nil { + return nil, nil, nil, err + } + + readOnlyMode := true // Prevent modifications to the cluster + + runtimeClient, err := client.New(kubeConfig, client.Options{ + Scheme: scheme.Scheme, + DryRun: &readOnlyMode, + }) + if err != nil { + return nil, nil, nil, err + } + + return clientset, dynamicClient, runtimeClient, nil +} + func addSupportedResources(clientset *clientsetfake.Clientset) { clientset.Resources = append(clientset.Resources, &metav1.APIResourceList{ GroupVersion: parentpolicyv1.GroupVersion.String(), diff --git a/pkg/dryrun/policy.open-cluster-management.io_configurationpolicies.yaml b/pkg/dryrun/policy.open-cluster-management.io_configurationpolicies.yaml index 7b6e1a43..f37bc10f 100644 --- a/pkg/dryrun/policy.open-cluster-management.io_configurationpolicies.yaml +++ b/pkg/dryrun/policy.open-cluster-management.io_configurationpolicies.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.3 + controller-gen.kubebuilder.io/version: v0.19.0 labels: policy.open-cluster-management.io/policy-type: template name: configurationpolicies.policy.open-cluster-management.io diff --git a/test/dryrun/context_vars/object_cluster_scoped/input.yaml b/test/dryrun/context_vars/object_cluster_scoped/input_ns.yaml similarity index 100% rename from test/dryrun/context_vars/object_cluster_scoped/input.yaml rename to test/dryrun/context_vars/object_cluster_scoped/input_ns.yaml diff --git a/test/dryrun/context_vars/object_cluster_scoped/output_from_cluster.txt b/test/dryrun/context_vars/object_cluster_scoped/output_from_cluster.txt new file mode 100644 index 00000000..8dbf13d5 --- /dev/null +++ b/test/dryrun/context_vars/object_cluster_scoped/output_from_cluster.txt @@ -0,0 +1,15 @@ +# Diffs: +v1 Namespace mega-mart: +--- mega-mart : existing ++++ mega-mart : updated +@@ -5,10 +5,12 @@ + city: durham + labels: + box: big ++ name: mega-mart ++ new-label: durham + name: mega-mart + spec: + finalizers: +# Compliance messages: +NonCompliant; violation - namespaces [mega-mart] found but not as specified diff --git a/test/dryrun/context_vars/object_namespaced/input.yaml b/test/dryrun/context_vars/object_namespaced/input.yaml index 51fff337..eb5ff5aa 100644 --- a/test/dryrun/context_vars/object_namespaced/input.yaml +++ b/test/dryrun/context_vars/object_namespaced/input.yaml @@ -1,12 +1,5 @@ --- apiVersion: v1 -kind: Namespace -metadata: - name: mega-mart - labels: - box: big ---- -apiVersion: v1 kind: ConfigMap metadata: name: inventory diff --git a/test/dryrun/context_vars/object_namespaced/input_ns.yaml b/test/dryrun/context_vars/object_namespaced/input_ns.yaml new file mode 100644 index 00000000..8fa457d0 --- /dev/null +++ b/test/dryrun/context_vars/object_namespaced/input_ns.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mega-mart + labels: + box: big + diff --git a/test/dryrun/context_vars/object_namespaced/output_from_cluster.txt b/test/dryrun/context_vars/object_namespaced/output_from_cluster.txt new file mode 100644 index 00000000..c24882d1 --- /dev/null +++ b/test/dryrun/context_vars/object_namespaced/output_from_cluster.txt @@ -0,0 +1,16 @@ +# Diffs: +v1 ConfigMap mega-mart/inventory: +--- mega-mart/inventory : existing ++++ mega-mart/inventory : updated +@@ -2,10 +2,12 @@ + data: + inventory.yaml: 'appliance: toaster' + kind: ConfigMap + metadata: ++ labels: ++ new-label: toaster + name: inventory + namespace: mega-mart + +# Compliance messages: +NonCompliant; violation - configmaps [inventory] found but not as specified in namespace mega-mart diff --git a/test/dryrun/context_vars/object_namespaced/policy.yaml b/test/dryrun/context_vars/object_namespaced/policy.yaml index c1b403e6..e323101a 100644 --- a/test/dryrun/context_vars/object_namespaced/policy.yaml +++ b/test/dryrun/context_vars/object_namespaced/policy.yaml @@ -19,4 +19,4 @@ spec: name: '{{ .ObjectName }}' namespace: '{{ .ObjectNamespace }}' labels: - new-label: '{{ (fromYAML (index .Object.data "inventory.yaml")).appliance }}' + new-label: '{{ ne (printf "%s" .ObjectName) "inventory" | skipObject }}{{ (fromYAML (index .Object.data "inventory.yaml")).appliance }}' diff --git a/test/dryrun/context_vars/object_pod/output_from_cluster.txt b/test/dryrun/context_vars/object_pod/output_from_cluster.txt new file mode 100644 index 00000000..363ae900 --- /dev/null +++ b/test/dryrun/context_vars/object_pod/output_from_cluster.txt @@ -0,0 +1,15 @@ +# Diffs: +v1 Pod default/nginx-pod: +--- default/nginx-pod : existing ++++ default/nginx-pod : updated +@@ -1,9 +1,11 @@ + apiVersion: v1 + kind: Pod + metadata: ++ labels: ++ image: nginx:1.7.9 + name: nginx-pod + namespace: default + spec: +# Compliance messages: +NonCompliant; violation - pods [nginx-pod] found but not as specified in namespace default diff --git a/test/dryrun/context_vars/object_pod_default_func/input.yaml b/test/dryrun/context_vars/object_pod_default_func/input.yaml index ed526672..3f7c15cb 100644 --- a/test/dryrun/context_vars/object_pod_default_func/input.yaml +++ b/test/dryrun/context_vars/object_pod_default_func/input.yaml @@ -1,10 +1,5 @@ --- apiVersion: v1 -kind: Namespace -metadata: - name: dangler ---- -apiVersion: v1 kind: Pod metadata: name: nginx-pod diff --git a/test/dryrun/context_vars/object_pod_default_func/input_ns.yaml b/test/dryrun/context_vars/object_pod_default_func/input_ns.yaml new file mode 100644 index 00000000..2d77ff4d --- /dev/null +++ b/test/dryrun/context_vars/object_pod_default_func/input_ns.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: dangler + diff --git a/test/dryrun/context_vars/object_pod_default_func/output_from_cluster.txt b/test/dryrun/context_vars/object_pod_default_func/output_from_cluster.txt new file mode 100644 index 00000000..a70641c1 --- /dev/null +++ b/test/dryrun/context_vars/object_pod_default_func/output_from_cluster.txt @@ -0,0 +1,17 @@ +# Diffs: +v1 Pod dangler/nginx-pod: + +v1 Pod default/nginx-pod: +--- default/nginx-pod : existing ++++ default/nginx-pod : updated +@@ -1,9 +1,11 @@ + apiVersion: v1 + kind: Pod + metadata: ++ labels: ++ image: nginx:1.7.9 + name: nginx-pod + namespace: default + spec: +# Compliance messages: +NonCompliant; violation - pods [nginx-pod] not found in namespace dangler; pods [nginx-pod] found but not as specified in namespace default diff --git a/test/dryrun/context_vars/object_pod_nsselector/input.yaml b/test/dryrun/context_vars/object_pod_nsselector/input.yaml index ed526672..3f7c15cb 100644 --- a/test/dryrun/context_vars/object_pod_nsselector/input.yaml +++ b/test/dryrun/context_vars/object_pod_nsselector/input.yaml @@ -1,10 +1,5 @@ --- apiVersion: v1 -kind: Namespace -metadata: - name: dangler ---- -apiVersion: v1 kind: Pod metadata: name: nginx-pod diff --git a/test/dryrun/context_vars/object_pod_nsselector/input_ns.yaml b/test/dryrun/context_vars/object_pod_nsselector/input_ns.yaml new file mode 100644 index 00000000..2d77ff4d --- /dev/null +++ b/test/dryrun/context_vars/object_pod_nsselector/input_ns.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: dangler + diff --git a/test/dryrun/context_vars/object_templated_ns/input.yaml b/test/dryrun/context_vars/object_templated_ns/input.yaml index 51fff337..eb5ff5aa 100644 --- a/test/dryrun/context_vars/object_templated_ns/input.yaml +++ b/test/dryrun/context_vars/object_templated_ns/input.yaml @@ -1,12 +1,5 @@ --- apiVersion: v1 -kind: Namespace -metadata: - name: mega-mart - labels: - box: big ---- -apiVersion: v1 kind: ConfigMap metadata: name: inventory diff --git a/test/dryrun/context_vars/object_templated_ns/input_ns.yaml b/test/dryrun/context_vars/object_templated_ns/input_ns.yaml new file mode 100644 index 00000000..8fa457d0 --- /dev/null +++ b/test/dryrun/context_vars/object_templated_ns/input_ns.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mega-mart + labels: + box: big + diff --git a/test/dryrun/context_vars/object_unnamed_objdef/input.yaml b/test/dryrun/context_vars/object_unnamed_objdef/input.yaml index 51fff337..eb5ff5aa 100644 --- a/test/dryrun/context_vars/object_unnamed_objdef/input.yaml +++ b/test/dryrun/context_vars/object_unnamed_objdef/input.yaml @@ -1,12 +1,5 @@ --- apiVersion: v1 -kind: Namespace -metadata: - name: mega-mart - labels: - box: big ---- -apiVersion: v1 kind: ConfigMap metadata: name: inventory diff --git a/test/dryrun/context_vars/object_unnamed_objdef/input_ns.yaml b/test/dryrun/context_vars/object_unnamed_objdef/input_ns.yaml new file mode 100644 index 00000000..8fa457d0 --- /dev/null +++ b/test/dryrun/context_vars/object_unnamed_objdef/input_ns.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mega-mart + labels: + box: big + diff --git a/test/dryrun/context_vars/objectname_nsselector/input.yaml b/test/dryrun/context_vars/objectname_nsselector/input.yaml index 969ec42f..68c001c4 100644 --- a/test/dryrun/context_vars/objectname_nsselector/input.yaml +++ b/test/dryrun/context_vars/objectname_nsselector/input.yaml @@ -1,20 +1,5 @@ --- apiVersion: v1 -kind: Namespace -metadata: - name: mega-mart ---- -apiVersion: v1 -kind: Namespace -metadata: - name: mega-mart-2 ---- -apiVersion: v1 -kind: Namespace -metadata: - name: mega-mart-3 ---- -apiVersion: v1 kind: ConfigMap metadata: name: inventory diff --git a/test/dryrun/context_vars/objectname_nsselector/input_ns.yaml b/test/dryrun/context_vars/objectname_nsselector/input_ns.yaml new file mode 100644 index 00000000..c22db20a --- /dev/null +++ b/test/dryrun/context_vars/objectname_nsselector/input_ns.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mega-mart +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mega-mart-2 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mega-mart-3 + diff --git a/test/dryrun/context_vars/objectname_nsselector/output_from_cluster.txt b/test/dryrun/context_vars/objectname_nsselector/output_from_cluster.txt new file mode 100644 index 00000000..21a4efc8 --- /dev/null +++ b/test/dryrun/context_vars/objectname_nsselector/output_from_cluster.txt @@ -0,0 +1,36 @@ +# Diffs: +v1 ConfigMap mega-mart/inventory: +--- mega-mart/inventory : existing ++++ mega-mart/inventory : updated +@@ -1,6 +1,8 @@ + apiVersion: v1 ++data: ++ hocus: pocus + kind: ConfigMap + metadata: + name: inventory + namespace: mega-mart +v1 ConfigMap mega-mart-2/inventory: +--- mega-mart-2/inventory : existing ++++ mega-mart-2/inventory : updated +@@ -1,7 +1,8 @@ + apiVersion: v1 + data: ++ hocus: pocus + things: original-stuff + kind: ConfigMap + metadata: + name: inventory +v1 ConfigMap mega-mart-2/inventory-2: +--- mega-mart-2/inventory-2 : existing ++++ mega-mart-2/inventory-2 : updated +@@ -1,7 +1,8 @@ + apiVersion: v1 + data: ++ hocus: pocus + things: stuff + kind: ConfigMap + metadata: + name: inventory-2 +# Compliance messages: +NonCompliant; violation - configmaps [inventory-2] found but not as specified in namespace mega-mart-2; configmaps [inventory] found but not as specified in namespaces: mega-mart, mega-mart-2 diff --git a/test/dryrun/context_vars/objectname_nsselector/policy.yaml b/test/dryrun/context_vars/objectname_nsselector/policy.yaml index 3c014dcf..0163c408 100644 --- a/test/dryrun/context_vars/objectname_nsselector/policy.yaml +++ b/test/dryrun/context_vars/objectname_nsselector/policy.yaml @@ -15,4 +15,4 @@ spec: kind: ConfigMap metadata: name: '{{ not (hasPrefix "inv" .ObjectName) | skipObject }}' - data: '{{ set .Object.data "hocus" "pocus" | toJSON | toLiteral }}' + data: '{{ set (.Object.data | default (dict)) "hocus" "pocus" | toJSON | toLiteral }}' diff --git a/test/dryrun/context_vars/objectns_cluster_scoped/input.yaml b/test/dryrun/context_vars/objectns_cluster_scoped/input_ns.yaml similarity index 100% rename from test/dryrun/context_vars/objectns_cluster_scoped/input.yaml rename to test/dryrun/context_vars/objectns_cluster_scoped/input_ns.yaml diff --git a/test/dryrun/context_vars/objectns_templated_empty/input.yaml b/test/dryrun/context_vars/objectns_templated_empty/input.yaml index 0ed619bc..f7364fc4 100644 --- a/test/dryrun/context_vars/objectns_templated_empty/input.yaml +++ b/test/dryrun/context_vars/objectns_templated_empty/input.yaml @@ -1,15 +1,5 @@ --- apiVersion: v1 -kind: Namespace -metadata: - name: my-namespace ---- -apiVersion: v1 -kind: Namespace -metadata: - name: my-other-namespace ---- -apiVersion: v1 kind: ConfigMap metadata: name: templated-ns-configmap diff --git a/test/dryrun/context_vars/objectns_templated_empty/input_ns.yaml b/test/dryrun/context_vars/objectns_templated_empty/input_ns.yaml new file mode 100644 index 00000000..a0c9268d --- /dev/null +++ b/test/dryrun/context_vars/objectns_templated_empty/input_ns.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: my-namespace +--- +apiVersion: v1 +kind: Namespace +metadata: + name: my-other-namespace + diff --git a/test/dryrun/context_vars/objectns_templated_no_nsselector/input.yaml b/test/dryrun/context_vars/objectns_templated_no_nsselector/input.yaml index 0ed619bc..f7364fc4 100644 --- a/test/dryrun/context_vars/objectns_templated_no_nsselector/input.yaml +++ b/test/dryrun/context_vars/objectns_templated_no_nsselector/input.yaml @@ -1,15 +1,5 @@ --- apiVersion: v1 -kind: Namespace -metadata: - name: my-namespace ---- -apiVersion: v1 -kind: Namespace -metadata: - name: my-other-namespace ---- -apiVersion: v1 kind: ConfigMap metadata: name: templated-ns-configmap diff --git a/test/dryrun/context_vars/objectns_templated_no_nsselector/input_ns.yaml b/test/dryrun/context_vars/objectns_templated_no_nsselector/input_ns.yaml new file mode 100644 index 00000000..a0c9268d --- /dev/null +++ b/test/dryrun/context_vars/objectns_templated_no_nsselector/input_ns.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: my-namespace +--- +apiVersion: v1 +kind: Namespace +metadata: + name: my-other-namespace + diff --git a/test/dryrun/diff/truncated/desired_status_from_cluster.yaml b/test/dryrun/diff/truncated/desired_status_from_cluster.yaml new file mode 100644 index 00000000..5143709f --- /dev/null +++ b/test/dryrun/diff/truncated/desired_status_from_cluster.yaml @@ -0,0 +1,62 @@ +compliant: NonCompliant +relatedObjects: +- compliant: NonCompliant + object: + apiVersion: v1 + kind: Namespace + properties: + diff: |- + # Truncated: showing 50/68 diff lines: + --- default : existing + +++ default : updated + @@ -1,8 +1,64 @@ + apiVersion: v1 + kind: Namespace + metadata: + + annotations: + + message1: message + + message2: message + + message3: message + + message4: message + + message5: message + + message6: message + + message7: message + + message8: message + + message9: message + + message10: message + + message11: message + + message12: message + + message13: message + + message14: message + + message15: message + + message16: message + + message17: message + + message18: message + + message19: message + + message20: message + + message21: message + + message22: message + + message23: message + + message24: message + + message25: message + + message26: message + + message27: message + + message28: message + + message29: message + + message30: message + + message31: message + + message32: message + + message33: message + + message34: message + + message35: message + + message36: message + + message37: message + + message38: message + + message39: message + + message40: message + + message41: message + + message42: message + + message43: message + + message44: message + + message45: message + + message46: message diff --git a/test/dryrun/diff/truncated/output_from_cluster.txt b/test/dryrun/diff/truncated/output_from_cluster.txt new file mode 100644 index 00000000..30e0b151 --- /dev/null +++ b/test/dryrun/diff/truncated/output_from_cluster.txt @@ -0,0 +1,64 @@ +# Status compare: +.compliant: 'NonCompliant' does match 'NonCompliant' +.relatedObjects[0] matches +.relatedObjects matches + Expected status matches the actual status + +# Diffs: +v1 Namespace default: +# Truncated: showing 50/68 diff lines: +--- default : existing ++++ default : updated +@@ -1,8 +1,64 @@ + apiVersion: v1 + kind: Namespace + metadata: ++ annotations: ++ message1: message ++ message2: message ++ message3: message ++ message4: message ++ message5: message ++ message6: message ++ message7: message ++ message8: message ++ message9: message ++ message10: message ++ message11: message ++ message12: message ++ message13: message ++ message14: message ++ message15: message ++ message16: message ++ message17: message ++ message18: message ++ message19: message ++ message20: message ++ message21: message ++ message22: message ++ message23: message ++ message24: message ++ message25: message ++ message26: message ++ message27: message ++ message28: message ++ message29: message ++ message30: message ++ message31: message ++ message32: message ++ message33: message ++ message34: message ++ message35: message ++ message36: message ++ message37: message ++ message38: message ++ message39: message ++ message40: message ++ message41: message ++ message42: message ++ message43: message ++ message44: message ++ message45: message ++ message46: message +# Compliance messages: +NonCompliant; violation - namespaces [default] found but not as specified diff --git a/test/dryrun/missing/missing_kind/input_ns_1.yaml b/test/dryrun/missing/missing_kind/input_ns_1.yaml new file mode 100644 index 00000000..62db25cf --- /dev/null +++ b/test/dryrun/missing/missing_kind/input_ns_1.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: n1 +spec: {} +--- +apiVersion: v1 +kind: Namespace +metadata: + name: n2 +spec: {} +--- +apiVersion: v1 +kind: Namespace +metadata: + name: n3 +spec: {} diff --git a/test/dryrun/missing/missing_kind_name/input_ns_1.yaml b/test/dryrun/missing/missing_kind_name/input_ns_1.yaml new file mode 100644 index 00000000..62db25cf --- /dev/null +++ b/test/dryrun/missing/missing_kind_name/input_ns_1.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: n1 +spec: {} +--- +apiVersion: v1 +kind: Namespace +metadata: + name: n2 +spec: {} +--- +apiVersion: v1 +kind: Namespace +metadata: + name: n3 +spec: {} diff --git a/test/dryrun/no_name/with_object_selector/musthave_mixed_noncompliant/output_from_cluster.txt b/test/dryrun/no_name/with_object_selector/musthave_mixed_noncompliant/output_from_cluster.txt new file mode 100644 index 00000000..06aa24b8 --- /dev/null +++ b/test/dryrun/no_name/with_object_selector/musthave_mixed_noncompliant/output_from_cluster.txt @@ -0,0 +1,41 @@ +# Status compare: +.compliant: 'NonCompliant' does match 'NonCompliant' +.relatedObjects[0] matches +.relatedObjects[1] matches +.relatedObjects[2] matches +.relatedObjects matches + Expected status matches the actual status + +# Diffs: +networking.k8s.io/v1 Ingress default/good-ingress: + +networking.k8s.io/v1 Ingress default/wrong-1-ingress: +--- default/wrong-1-ingress : existing ++++ default/wrong-1-ingress : updated +@@ -7,11 +7,11 @@ + name: wrong-1-ingress + namespace: default + spec: +- ingressClassName: wrong-name ++ ingressClassName: test + rules: + - http: + paths: + - backend: + service: +networking.k8s.io/v1 Ingress default/wrong-2-ingress: +--- default/wrong-2-ingress : existing ++++ default/wrong-2-ingress : updated +@@ -7,11 +7,11 @@ + name: wrong-2-ingress + namespace: default + spec: +- ingressClassName: wrong-name ++ ingressClassName: test + rules: + - http: + paths: + - backend: + service: +# Compliance messages: +NonCompliant; violation - ingresses [wrong-1-ingress, wrong-2-ingress] found but not as specified in namespace default diff --git a/test/e2e/case46_dryrun_cli_test.go b/test/e2e/case46_dryrun_cli_test.go new file mode 100644 index 00000000..61155ae1 --- /dev/null +++ b/test/e2e/case46_dryrun_cli_test.go @@ -0,0 +1,323 @@ +// Copyright (c) 2025 Red Hat, Inc. +// Copyright Contributors to the Open Cluster Management project + +package e2e + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + + "open-cluster-management.io/config-policy-controller/pkg/dryrun" + "open-cluster-management.io/config-policy-controller/test/utils" +) + +type dryrunTestFiles struct { + testPath string + inputNamespaces []string + inputResources []string + desiredStatusPath string + outputPath string +} + +var _ = Describe("Testing dryrun CLI", Serial, func() { + const case46TestDataPath = "../dryrun/" + + testFilesCache, err := findDryrunTestFiles(case46TestDataPath) + Expect(err).ToNot(HaveOccurred()) + + describeTableArgs := []any{func(testPath string) { + files := testFilesCache[testPath] + + DeferCleanup(func() { + for _, resource := range files.inputResources { + By("Deleting input file " + resource) + utils.KubectlDelete("-f", resource, "--wait") + } + + for _, nsFilePath := range files.inputNamespaces { + isManagedNs, err := containsManagedNamespace(nsFilePath) + if isManagedNs || err != nil { + continue + } + + By("Deleting namespace from " + nsFilePath) + utils.KubectlDelete("-f", nsFilePath, "--wait") + } + }) + + Eventually(func(g Gomega) { + for _, nsFilePath := range files.inputNamespaces { + isManagedNs, err := containsManagedNamespace(nsFilePath) + if isManagedNs || err != nil { + continue + } + + By("Creating namespace from " + nsFilePath) + utils.Kubectl("apply", "-f", nsFilePath) + } + + for _, resource := range files.inputResources { + By("Applying input file " + resource) + utils.Kubectl("apply", "-f", resource) + } + + verifyDryrunOutput(g, files) + }, defaultTimeoutSeconds, 1).Should(Succeed()) + }} + + // Generate Entry items dynamically for each test + for testPath := range testFilesCache { + relPath := strings.TrimPrefix(testPath, case46TestDataPath) + relPath = strings.TrimPrefix(relPath, "/") + describeTableArgs = append(describeTableArgs, Entry("Should handle "+relPath, testPath)) + } + + DescribeTable("When reading cluster resources with dryrun CLI", describeTableArgs...) +}) + +// findDryrunTestFiles discovers all test directories and their files in a single traversal +func findDryrunTestFiles(rootPath string) (map[string]dryrunTestFiles, error) { + const ( + policyYAML = "policy.yaml" + errOutputFile = "error.txt" + namespaceYamlPrefix = "input_ns" + defaultOutputFile = "output.txt" + outputFromClusterFile = "output_from_cluster.txt" + desiredStatusFile = "desired_status.yaml" + desiredStatusFromClusterFile = "desired_status_from_cluster.yaml" + ) + + result := make(map[string]dryrunTestFiles) + + err := filepath.WalkDir(rootPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip if policy file not found + if d.IsDir() || d.Name() != policyYAML { + return nil + } + + testDir := filepath.Dir(path) + + files, err := os.ReadDir(testDir) + if err != nil { + return err + } + + var inputNamespaces []string + var inputResources []string + var desiredStatusPath string + var outputPath string + + // Categorize all files in the test directory + for _, file := range files { + name := file.Name() + fullPath := filepath.Join(testDir, name) + + // Handle input YAML files + if strings.HasPrefix(name, "input") && strings.HasSuffix(name, ".yaml") { + if strings.HasPrefix(name, namespaceYamlPrefix) { + inputNamespaces = append(inputNamespaces, fullPath) + } else { + inputResources = append(inputResources, fullPath) + } + + continue + } + + // Handle output files (prefer cluster files when present) + switch name { + case errOutputFile, outputFromClusterFile: + outputPath = fullPath + case defaultOutputFile: + if outputPath == "" { + outputPath = fullPath + } + case desiredStatusFromClusterFile: + desiredStatusPath = fullPath + case desiredStatusFile: + if desiredStatusPath == "" { + desiredStatusPath = fullPath + } + } + } + + result[testDir] = dryrunTestFiles{ + testPath: testDir, + inputNamespaces: inputNamespaces, + inputResources: inputResources, + desiredStatusPath: desiredStatusPath, + outputPath: outputPath, + } + + return nil + }) + if err != nil { + return nil, err + } + + return result, nil +} + +// verifyDryrunOutput executes the dryrun command and compares actual vs expected output +func verifyDryrunOutput(g Gomega, files dryrunTestFiles) { + GinkgoHelper() + + const ( + policyYAML = "policy.yaml" + errOutputFile = "error.txt" + ) + + expectedBytes, err := os.ReadFile(files.outputPath) + g.Expect(err).ToNot(HaveOccurred()) + + expectedOutput := string(expectedBytes) + wantedErr := filepath.Base(files.outputPath) == errOutputFile + + if wantedErr { + // Match dry run error whitespace formatting for error files + expectedOutput = strings.TrimSpace(strings.ReplaceAll(expectedOutput, "\n", " ")) + } + + By("Running dryrun command") + var output bytes.Buffer + + cmd := (&dryrun.DryRunner{}).GetCmd() + cmd.SetOut(&output) + + args := []string{"--from-cluster", "--policy", filepath.Join(files.testPath, policyYAML), "--no-colors"} + if files.desiredStatusPath != "" { + args = append(args, "--desired-status", files.desiredStatusPath) + } + + cmd.SetArgs(args) + err = cmd.Execute() + actualOutput := output.String() + + if wantedErr { + g.Expect(err).To(HaveOccurred()) + + actualOutput = fmt.Sprintf("Error: %v", err.Error()) + } else if err != nil { + g.Expect(err).To(MatchError(dryrun.ErrNonCompliant)) + + actualOutput = normalizeDiffOutput(actualOutput) + } + + g.Expect(actualOutput).To(Equal(expectedOutput)) +} + +// containsManagedNamespace checks if a namespace YAML file contains any managed namespaces +func containsManagedNamespace(nsFilePath string) (bool, error) { + // managedNamespaces are namespaces that should not be deleted after this test case + // because they persist across test suites or are system namespaces + managedNamespaces := []string{ + "managed", + "default", + } + + hasPersistent := false + err := listNamespacesInFile(nsFilePath, func(name string) error { + if slices.Contains(managedNamespaces, name) { + hasPersistent = true + + return errors.New("found persistent namespace") + } + + return nil + }) + + // Ignore the early exit error + if err != nil && hasPersistent { + return true, nil + } + + return hasPersistent, err +} + +// normalizeDiffOutput removes metadata fields that should not be +// compared with diff output +func normalizeDiffOutput(dryrunOutput string) string { + untrackedMetadata := []string{ + "resourceVersion:", + "generatedName:", + "creationTimestamp:", + "deletionTimestamp:", + "selfLink:", + "uid:", + "metadata.kubernetes.io/metadata.name:", + "kubernetes.io/metadata.name:", + "deletionGracePeriodSeconds:", + } + + lines := strings.Split(dryrunOutput, "\n") + + var result []string + + for _, line := range lines { + content := strings.TrimSpace(line) + shouldKeep := true + + for _, field := range untrackedMetadata { + if strings.Contains(content, field) { + shouldKeep = false + + break + } + } + + if shouldKeep { + result = append(result, line) + } + } + + return strings.Join(result, "\n") +} + +// listNamespacesInFile reads a YAML file and calls the provided function for each namespace found +func listNamespacesInFile(nsFilePath string, fn func(name string) error) error { + content, err := os.ReadFile(nsFilePath) + if err != nil { + return err + } + + for _, doc := range strings.Split(string(content), "---") { + if doc = strings.TrimSpace(doc); doc == "" { + continue + } + + var obj map[string]any + if yaml.Unmarshal([]byte(doc), &obj) != nil { + continue + } + + if obj["kind"] != "Namespace" { + continue + } + + metadata, ok := obj["metadata"].(map[string]any) + if !ok { + continue + } + + name, ok := metadata["name"].(string) + if ok { + if err := fn(name); err != nil { + return err + } + } + } + + return nil +}