From bfa143297bb76670f2e7fcbb3168e68dc83ae25a Mon Sep 17 00:00:00 2001 From: Brian Kane Date: Tue, 10 Feb 2026 14:08:48 +0000 Subject: [PATCH] Checkpoint - working with caching and globals --- apis/core.oam.dev/common/types.go | 35 +- .../common/zz_generated.deepcopy.go | 22 +- .../condition/zz_generated.deepcopy.go | 2 + .../v1alpha1/zz_generated.deepcopy.go | 3 +- .../core.oam.dev/v1beta1/policy_definition.go | 21 +- .../v1beta1/zz_generated.deepcopy.go | 3 +- .../core.oam.dev_applicationrevisions.yaml | 75 + .../crds/core.oam.dev_applications.yaml | 42 + .../core.oam.dev_definitionrevisions.yaml | 33 + .../crds/core.oam.dev_policydefinitions.yaml | 33 + cmd/core/app/config/sedCGWemX | 0 examples/policy-transforms/CLI-DESIGN.md | 399 +++ .../policy-transforms/CLI-OUTPUT-EXAMPLES.md | 1083 +++++++ examples/policy-transforms/OBSERVABILITY.md | 132 +- examples/policy-transforms/README.md | 42 + go.mod | 2 +- pkg/appfile/appfile.go | 46 +- pkg/appfile/dryrun/dryrun.go | 2 +- pkg/appfile/parser.go | 35 + .../application/application_controller.go | 2 +- .../core.oam.dev/v1beta1/application/apply.go | 2 +- .../v1beta1/application/generator_test.go | 4 +- .../v1beta1/application/policy_dryrun.go | 365 +++ .../v1beta1/application/policy_transforms.go | 655 +++- .../application/policy_transforms_test.go | 1307 +++++++- .../application/policy_transforms_test.go.bak | 2812 +++++++++++++++++ .../v1beta1/application/suite_test.go | 7 + pkg/oam/sedyYAJzl | 176 ++ references/cli/cli.go | 1 + references/cli/policy.go | 828 +++++ 30 files changed, 7933 insertions(+), 236 deletions(-) create mode 100644 cmd/core/app/config/sedCGWemX create mode 100644 examples/policy-transforms/CLI-DESIGN.md create mode 100644 examples/policy-transforms/CLI-OUTPUT-EXAMPLES.md create mode 100644 pkg/controller/core.oam.dev/v1beta1/application/policy_dryrun.go create mode 100644 pkg/controller/core.oam.dev/v1beta1/application/policy_transforms_test.go.bak create mode 100644 pkg/oam/sedyYAJzl create mode 100644 references/cli/policy.go diff --git a/apis/core.oam.dev/common/types.go b/apis/core.oam.dev/common/types.go index 32793abba..978454a02 100644 --- a/apis/core.oam.dev/common/types.go +++ b/apis/core.oam.dev/common/types.go @@ -205,22 +205,21 @@ type Revision struct { RevisionHash string `json:"revisionHash,omitempty"` } -// AppliedGlobalPolicy records information about a global policy's application -type AppliedGlobalPolicy struct { +// AppliedApplicationPolicy records minimal status information about an Application-scoped policy. +// This covers both global (auto-discovered) and explicit (spec-referenced) policies. +// Full details (transforms, labels, annotations, etc.) are stored in the ConfigMap referenced +// by ApplicationPoliciesConfigMap for persistent storage and observability. +type AppliedApplicationPolicy struct { Name string `json:"name"` Namespace string `json:"namespace"` Applied bool `json:"applied"` Reason string `json:"reason,omitempty"` // e.g., "enabled=false: namespace mismatch" - // Track what this policy changed (for debugging/observability) - AddedLabels map[string]string `json:"addedLabels,omitempty"` - AddedAnnotations map[string]string `json:"addedAnnotations,omitempty"` - AdditionalContext map[string]interface{} `json:"additionalContext,omitempty"` - SpecModified bool `json:"specModified,omitempty"` - - // Execution order tracking - Sequence int `json:"sequence"` // Execution order (1, 2, 3, ...) - Priority int32 `json:"priority"` // Policy priority for reference + // Summary of changes (counts only - full details in ConfigMap) + SpecModified bool `json:"specModified,omitempty"` + LabelsCount int `json:"labelsCount,omitempty"` // Number of labels added + AnnotationsCount int `json:"annotationsCount,omitempty"` // Number of annotations added + HasContext bool `json:"hasContext,omitempty"` // Has additionalContext data } // AppStatus defines the observed state of Application @@ -257,15 +256,17 @@ type AppStatus struct { // AppliedResources record the resources that the workflow step apply. AppliedResources []ClusterObjectReference `json:"appliedResources,omitempty"` - // AppliedGlobalPolicies lists global policies that were discovered and applied - // (or skipped) during reconciliation. This provides transparency for debugging. + // AppliedApplicationPolicies lists Application-scoped policies (both global and explicit) + // that were discovered and applied (or skipped) during reconciliation. + // This provides transparency for debugging policy effects on the Application spec. // +optional - AppliedGlobalPolicies []AppliedGlobalPolicy `json:"appliedGlobalPolicies,omitempty"` + AppliedApplicationPolicies []AppliedApplicationPolicy `json:"appliedApplicationPolicies,omitempty"` - // PolicyDiffsConfigMap references the ConfigMap containing spec diffs from global policies. - // Format: "{app-name}-policy-diffs" + // ApplicationPoliciesConfigMap references the ConfigMap containing rendered policy outputs + // (transforms, additionalContext, etc.) for Application-scoped policies. + // Format: "application-policies-{namespace}-{name}" // +optional - PolicyDiffsConfigMap string `json:"policyDiffsConfigMap,omitempty"` + ApplicationPoliciesConfigMap string `json:"applicationPoliciesConfigMap,omitempty"` // PolicyStatus records the status of policy // Deprecated This field is only used by EnvBinding Policy which is deprecated. diff --git a/apis/core.oam.dev/common/zz_generated.deepcopy.go b/apis/core.oam.dev/common/zz_generated.deepcopy.go index c2e2b8d33..55d9edd3a 100644 --- a/apis/core.oam.dev/common/zz_generated.deepcopy.go +++ b/apis/core.oam.dev/common/zz_generated.deepcopy.go @@ -24,7 +24,7 @@ import ( oamv1alpha1 "github.com/kubevela/pkg/apis/oam/v1alpha1" "github.com/kubevela/workflow/api/v1alpha1" crossplane_runtime "github.com/oam-dev/terraform-controller/api/types/crossplane-runtime" - v1 "k8s.io/api/core/v1" + "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -63,6 +63,11 @@ func (in *AppStatus) DeepCopyInto(out *AppStatus) { *out = make([]ClusterObjectReference, len(*in)) copy(*out, *in) } + if in.AppliedApplicationPolicies != nil { + in, out := &in.AppliedApplicationPolicies, &out.AppliedApplicationPolicies + *out = make([]AppliedApplicationPolicy, len(*in)) + copy(*out, *in) + } if in.PolicyStatus != nil { in, out := &in.PolicyStatus, &out.PolicyStatus *out = make([]PolicyStatus, len(*in)) @@ -208,6 +213,21 @@ func (in *ApplicationTraitStatus) DeepCopy() *ApplicationTraitStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppliedApplicationPolicy) DeepCopyInto(out *AppliedApplicationPolicy) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppliedApplicationPolicy. +func (in *AppliedApplicationPolicy) DeepCopy() *AppliedApplicationPolicy { + if in == nil { + return nil + } + out := new(AppliedApplicationPolicy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CUE) DeepCopyInto(out *CUE) { *out = *in diff --git a/apis/core.oam.dev/condition/zz_generated.deepcopy.go b/apis/core.oam.dev/condition/zz_generated.deepcopy.go index 7134c636a..cf9280958 100644 --- a/apis/core.oam.dev/condition/zz_generated.deepcopy.go +++ b/apis/core.oam.dev/condition/zz_generated.deepcopy.go @@ -20,6 +20,8 @@ limitations under the License. package condition +import () + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Condition) DeepCopyInto(out *Condition) { *out = *in diff --git a/apis/core.oam.dev/v1alpha1/zz_generated.deepcopy.go b/apis/core.oam.dev/v1alpha1/zz_generated.deepcopy.go index e62168b46..2eb230f08 100644 --- a/apis/core.oam.dev/v1alpha1/zz_generated.deepcopy.go +++ b/apis/core.oam.dev/v1alpha1/zz_generated.deepcopy.go @@ -21,9 +21,8 @@ limitations under the License. package v1alpha1 import ( - "k8s.io/apimachinery/pkg/runtime" - "github.com/oam-dev/kubevela/apis/core.oam.dev/common" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. diff --git a/apis/core.oam.dev/v1beta1/policy_definition.go b/apis/core.oam.dev/v1beta1/policy_definition.go index fbd2426dc..b58dbc3e5 100644 --- a/apis/core.oam.dev/v1beta1/policy_definition.go +++ b/apis/core.oam.dev/v1beta1/policy_definition.go @@ -28,7 +28,12 @@ import ( type PolicyScope string const ( + // DefaultScope (empty string) means standard output-based policy (topology, override, etc.) + // These policies generate Kubernetes resources from their CUE templates + DefaultScope PolicyScope = "" + // ApplicationScope means the policy transforms the Application CR before parsing + // These policies use the transforms pattern and don't generate resources ApplicationScope PolicyScope = "Application" ) @@ -50,8 +55,10 @@ type PolicyDefinitionSpec struct { Version string `json:"version,omitempty"` // Scope defines the scope at which this policy operates. - // Application scope policies transform the Application CR before it's parsed. - // Policies without this field use the default resource-generation behavior. + // - DefaultScope (empty/omitted): Standard output-based policies (topology, override, etc.) + // These generate Kubernetes resources from CUE templates with an 'output' field. + // - ApplicationScope: Transform-based policies that modify the Application CR before parsing. + // These use 'transforms' instead of 'output' and don't generate resources. // +optional Scope PolicyScope `json:"scope,omitempty"` @@ -68,6 +75,16 @@ type PolicyDefinitionSpec struct { // If not specified, defaults to 0. // +optional Priority int32 `json:"priority,omitempty"` + + // CacheTTLSeconds defines how long the rendered policy output should be cached + // before re-rendering. This is stored per-policy in the ConfigMap. + // - -1 (default): Never refresh, always reuse cached result (deterministic) + // - 0: Never cache, always re-render (useful for policies with external dependencies) + // - >0: Cache for this many seconds before re-rendering + // The prior cached result is available to the policy template as context.prior + // +optional + // +kubebuilder:default=-1 + CacheTTLSeconds int32 `json:"cacheTTLSeconds,omitempty"` } // PolicyDefinitionStatus is the status of PolicyDefinition diff --git a/apis/core.oam.dev/v1beta1/zz_generated.deepcopy.go b/apis/core.oam.dev/v1beta1/zz_generated.deepcopy.go index b1e71fb82..66173be70 100644 --- a/apis/core.oam.dev/v1beta1/zz_generated.deepcopy.go +++ b/apis/core.oam.dev/v1beta1/zz_generated.deepcopy.go @@ -22,10 +22,9 @@ package v1beta1 import ( "github.com/kubevela/pkg/apis/oam/v1alpha1" - "k8s.io/apimachinery/pkg/runtime" - "github.com/oam-dev/kubevela/apis/core.oam.dev/common" core_oam_devv1alpha1 "github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. diff --git a/charts/vela-core/crds/core.oam.dev_applicationrevisions.yaml b/charts/vela-core/crds/core.oam.dev_applicationrevisions.yaml index 4ab406158..21920fea5 100644 --- a/charts/vela-core/crds/core.oam.dev_applicationrevisions.yaml +++ b/charts/vela-core/crds/core.oam.dev_applicationrevisions.yaml @@ -372,6 +372,48 @@ spec: status: description: AppStatus defines the observed state of Application properties: + applicationPoliciesConfigMap: + description: |- + ApplicationPoliciesConfigMap references the ConfigMap containing rendered policy outputs + (transforms, additionalContext, etc.) for Application-scoped policies. + Format: "application-policies-{namespace}-{name}" + type: string + appliedApplicationPolicies: + description: |- + AppliedApplicationPolicies lists Application-scoped policies (both global and explicit) + that were discovered and applied (or skipped) during reconciliation. + This provides transparency for debugging policy effects on the Application spec. + items: + description: |- + AppliedApplicationPolicy records minimal status information about an Application-scoped policy. + This covers both global (auto-discovered) and explicit (spec-referenced) policies. + Full details (transforms, labels, annotations, etc.) are stored in the ConfigMap referenced + by ApplicationPoliciesConfigMap for persistent storage and observability. + properties: + annotationsCount: + type: integer + applied: + type: boolean + hasContext: + type: boolean + labelsCount: + type: integer + name: + type: string + namespace: + type: string + reason: + type: string + specModified: + description: Summary of changes (counts only - full + details in ConfigMap) + type: boolean + required: + - applied + - name + - namespace + type: object + type: array appliedResources: description: AppliedResources record the resources that the workflow step apply. @@ -1210,6 +1252,16 @@ spec: description: PolicyDefinitionSpec defines the desired state of PolicyDefinition properties: + cacheTTLSeconds: + description: |- + CacheTTLSeconds defines how long the rendered policy output should be cached + before re-rendering. This is stored per-policy in the ConfigMap. + - -1 (default): Never refresh, always reuse cached result (deterministic) + - 0: Never cache, always re-render (useful for policies with external dependencies) + - >0: Cache for this many seconds before re-rendering + The prior cached result is available to the policy template as context.prior + format: int32 + type: integer definitionRef: description: Reference to the CustomResourceDefinition that defines this trait kind. @@ -1225,11 +1277,26 @@ spec: required: - name type: object + global: + description: |- + Global indicates this policy should automatically apply to all Applications + in this namespace (or all namespaces if in vela-system). + Global policies cannot be explicitly referenced in Application specs. + Requires EnableGlobalPolicies feature gate. + type: boolean manageHealthCheck: description: |- ManageHealthCheck means the policy will handle health checking and skip application controller built-in health checking. type: boolean + priority: + description: |- + Priority defines the order in which global policies are applied. + Higher priority policies run first. Policies with the same priority + are applied in alphabetical order by name. + If not specified, defaults to 0. + format: int32 + type: integer schematic: description: |- Schematic defines the data format and template of the encapsulation of the policy definition. @@ -1326,6 +1393,14 @@ spec: - configuration type: object type: object + scope: + description: |- + Scope defines the scope at which this policy operates. + - DefaultScope (empty/omitted): Standard output-based policies (topology, override, etc.) + These generate Kubernetes resources from CUE templates with an 'output' field. + - ApplicationScope: Transform-based policies that modify the Application CR before parsing. + These use 'transforms' instead of 'output' and don't generate resources. + type: string version: type: string type: object diff --git a/charts/vela-core/crds/core.oam.dev_applications.yaml b/charts/vela-core/crds/core.oam.dev_applications.yaml index 5e0e06990..2529381c4 100644 --- a/charts/vela-core/crds/core.oam.dev_applications.yaml +++ b/charts/vela-core/crds/core.oam.dev_applications.yaml @@ -322,6 +322,48 @@ spec: status: description: AppStatus defines the observed state of Application properties: + applicationPoliciesConfigMap: + description: |- + ApplicationPoliciesConfigMap references the ConfigMap containing rendered policy outputs + (transforms, additionalContext, etc.) for Application-scoped policies. + Format: "application-policies-{namespace}-{name}" + type: string + appliedApplicationPolicies: + description: |- + AppliedApplicationPolicies lists Application-scoped policies (both global and explicit) + that were discovered and applied (or skipped) during reconciliation. + This provides transparency for debugging policy effects on the Application spec. + items: + description: |- + AppliedApplicationPolicy records minimal status information about an Application-scoped policy. + This covers both global (auto-discovered) and explicit (spec-referenced) policies. + Full details (transforms, labels, annotations, etc.) are stored in the ConfigMap referenced + by ApplicationPoliciesConfigMap for persistent storage and observability. + properties: + annotationsCount: + type: integer + applied: + type: boolean + hasContext: + type: boolean + labelsCount: + type: integer + name: + type: string + namespace: + type: string + reason: + type: string + specModified: + description: Summary of changes (counts only - full details + in ConfigMap) + type: boolean + required: + - applied + - name + - namespace + type: object + type: array appliedResources: description: AppliedResources record the resources that the workflow step apply. diff --git a/charts/vela-core/crds/core.oam.dev_definitionrevisions.yaml b/charts/vela-core/crds/core.oam.dev_definitionrevisions.yaml index 9dce47fee..04c1747c0 100644 --- a/charts/vela-core/crds/core.oam.dev_definitionrevisions.yaml +++ b/charts/vela-core/crds/core.oam.dev_definitionrevisions.yaml @@ -380,6 +380,16 @@ spec: description: PolicyDefinitionSpec defines the desired state of PolicyDefinition properties: + cacheTTLSeconds: + description: |- + CacheTTLSeconds defines how long the rendered policy output should be cached + before re-rendering. This is stored per-policy in the ConfigMap. + - -1 (default): Never refresh, always reuse cached result (deterministic) + - 0: Never cache, always re-render (useful for policies with external dependencies) + - >0: Cache for this many seconds before re-rendering + The prior cached result is available to the policy template as context.prior + format: int32 + type: integer definitionRef: description: Reference to the CustomResourceDefinition that defines this trait kind. @@ -395,11 +405,26 @@ spec: required: - name type: object + global: + description: |- + Global indicates this policy should automatically apply to all Applications + in this namespace (or all namespaces if in vela-system). + Global policies cannot be explicitly referenced in Application specs. + Requires EnableGlobalPolicies feature gate. + type: boolean manageHealthCheck: description: |- ManageHealthCheck means the policy will handle health checking and skip application controller built-in health checking. type: boolean + priority: + description: |- + Priority defines the order in which global policies are applied. + Higher priority policies run first. Policies with the same priority + are applied in alphabetical order by name. + If not specified, defaults to 0. + format: int32 + type: integer schematic: description: |- Schematic defines the data format and template of the encapsulation of the policy definition. @@ -495,6 +520,14 @@ spec: - configuration type: object type: object + scope: + description: |- + Scope defines the scope at which this policy operates. + - DefaultScope (empty/omitted): Standard output-based policies (topology, override, etc.) + These generate Kubernetes resources from CUE templates with an 'output' field. + - ApplicationScope: Transform-based policies that modify the Application CR before parsing. + These use 'transforms' instead of 'output' and don't generate resources. + type: string version: type: string type: object diff --git a/charts/vela-core/crds/core.oam.dev_policydefinitions.yaml b/charts/vela-core/crds/core.oam.dev_policydefinitions.yaml index 69f70f9f3..1e7f4b35d 100644 --- a/charts/vela-core/crds/core.oam.dev_policydefinitions.yaml +++ b/charts/vela-core/crds/core.oam.dev_policydefinitions.yaml @@ -43,6 +43,16 @@ spec: spec: description: PolicyDefinitionSpec defines the desired state of PolicyDefinition properties: + cacheTTLSeconds: + description: |- + CacheTTLSeconds defines how long the rendered policy output should be cached + before re-rendering. This is stored per-policy in the ConfigMap. + - -1 (default): Never refresh, always reuse cached result (deterministic) + - 0: Never cache, always re-render (useful for policies with external dependencies) + - >0: Cache for this many seconds before re-rendering + The prior cached result is available to the policy template as context.prior + format: int32 + type: integer definitionRef: description: Reference to the CustomResourceDefinition that defines this trait kind. @@ -58,11 +68,26 @@ spec: required: - name type: object + global: + description: |- + Global indicates this policy should automatically apply to all Applications + in this namespace (or all namespaces if in vela-system). + Global policies cannot be explicitly referenced in Application specs. + Requires EnableGlobalPolicies feature gate. + type: boolean manageHealthCheck: description: |- ManageHealthCheck means the policy will handle health checking and skip application controller built-in health checking. type: boolean + priority: + description: |- + Priority defines the order in which global policies are applied. + Higher priority policies run first. Policies with the same priority + are applied in alphabetical order by name. + If not specified, defaults to 0. + format: int32 + type: integer schematic: description: |- Schematic defines the data format and template of the encapsulation of the policy definition. @@ -156,6 +181,14 @@ spec: - configuration type: object type: object + scope: + description: |- + Scope defines the scope at which this policy operates. + - DefaultScope (empty/omitted): Standard output-based policies (topology, override, etc.) + These generate Kubernetes resources from CUE templates with an 'output' field. + - ApplicationScope: Transform-based policies that modify the Application CR before parsing. + These use 'transforms' instead of 'output' and don't generate resources. + type: string version: type: string type: object diff --git a/cmd/core/app/config/sedCGWemX b/cmd/core/app/config/sedCGWemX new file mode 100644 index 000000000..e69de29bb diff --git a/examples/policy-transforms/CLI-DESIGN.md b/examples/policy-transforms/CLI-DESIGN.md new file mode 100644 index 000000000..924252d06 --- /dev/null +++ b/examples/policy-transforms/CLI-DESIGN.md @@ -0,0 +1,399 @@ +# Policy CLI Commands - Design Specification + +## Overview + +Two new CLI commands for debugging and testing global policies: +1. `vela policy view` - View applied policies and their effects +2. `vela policy dry-run` - Preview policy effects before applying + +## Command 1: `vela policy view` + +### Purpose +Interactive viewer for policy changes applied to an Application. + +### Usage +```bash +vela policy view [flags] +``` + +### Flags +- `-n, --namespace ` - Application namespace (default: current context) +- `--output ` - Output format: `table` (default), `json`, `yaml` + +### Behavior + +#### 1. Display Summary Table +``` +Applied Global Policies (3): +┌──────────────────────┬─────────────┬──────────┬──────────────┬─────────────┐ +│ Policy │ Namespace │ Sequence │ Priority │ Spec Changed│ +├──────────────────────┼─────────────┼──────────┼──────────────┼─────────────┤ +│ inject-sidecar │ vela-system │ 1 │ 100 │ Yes │ +│ resource-limits │ vela-system │ 2 │ 50 │ Yes │ +│ platform-labels │ vela-system │ 3 │ 0 │ No │ +└──────────────────────┴─────────────┴──────────┴──────────────┴─────────────┘ + +Labels Added: + platform.io/managed-by: kubevela (platform-labels) + sidecar.io/injected: true (inject-sidecar) + +Annotations Added: + config.io/resource-profile: standard (resource-limits) + +Spec Changes: 2 policies modified the spec +View detailed diffs: kubectl get configmap my-app-policy-diffs +``` + +#### 2. Interactive Mode (if terminal supports) +``` +Select a policy to view changes: [Use arrows to move, type to filter, Enter to view] +> inject-sidecar (added monitoring-sidecar component) + resource-limits (modified resource constraints) + platform-labels (metadata only) +``` + +When selected, show: +``` +Policy: inject-sidecar (vela-system) +Sequence: 1, Priority: 100 + +Changes Made: + ✓ Spec Modified: Yes + ✓ Added Labels: sidecar.io/injected=true + +Spec Diff (JSON Merge Patch): +{ + "components": [ + null, + { + "name": "monitoring-sidecar", + "type": "webservice", + "properties": { + "image": "monitoring:latest" + } + } + ] +} + +Interpretation: + • Added component at index 1: monitoring-sidecar +``` + +#### 3. Non-interactive Mode (CI/scripting) +```bash +# JSON output +vela policy view my-app --output json + +# YAML output +vela policy view my-app --output yaml +``` + +### Implementation Notes +- Read from `app.Status.AppliedGlobalPolicies` +- Read diffs from ConfigMap if `PolicyDiffsConfigMap` is set +- Use `github.com/AlecAivazis/survey/v2` for interactive selection (like `vela debug`) +- Use `github.com/FogDong/uitable` for table display (like other vela commands) +- Parse JSON Merge Patch to provide human-readable interpretation + +--- + +## Command 2: `vela policy dry-run` + +### Purpose +Preview policy effects before applying to an Application. + +### Usage +```bash +vela policy dry-run [flags] +``` + +### Flags +- `-n, --namespace ` - Application namespace (default: current context) +- `--policies ` - Specific policies to test (comma-separated) +- `--include-global-policies` - Include existing global policies +- `--include-app-policies` - Include policies from Application spec +- `--output ` - Output format: `table` (default), `summary`, `json`, `yaml`, `diff` + - `table`: Full output with policy details and final Application spec + - `summary`: Show only labels, annotations, and context (no spec) + - `json`: Machine-readable JSON output + - `yaml`: Machine-readable YAML output + - `diff`: Unified diff format showing changes + +### Behavior Modes + +#### Mode 1: Isolated Testing (default when --policies specified) +Test ONLY specified policies, ignore all existing policies. + +```bash +vela policy dry-run my-app --policies inject-monitoring +``` + +**Use case**: Testing a new policy in isolation before deploying it. + +**Execution order**: +1. Specified policies (in CLI order) + +#### Mode 2: Additive Testing (--include-global-policies) +Test specified policies WITH existing global policies. + +```bash +vela policy dry-run my-app --policies new-security-policy --include-global-policies +``` + +**Use case**: Test how a new policy interacts with existing globals, detect conflicts. + +**Execution order**: +1. Existing global policies (sorted by priority + name) +2. Specified policies (in CLI order) + +#### Mode 3: Full Simulation (no --policies flag) +Simulate complete policy chain that would apply to the Application. + +```bash +vela policy dry-run my-app +``` + +**Use case**: Debug unexpected behavior, understand current state. + +**Execution order**: +1. Existing global policies (sorted by priority + name) +2. App spec policies (in spec order) + +#### Mode 4: Full + Additional Policies +Full simulation plus extra test policies. + +```bash +vela policy dry-run my-app --policies test-policy --include-global-policies --include-app-policies +``` + +**Execution order**: +1. Existing global policies (sorted by priority + name) +2. Specified policies (in CLI order) +3. App spec policies (in spec order) + +### Output Format + +#### Default Output +``` +Dry-run simulation for Application: my-app (namespace: default) + +Execution Plan: + 1. inject-sidecar (vela-system, priority: 100) [global] + 2. resource-limits (vela-system, priority: 50) [global] + 3. security-hardening (specified) [test] + +Applying policies... + +✓ Policy 1/3: inject-sidecar + Sequence: 1 + Enabled: true + Changes: + • Spec Modified: Yes + • Added Labels: sidecar.io/injected=true + • Added component: monitoring-sidecar + +✓ Policy 2/3: resource-limits + Sequence: 2 + Enabled: true + Changes: + • Spec Modified: Yes + • Modified: components[0].properties.resources.limits.cpu → 500m + • Modified: components[0].properties.resources.limits.memory → 512Mi + +✓ Policy 3/3: security-hardening + Sequence: 3 + Enabled: true + Changes: + • Spec Modified: Yes + • Added: components[0].properties.securityContext.runAsNonRoot=true + +Summary: + Total Policies: 3 + Applied: 3 + Skipped: 0 + Spec Modifications: 3 + Labels Added: 1 + Annotations Added: 0 + +⚠ Warnings: + • No conflicts detected + +Final Application State: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Labels (1 total): + sidecar.io/injected: "true" (inject-sidecar) + +Annotations: + (none) + +Additional Context: + (none) + +Application Spec: +--- + +--- +``` + +#### Summary Output (--output summary) +Show only labels, annotations, and context (no spec): + +```bash +vela policy dry-run my-app --output summary +``` + +Displays: +- Summary counts (policies applied/skipped, changes) +- Final labels table with attribution +- Final annotations table with attribution +- Final additional context with attribution + +**Use case**: When you only care about metadata changes (labels/annotations) and don't need to see spec modifications. Useful for quickly checking if policies are adding the correct platform labels. + +#### Diff Output (--output diff) +```bash +vela policy dry-run my-app --output diff +``` + +Shows unified diff of original spec vs final spec: +```diff +--- Original Spec ++++ After Policies +@@ -1,5 +1,10 @@ + components: + - name: backend + type: webservice + properties: + image: myapp:v1 ++ resources: ++ limits: ++ cpu: 500m ++ memory: 512Mi ++ - name: monitoring-sidecar ++ type: webservice +``` + +#### JSON/YAML Output +Machine-readable output for scripting: +```json +{ + "application": "my-app", + "namespace": "default", + "executionPlan": [ + { + "sequence": 1, + "policyName": "inject-sidecar", + "policyNamespace": "vela-system", + "priority": 100, + "source": "global" + } + ], + "results": [ + { + "sequence": 1, + "policyName": "inject-sidecar", + "enabled": true, + "applied": true, + "specModified": true, + "addedLabels": {"sidecar.io/injected": "true"}, + "addedAnnotations": {}, + "additionalContext": null, + "diff": { ... } + } + ], + "finalState": { + "labels": { + "sidecar.io/injected": "true" + }, + "annotations": {}, + "additionalContext": {}, + "spec": { ... } + } +} +``` + +### Implementation Notes + +#### Core Logic +1. **Load Application**: Get Application from cluster (or from file with `-f`) +2. **Discover Policies**: Based on flags, build execution plan +3. **Create Temporary AppHandler**: Use existing `ApplyApplicationScopeTransforms` logic +4. **Apply Policies in Dry-Run Mode**: + - Execute transforms on in-memory copy + - Track diffs for each policy + - Don't persist to cluster +5. **Display Results**: Format based on `--output` flag + +#### Code Reuse +- Reuse `ApplyApplicationScopeTransforms` from `policy_transforms.go` +- Reuse `discoverGlobalPolicies` for global policy discovery +- Reuse diff computation logic (`deepCopyAppSpec`, `computeJSONPatch`) +- Similar pattern to existing `vela dry-run` command + +#### Key Differences from Real Reconciliation +- No persistence to cluster +- No status updates +- No events emitted +- No ConfigMap creation +- Returns results to CLI instead + +#### Error Handling +- Policy not found: Clear error message +- Invalid policy template: Show CUE compilation error +- Policy conflicts: Detect and warn +- Application not found: Offer to use `-f` flag for file input + +--- + +## Implementation Plan + +### Phase 1: `vela policy view` (simpler, no dry-run logic) +**Files to create/modify**: +- `/workspaces/development/kubevela/references/cli/policy.go` (new) +- `/workspaces/development/kubevela/references/cli/cli.go` (register command) + +**Dependencies**: +- Application status reading +- ConfigMap reading +- Table formatting (existing utilities) +- Interactive selection (survey/v2) + +**Estimated complexity**: Low (mostly UI/formatting) + +### Phase 2: `vela policy dry-run` (complex, requires simulation logic) +**Files to create/modify**: +- `/workspaces/development/kubevela/references/cli/policy.go` (extend) +- Factor out reusable logic from `policy_transforms.go` if needed + +**Dependencies**: +- Application loading +- Policy discovery +- Transform application (reuse controller logic) +- Diff computation +- Output formatting + +**Estimated complexity**: Medium-High (simulation logic, multiple modes) + +### Phase 3: Integration & Testing +- Add tests in `policy_test.go` +- Update CLI documentation +- Add examples to README + +--- + +## Open Questions + +1. **Application source**: Should dry-run support `-f ` for Applications not yet in cluster? +2. **Policy source**: Should we support testing policies from files before deploying them? +3. **Conflict detection**: How aggressive should we be in detecting policy conflicts? +4. **Performance**: For large Applications with many policies, should we add progress indicators? + +--- + +## Future Enhancements + +1. **Watch Mode**: `vela policy view my-app --watch` - Live updates as policies change +2. **Compare Mode**: `vela policy diff app1 app2` - Compare policy effects across apps +3. **Export**: `vela policy view my-app --export > report.html` - Generate HTML report +4. **Validation**: `vela policy validate ` - Validate policy syntax before applying diff --git a/examples/policy-transforms/CLI-OUTPUT-EXAMPLES.md b/examples/policy-transforms/CLI-OUTPUT-EXAMPLES.md new file mode 100644 index 000000000..79d292be8 --- /dev/null +++ b/examples/policy-transforms/CLI-OUTPUT-EXAMPLES.md @@ -0,0 +1,1083 @@ +# Policy CLI Command Output Examples + +This document shows realistic output examples for the proposed `vela policy` commands. + +## Command: `vela policy view my-app` + +### Example 1: Application with Multiple Policies + +```bash +$ vela policy view my-app +``` + +**Output:** + +``` +Applied Global Policies: 3 applied, 1 skipped + +┌─────┬──────────────────────┬─────────────┬──────────┬──────────┬──────┬────────┬────────┬─────────┐ +│ Seq │ Policy │ Namespace │ Priority │ Applied │ Spec │ Labels │ Annot. │ Context │ +├─────┼──────────────────────┼─────────────┼──────────┼──────────┼──────┼────────┼────────┼─────────┤ +│ 1 │ inject-sidecar │ vela-system │ 100 │ ✓ Yes │ ✓ 1 │ ✓ 1 │ ✗ 0 │ ✗ 0 │ +│ 2 │ resource-limits │ vela-system │ 50 │ ✓ Yes │ ✓ 1 │ ✗ 0 │ ✓ 1 │ ✓ Yes │ +│ 3 │ platform-labels │ vela-system │ 10 │ ✓ Yes │ ✗ 0 │ ✓ 2 │ ✗ 0 │ ✗ 0 │ +│ - │ tenant-isolation │ vela-system │ 5 │ ✗ No │ - │ - │ - │ - │ +└─────┴──────────────────────┴─────────────┴──────────┴──────────┴──────┴────────┴────────┴─────────┘ + +Legend: + Spec: Number of spec changes (or ✗ if none) + Labels: Number of labels added (or ✗ if none) + Annot.: Number of annotations added (or ✗ if none) + Context: ✓ if additional context provided + +Skipped (1): + • tenant-isolation: enabled=false (namespace does not match tenant-* pattern) + +Summary: + Total Policies: 4 (3 applied, 1 skipped) + Spec Changes: 2 policies + Labels Added: 3 total + Annotations: 1 total + Context Data: 1 policy + +Press Enter to select a policy for details, or 'q' to quit +``` + +### Example 2: Interactive Policy Selection + +When user presses Enter, show policy selection: + +``` +Select a policy to view details: + + ❯ inject-sidecar (vela-system) + resource-limits (vela-system) + platform-labels (vela-system) + + [↑↓ to move, Enter to select, q to quit] +``` + +After selecting `inject-sidecar`, show category submenu: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Policy: inject-sidecar (vela-system) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Sequence: 1 +Priority: 100 +Applied: Yes + +What would you like to view? + + ❯ Spec Changes (1 modification) + Labels (1 added) + Annotations (none) + Additional Context (none) + [All Details] + + [↑↓ to move, Enter to select, b to go back, q to quit] +``` + +After selecting `Spec Changes` (default view: Unified Diff): + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Policy: inject-sidecar > Spec Changes [Unified Diff] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +--- Original Spec ++++ After inject-sidecar + + spec: + components: + - name: backend + type: webservice + properties: + image: myapp:v1.0.0 ++ - name: monitoring-sidecar ++ type: webservice ++ properties: ++ image: monitoring-agent:v2.1.0 ++ cpu: 100m ++ memory: 128Mi ++ env: ++ - name: MONITOR_TARGET ++ value: backend + +ConfigMap: my-app-policy-diffs/001-inject-sidecar + +Press 't' to toggle JSON view, 'b' to go back, 'n' for next policy, 'q' to quit +``` + +User presses 't' to toggle to JSON Merge Patch view: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Policy: inject-sidecar > Spec Changes [JSON Merge Patch] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{ + "components": [ + null, + { + "name": "monitoring-sidecar", + "type": "webservice", + "properties": { + "image": "monitoring-agent:v2.1.0", + "cpu": "100m", + "memory": "128Mi", + "env": [ + { + "name": "MONITOR_TARGET", + "value": "backend" + } + ] + } + } + ] +} + +Note: JSON Merge Patch (RFC 7386) + • null values = no change at that position + • New objects = additions + • Replaced values = modifications + +ConfigMap: my-app-policy-diffs/001-inject-sidecar + +Press 't' to toggle Unified Diff, 'b' to go back, 'n' for next policy, 'q' to quit +``` + +After going back and selecting `Labels`: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Policy: inject-sidecar > Labels +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Labels Added (1): + +┌─────────────────────────┬─────────┐ +│ Key │ Value │ +├─────────────────────────┼─────────┤ +│ sidecar.io/injected │ true │ +└─────────────────────────┴─────────┘ + +These labels are added to Application.metadata.labels + +Press 'b' to go back, 'n' for next policy, 'q' to quit +``` + +If selecting `[All Details]` from the submenu: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Policy: inject-sidecar (Complete Details) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Metadata: + Namespace: vela-system + Sequence: 1 + Priority: 100 + Applied: Yes + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Labels Added (1) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + sidecar.io/injected: "true" + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Annotations Added +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + (none) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Additional Context +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + (none) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Spec Changes [Unified Diff] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +--- Original Spec ++++ After inject-sidecar + + spec: + components: + - name: backend + type: webservice + properties: + image: myapp:v1.0.0 ++ - name: monitoring-sidecar ++ type: webservice ++ properties: ++ image: monitoring-agent:v2.1.0 ++ cpu: 100m ++ memory: 128Mi + +ConfigMap: my-app-policy-diffs/001-inject-sidecar + +Press 't' to toggle JSON view, 'b' to go back, 'n' for next policy, 'q' to quit +``` + +### Example 3: Viewing Policy with Multiple Labels + +After selecting `platform-labels` from policy list, then selecting `Labels`: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Policy: platform-labels > Labels +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Labels Added (2): + +┌──────────────────────────┬─────────────┐ +│ Key │ Value │ +├──────────────────────────┼─────────────┤ +│ platform.io/managed-by │ kubevela │ +│ platform.io/team │ backend-team│ +└──────────────────────────┴─────────────┘ + +These labels are added to Application.metadata.labels + +Press 'b' to go back, 'n' for next policy, 'q' to quit +``` + +### Example 4: Viewing Additional Context + +After selecting `resource-limits` from policy list, then selecting `Additional Context`: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Policy: resource-limits > Additional Context +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Additional Context Data: + +{ + "resourceProfile": { + "tier": "standard", + "burstable": true, + "limits": { + "cpu": "500m", + "memory": "512Mi" + } + } +} + +This context data is made available to workflows via: + context.additionalContext.resourceProfile + +Example usage in workflow: + if context.additionalContext.resourceProfile.tier == "standard" { + // Apply standard monitoring + } + +Press 'b' to go back, 'n' for next policy, 'q' to quit +``` + +### Example 5: No Policies Applied + +```bash +$ vela policy view minimal-app +``` + +**Output:** + +``` +No global policies applied to Application 'minimal-app' + +This could be because: + • No global policies exist in vela-system or the application namespace + • Application has annotation: policy.oam.dev/skip-global: "true" + • Global policies feature is disabled (feature gate not enabled) + +Application policies from spec: 0 + +To view available global policies: + kubectl get policydefinitions -n vela-system -l 'policydefinition.oam.dev/global=true' +``` + +### Example 6: JSON Output + +```bash +$ vela policy view my-app --output json +``` + +**Output:** + +```json +{ + "application": "my-app", + "namespace": "default", + "appliedPolicies": [ + { + "sequence": 1, + "name": "inject-sidecar", + "namespace": "vela-system", + "priority": 100, + "applied": true, + "specModified": true, + "addedLabels": { + "sidecar.io/injected": "true" + }, + "addedAnnotations": {}, + "additionalContext": null, + "reason": "" + }, + { + "sequence": 2, + "name": "resource-limits", + "namespace": "vela-system", + "priority": 50, + "applied": true, + "specModified": true, + "addedLabels": {}, + "addedAnnotations": { + "policy.io/resource-profile": "standard" + }, + "additionalContext": { + "resourceProfile": { + "tier": "standard", + "burstable": true + } + }, + "reason": "" + }, + { + "sequence": 3, + "name": "platform-labels", + "namespace": "vela-system", + "priority": 10, + "applied": true, + "specModified": false, + "addedLabels": { + "platform.io/managed-by": "kubevela", + "platform.io/team": "backend-team" + }, + "addedAnnotations": {}, + "additionalContext": null, + "reason": "" + }, + { + "sequence": 0, + "name": "tenant-isolation", + "namespace": "vela-system", + "priority": 5, + "applied": false, + "specModified": false, + "addedLabels": {}, + "addedAnnotations": {}, + "additionalContext": null, + "reason": "enabled=false (namespace does not match tenant-* pattern)" + } + ], + "policyDiffsConfigMap": "my-app-policy-diffs", + "summary": { + "totalDiscovered": 4, + "applied": 3, + "skipped": 1, + "specModifications": 2, + "labelsAdded": 3, + "annotationsAdded": 1 + } +} +``` + +--- + +## Command: `vela policy dry-run my-app` + +### Example 1: Full Simulation (Default Mode) + +```bash +$ vela policy dry-run my-app +``` + +**Output:** + +``` +Dry-run Simulation +Application: my-app (namespace: default) +Mode: Full simulation (all policies that would apply) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Execution Plan +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Discovered policies that will be applied: + + 1. inject-sidecar (vela-system, priority: 100) [global] + 2. resource-limits (vela-system, priority: 50) [global] + 3. platform-labels (vela-system, priority: 10) [global] + 4. my-custom-policy (from Application spec) [app-spec] + +Skipped policies: + • tenant-isolation (vela-system) - enabled=false + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Applying Policies... +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[1/4] inject-sidecar + ✓ Policy enabled + ✓ CUE template compiled successfully + + Changes: + Spec Modified: Yes (1 change) + Labels Added: 1 + • sidecar.io/injected: "true" + Annotations Added: 0 + Context Data: None + +[2/4] resource-limits + ✓ Policy enabled + ✓ CUE template compiled successfully + + Changes: + Spec Modified: Yes (2 changes) + Labels Added: 0 + Annotations Added: 1 + • policy.io/resource-profile: "standard" + Context Data: resourceProfile + +[3/4] platform-labels + ✓ Policy enabled + ✓ CUE template compiled successfully + + Changes: + Spec Modified: No + Labels Added: 2 + • platform.io/managed-by: "kubevela" + • platform.io/team: "backend-team" + Annotations Added: 0 + Context Data: None + +[4/4] my-custom-policy + ✓ Policy enabled + ✓ CUE template compiled successfully + + Changes: + Spec Modified: Yes (1 change) + Labels Added: 0 + Annotations Added: 0 + Context Data: None + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Policies Applied: 4 +Policies Skipped: 1 +Spec Modifications: 3 +Labels Added: 3 +Annotations Added: 1 + +⚠ Warnings: + • resource-limits modified the same field as my-custom-policy (components[0].properties) + Both policies should be reviewed for conflicts + +✓ No errors detected + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Final Application State +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Labels (3 total): + platform.io/managed-by: kubevela (platform-labels) + platform.io/team: backend-team (platform-labels) + sidecar.io/injected: true (inject-sidecar) + +Annotations (1 total): + policy.io/resource-profile: standard (resource-limits) + +Additional Context: + resourceProfile: (resource-limits) + tier: standard + burstable: true + +Application Spec: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +apiVersion: core.oam.dev/v1beta1 +kind: Application +metadata: + name: my-app + namespace: default + labels: + platform.io/managed-by: kubevela + platform.io/team: backend-team + sidecar.io/injected: "true" + annotations: + policy.io/resource-profile: standard +spec: + components: + - name: backend + type: webservice + properties: + image: myapp:v1.0.0 + resources: + limits: + cpu: 500m + memory: 512Mi + traits: + - type: scaler + properties: + replicas: 3 + - name: monitoring-sidecar + type: webservice + properties: + image: monitoring-agent:v2.1.0 + cpu: 100m + memory: 128Mi + resources: + limits: + cpu: 100m + memory: 128Mi + env: + - name: MONITOR_TARGET + value: backend +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +This is a dry-run. No changes were applied to the cluster. + +To view detailed diffs for each policy: + vela policy dry-run my-app --output diff + +To get JSON output for scripting: + vela policy dry-run my-app --output json +``` + +### Example 2: Isolated Testing (Single Policy) + +```bash +$ vela policy dry-run my-app --policies inject-sidecar +``` + +**Output:** + +``` +Dry-run Simulation +Application: my-app (namespace: default) +Mode: Isolated (testing specified policies only) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Execution Plan +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Testing policies in isolation (ignoring existing global and app policies): + + 1. inject-sidecar (vela-system) [specified] + +Note: This is an isolated test. In production, this policy would run alongside: + • 2 other global policies + • 1 application spec policy + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Applying Policies... +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[1/1] inject-sidecar + ✓ Policy enabled + ✓ CUE template compiled successfully + + Changes: + Spec Modified: Yes (1 change) + Labels Added: 1 + • sidecar.io/injected: "true" + Annotations Added: 0 + Context Data: None + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Policies Applied: 1 +Spec Modifications: 1 +Labels Added: 1 +Annotations Added: 0 + +✓ No warnings +✓ No errors + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Final Application State +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Impact Summary: + • +1 component (monitoring-sidecar) + • +1 label + +Labels (1 total): + sidecar.io/injected: true (inject-sidecar) + +Annotations: + (none) + +Additional Context: + (none) + +Spec Changes: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +--- Original Application Spec ++++ After inject-sidecar Policy + + spec: + components: + - name: backend + type: webservice + properties: + image: myapp:v1.0.0 ++ - name: monitoring-sidecar ++ type: webservice ++ properties: ++ image: monitoring-agent:v2.1.0 ++ cpu: 100m ++ memory: 128Mi +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +### Example 3: Additive Testing (With Global Policies) + +```bash +$ vela policy dry-run my-app --policies new-security-policy --include-global-policies +``` + +**Output:** + +``` +Dry-run Simulation +Application: my-app (namespace: default) +Mode: Additive (specified policies + existing global policies) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Execution Plan +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + 1. inject-sidecar (vela-system, priority: 100) [global] + 2. resource-limits (vela-system, priority: 50) [global] + 3. platform-labels (vela-system, priority: 10) [global] + 4. new-security-policy (vela-system) [specified - TESTING] + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Applying Policies... +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[1/4] inject-sidecar [global] + ✓ Applied (see full simulation mode for details) + +[2/4] resource-limits [global] + ✓ Applied (see full simulation mode for details) + +[3/4] platform-labels [global] + ✓ Applied (see full simulation mode for details) + +[4/4] new-security-policy [TESTING] + ✓ Policy enabled + ✓ CUE template compiled successfully + + Changes: + Spec Modified: Yes (2 changes) + Labels Added: 1 + • security.io/hardened: "true" + Annotations Added: 2 + • security.io/scan-date: "2024-02-09" + • security.io/scan-tool: "trivy" + Context Data: None + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Policies Applied: 4 (3 global + 1 test) +Spec Modifications: 4 +Labels Added: 4 +Annotations Added: 3 + +✓ No conflicts detected with existing policies +✓ new-security-policy integrates cleanly + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Final Application State +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Labels (4 total): + platform.io/managed-by: kubevela (platform-labels) + platform.io/team: backend-team (platform-labels) + sidecar.io/injected: true (inject-sidecar) + security.io/hardened: true (new-security-policy) + +Annotations (3 total): + policy.io/resource-profile: standard (resource-limits) + security.io/scan-date: 2024-02-09 (new-security-policy) + security.io/scan-tool: trivy (new-security-policy) + +Additional Context: + resourceProfile: (resource-limits) + tier: standard + burstable: true + +Note: new-security-policy added 2 spec changes, 1 label, and 2 annotations + All changes integrate cleanly with existing global policies + +To deploy this policy: + kubectl apply -f new-security-policy.yaml +``` + +### Example 4: Viewing Only Labels/Annotations/Context + +If you only care about metadata changes (not spec): + +```bash +$ vela policy dry-run my-app --output summary +``` + +**Output:** + +``` +Dry-run Simulation +Application: my-app (namespace: default) +Mode: Full simulation (all policies that would apply) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Policies Applied: 3 +Policies Skipped: 1 +Spec Modifications: 2 +Labels Added: 3 +Annotations Added: 1 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Labels +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +┌───────────────────────────┬──────────────┬──────────────────────┐ +│ Key │ Value │ Added By │ +├───────────────────────────┼──────────────┼──────────────────────┤ +│ platform.io/managed-by │ kubevela │ platform-labels │ +│ platform.io/team │ backend-team │ platform-labels │ +│ sidecar.io/injected │ true │ inject-sidecar │ +└───────────────────────────┴──────────────┴──────────────────────┘ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Annotations +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +┌──────────────────────────────┬──────────┬──────────────────────┐ +│ Key │ Value │ Added By │ +├──────────────────────────────┼──────────┼──────────────────────┤ +│ policy.io/resource-profile │ standard │ resource-limits │ +└──────────────────────────────┴──────────┴──────────────────────┘ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Additional Context +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +resourceProfile (from resource-limits): +{ + "tier": "standard", + "burstable": true, + "limits": { + "cpu": "500m", + "memory": "512Mi" + } +} + +Available in workflows as: + context.additionalContext.resourceProfile + +To see spec changes: + vela policy dry-run my-app + vela policy dry-run my-app --output diff +``` + +### Example 5: Diff Output Format + +```bash +$ vela policy dry-run my-app --policies inject-sidecar --output diff +``` + +**Output:** + +```diff +--- Original Application Spec ++++ After Policy: inject-sidecar + + apiVersion: core.oam.dev/v1beta1 + kind: Application + metadata: + name: my-app + namespace: default ++ labels: ++ sidecar.io/injected: "true" + spec: + components: + - name: backend + type: webservice + properties: + image: myapp:v1.0.0 ++ - name: monitoring-sidecar ++ type: webservice ++ properties: ++ image: monitoring-agent:v2.1.0 ++ cpu: 100m ++ memory: 128Mi ++ env: ++ - name: MONITOR_TARGET ++ value: backend +``` + +### Example 6: Error Cases + +#### Policy Not Found + +```bash +$ vela policy dry-run my-app --policies non-existent-policy +``` + +**Output:** + +``` +Error: Policy not found + +Could not find PolicyDefinition: non-existent-policy + +Searched in: + • Namespace: vela-system + • Namespace: default + +Available policies in vela-system: + • inject-sidecar (global, priority: 100) + • resource-limits (global, priority: 50) + • platform-labels (global, priority: 10) + +Available policies in default: + • none + +Did you mean one of these? + • inject-sidecar + • resource-limits + +To list all policies: + kubectl get policydefinitions -A +``` + +#### CUE Compilation Error + +```bash +$ vela policy dry-run my-app --policies broken-policy +``` + +**Output:** + +``` +Dry-run Simulation +Application: my-app (namespace: default) +Mode: Isolated (testing specified policies only) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Execution Plan +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + 1. broken-policy (vela-system) [specified] + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Applying Policies... +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[1/1] broken-policy + ✗ CUE compilation failed + +Error: failed to compile policy template + + transforms.spec.value.components[0]: reference "nonExistentVariable" not found: + template.cue:15:9 + + 15 | image: nonExistentVariable + | ^ + + Available context variables: + • context.application + • context.name + • context.namespace + • parameter + +Policy template location: + PolicyDefinition: broken-policy + Namespace: vela-system + +To fix: Edit the PolicyDefinition and correct the CUE template + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Policies Applied: 0 +Errors: 1 + +✗ Dry-run failed - fix policy template errors before deploying +``` + +### Example 7: JSON Output (for CI/CD) + +```bash +$ vela policy dry-run my-app --policies inject-sidecar --output json +``` + +**Output:** + +```json +{ + "application": "my-app", + "namespace": "default", + "mode": "isolated", + "executionPlan": [ + { + "sequence": 1, + "policyName": "inject-sidecar", + "policyNamespace": "vela-system", + "priority": 100, + "source": "specified" + } + ], + "results": [ + { + "sequence": 1, + "policyName": "inject-sidecar", + "policyNamespace": "vela-system", + "enabled": true, + "applied": true, + "error": null, + "specModified": true, + "addedLabels": { + "sidecar.io/injected": "true" + }, + "addedAnnotations": {}, + "additionalContext": null, + "diff": { + "components": [ + null, + { + "name": "monitoring-sidecar", + "type": "webservice", + "properties": { + "image": "monitoring-agent:v2.1.0", + "cpu": "100m", + "memory": "128Mi" + } + } + ] + } + } + ], + "summary": { + "policiesApplied": 1, + "policiesSkipped": 0, + "specModifications": 1, + "labelsAdded": 1, + "annotationsAdded": 0, + "errors": 0, + "warnings": 0 + }, + "warnings": [], + "errors": [], + "finalSpec": { + "components": [ + { + "name": "backend", + "type": "webservice", + "properties": { + "image": "myapp:v1.0.0" + } + }, + { + "name": "monitoring-sidecar", + "type": "webservice", + "properties": { + "image": "monitoring-agent:v2.1.0", + "cpu": "100m", + "memory": "128Mi" + } + } + ] + } +} +``` + +--- + +## Color Coding (Terminal with Color Support) + +When running in a terminal with color support: + +- **Green (✓)**: Success indicators, applied policies +- **Red (✗)**: Errors, failed policies +- **Yellow (⚠)**: Warnings, skipped policies +- **Blue**: Headers, section dividers +- **Cyan**: Policy names +- **Magenta**: Field names (labels, annotations) +- **Gray**: Metadata (sequence numbers, priorities) + +--- + +## Progress Indicators (for Slow Operations) + +When applying many policies or large Applications: + +``` +Dry-run Simulation +Application: large-app (namespace: production) + +Loading Application... [████████████████████] 100% (1.2s) +Discovering policies... [████████████████████] 100% (0.8s) +Applying 15 policies... [████████████░░░░░░░░] 65% (3.4s) + • inject-sidecar ✓ + • resource-limits ✓ + • platform-labels ✓ + • security-hardening ✓ + • backup-policy ✓ + • monitoring-config ✓ + • logging-policy ✓ + • network-policy ✓ + • compliance-check ✓ + • data-classification ⏳ (running...) +``` + +--- + +## Comparison: Side-by-Side View + +Future enhancement for `--show-diffs` flag: + +```bash +$ vela policy dry-run my-app --policies inject-sidecar --show-diffs +``` + +**Output:** + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Side-by-Side Comparison: inject-sidecar +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Before Policy After Policy +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +spec: spec: + components: components: + - name: backend - name: backend + type: webservice type: webservice + properties: properties: + image: myapp:v1.0.0 image: myapp:v1.0.0 + - name: monitoring-sidecar [+] + type: webservice [+] + properties: [+] + image: monitoring-agent:v2 [+] + +Labels: Labels: + sidecar.io/injected: "true" [+] +``` diff --git a/examples/policy-transforms/OBSERVABILITY.md b/examples/policy-transforms/OBSERVABILITY.md index 28f45904e..3dccdbfa3 100644 --- a/examples/policy-transforms/OBSERVABILITY.md +++ b/examples/policy-transforms/OBSERVABILITY.md @@ -389,15 +389,137 @@ metadata: security.platform.io/scanned: "true" ``` +## Viewing Spec Diffs (✅ Implemented) + +When global policies modify the Application spec, KubeVela stores detailed JSON Merge Patch diffs in a ConfigMap for debugging and auditing. + +### ConfigMap Structure + +Policy diffs are stored in a ConfigMap named `{app-name}-policy-diffs` with: +- **Labels**: Standard KubeVela labels (`app.oam.dev/name`, `app.oam.dev/namespace`, `app.oam.dev/uid`) +- **Keys**: Sequence-prefixed format `001-policy-name`, `002-policy-name` to preserve execution order +- **Values**: JSON Merge Patch (RFC 7386) showing what changed + +### Basic Diff Viewing + +```bash +# Check if policy diffs exist +kubectl get app my-app -o jsonpath='{.status.policyDiffsConfigMap}' +# Output: my-app-policy-diffs + +# List all policy diffs in execution order +kubectl get configmap my-app-policy-diffs -o jsonpath='{.data}' | jq 'keys' +# Output: ["001-inject-sidecar", "002-resource-limits"] + +# View specific policy diff +kubectl get configmap my-app-policy-diffs -o jsonpath='{.data.001-inject-sidecar}' | jq +``` + +### Example Diff Output + +```json +{ + "components": [ + null, + { + "name": "monitoring-sidecar", + "type": "webservice", + "properties": { + "image": "monitoring:latest", + "cpu": "100m" + } + } + ] +} +``` + +This shows the `inject-sidecar` policy added a new component at index 1. + +### Finding All Applications with Policy Diffs + +```bash +# List all policy-diffs ConfigMaps +kubectl get configmaps -l "app.oam.dev/policy-diffs=true" + +# List ConfigMaps for a specific app +kubectl get configmaps -l "app.oam.dev/name=my-app" +``` + +### Diff Interpretation + +JSON Merge Patch format: +- **New fields**: Added to object (e.g., `"components": [null, {...}]` adds component at index 1) +- **Modified fields**: Replaced with new value (e.g., `"replicas": 3` changes replicas) +- **`null` values**: Indicate no change at that position + +### Combining Status + Diffs + +Get complete picture of policy effects: + +```bash +# Get metadata about policies +kubectl get app my-app -o json | jq '.status.appliedGlobalPolicies[]' + +# Get actual spec changes +kubectl get configmap my-app-policy-diffs -o json | jq '.data' +``` + ## Future Enhancements Planned improvements to observability: -1. **Dry-Run Mode**: Preview policy effects before applying -2. **Policy Diff**: Show before/after comparison in status -3. **Metrics**: Prometheus metrics for policy application -4. **CLI Tool**: `kubectl vela policy audit my-app` -5. **Web UI**: Visual policy impact dashboard +### 1. CLI Tools (High Priority) + +**`vela policy view `** +- Interactive viewer for policy changes +- Shows before/after comparison +- Highlights which policies made which changes +- Similar UX to `vela debug` + +Example usage: +```bash +$ vela policy view my-app + +Applied Global Policies (3): +┌──────────────────────┬─────────────┬──────────┬──────────────┐ +│ Policy │ Namespace │ Sequence │ Spec Changed │ +├──────────────────────┼─────────────┼──────────┼──────────────┤ +│ inject-sidecar │ vela-system │ 1 │ Yes │ +│ resource-limits │ vela-system │ 2 │ Yes │ +│ platform-labels │ vela-system │ 3 │ No │ +└──────────────────────┴─────────────┴──────────┴──────────────┘ + +Select a policy to view changes: [Use arrows to move, type to filter] +> inject-sidecar (added monitoring-sidecar component) + resource-limits (modified resource constraints) +``` + +**`vela policy dry-run --policies `** +- Preview policy effects before applying +- Test policy changes without creating Application +- Validate policy templates and detect conflicts + +Example usage: +```bash +$ vela policy dry-run my-app --policies inject-sidecar resource-limits + +Dry-run simulation: +✓ Policy: inject-sidecar (priority: 100) + - Added component: monitoring-sidecar + - Added label: sidecar.io/injected=true + +✓ Policy: resource-limits (priority: 50) + - Modified: components[0].properties.resources.limits.cpu → 500m + - Modified: components[0].properties.resources.limits.memory → 512Mi + +⚠ Warning: No conflicts detected +``` + +### 2. Additional Tools + +1. **Metrics**: Prometheus metrics for policy application +2. **Web UI**: Visual policy impact dashboard in VelaUX +3. **Policy Audit**: `vela policy audit ` - complete audit trail ## Summary diff --git a/examples/policy-transforms/README.md b/examples/policy-transforms/README.md index 25b6dca33..bfc2f69c0 100644 --- a/examples/policy-transforms/README.md +++ b/examples/policy-transforms/README.md @@ -339,6 +339,48 @@ To test this feature: 4. **Conditional Execution**: Use `enabled: false` to skip policy application +## Debugging & Observability + +### Viewing Applied Policies + +Check which global policies were applied: + +```bash +kubectl get app my-app -o jsonpath='{.status.appliedGlobalPolicies}' | jq +``` + +This shows: +- Which policies were discovered and applied +- Labels, annotations, and context added by each policy +- Whether each policy modified the Application spec +- Why policies were skipped (if `applied: false`) + +### Viewing Spec Changes (✅ New Feature) + +When global policies modify the Application spec, KubeVela stores detailed diffs in a ConfigMap for auditing: + +```bash +# Check if spec diffs exist +kubectl get app my-app -o jsonpath='{.status.policyDiffsConfigMap}' +# Output: my-app-policy-diffs + +# View diff for first policy +kubectl get configmap my-app-policy-diffs -o jsonpath='{.data.001-policy-name}' | jq +``` + +The ConfigMap contains: +- **Sequence-prefixed keys**: `001-policy-name`, `002-policy-name` (preserves execution order) +- **JSON Merge Patch diffs**: Shows exactly what each policy changed +- **Standard labels**: Discoverable with `kubectl get cm -l "app.oam.dev/policy-diffs=true"` + +### Future: CLI Tools + +Planned CLI commands for better UX: +- `vela policy view ` - Interactive viewer for policy changes with before/after comparison +- `vela policy dry-run --policies ` - Preview policy effects before applying + +See [OBSERVABILITY.md](./OBSERVABILITY.md) for detailed debugging examples and use cases. + ## Implementation Details - **File**: `pkg/controller/core.oam.dev/v1beta1/application/policy_transforms.go` diff --git a/go.mod b/go.mod index 6b39ded51..0e6296cbf 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/dave/jennifer v1.6.1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/ettle/strcase v0.2.0 + github.com/evanphx/json-patch v5.7.0+incompatible github.com/fatih/color v1.18.0 github.com/fluxcd/helm-controller/api v0.32.2 github.com/fluxcd/source-controller/api v0.30.0 @@ -147,7 +148,6 @@ require ( github.com/emicklei/go-restful/v3 v3.12.0 // indirect github.com/emicklei/proto v1.14.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/evanphx/json-patch v5.7.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect github.com/fatih/camelcase v1.0.0 // indirect diff --git a/pkg/appfile/appfile.go b/pkg/appfile/appfile.go index 2bbd61cf8..a9ce18a6a 100644 --- a/pkg/appfile/appfile.go +++ b/pkg/appfile/appfile.go @@ -191,9 +191,15 @@ type Appfile struct { // GeneratePolicyManifests generates policy manifests from an appFile // internal policies like apply-once, topology, will not render manifests -func (af *Appfile) GeneratePolicyManifests(_ context.Context) ([]*unstructured.Unstructured, error) { +func (af *Appfile) GeneratePolicyManifests(ctx context.Context, cli client.Client) ([]*unstructured.Unstructured, error) { var manifests []*unstructured.Unstructured for _, policy := range af.ParsedPolicies { + // Skip Application-scoped policies - they were already processed in ApplyApplicationScopeTransforms + if af.isApplicationScopedPolicy(ctx, cli, policy) { + // Policy has non-default scope, skip resource generation + continue + } + un, err := af.generatePolicyUnstructured(policy) if err != nil { return nil, err @@ -246,6 +252,44 @@ func generatePolicyUnstructuredFromCUEModule(comp *Component, artifacts []*types return res, nil } +// isApplicationScopedPolicy checks if a policy has a non-default Scope +// DefaultScope (empty) policies use standard output-based rendering +// Non-default scopes (e.g., ApplicationScope) are handled in specialized pipelines +// and should not be rendered as standard K8s resources +// Note: Backward compatible - unset Scope field defaults to empty string (DefaultScope) +func (af *Appfile) isApplicationScopedPolicy(ctx context.Context, cli client.Client, policy *Component) bool { + var policyDef *v1beta1.PolicyDefinition + + // Try to get from AppRevision first (preferred - cached in revision) + if af.AppRevision != nil && af.AppRevision.Spec.PolicyDefinitions != nil { + if def, ok := af.AppRevision.Spec.PolicyDefinitions[policy.Type]; ok { + policyDef = &def + } + } + + // If not in AppRevision, look it up from cluster + // This handles the case where GeneratePolicyManifests is called before AppRevision is set + if policyDef == nil && cli != nil { + def := &v1beta1.PolicyDefinition{} + // Try app namespace first, then vela-system + err := util.GetCapabilityDefinition(ctx, cli, def, policy.Type, af.app.Annotations) + if err != nil { + // Policy not found or error - assume default scope + return false + } + policyDef = def + } + + // If still not found, assume default scope + if policyDef == nil { + return false + } + + // Check if it has a non-default scope + // DefaultScope = "", so any non-empty scope means it's scoped + return policyDef.Spec.Scope != v1beta1.DefaultScope +} + // artifacts contains resources in unstructured shape of all components // it allows to access values of workloads and traits in CUE template, i.g., // `if context.artifacts..ready` to determine whether it's ready to access diff --git a/pkg/appfile/dryrun/dryrun.go b/pkg/appfile/dryrun/dryrun.go index 194f84609..936c4a66d 100644 --- a/pkg/appfile/dryrun/dryrun.go +++ b/pkg/appfile/dryrun/dryrun.go @@ -158,7 +158,7 @@ func (d *Option) ExecuteDryRun(ctx context.Context, application *v1beta1.Applica if err != nil { return nil, nil, errors.WithMessage(err, "cannot generate manifests from components and traits") } - policyManifests, err := appFile.GeneratePolicyManifests(ctx) + policyManifests, err := appFile.GeneratePolicyManifests(ctx, d.Client) if err != nil { return nil, nil, errors.WithMessage(err, "cannot generate manifests from policies") } diff --git a/pkg/appfile/parser.go b/pkg/appfile/parser.go index 4ea23d81e..b01262749 100644 --- a/pkg/appfile/parser.go +++ b/pkg/appfile/parser.go @@ -342,6 +342,16 @@ func (p *Parser) parsePoliciesFromRevision(ctx context.Context, af *Appfile) (er case v1alpha1.DebugPolicyType: af.Debug = true default: + // Skip policies with non-default scope - they're already processed earlier + // Check PolicyDefinition scope from AppRevision + if af.AppRevision != nil && af.AppRevision.Spec.PolicyDefinitions != nil { + if policyDef, ok := af.AppRevision.Spec.PolicyDefinitions[policy.Type]; ok { + if policyDef.Spec.Scope != v1beta1.DefaultScope { + continue // Skip - non-default scope + } + } + } + w, err := p.makeComponentFromRevision(policy.Name, policy.Type, types.TypePolicy, policy.Properties, af.AppRevision) if err != nil { return err @@ -385,6 +395,12 @@ func (p *Parser) parsePolicies(ctx context.Context, af *Appfile) (err error) { af.RelatedTraitDefinitions[def.Name] = def } default: + // Skip Application-scoped policies - they're already processed in ApplyApplicationScopeTransforms() + // and should not be rendered as K8s resources + if p.isApplicationScopedPolicy(ctx, policy.Type, af.app.Annotations) { + continue + } + w, err := p.makeComponent(ctx, policy.Name, policy.Type, types.TypePolicy, policy.Properties, af.app.Annotations) if err != nil { return err @@ -395,6 +411,25 @@ func (p *Parser) parsePolicies(ctx context.Context, af *Appfile) (err error) { return nil } +// isApplicationScopedPolicy checks if a policy has a non-default Scope. +// Policies with non-default scopes (e.g., "Application") are handled in specialized +// pipelines before parsing and should not be added to ParsedPolicies. +// Returns true if the policy has ANY non-default scope (Scope != DefaultScope). +func (p *Parser) isApplicationScopedPolicy(ctx context.Context, policyType string, annotations map[string]string) bool { + policyDef := &v1beta1.PolicyDefinition{} + + // Try to load PolicyDefinition (checks namespace first, then vela-system) + err := util.GetCapabilityDefinition(ctx, p.client, policyDef, policyType, annotations) + if err != nil { + // If not found or error, assume DefaultScope (safe default - include the policy) + return false + } + + // Check if scope is non-default + // DefaultScope = "" (empty string), so ANY non-empty scope means it should be filtered + return policyDef.Spec.Scope != v1beta1.DefaultScope +} + func (p *Parser) loadWorkflowToAppfile(ctx context.Context, af *Appfile) error { var err error // parse workflow steps diff --git a/pkg/controller/core.oam.dev/v1beta1/application/application_controller.go b/pkg/controller/core.oam.dev/v1beta1/application/application_controller.go index 1ec0407bc..7f52aba2c 100644 --- a/pkg/controller/core.oam.dev/v1beta1/application/application_controller.go +++ b/pkg/controller/core.oam.dev/v1beta1/application/application_controller.go @@ -168,7 +168,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu } // Emit events for applied global policies (for observability) - for _, appliedPolicy := range app.Status.AppliedGlobalPolicies { + for _, appliedPolicy := range app.Status.AppliedApplicationPolicies { if appliedPolicy.Applied { r.Recorder.Event(app, event.Normal("GlobalPolicyApplied", fmt.Sprintf("Applied global policy %s from namespace %s", appliedPolicy.Name, appliedPolicy.Namespace))) diff --git a/pkg/controller/core.oam.dev/v1beta1/application/apply.go b/pkg/controller/core.oam.dev/v1beta1/application/apply.go index 59adb7495..06e27c20c 100644 --- a/pkg/controller/core.oam.dev/v1beta1/application/apply.go +++ b/pkg/controller/core.oam.dev/v1beta1/application/apply.go @@ -401,7 +401,7 @@ func (h *AppHandler) ApplyPolicies(ctx context.Context, af *appfile.Appfile) err })) defer subCtx.Commit("finish apply policies") } - policyManifests, err := af.GeneratePolicyManifests(ctx) + policyManifests, err := af.GeneratePolicyManifests(ctx, h.Client) if err != nil { return errors.Wrapf(err, "failed to render policy manifests") } diff --git a/pkg/controller/core.oam.dev/v1beta1/application/generator_test.go b/pkg/controller/core.oam.dev/v1beta1/application/generator_test.go index 5351a608f..78e4fbae6 100644 --- a/pkg/controller/core.oam.dev/v1beta1/application/generator_test.go +++ b/pkg/controller/core.oam.dev/v1beta1/application/generator_test.go @@ -110,7 +110,7 @@ var _ = Describe("Test Application workflow generator", func() { } af, err := appParser.GenerateAppFile(ctx, app) Expect(err).Should(BeNil()) - _, err = af.GeneratePolicyManifests(context.Background()) + _, err = af.GeneratePolicyManifests(context.Background(), k8sClient) Expect(err).Should(BeNil()) handler, err := NewAppHandler(ctx, reconciler, app) @@ -153,7 +153,7 @@ var _ = Describe("Test Application workflow generator", func() { } af, err := appParser.GenerateAppFile(ctx, app) Expect(err).Should(BeNil()) - _, err = af.GeneratePolicyManifests(context.Background()) + _, err = af.GeneratePolicyManifests(context.Background(), k8sClient) Expect(err).Should(BeNil()) handler, err := NewAppHandler(ctx, reconciler, app) diff --git a/pkg/controller/core.oam.dev/v1beta1/application/policy_dryrun.go b/pkg/controller/core.oam.dev/v1beta1/application/policy_dryrun.go new file mode 100644 index 000000000..d043b2d91 --- /dev/null +++ b/pkg/controller/core.oam.dev/v1beta1/application/policy_dryrun.go @@ -0,0 +1,365 @@ +/* +Copyright 2021 The KubeVela Authors. + +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. +*/ + +package application + +import ( + "context" + "encoding/json" + "fmt" + + monitorContext "github.com/kubevela/pkg/monitor/context" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" + utilfeature "k8s.io/apiserver/pkg/util/feature" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1" + "github.com/oam-dev/kubevela/pkg/features" + "github.com/oam-dev/kubevela/pkg/oam" +) + +// PolicyDryRunMode defines how policies are discovered and applied in dry-run +type PolicyDryRunMode string + +const ( + // DryRunModeIsolated tests only specified policies + DryRunModeIsolated PolicyDryRunMode = "isolated" + // DryRunModeAdditive tests specified policies with existing globals + DryRunModeAdditive PolicyDryRunMode = "additive" + // DryRunModeFull simulates complete policy chain (globals + app policies) + DryRunModeFull PolicyDryRunMode = "full" +) + +// PolicyDryRunOptions contains configuration for policy dry-run simulation +type PolicyDryRunOptions struct { + // Mode determines which policies are applied + Mode PolicyDryRunMode + // SpecifiedPolicies are the policy names to test (for isolated/additive modes) + SpecifiedPolicies []string + // IncludeAppPolicies includes policies from Application spec (for full mode) + IncludeAppPolicies bool +} + +// PolicyDryRunResult contains the results of a policy dry-run simulation +type PolicyDryRunResult struct { + // Application is the final state after all policies applied + Application *v1beta1.Application + // ExecutionPlan shows which policies were discovered and in what order + ExecutionPlan []PolicyExecutionStep + // PolicyResults contains detailed results for each policy + PolicyResults []PolicyApplicationResult + // Diffs contains the JSON patches for each policy that modified the spec + Diffs map[string][]byte + // Warnings contains any warnings detected during simulation + Warnings []string + // Errors contains any errors encountered + Errors []string +} + +// PolicyExecutionStep represents a policy in the execution plan +type PolicyExecutionStep struct { + Sequence int + PolicyName string + PolicyNamespace string + Priority int32 + Source string // "global", "app-spec", or "specified" +} + +// PolicyApplicationResult contains the results of applying a single policy +type PolicyApplicationResult struct { + Sequence int + PolicyName string + PolicyNamespace string + Priority int32 + Enabled bool + Applied bool + SpecModified bool + AddedLabels map[string]string + AddedAnnotations map[string]string + AdditionalContext *runtime.RawExtension + SkipReason string + Error string +} + +// SimulatePolicyApplication performs a dry-run simulation of policy application +// This function can be used by CLI tools to preview policy effects without persisting changes +func SimulatePolicyApplication(ctx context.Context, cli client.Client, app *v1beta1.Application, opts PolicyDryRunOptions) (*PolicyDryRunResult, error) { + // Create a deep copy of the application to avoid modifying the original + appCopy := app.DeepCopy() + + // Create a monitor context + monCtx := monitorContext.NewTraceContext(ctx, "") + + // Create AppHandler for policy operations + handler := &AppHandler{ + Client: cli, + app: appCopy, + } + + result := &PolicyDryRunResult{ + Application: appCopy, + ExecutionPlan: []PolicyExecutionStep{}, + PolicyResults: []PolicyApplicationResult{}, + Diffs: make(map[string][]byte), + Warnings: []string{}, + Errors: []string{}, + } + + // Clear any existing policy status + appCopy.Status.AppliedApplicationPolicies = nil + + // Step 1: Build execution plan based on mode + var policiesToApply []v1beta1.PolicyDefinition + var sequence int = 1 + + switch opts.Mode { + case DryRunModeIsolated: + // Test only specified policies + if len(opts.SpecifiedPolicies) == 0 { + return nil, errors.New("isolated mode requires at least one policy to be specified") + } + for _, policyName := range opts.SpecifiedPolicies { + // Try to load from vela-system first, then app namespace + policy, err := loadPolicyDefinition(ctx, cli, policyName, oam.SystemDefinitionNamespace) + if err != nil { + // Try app namespace + policy, err = loadPolicyDefinition(ctx, cli, policyName, appCopy.Namespace) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("Policy %s not found in vela-system or %s", policyName, appCopy.Namespace)) + continue + } + } + policiesToApply = append(policiesToApply, *policy) + + result.ExecutionPlan = append(result.ExecutionPlan, PolicyExecutionStep{ + Sequence: sequence, + PolicyName: policy.Name, + PolicyNamespace: policy.Namespace, + Priority: policy.Spec.Priority, + Source: "specified", + }) + sequence++ + } + + case DryRunModeAdditive: + // Include global policies + specified policies + if !shouldSkipGlobalPolicies(appCopy) && utilfeature.DefaultMutableFeatureGate.Enabled(features.EnableGlobalPolicies) { + // Discover global policies + globalPolicies, err := discoverAndDeduplicateGlobalPolicies(monCtx, cli, appCopy.Namespace) + if err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to discover global policies: %v", err)) + } else { + for _, policy := range globalPolicies { + policiesToApply = append(policiesToApply, policy) + result.ExecutionPlan = append(result.ExecutionPlan, PolicyExecutionStep{ + Sequence: sequence, + PolicyName: policy.Name, + PolicyNamespace: policy.Namespace, + Priority: policy.Spec.Priority, + Source: "global", + }) + sequence++ + } + } + } + + // Add specified policies + for _, policyName := range opts.SpecifiedPolicies { + policy, err := loadPolicyDefinition(ctx, cli, policyName, oam.SystemDefinitionNamespace) + if err != nil { + policy, err = loadPolicyDefinition(ctx, cli, policyName, appCopy.Namespace) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("Policy %s not found", policyName)) + continue + } + } + policiesToApply = append(policiesToApply, *policy) + result.ExecutionPlan = append(result.ExecutionPlan, PolicyExecutionStep{ + Sequence: sequence, + PolicyName: policy.Name, + PolicyNamespace: policy.Namespace, + Priority: policy.Spec.Priority, + Source: "specified", + }) + sequence++ + } + + case DryRunModeFull: + // Full simulation: global + app policies + if !shouldSkipGlobalPolicies(appCopy) && utilfeature.DefaultMutableFeatureGate.Enabled(features.EnableGlobalPolicies) { + globalPolicies, err := discoverAndDeduplicateGlobalPolicies(monCtx, cli, appCopy.Namespace) + if err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to discover global policies: %v", err)) + } else { + for _, policy := range globalPolicies { + policiesToApply = append(policiesToApply, policy) + result.ExecutionPlan = append(result.ExecutionPlan, PolicyExecutionStep{ + Sequence: sequence, + PolicyName: policy.Name, + PolicyNamespace: policy.Namespace, + Priority: policy.Spec.Priority, + Source: "global", + }) + sequence++ + } + } + } + + // Add app spec policies if requested + if opts.IncludeAppPolicies { + for _, policyRef := range appCopy.Spec.Policies { + policy, err := loadPolicyDefinition(ctx, cli, policyRef.Type, appCopy.Namespace) + if err != nil { + policy, err = loadPolicyDefinition(ctx, cli, policyRef.Type, oam.SystemDefinitionNamespace) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("Policy %s not found", policyRef.Type)) + continue + } + } + policiesToApply = append(policiesToApply, *policy) + result.ExecutionPlan = append(result.ExecutionPlan, PolicyExecutionStep{ + Sequence: sequence, + PolicyName: policy.Name, + PolicyNamespace: policy.Namespace, + Priority: policy.Spec.Priority, + Source: "app-spec", + }) + sequence++ + } + } + } + + // Step 2: Apply each policy and track results + policySequence := 1 + for _, policy := range policiesToApply { + // Take snapshot before applying policy (for future use in diff computation) + _, err := deepCopyAppSpec(&appCopy.Spec) + if err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to snapshot spec for policy %s: %v", policy.Name, err)) + } + + // Render the policy + policyRef := v1beta1.AppPolicy{ + Name: policy.Name, + Type: policy.Name, + } + + renderedResult, err := handler.renderPolicy(monCtx, appCopy, policyRef, &policy) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("Policy %s render error: %v", policy.Name, err)) + result.PolicyResults = append(result.PolicyResults, PolicyApplicationResult{ + Sequence: policySequence, + PolicyName: policy.Name, + PolicyNamespace: policy.Namespace, + Priority: policy.Spec.Priority, + Enabled: false, + Applied: false, + Error: err.Error(), + }) + continue + } + + // Apply the rendered policy + var policyChanges *PolicyChanges + monCtx, policyChanges, err = handler.applyRenderedPolicyResult(monCtx, appCopy, renderedResult, policySequence, policy.Spec.Priority) + + policyResult := PolicyApplicationResult{ + Sequence: policySequence, + PolicyName: policy.Name, + PolicyNamespace: policy.Namespace, + Priority: policy.Spec.Priority, + Enabled: renderedResult.Enabled, + Applied: renderedResult.Enabled && err == nil, + SkipReason: renderedResult.SkipReason, + } + + if err != nil { + policyResult.Error = err.Error() + result.Errors = append(result.Errors, fmt.Sprintf("Policy %s application error: %v", policy.Name, err)) + } else if renderedResult.Enabled && policyChanges != nil { + // Extract changes from policyChanges + policyResult.SpecModified = policyChanges.SpecModified + policyResult.AddedLabels = policyChanges.AddedLabels + policyResult.AddedAnnotations = policyChanges.AddedAnnotations + + // Convert additional context from map to RawExtension + if policyChanges.AdditionalContext != nil { + contextBytes, err := json.Marshal(policyChanges.AdditionalContext) + if err == nil { + policyResult.AdditionalContext = &runtime.RawExtension{Raw: contextBytes} + } + } + + policySequence++ + } + + result.PolicyResults = append(result.PolicyResults, policyResult) + } + + result.Application = appCopy + return result, nil +} + +// loadPolicyDefinition loads a PolicyDefinition from the cluster +func loadPolicyDefinition(ctx context.Context, cli client.Client, name, namespace string) (*v1beta1.PolicyDefinition, error) { + policy := &v1beta1.PolicyDefinition{} + err := cli.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, policy) + if err != nil { + return nil, err + } + return policy, nil +} + +// discoverAndDeduplicateGlobalPolicies discovers global policies from both vela-system and app namespace +// and returns the deduplicated list (namespace policies win over vela-system) +func discoverAndDeduplicateGlobalPolicies(ctx monitorContext.Context, cli client.Client, appNamespace string) ([]v1beta1.PolicyDefinition, error) { + var globalPolicies []v1beta1.PolicyDefinition + + // Discover from vela-system + velaSystemPolicies, err := discoverGlobalPolicies(ctx, cli, oam.SystemDefinitionNamespace) + if err != nil { + return nil, err + } + + // Discover from app namespace (if different) + var namespacePolicies []v1beta1.PolicyDefinition + if appNamespace != oam.SystemDefinitionNamespace { + namespacePolicies, err = discoverGlobalPolicies(ctx, cli, appNamespace) + if err != nil { + // Non-fatal, continue with vela-system policies only + namespacePolicies = []v1beta1.PolicyDefinition{} + } + } + + // Deduplicate: namespace policies win + namespacePolicyNames := make(map[string]bool) + for _, policy := range namespacePolicies { + namespacePolicyNames[policy.Name] = true + } + + // Add namespace policies first + globalPolicies = append(globalPolicies, namespacePolicies...) + + // Add vela-system policies (skip if name exists in namespace) + for _, policy := range velaSystemPolicies { + if !namespacePolicyNames[policy.Name] { + globalPolicies = append(globalPolicies, policy) + } + } + + return globalPolicies, nil +} diff --git a/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms.go b/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms.go index 6d4d6b748..22e4f5c91 100644 --- a/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms.go +++ b/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms.go @@ -18,13 +18,16 @@ package application import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" - "reflect" "sort" "strings" + "time" "cuelang.org/go/cue" + "github.com/crossplane/crossplane-runtime/pkg/meta" jsonpatch "github.com/evanphx/json-patch" "github.com/kubevela/pkg/cue/cuex" monitorContext "github.com/kubevela/pkg/monitor/context" @@ -50,13 +53,22 @@ const ( // ApplyApplicationScopeTransforms iterates through policies in the Application spec // and applies transforms from any Application-scoped PolicyDefinitions. +// +// Two-level caching strategy: +// 1. In-memory global cache (globalPolicyCache) - Caches rendered policy results for rapid +// reconciliations. Invalidated when Application or global policy set changes. +// 2. ConfigMap persistent cache - Stores individual policy results with TTL control: +// - TTL=-1: Never refresh (deterministic policies) +// - TTL=0: Never cache (policies with external dependencies) +// - TTL>0: Refresh after N seconds +// // It first discovers and applies any global policies (if feature gate enabled), // then applies explicit policies from the Application spec. // This modifies the in-memory Application object before it's parsed into an AppFile. // Returns the updated context with any additionalContext from policies. func (h *AppHandler) ApplyApplicationScopeTransforms(ctx monitorContext.Context, app *v1beta1.Application) (monitorContext.Context, error) { // Clear previous global policy status - app.Status.AppliedGlobalPolicies = nil + app.Status.AppliedApplicationPolicies = nil // Step 1: Validate explicit policies are not global for _, policy := range app.Spec.Policies { @@ -67,8 +79,9 @@ func (h *AppHandler) ApplyApplicationScopeTransforms(ctx monitorContext.Context, // Step 2: Handle global policies (if feature gate enabled and not opted out) var globalRenderedResults []RenderedPolicyResult - allDiffs := make(map[string][]byte) // Track spec diffs: policy-name -> JSON patch - sequence := 1 // Track execution order + allPolicyChanges := make(map[string]*PolicyChanges) // Track full changes for ConfigMap storage + policyMetadata := make(map[string]*policyConfigMapMetadata) // Track metadata for ConfigMap + sequence := 1 // Track execution order if !shouldSkipGlobalPolicies(app) && utilfeature.DefaultMutableFeatureGate.Enabled(features.EnableGlobalPolicies) { // Compute current global policy hash for cache validation @@ -158,20 +171,25 @@ func (h *AppHandler) ApplyApplicationScopeTransforms(ctx monitorContext.Context, // Get priority from the result (stored during render) priority := result.Priority - var diffBytes []byte - ctx, diffBytes, err = h.applyRenderedPolicyResult(ctx, app, result, sequence, priority) + var policyChanges *PolicyChanges + ctx, policyChanges, err = h.applyRenderedPolicyResult(ctx, app, result, sequence, priority) if err != nil { return ctx, errors.Wrapf(err, "failed to apply global policy %s", result.PolicyName) } - // Store diff if policy modified spec - if diffBytes != nil && len(diffBytes) > 0 { - allDiffs[result.PolicyName] = diffBytes + // Store changes and metadata for ConfigMap storage + if policyChanges != nil { + allPolicyChanges[result.PolicyName] = policyChanges } - - // Increment sequence only if policy was applied (enabled=true) if result.Enabled { - sequence++ + policyMetadata[result.PolicyName] = &policyConfigMapMetadata{ + Name: result.PolicyName, + Namespace: result.PolicyNamespace, + Source: "global", + Sequence: sequence, + Priority: priority, + } + sequence++ // Increment sequence only if policy was applied (enabled=true) } } } else if shouldSkipGlobalPolicies(app) { @@ -198,33 +216,110 @@ func (h *AppHandler) ApplyApplicationScopeTransforms(ctx monitorContext.Context, ctx.Info("Applying explicit Application-scoped policy", "policy", policy.Type, "name", policy.Name) // Render and apply (not cached - explicit policies can have unique parameters) - ctx, err = h.applyPolicyTransform(ctx, app, policy, templ.PolicyDefinition) + var changes *PolicyChanges + ctx, changes, err = h.applyPolicyTransform(ctx, app, policy, templ.PolicyDefinition) if err != nil { + // Record failure in status + recordApplicationPolicyStatus(app, policy.Name, templ.PolicyDefinition.Namespace, "explicit", sequence, 0, false, fmt.Sprintf("error: %s", err.Error()), nil) return ctx, errors.Wrapf(err, "failed to apply transform from policy %s", policy.Type) } + + // Record successful application in status + if changes != nil { + // Determine if spec was modified by checking if transforms.Spec exists + changes.SpecModified = changes.Transforms != nil && changes.Transforms.Spec != nil + allPolicyChanges[policy.Name] = changes // Store for ConfigMap serialization + } + recordApplicationPolicyStatus(app, policy.Name, templ.PolicyDefinition.Namespace, "explicit", sequence, 0, true, "", changes) + + // Store metadata for ConfigMap + policyMetadata[policy.Name] = &policyConfigMapMetadata{ + Name: policy.Name, + Namespace: templ.PolicyDefinition.Namespace, + Source: "explicit", + Sequence: sequence, + Priority: 0, // Explicit policies don't have priority + } + sequence++ } - // Step 4: Store all spec diffs in a single ConfigMap (if any policies modified spec) - if len(allDiffs) > 0 { - // Build ConfigMap data with sequence-prefixed keys - orderedData := make(map[string]string) - for _, appliedPolicy := range app.Status.AppliedGlobalPolicies { - if diff, ok := allDiffs[appliedPolicy.Name]; ok { - key := fmt.Sprintf("%03d-%s", appliedPolicy.Sequence, appliedPolicy.Name) - orderedData[key] = string(diff) + // Step 4: Store all rendered policy outputs in ConfigMap for reuse and observability + // This creates a persistent cache with TTL that can be used to avoid re-rendering + orderedData := make(map[string]string) + + // Compute hash of Application state (spec + metadata) for cache invalidation + appHash, err := computeApplicationHash(app) + if err != nil { + ctx.Info("Failed to compute Application hash", "error", err) + appHash = "" // Continue without hash + } + + // Build ConfigMap data from metadata and changes tracked during reconciliation + for policyName, metadata := range policyMetadata { + // Get TTL from PolicyDefinition (default -1 = never refresh) + ttlSeconds := int32(-1) + policyDef := &v1beta1.PolicyDefinition{} + if err := h.Client.Get(ctx, client.ObjectKey{Name: metadata.Name, Namespace: metadata.Namespace}, policyDef); err == nil { + ttlSeconds = policyDef.Spec.CacheTTLSeconds + } + + // Build the rendered policy record with everything needed to reapply it + policyRecord := map[string]interface{}{ + "policy": metadata.Name, + "namespace": metadata.Namespace, + "source": metadata.Source, + "sequence": metadata.Sequence, + "priority": metadata.Priority, + "rendered_at": time.Now().Format(time.RFC3339), + "ttl_seconds": ttlSeconds, + "enabled": true, + "application_hash": appHash, // Hash of Application for cache invalidation + } + + // Get the full policy changes if available (includes transforms, labels, annotations, context) + if policyChanges, ok := allPolicyChanges[policyName]; ok && policyChanges != nil { + // Store the full transforms object in reusable format + if policyChanges.Transforms != nil { + transformsData := serializeTransformsForStorage(policyChanges.Transforms) + if len(transformsData) > 0 { + policyRecord["transforms"] = transformsData + } + } + + // Add additional context if available + if policyChanges.AdditionalContext != nil && len(policyChanges.AdditionalContext) > 0 { + policyRecord["additional_context"] = policyChanges.AdditionalContext + } + + // Add observability summary + policyRecord["summary"] = map[string]interface{}{ + "labels_added": len(policyChanges.AddedLabels), + "annotations_added": len(policyChanges.AddedAnnotations), + "spec_modified": policyChanges.SpecModified, + "has_context": len(policyChanges.AdditionalContext) > 0, } } - // Create/update ConfigMap - if len(orderedData) > 0 { - err := createOrUpdateDiffsConfigMap(ctx, h.Client, app, orderedData) - if err != nil { - ctx.Info("Failed to store policy diffs in ConfigMap", "error", err) - // Don't fail reconciliation - observability is optional - } else { - app.Status.PolicyDiffsConfigMap = fmt.Sprintf("%s-policy-diffs", app.Name) - ctx.Info("Stored policy diffs in ConfigMap", "configmap", app.Status.PolicyDiffsConfigMap, "count", len(orderedData)) - } + // Marshal to pretty JSON for human readability and tool consumption + policyJSON, err := json.MarshalIndent(policyRecord, "", " ") + if err != nil { + ctx.Info("Failed to marshal policy record", "policy", metadata.Name, "error", err) + continue + } + + key := fmt.Sprintf("%03d-%s", metadata.Sequence, metadata.Name) + orderedData[key] = string(policyJSON) + } + + // Create/update ConfigMap if any policies were applied + if len(orderedData) > 0 { + err := createOrUpdateDiffsConfigMap(ctx, h.Client, app, orderedData) + if err != nil { + ctx.Info("Failed to store policy records in ConfigMap", "error", err) + // Don't fail reconciliation - observability/caching is optional + } else { + app.Status.ApplicationPoliciesConfigMap = fmt.Sprintf("application-policies-%s-%s", app.Namespace, app.Name) + ctx.Info("Stored policy records in ConfigMap", "configmap", app.Status.ApplicationPoliciesConfigMap, "policies", len(orderedData)) } } @@ -233,30 +328,37 @@ func (h *AppHandler) ApplyApplicationScopeTransforms(ctx monitorContext.Context, // applyPolicyTransform renders the policy's CUE template and applies transforms to the Application. // Returns the updated context with any additionalContext merged in. -func (h *AppHandler) applyPolicyTransform(ctx monitorContext.Context, app *v1beta1.Application, policyRef v1beta1.AppPolicy, policyDef *v1beta1.PolicyDefinition) (monitorContext.Context, error) { +func (h *AppHandler) applyPolicyTransform(ctx monitorContext.Context, app *v1beta1.Application, policyRef v1beta1.AppPolicy, policyDef *v1beta1.PolicyDefinition) (monitorContext.Context, *PolicyChanges, error) { // Validate policy has CUE schematic if policyDef.Spec.Schematic == nil || policyDef.Spec.Schematic.CUE == nil { - return ctx, errors.Errorf("Application-scoped policy %s must have a CUE schematic", policyDef.Name) + return ctx, nil, errors.Errorf("Application-scoped policy %s must have a CUE schematic", policyDef.Name) } // Parse policy parameters var policyParams map[string]interface{} if policyRef.Properties != nil && len(policyRef.Properties.Raw) > 0 { if err := json.Unmarshal(policyRef.Properties.Raw, &policyParams); err != nil { - return ctx, errors.Wrap(err, "failed to unmarshal policy parameters") + return ctx, nil, errors.Wrap(err, "failed to unmarshal policy parameters") } } - // Render the CUE template with context.application - rendered, err := h.renderPolicyCUETemplate(ctx, app, policyParams, policyDef) + // Load prior cached result (if any) to pass as context.prior + // This allows the policy template to access previous rendered values + var priorResult map[string]interface{} + if app.Status.ApplicationPoliciesConfigMap != "" { + priorResult, _ = loadCachedPolicyFromConfigMap(ctx, h.Client, app, policyDef.Name, -1) // Always load regardless of TTL + } + + // Render the CUE template with context.application and context.prior + rendered, err := h.renderPolicyCUETemplate(ctx, app, policyParams, policyDef, priorResult) if err != nil { - return ctx, errors.Wrap(err, "failed to render CUE template") + return ctx, nil, errors.Wrap(err, "failed to render CUE template") } // Check if the transform should be applied (default: true) shouldApply, err := h.extractEnabled(rendered) if err != nil { - return ctx, errors.Wrap(err, "failed to extract enabled") + return ctx, nil, errors.Wrap(err, "failed to extract enabled") } if !shouldApply { @@ -265,38 +367,84 @@ func (h *AppHandler) applyPolicyTransform(ctx monitorContext.Context, app *v1bet // Note: This should not happen for explicit policies as we validate earlier // that global policies cannot be explicitly referenced if policyDef.Spec.Global { - recordGlobalPolicyStatus(app, policyRef.Name, policyDef.Namespace, 0, policyDef.Spec.Priority, false, "enabled=false", nil) + recordApplicationPolicyStatus(app, policyRef.Name, policyDef.Namespace, "global", 0, policyDef.Spec.Priority, false, "enabled=false", nil) } - return ctx, nil + return ctx, nil, nil } // Extract transforms field transforms, err := h.extractTransforms(rendered) if err != nil { - return ctx, errors.Wrap(err, "failed to extract transforms") + return ctx, nil, errors.Wrap(err, "failed to extract transforms") + } + + // Track changes from before transform application + changes := &PolicyChanges{ + Enabled: true, // We already checked it's enabled above + Transforms: transforms, + } + + // Take snapshot of labels and annotations BEFORE applying transform + labelsBefore := make(map[string]string) + if app.Labels != nil { + for k, v := range app.Labels { + labelsBefore[k] = v + } + } + annotationsBefore := make(map[string]string) + if app.Annotations != nil { + for k, v := range app.Annotations { + annotationsBefore[k] = v + } } // Apply transforms to the in-memory Application if transforms != nil { if err := h.applyTransformsToApplication(ctx, app, transforms); err != nil { - return ctx, errors.Wrap(err, "failed to apply transforms") + return ctx, nil, errors.Wrap(err, "failed to apply transforms") } ctx.Info("Applied transforms to Application", "policy", policyRef.Type) } + // Compare AFTER to capture actual changes (works regardless of how CUE modifies the Application) + labelsAdded := make(map[string]string) + if app.Labels != nil { + for k, v := range app.Labels { + if labelsBefore[k] != v { + labelsAdded[k] = v + } + } + } + if len(labelsAdded) > 0 { + changes.AddedLabels = labelsAdded + } + + annotationsAdded := make(map[string]string) + if app.Annotations != nil { + for k, v := range app.Annotations { + if annotationsBefore[k] != v { + annotationsAdded[k] = v + } + } + } + if len(annotationsAdded) > 0 { + changes.AddedAnnotations = annotationsAdded + } + // Extract and store additionalContext additionalContext, err := h.extractAdditionalContext(rendered) if err != nil { - return ctx, errors.Wrap(err, "failed to extract additionalContext") + return ctx, nil, errors.Wrap(err, "failed to extract additionalContext") } if additionalContext != nil { ctx = storeAdditionalContextInCtx(ctx, additionalContext) ctx.Info("Stored additionalContext in context", "policy", policyRef.Type, "keys", len(additionalContext)) + changes.AdditionalContext = additionalContext } ctx.Info("Successfully applied transform", "policy", policyRef.Type) - return ctx, nil + return ctx, changes, nil } // renderPolicy renders a policy's CUE template and extracts the results for caching @@ -309,6 +457,33 @@ func (h *AppHandler) renderPolicy(ctx monitorContext.Context, app *v1beta1.Appli Enabled: false, } + // Check if we have a valid cached result based on TTL + ttlSeconds := policyDef.Spec.CacheTTLSeconds + cachedRecord, err := loadCachedPolicyFromConfigMap(ctx, h.Client, app, policyDef.Name, ttlSeconds) + if err != nil { + ctx.Info("Failed to load cached policy from ConfigMap", "policy", policyDef.Name, "error", err) + // Continue with rendering + } else if cachedRecord != nil { + ctx.Info("Using cached policy result", "policy", policyDef.Name, "ttl", ttlSeconds) + + // Deserialize the cached result + if transformsData, ok := cachedRecord["transforms"].(map[string]interface{}); ok { + result.Transforms = deserializeTransformsFromStorage(transformsData) + } + + if additionalContext, ok := cachedRecord["additional_context"].(map[string]interface{}); ok { + result.AdditionalContext = additionalContext + } + + if enabled, ok := cachedRecord["enabled"].(bool); ok { + result.Enabled = enabled + } else { + result.Enabled = true // Default + } + + return result, nil + } + // Validate policy has CUE schematic if policyDef.Spec.Schematic == nil || policyDef.Spec.Schematic.CUE == nil { result.SkipReason = "no CUE schematic" @@ -324,8 +499,15 @@ func (h *AppHandler) renderPolicy(ctx monitorContext.Context, app *v1beta1.Appli } } - // Render the CUE template with context.application - rendered, err := h.renderPolicyCUETemplate(ctx, app, policyParams, policyDef) + // Load prior cached result (if any) to pass as context.prior + // Even if we're re-rendering (cache expired), pass the prior result to the template + var priorResult map[string]interface{} + if app.Status.ApplicationPoliciesConfigMap != "" { + priorResult, _ = loadCachedPolicyFromConfigMap(ctx, h.Client, app, policyDef.Name, -1) // Always load regardless of TTL + } + + // Render the CUE template with context.application and context.prior + rendered, err := h.renderPolicyCUETemplate(ctx, app, policyParams, policyDef, priorResult) if err != nil { result.SkipReason = fmt.Sprintf("CUE render error: %s", err.Error()) return result, errors.Wrap(err, "failed to render CUE template") @@ -365,79 +547,67 @@ func (h *AppHandler) renderPolicy(ctx monitorContext.Context, app *v1beta1.Appli // applyRenderedPolicyResult applies a cached/rendered policy result to the Application // This skips all the expensive CUE rendering and just applies the pre-computed transforms -// Returns the diff bytes if spec was modified -func (h *AppHandler) applyRenderedPolicyResult(ctx monitorContext.Context, app *v1beta1.Application, result RenderedPolicyResult, sequence int, priority int32) (monitorContext.Context, []byte, error) { +// Returns the updated context and the PolicyChanges +func (h *AppHandler) applyRenderedPolicyResult(ctx monitorContext.Context, app *v1beta1.Application, result RenderedPolicyResult, sequence int, priority int32) (monitorContext.Context, *PolicyChanges, error) { if !result.Enabled { ctx.Info("Skipping policy (from cache)", "policy", result.PolicyName, "reason", result.SkipReason) - recordGlobalPolicyStatus(app, result.PolicyName, result.PolicyNamespace, sequence, priority, false, result.SkipReason, nil) + recordApplicationPolicyStatus(app, result.PolicyName, result.PolicyNamespace, "global", sequence, priority, false, result.SkipReason, nil) return ctx, nil, nil } + // Cast transforms from interface{} + transforms, ok := result.Transforms.(*PolicyTransforms) + if !ok && result.Transforms != nil { + return ctx, nil, errors.Errorf("cached transforms have invalid type for policy %s", result.PolicyName) + } + // Track what changes we're making changes := &PolicyChanges{ AdditionalContext: result.AdditionalContext, + Enabled: result.Enabled, + Transforms: transforms, } - // Take snapshot of spec BEFORE applying transforms (for diff tracking) - specBefore, err := deepCopyAppSpec(&app.Spec) - if err != nil { - ctx.Info("Failed to copy spec for diff tracking", "error", err) - specBefore = nil // Continue without diff tracking - } - - var diffBytes []byte - // Apply transforms to the in-memory Application - if result.Transforms != nil { - // Cast from interface{} back to *PolicyTransforms - transforms, ok := result.Transforms.(*PolicyTransforms) - if !ok { - return ctx, nil, errors.Errorf("cached transforms have invalid type for policy %s", result.PolicyName) - } - - // Capture what labels/annotations/spec will be changed - if transforms.Labels != nil && transforms.Labels.Value != nil { - if labelMap, ok := transforms.Labels.Value.(map[string]interface{}); ok { - changes.AddedLabels = make(map[string]string) - for k, v := range labelMap { - if str, ok := v.(string); ok { - changes.AddedLabels[k] = str - } - } - } - } - if transforms.Annotations != nil && transforms.Annotations.Value != nil { - if annotationMap, ok := transforms.Annotations.Value.(map[string]interface{}); ok { - changes.AddedAnnotations = make(map[string]string) - for k, v := range annotationMap { - if str, ok := v.(string); ok { - changes.AddedAnnotations[k] = str - } - } - } - } - if transforms.Spec != nil { - changes.SpecModified = true - } - + if transforms != nil { if err := h.applyTransformsToApplication(ctx, app, transforms); err != nil { return ctx, nil, errors.Wrap(err, "failed to apply transforms") } ctx.Info("Applied cached transforms to Application", "policy", result.PolicyName) - // Compute diff if spec was modified - if specBefore != nil && changes.SpecModified { - // Check if spec actually changed - if !reflect.DeepEqual(specBefore, &app.Spec) { - diff, diffErr := computeJSONPatch(specBefore, &app.Spec) - if diffErr != nil { - ctx.Info("Failed to compute diff", "policy", result.PolicyName, "error", diffErr) - } else { - diffBytes = diff - ctx.Info("Computed spec diff", "policy", result.PolicyName, "size", len(diff)) + // Extract changes directly from transforms + if transforms.Labels != nil { + if labelsMap, ok := transforms.Labels.Value.(map[string]string); ok { + changes.AddedLabels = labelsMap + } else if labelsMap, ok := transforms.Labels.Value.(map[string]interface{}); ok { + // Convert from interface{} map + stringMap := make(map[string]string) + for k, v := range labelsMap { + if strVal, ok := v.(string); ok { + stringMap[k] = strVal + } } + changes.AddedLabels = stringMap } } + + if transforms.Annotations != nil { + if annotationsMap, ok := transforms.Annotations.Value.(map[string]string); ok { + changes.AddedAnnotations = annotationsMap + } else if annotationsMap, ok := transforms.Annotations.Value.(map[string]interface{}); ok { + // Convert from interface{} map + stringMap := make(map[string]string) + for k, v := range annotationsMap { + if strVal, ok := v.(string); ok { + stringMap[k] = strVal + } + } + changes.AddedAnnotations = stringMap + } + } + + // Check if spec was modified + changes.SpecModified = transforms.Spec != nil } // Store additionalContext in context @@ -446,47 +616,26 @@ func (h *AppHandler) applyRenderedPolicyResult(ctx monitorContext.Context, app * ctx.Info("Stored cached additionalContext in context", "policy", result.PolicyName, "keys", len(result.AdditionalContext)) } - recordGlobalPolicyStatus(app, result.PolicyName, result.PolicyNamespace, sequence, priority, true, "", changes) + recordApplicationPolicyStatus(app, result.PolicyName, result.PolicyNamespace, "global", sequence, priority, true, "", changes) ctx.Info("Successfully applied cached policy result", "policy", result.PolicyName) - return ctx, diffBytes, nil + return ctx, changes, nil } -// policyTransformSchema provides type-safe schema for Application-scoped policy transforms -const policyTransformSchema = ` -parameter: {[string]: _} -enabled: *true | bool -transforms?: { - spec?: { - type: "replace" | "merge" - value: {...} - } - labels?: { - type: "merge" - value: {[string]: string} - } - annotations?: { - type: "merge" - value: {[string]: string} - } -} -additionalContext?: {...} -context: { - application: {...} -} -` - // renderPolicyCUETemplate renders the policy CUE template with parameter and context.application -func (h *AppHandler) renderPolicyCUETemplate(ctx monitorContext.Context, app *v1beta1.Application, params map[string]interface{}, policyDef *v1beta1.PolicyDefinition) (cue.Value, error) { - // Build CUE source with parameter and context +// Follows the same pattern as workloadDef.Complete() and traitDef.Complete() to properly handle import statements +func (h *AppHandler) renderPolicyCUETemplate(ctx monitorContext.Context, app *v1beta1.Application, params map[string]interface{}, policyDef *v1beta1.PolicyDefinition, priorResult map[string]interface{}) (cue.Value, error) { + // Build CUE source following the pattern from pkg/cue/definition/template.go + // Order matters: template (with imports) must come first, then type annotations, then values var cueSources []string - // Add type safety schema - cueSources = append(cueSources, policyTransformSchema) - - // Add the policy template + // 1. Add the policy template FIRST (preserves any import statements at the top) cueSources = append(cueSources, policyDef.Spec.Schematic.CUE.Template) - // Add parameter + // 2. Add type annotations (following renderTemplate() pattern from template.go:489) + cueSources = append(cueSources, "parameter: _") + cueSources = append(cueSources, "context: _") + + // 3. Add parameter values if params != nil { paramJSON, err := json.Marshal(params) if err != nil { @@ -497,13 +646,22 @@ func (h *AppHandler) renderPolicyCUETemplate(ctx monitorContext.Context, app *v1 cueSources = append(cueSources, "parameter: {}") } - // Add context.application (convert Application to JSON) + // 4. Add context.application (convert Application to JSON) appJSON, err := json.Marshal(app) if err != nil { return cue.Value{}, errors.Wrap(err, "failed to marshal Application") } cueSources = append(cueSources, fmt.Sprintf("context: application: %s", string(appJSON))) + // 5. Add context.prior if available (previous cached policy result) + if priorResult != nil { + priorJSON, err := json.Marshal(priorResult) + if err != nil { + return cue.Value{}, errors.Wrap(err, "failed to marshal prior result") + } + cueSources = append(cueSources, fmt.Sprintf("context: prior: %s", string(priorJSON))) + } + // Compile the CUE using the default CueX compiler cueSource := strings.Join(cueSources, "\n") val, err := cuex.DefaultCompiler.Get().CompileString(ctx.GetContext(), cueSource) @@ -832,26 +990,26 @@ func validateNotGlobalPolicy(ctx monitorContext.Context, cli client.Client, poli return nil } -// recordGlobalPolicyStatus records the application status of a global policy -func recordGlobalPolicyStatus(app *v1beta1.Application, policyName, policyNamespace string, sequence int, priority int32, applied bool, reason string, changes *PolicyChanges) { - entry := common.AppliedGlobalPolicy{ +// recordApplicationPolicyStatus records the application status of an Application-scoped policy +// (global or explicit) +func recordApplicationPolicyStatus(app *v1beta1.Application, policyName, policyNamespace, source string, sequence int, priority int32, applied bool, reason string, changes *PolicyChanges) { + entry := common.AppliedApplicationPolicy{ Name: policyName, Namespace: policyNamespace, Applied: applied, Reason: reason, - Sequence: sequence, - Priority: priority, } - // Record what was changed (if policy was applied) + // Record summary counts of what was changed (if policy was applied) + // Full details are stored in the ApplicationPoliciesConfigMap if applied && changes != nil { - entry.AddedLabels = changes.AddedLabels - entry.AddedAnnotations = changes.AddedAnnotations - entry.AdditionalContext = changes.AdditionalContext entry.SpecModified = changes.SpecModified + entry.LabelsCount = len(changes.AddedLabels) + entry.AnnotationsCount = len(changes.AddedAnnotations) + entry.HasContext = changes.AdditionalContext != nil && len(changes.AdditionalContext) > 0 } - app.Status.AppliedGlobalPolicies = append(app.Status.AppliedGlobalPolicies, entry) + app.Status.AppliedApplicationPolicies = append(app.Status.AppliedApplicationPolicies, entry) } // PolicyChanges tracks what a policy modified @@ -860,6 +1018,188 @@ type PolicyChanges struct { AddedAnnotations map[string]string AdditionalContext map[string]interface{} SpecModified bool + + // Full rendered output for caching/reuse + Enabled bool + Transforms *PolicyTransforms +} + +// policyConfigMapMetadata tracks metadata needed for ConfigMap storage +type policyConfigMapMetadata struct { + Name string + Namespace string + Source string // "global" or "explicit" + Sequence int + Priority int32 +} + +// serializeTransformsForStorage converts PolicyTransforms to a format suitable for storage and reuse +func serializeTransformsForStorage(transforms *PolicyTransforms) map[string]interface{} { + if transforms == nil { + return nil + } + + result := make(map[string]interface{}) + + if transforms.Labels != nil { + result["labels"] = map[string]interface{}{ + "type": transforms.Labels.Type, + "value": transforms.Labels.Value, + } + } + + if transforms.Annotations != nil { + result["annotations"] = map[string]interface{}{ + "type": transforms.Annotations.Type, + "value": transforms.Annotations.Value, + } + } + + if transforms.Spec != nil { + result["spec"] = map[string]interface{}{ + "type": transforms.Spec.Type, + "value": transforms.Spec.Value, + } + } + + return result +} + +// loadCachedPolicyFromConfigMap attempts to load a cached policy result from the ConfigMap +// Returns the cached result if found and valid according to TTL and Application state, nil otherwise +func loadCachedPolicyFromConfigMap(ctx context.Context, cli client.Client, app *v1beta1.Application, policyName string, ttlSeconds int32) (map[string]interface{}, error) { + if app.Status.ApplicationPoliciesConfigMap == "" { + return nil, nil // No ConfigMap exists yet + } + + // Get the ConfigMap + cm := &corev1.ConfigMap{} + cmName := app.Status.ApplicationPoliciesConfigMap + if err := cli.Get(ctx, client.ObjectKey{Name: cmName, Namespace: app.Namespace}, cm); err != nil { + if client.IgnoreNotFound(err) == nil { + return nil, nil // ConfigMap doesn't exist + } + return nil, err + } + + // Find the entry for this policy + var cachedData string + for key, value := range cm.Data { + // Keys are formatted as "001-policy-name" + if strings.HasSuffix(key, "-"+policyName) { + cachedData = value + break + } + } + + if cachedData == "" { + return nil, nil // Policy not in ConfigMap + } + + // Parse the cached record + var record map[string]interface{} + if err := json.Unmarshal([]byte(cachedData), &record); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal cached policy record") + } + + // Check if Application state has changed (cache invalidation) + currentHash, err := computeApplicationHash(app) + if err == nil && currentHash != "" { + cachedHash, _ := record["application_hash"].(string) + if cachedHash != currentHash { + // Application changed - cache is invalid even if TTL hasn't expired + return nil, nil + } + } + + // Check TTL + if ttlSeconds == -1 { + // Never refresh - always use cached (if Application hasn't changed) + return record, nil + } + + if ttlSeconds == 0 { + // Never cache - always re-render + return nil, nil + } + + // Check if cache is still valid by time + renderedAtStr, ok := record["rendered_at"].(string) + if !ok { + return nil, nil // Invalid format + } + + renderedAt, err := time.Parse(time.RFC3339, renderedAtStr) + if err != nil { + return nil, nil // Invalid timestamp + } + + elapsed := time.Since(renderedAt) + ttl := time.Duration(ttlSeconds) * time.Second + + if elapsed < ttl { + // Cache is still valid (both time and Application state) + return record, nil + } + + // Cache expired + return nil, nil +} + +// computeApplicationHash computes a hash of the Application state that affects policy rendering +// This includes spec, labels, and annotations. Used for cache invalidation. +func computeApplicationHash(app *v1beta1.Application) (string, error) { + // Build a structure with only the fields that affect policy rendering + hashInput := map[string]interface{}{ + "spec": app.Spec, + "labels": app.Labels, + "annotations": app.Annotations, + } + + // Marshal to JSON for consistent hashing + jsonBytes, err := json.Marshal(hashInput) + if err != nil { + return "", errors.Wrap(err, "failed to marshal Application for hashing") + } + + // Compute SHA256 hash + hash := sha256.Sum256(jsonBytes) + return hex.EncodeToString(hash[:]), nil +} + +// deserializeTransformsFromStorage converts stored transforms back to PolicyTransforms +func deserializeTransformsFromStorage(transformsData map[string]interface{}) *PolicyTransforms { + if transformsData == nil { + return nil + } + + transforms := &PolicyTransforms{} + + if labelsData, ok := transformsData["labels"].(map[string]interface{}); ok { + typeStr, _ := labelsData["type"].(string) + transforms.Labels = &Transform{ + Type: TransformOperationType(typeStr), + Value: labelsData["value"], + } + } + + if annotationsData, ok := transformsData["annotations"].(map[string]interface{}); ok { + typeStr, _ := annotationsData["type"].(string) + transforms.Annotations = &Transform{ + Type: TransformOperationType(typeStr), + Value: annotationsData["value"], + } + } + + if specData, ok := transformsData["spec"].(map[string]interface{}); ok { + typeStr, _ := specData["type"].(string) + transforms.Spec = &Transform{ + Type: TransformOperationType(typeStr), + Value: specData["value"], + } + } + + return transforms } // computeCurrentGlobalPolicyHash discovers current global policies and computes their hash @@ -926,9 +1266,9 @@ func computeJSONPatch(before, after *v1beta1.ApplicationSpec) ([]byte, error) { return patch, nil } -// createOrUpdateDiffsConfigMap creates or updates a ConfigMap containing all policy diffs +// createOrUpdateDiffsConfigMap creates or updates a ConfigMap containing all policy records func createOrUpdateDiffsConfigMap(ctx context.Context, cli client.Client, app *v1beta1.Application, orderedData map[string]string) error { - cmName := fmt.Sprintf("%s-policy-diffs", app.Name) + cmName := fmt.Sprintf("application-policies-%s-%s", app.Namespace, app.Name) // Create the ConfigMap cm := &corev1.ConfigMap{ @@ -937,8 +1277,9 @@ func createOrUpdateDiffsConfigMap(ctx context.Context, cli client.Client, app *v Namespace: app.Namespace, OwnerReferences: []metav1.OwnerReference{ { - APIVersion: app.APIVersion, - Kind: app.Kind, + // Use hardcoded values since TypeMeta is cleared by k8s client after Create/Get + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, Name: app.Name, UID: app.UID, Controller: ptrBool(true), @@ -949,6 +1290,19 @@ func createOrUpdateDiffsConfigMap(ctx context.Context, cli client.Client, app *v Data: orderedData, } + // Add standard KubeVela labels (following ResourceTracker pattern) + meta.AddLabels(cm, map[string]string{ + oam.LabelAppName: app.Name, + oam.LabelAppNamespace: app.Namespace, + oam.LabelAppUID: string(app.UID), + "app.oam.dev/application-policies": "true", // Identify this as an application-policies ConfigMap + }) + + // Add annotations to track update time + meta.AddAnnotations(cm, map[string]string{ + oam.AnnotationLastAppliedTime: time.Now().Format(time.RFC3339), + }) + // Try to create the ConfigMap err := cli.Create(ctx, cm) if err != nil { @@ -960,8 +1314,11 @@ func createOrUpdateDiffsConfigMap(ctx context.Context, cli client.Client, app *v return errors.Wrap(getErr, "failed to get existing ConfigMap") } - // Update data + // Update data and annotations existing.Data = orderedData + meta.AddAnnotations(existing, map[string]string{ + oam.AnnotationLastAppliedTime: time.Now().Format(time.RFC3339), + }) if updateErr := cli.Update(ctx, existing); updateErr != nil { return errors.Wrap(updateErr, "failed to update ConfigMap") } diff --git a/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms_test.go b/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms_test.go index 3bd3e25f0..3636cdd94 100644 --- a/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms_test.go +++ b/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms_test.go @@ -19,12 +19,14 @@ package application import ( "context" "encoding/json" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" monitorContext "github.com/kubevela/pkg/monitor/context" "github.com/oam-dev/kubevela/apis/core.oam.dev/common" @@ -32,11 +34,25 @@ import ( "github.com/oam-dev/kubevela/pkg/oam/util" ) +// Helper function to wait for PolicyDefinition to be retrievable +func waitForPolicyDef(ctx context.Context, name, ns string) { + Eventually(func() error { + pd := &v1beta1.PolicyDefinition{} + return k8sClient.Get(ctx, client.ObjectKey{Name: name, Namespace: ns}, pd) + }, "30s", "500ms").Should(Succeed()) + + // Additional wait to ensure appfile.LoadTemplate can find it + time.Sleep(100 * time.Millisecond) +} + var _ = Describe("Test Application-scoped PolicyDefinition transforms", func() { - ctx := context.Background() namespace := "policy-transform-test" + var ctx context.Context BeforeEach(func() { + // Set namespace in context for definition lookups + ctx = util.SetNamespaceInCtx(context.Background(), namespace) + ns := corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespace, @@ -45,6 +61,15 @@ var _ = Describe("Test Application-scoped PolicyDefinition transforms", func() { Expect(k8sClient.Create(ctx, &ns)).Should(SatisfyAny(BeNil(), &util.AlreadyExistMatcher{})) }) + AfterEach(func() { + // Clean up PolicyDefinitions to avoid test pollution + policyList := &v1beta1.PolicyDefinitionList{} + _ = k8sClient.List(ctx, policyList, client.InNamespace(namespace)) + for _, policy := range policyList.Items { + _ = k8sClient.Delete(ctx, &policy) + } + }) + It("Test Application-scoped policy with spec merge transform", func() { // Create a PolicyDefinition with Application scope policyDef := &v1beta1.PolicyDefinition{ @@ -89,6 +114,8 @@ additionalContext: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "add-test-env", namespace) + waitForPolicyDef(ctx, "add-test-env", namespace) // Create an Application that uses this policy app := &v1beta1.Application{ @@ -178,9 +205,14 @@ transforms: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "add-labels", namespace) // Create an Application with existing labels app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, ObjectMeta: metav1.ObjectMeta{ Name: "test-app-labels", Namespace: namespace, @@ -207,6 +239,9 @@ transforms: { }, } + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + handler := &AppHandler{ Client: k8sClient, } @@ -255,6 +290,7 @@ transforms: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "conditional-policy", namespace) // Create an Application with applyPolicy=false app := &v1beta1.Application{ @@ -329,6 +365,7 @@ transforms: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "replace-spec", namespace) // Create an Application app := &v1beta1.Application{ @@ -396,6 +433,7 @@ output: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "regular-policy", namespace) // Create an Application app := &v1beta1.Application{ @@ -437,10 +475,13 @@ output: { }) var _ = Describe("Test Global Policy Cache", func() { - ctx := context.Background() namespace := "cache-test" + var ctx context.Context BeforeEach(func() { + // Set namespace in context for definition lookups + ctx = util.SetNamespaceInCtx(context.Background(), namespace) + ns := corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespace, @@ -452,6 +493,15 @@ var _ = Describe("Test Global Policy Cache", func() { globalPolicyCache.InvalidateAll() }) + AfterEach(func() { + // Clean up PolicyDefinitions to avoid test pollution + policyList := &v1beta1.PolicyDefinitionList{} + _ = k8sClient.List(ctx, policyList, client.InNamespace(namespace)) + for _, policy := range policyList.Items { + _ = k8sClient.Delete(ctx, &policy) + } + }) + It("Test cache basic operations", func() { // Verify cache starts empty Expect(globalPolicyCache.Size()).Should(Equal(0)) @@ -787,11 +837,14 @@ var _ = Describe("Test Global Policy Cache", func() { }) var _ = Describe("Test Global PolicyDefinition Features", func() { - ctx := context.Background() namespace := "global-policy-test" + var ctx context.Context velaSystem := "vela-system" BeforeEach(func() { + // Set namespace in context for definition lookups + ctx = util.SetNamespaceInCtx(context.Background(), namespace) + ns := corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespace, @@ -810,6 +863,22 @@ var _ = Describe("Test Global PolicyDefinition Features", func() { globalPolicyCache.InvalidateAll() }) + AfterEach(func() { + // Clean up PolicyDefinitions to avoid test pollution in both namespaces + policyList := &v1beta1.PolicyDefinitionList{} + _ = k8sClient.List(ctx, policyList, client.InNamespace(namespace)) + for _, policy := range policyList.Items { + _ = k8sClient.Delete(ctx, &policy) + } + + // Also clean up vela-system policies + velaSystemPolicyList := &v1beta1.PolicyDefinitionList{} + _ = k8sClient.List(ctx, velaSystemPolicyList, client.InNamespace(velaSystem)) + for _, policy := range velaSystemPolicyList.Items { + _ = k8sClient.Delete(ctx, &policy) + } + }) + It("Test global policy discovery from vela-system applies to all namespaces", func() { // Create global policy in vela-system policyDef := &v1beta1.PolicyDefinition{ @@ -841,6 +910,7 @@ transforms: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "vela-system-global", velaSystem) monCtx := monitorContext.NewTraceContext(ctx, "test") @@ -884,6 +954,7 @@ transforms: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "namespace-global", namespace) monCtx := monitorContext.NewTraceContext(ctx, "test") @@ -1055,6 +1126,7 @@ transforms: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "global-policy", namespace) // Create Application with opt-out annotation app := &v1beta1.Application{ @@ -1104,6 +1176,7 @@ transforms: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "global-policy", namespace) // Create non-global policy for comparison regularPolicyDef := &v1beta1.PolicyDefinition{ @@ -1139,10 +1212,13 @@ transforms: { }) var _ = Describe("Test Application-scoped Policy Rendering", func() { - ctx := context.Background() namespace := "rendering-test" + var ctx context.Context BeforeEach(func() { + // Set namespace in context for definition lookups + ctx = util.SetNamespaceInCtx(context.Background(), namespace) + ns := corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespace, @@ -1151,6 +1227,15 @@ var _ = Describe("Test Application-scoped Policy Rendering", func() { Expect(k8sClient.Create(ctx, &ns)).Should(SatisfyAny(BeNil(), &util.AlreadyExistMatcher{})) }) + AfterEach(func() { + // Clean up PolicyDefinitions to avoid test pollution + policyList := &v1beta1.PolicyDefinitionList{} + _ = k8sClient.List(ctx, policyList, client.InNamespace(namespace)) + for _, policy := range policyList.Items { + _ = k8sClient.Delete(ctx, &policy) + } + }) + It("Test annotation transforms with merge", func() { // Create a PolicyDefinition that adds annotations policyDef := &v1beta1.PolicyDefinition{ @@ -1180,6 +1265,7 @@ transforms: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "add-annotations", namespace) // Create an Application with existing annotations app := &v1beta1.Application{ @@ -1246,6 +1332,7 @@ transforms: { value: { "app-name": context.application.metadata.name "app-namespace": context.application.metadata.namespace + "app-name-upper": strings.ToUpper(context.application.metadata.name) } } } @@ -1260,9 +1347,14 @@ additionalContext: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "context-aware-policy", namespace) // Create an Application app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, ObjectMeta: metav1.ObjectMeta{ Name: "my-test-app", Namespace: namespace, @@ -1281,6 +1373,9 @@ additionalContext: { }, } + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + handler := &AppHandler{ Client: k8sClient, } @@ -1292,12 +1387,14 @@ additionalContext: { // Verify labels from context Expect(app.Labels["app-name"]).Should(Equal("my-test-app")) Expect(app.Labels["app-namespace"]).Should(Equal(namespace)) + Expect(app.Labels["app-name-upper"]).Should(Equal("MY-TEST-APP")) // Verify additionalContext from context additionalCtx := getAdditionalContextFromCtx(resultCtx) Expect(additionalCtx).ShouldNot(BeNil()) Expect(additionalCtx["originalAppName"]).Should(Equal("my-test-app")) - Expect(additionalCtx["componentCount"]).Should(Equal(float64(2))) + // CUE's len() returns int64, not float64 + Expect(additionalCtx["componentCount"]).Should(Equal(int64(2))) }) It("Test renderPolicy function extracts all fields correctly", func() { @@ -1337,6 +1434,7 @@ additionalContext: { }, } Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "comprehensive-policy", namespace) app := &v1beta1.Application{ ObjectMeta: metav1.ObjectMeta{ @@ -1430,7 +1528,7 @@ additionalContext: { } // Apply the rendered result - resultCtx, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult) + resultCtx, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) Expect(err).Should(BeNil()) // Verify labels were applied @@ -1444,9 +1542,9 @@ additionalContext: { Expect(additionalCtx["timestamp"]).Should(Equal("2024-01-01")) // Verify status was recorded - Expect(app.Status.AppliedGlobalPolicies).Should(HaveLen(1)) - Expect(app.Status.AppliedGlobalPolicies[0].Name).Should(Equal("cached-policy")) - Expect(app.Status.AppliedGlobalPolicies[0].Applied).Should(BeTrue()) + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Name).Should(Equal("cached-policy")) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeTrue()) }) It("Test applyRenderedPolicyResult skips disabled policies", func() { @@ -1477,17 +1575,17 @@ additionalContext: { } // Apply the rendered result - _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult) + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) Expect(err).Should(BeNil()) // Verify no labels were applied Expect(app.Labels).Should(BeEmpty()) // Verify status shows skipped - Expect(app.Status.AppliedGlobalPolicies).Should(HaveLen(1)) - Expect(app.Status.AppliedGlobalPolicies[0].Name).Should(Equal("disabled-policy")) - Expect(app.Status.AppliedGlobalPolicies[0].Applied).Should(BeFalse()) - Expect(app.Status.AppliedGlobalPolicies[0].Reason).Should(Equal("enabled=false")) + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Name).Should(Equal("disabled-policy")) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeFalse()) + Expect(app.Status.AppliedApplicationPolicies[0].Reason).Should(Equal("enabled=false")) }) It("Test applyRenderedPolicyResult tracks label changes in status", func() { @@ -1518,7 +1616,7 @@ additionalContext: { Labels: &Transform{ Type: "merge", Value: map[string]interface{}{ - "added-by": "policy", + "added-by": "policy", "environment": "test", }, }, @@ -1526,15 +1624,16 @@ additionalContext: { } // Apply the rendered result - _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult) + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) Expect(err).Should(BeNil()) - // Verify status tracks what labels were added - Expect(app.Status.AppliedGlobalPolicies).Should(HaveLen(1)) - Expect(app.Status.AppliedGlobalPolicies[0].Applied).Should(BeTrue()) - Expect(app.Status.AppliedGlobalPolicies[0].AddedLabels).Should(HaveLen(2)) - Expect(app.Status.AppliedGlobalPolicies[0].AddedLabels["added-by"]).Should(Equal("policy")) - Expect(app.Status.AppliedGlobalPolicies[0].AddedLabels["environment"]).Should(Equal("test")) + // Verify status tracks summary counts of what labels were added + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeTrue()) + Expect(app.Status.AppliedApplicationPolicies[0].LabelsCount).Should(Equal(2)) + // Full label details are stored in ConfigMap, status only has counts + Expect(app.Labels["added-by"]).Should(Equal("policy")) + Expect(app.Labels["environment"]).Should(Equal("test")) }) It("Test applyRenderedPolicyResult tracks annotation changes in status", func() { @@ -1573,15 +1672,16 @@ additionalContext: { } // Apply the rendered result - _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult) + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) Expect(err).Should(BeNil()) - // Verify status tracks what annotations were added - Expect(app.Status.AppliedGlobalPolicies).Should(HaveLen(1)) - Expect(app.Status.AppliedGlobalPolicies[0].Applied).Should(BeTrue()) - Expect(app.Status.AppliedGlobalPolicies[0].AddedAnnotations).Should(HaveLen(2)) - Expect(app.Status.AppliedGlobalPolicies[0].AddedAnnotations["policy.oam.dev/applied"]).Should(Equal("true")) - Expect(app.Status.AppliedGlobalPolicies[0].AddedAnnotations["policy.oam.dev/version"]).Should(Equal("v1.0")) + // Verify status tracks summary counts of what annotations were added + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeTrue()) + Expect(app.Status.AppliedApplicationPolicies[0].AnnotationsCount).Should(Equal(2)) + // Full annotation details are stored in ConfigMap, status only has counts + Expect(app.Annotations["policy.oam.dev/applied"]).Should(Equal("true")) + Expect(app.Annotations["policy.oam.dev/version"]).Should(Equal("v1.0")) }) It("Test applyRenderedPolicyResult tracks additionalContext in status", func() { @@ -1616,16 +1716,14 @@ additionalContext: { } // Apply the rendered result - _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult) + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) Expect(err).Should(BeNil()) - // Verify status tracks additionalContext - Expect(app.Status.AppliedGlobalPolicies).Should(HaveLen(1)) - Expect(app.Status.AppliedGlobalPolicies[0].Applied).Should(BeTrue()) - Expect(app.Status.AppliedGlobalPolicies[0].AdditionalContext).Should(HaveLen(3)) - Expect(app.Status.AppliedGlobalPolicies[0].AdditionalContext["policyApplied"]).Should(Equal("context-policy")) - Expect(app.Status.AppliedGlobalPolicies[0].AdditionalContext["timestamp"]).Should(Equal("2024-01-01")) - Expect(app.Status.AppliedGlobalPolicies[0].AdditionalContext["configHash"]).Should(Equal("abc123")) + // Verify status tracks presence of additionalContext (full details in ConfigMap) + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeTrue()) + Expect(app.Status.AppliedApplicationPolicies[0].HasContext).Should(BeTrue()) + // Full context details are stored in ConfigMap, status only has boolean flag }) It("Test applyRenderedPolicyResult tracks spec modification in status", func() { @@ -1661,13 +1759,13 @@ additionalContext: { } // Apply the rendered result - _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult) + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) Expect(err).Should(BeNil()) // Verify status tracks spec modification - Expect(app.Status.AppliedGlobalPolicies).Should(HaveLen(1)) - Expect(app.Status.AppliedGlobalPolicies[0].Applied).Should(BeTrue()) - Expect(app.Status.AppliedGlobalPolicies[0].SpecModified).Should(BeTrue()) + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeTrue()) + Expect(app.Status.AppliedApplicationPolicies[0].SpecModified).Should(BeTrue()) }) It("Test applyRenderedPolicyResult tracks all changes together", func() { @@ -1718,19 +1816,1126 @@ additionalContext: { } // Apply the rendered result - _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult) + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) Expect(err).Should(BeNil()) - // Verify status tracks all changes - Expect(app.Status.AppliedGlobalPolicies).Should(HaveLen(1)) - policy := app.Status.AppliedGlobalPolicies[0] + // Verify status tracks summary counts of all changes + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + policy := app.Status.AppliedApplicationPolicies[0] Expect(policy.Applied).Should(BeTrue()) - Expect(policy.AddedLabels).Should(HaveLen(1)) - Expect(policy.AddedLabels["team"]).Should(Equal("platform")) - Expect(policy.AddedAnnotations).Should(HaveLen(1)) - Expect(policy.AddedAnnotations["policy.oam.dev/applied"]).Should(Equal("true")) + Expect(policy.LabelsCount).Should(Equal(1)) + Expect(policy.AnnotationsCount).Should(Equal(1)) Expect(policy.SpecModified).Should(BeTrue()) - Expect(policy.AdditionalContext).Should(HaveLen(1)) - Expect(policy.AdditionalContext["applied"]).Should(Equal(true)) + Expect(policy.HasContext).Should(BeTrue()) + // Full details are stored in ConfigMap, status only has counts + Expect(app.Labels["team"]).Should(Equal("platform")) + Expect(app.Annotations["policy.oam.dev/applied"]).Should(Equal("true")) + }) + + Context("Test spec diff tracking with ConfigMap storage", func() { + It("Test spec diff tracking stores diffs in ConfigMap", func() { + // Create a global policy that modifies spec + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "modify-spec-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} + +enabled: true + +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + replicas: 3 + } + }] + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "modify-spec-policy", namespace) + + // Create Application with initial spec + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-diff-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main-component", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx","replicas":1}`), + }, + }, + }, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test-trace") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap reference is set in status + Expect(app.Status.ApplicationPoliciesConfigMap).ShouldNot(BeEmpty()) + expectedCMName := "application-policies-" + namespace + "-test-diff-app" + Expect(app.Status.ApplicationPoliciesConfigMap).Should(Equal(expectedCMName)) + + // Verify ConfigMap exists + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: expectedCMName, + Namespace: namespace, + }, cm) + Expect(err).Should(BeNil()) + + // Verify sequence-prefixed key exists + Expect(cm.Data).Should(HaveKey("001-modify-spec-policy")) + + // Verify it's valid JSON + var diff map[string]interface{} + err = json.Unmarshal([]byte(cm.Data["001-modify-spec-policy"]), &diff) + Expect(err).Should(BeNil()) + + // Verify OwnerReference points to Application + Expect(cm.OwnerReferences).Should(HaveLen(1)) + Expect(cm.OwnerReferences[0].Name).Should(Equal("test-diff-app")) + Expect(cm.OwnerReferences[0].UID).Should(Equal(app.UID)) + Expect(*cm.OwnerReferences[0].Controller).Should(BeTrue()) + + // Verify status has summary information (sequence/priority are in ConfigMap) + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].SpecModified).Should(BeTrue()) + // Sequence and priority are in ConfigMap data, not in status + }) + + It("Test multiple policies create ordered diffs in ConfigMap", func() { + // Create 3 global policies with different priorities + policy1 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-first", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + cpu: "100m" + } + }] + } + } +} +`, + }, + }, + }, + } + + policy2 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-second", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 50, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + memory: "256Mi" + } + }] + } + } +} +`, + }, + }, + }, + } + + policy3 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-third", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 10, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + replicas: 5 + } + }] + } + } +} +`, + }, + }, + }, + } + + Expect(k8sClient.Create(ctx, policy1)).Should(Succeed()) + Expect(k8sClient.Create(ctx, policy2)).Should(Succeed()) + Expect(k8sClient.Create(ctx, policy3)).Should(Succeed()) + + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multi-diff-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx"}`), + }, + }, + }, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test-trace") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap has ordered keys + cm := &corev1.ConfigMap{} + expectedCMName := "application-policies-" + namespace + "-test-multi-diff-app" + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: expectedCMName, + Namespace: namespace, + }, cm) + Expect(err).Should(BeNil()) + + // Verify keys are in execution order (sequence prefix) + Expect(cm.Data).Should(HaveKey("001-policy-first")) + Expect(cm.Data).Should(HaveKey("002-policy-second")) + Expect(cm.Data).Should(HaveKey("003-policy-third")) + + // Verify status records (sequence/priority are in ConfigMap, not status) + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(3)) + Expect(app.Status.AppliedApplicationPolicies[0].Name).Should(Equal("policy-first")) + Expect(app.Status.AppliedApplicationPolicies[1].Name).Should(Equal("policy-second")) + Expect(app.Status.AppliedApplicationPolicies[2].Name).Should(Equal("policy-third")) + // Sequence and priority are stored in ConfigMap JSON data, not in status + }) + + It("Test ConfigMap is not created when no spec modifications", func() { + // Create policy that only adds labels (no spec change) + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "labels-only-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} + +enabled: true + +transforms: { + labels: { + type: "merge" + value: { + "team": "platform" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "labels-only-policy", namespace) + + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-no-diff-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx"}`), + }, + }, + }, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test-trace") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify policy was applied + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].SpecModified).Should(BeFalse()) + Expect(app.Status.AppliedApplicationPolicies[0].LabelsCount).Should(Equal(1)) + Expect(app.Labels["team"]).Should(Equal("platform")) + + // ConfigMap IS created even without spec modifications (stores all transforms) + Expect(app.Status.ApplicationPoliciesConfigMap).ShouldNot(BeEmpty()) + expectedCMName := "application-policies-" + namespace + "-test-no-diff-app" + Expect(app.Status.ApplicationPoliciesConfigMap).Should(Equal(expectedCMName)) + }) + + It("Test spec diff contains meaningful change information", func() { + // Create a policy that makes multiple types of changes + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "complex-changes-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} + +enabled: true + +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + replicas: 3 + cpu: "200m" + memory: "512Mi" + env: [{ + name: "LOG_LEVEL" + value: "debug" + }] + } + }] + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "complex-changes-policy", namespace) + + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-complex-diff-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx","replicas":1}`), + }, + }, + }, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test-trace") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Get the ConfigMap with diffs + cm := &corev1.ConfigMap{} + expectedCMName := "application-policies-" + namespace + "-test-complex-diff-app" + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: expectedCMName, + Namespace: namespace, + }, cm) + Expect(err).Should(BeNil()) + + // Verify diff exists + Expect(cm.Data).Should(HaveKey("001-complex-changes-policy")) + diffJSON := cm.Data["001-complex-changes-policy"] + + // Parse the diff (JSON Merge Patch format) + var diff map[string]interface{} + err = json.Unmarshal([]byte(diffJSON), &diff) + Expect(err).Should(BeNil()) + + // Verify diff is not empty (contains actual changes) + Expect(diff).ShouldNot(BeEmpty()) + + // The diff should contain transforms with spec changes + Expect(diff).Should(HaveKey("transforms")) + transforms, ok := diff["transforms"].(map[string]interface{}) + Expect(ok).Should(BeTrue()) + Expect(transforms).Should(HaveKey("spec")) + spec, ok := transforms["spec"].(map[string]interface{}) + Expect(ok).Should(BeTrue()) + Expect(spec).Should(HaveKey("value")) + value, ok := spec["value"].(map[string]interface{}) + Expect(ok).Should(BeTrue()) + Expect(value).Should(HaveKey("components")) + }) + + It("Test ConfigMap updates when policies change", func() { + // Create initial policy + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "updateable-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + replicas: 2 + } + }] + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "updateable-policy", namespace) + + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-update-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx","replicas":1}`), + }, + }, + }, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + // First reconciliation + monCtx := monitorContext.NewTraceContext(ctx, "test-trace") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap was created + cm := &corev1.ConfigMap{} + expectedCMName := "application-policies-" + namespace + "-test-update-app" + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: expectedCMName, + Namespace: namespace, + }, cm) + Expect(err).Should(BeNil()) + Expect(cm.Data).Should(HaveKey("001-updateable-policy")) + + // Parse initial diff + var initialDiff map[string]interface{} + err = json.Unmarshal([]byte(cm.Data["001-updateable-policy"]), &initialDiff) + Expect(err).Should(BeNil()) + + // Second reconciliation with same policy should update ConfigMap + app2 := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-update-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx","replicas":1}`), + }, + }, + }, + }, + } + + handler2 := &AppHandler{ + Client: k8sClient, + app: app2, + } + + monCtx2 := monitorContext.NewTraceContext(ctx, "test-trace-2") + _, err = handler2.ApplyApplicationScopeTransforms(monCtx2, app2) + Expect(err).Should(BeNil()) + + // ConfigMap should still exist and be updated + cm2 := &corev1.ConfigMap{} + expectedCMName2 := "application-policies-" + namespace + "-test-update-app" + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: expectedCMName2, + Namespace: namespace, + }, cm2) + Expect(err).Should(BeNil()) + Expect(cm2.Data).Should(HaveKey("001-updateable-policy")) + }) + }) + + Context("Test Application hash-based cache invalidation", func() { + It("Test ConfigMap cache invalidates when Application spec changes", func() { + // Create a policy + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hash-test-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + CacheTTLSeconds: -1, // Never expire based on time + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + labels: { + type: "merge" + value: { + "cached": "true" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "hash-test-policy", namespace) + + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "hash-test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "comp1", + Type: "webservice", + }, + }, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + // First application - creates ConfigMap with hash + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Get ConfigMap and extract hash + cmName := "application-policies-" + namespace + "-hash-test-app" + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm) + Expect(err).Should(BeNil()) + + // Extract original hash from ConfigMap data + var originalHash string + for _, value := range cm.Data { + var record map[string]interface{} + err := json.Unmarshal([]byte(value), &record) + Expect(err).Should(BeNil()) + if hash, ok := record["application_hash"].(string); ok { + originalHash = hash + break + } + } + Expect(originalHash).ShouldNot(BeEmpty()) + + // Modify Application spec - this should invalidate cache + app.Spec.Components = append(app.Spec.Components, common.ApplicationComponent{ + Name: "comp2", + Type: "worker", + }) + + // Re-apply policies + handler2 := &AppHandler{ + Client: k8sClient, + app: app, + } + monCtx2 := monitorContext.NewTraceContext(ctx, "test2") + _, err = handler2.ApplyApplicationScopeTransforms(monCtx2, app) + Expect(err).Should(BeNil()) + + // Get updated ConfigMap + cm2 := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm2) + Expect(err).Should(BeNil()) + + // Extract new hash - it should be different + var newHash string + for _, value := range cm2.Data { + var record map[string]interface{} + err := json.Unmarshal([]byte(value), &record) + Expect(err).Should(BeNil()) + if hash, ok := record["application_hash"].(string); ok { + newHash = hash + break + } + } + Expect(newHash).ShouldNot(BeEmpty()) + Expect(newHash).ShouldNot(Equal(originalHash), "Hash should change when spec changes") + }) + + It("Test ConfigMap cache invalidates when Application labels change", func() { + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "label-hash-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + CacheTTLSeconds: -1, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + labels: { + type: "merge" + value: { + "test": "value" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "label-hash-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "label-hash-app", + Namespace: namespace, + UID: "label-hash-uid", + Labels: map[string]string{ + "original": "label", + }, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "comp", Type: "webservice"}}, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{Client: k8sClient, app: app} + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Get original hash + cmName := "application-policies-" + namespace + "-label-hash-app" + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm) + Expect(err).Should(BeNil()) + + var originalHash string + for _, value := range cm.Data { + var record map[string]interface{} + json.Unmarshal([]byte(value), &record) + if hash, ok := record["application_hash"].(string); ok { + originalHash = hash + break + } + } + + // Change Application labels + app.Labels["new"] = "label" + handler2 := &AppHandler{Client: k8sClient, app: app} + monCtx2 := monitorContext.NewTraceContext(ctx, "test2") + _, err = handler2.ApplyApplicationScopeTransforms(monCtx2, app) + Expect(err).Should(BeNil()) + + // Get new hash + cm2 := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm2) + Expect(err).Should(BeNil()) + + var newHash string + for _, value := range cm2.Data { + var record map[string]interface{} + json.Unmarshal([]byte(value), &record) + if hash, ok := record["application_hash"].(string); ok { + newHash = hash + break + } + } + + Expect(newHash).ShouldNot(Equal(originalHash), "Hash should change when labels change") + }) + }) + + Context("Test TTL-based caching (cacheTTLSeconds)", func() { + It("Test policy with cacheTTLSeconds: -1 stores TTL in ConfigMap", func() { + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-never-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + CacheTTLSeconds: -1, // Never refresh + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + labels: { + type: "merge" + value: {"ttl": "never"} + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "ttl-never-policy", namespace) + + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-never-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "comp", Type: "webservice"}}, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{Client: k8sClient, app: app} + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap contains ttl_seconds: -1 + cmName := "application-policies-" + namespace + "-ttl-never-app" + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm) + Expect(err).Should(BeNil()) + + // Parse and verify TTL + for _, value := range cm.Data { + var record map[string]interface{} + err := json.Unmarshal([]byte(value), &record) + Expect(err).Should(BeNil()) + + ttl, ok := record["ttl_seconds"].(float64) + Expect(ok).Should(BeTrue(), "ttl_seconds should be present") + Expect(int32(ttl)).Should(Equal(int32(-1)), "TTL should be -1 (never refresh)") + + // Verify rendered_at timestamp exists + _, ok = record["rendered_at"].(string) + Expect(ok).Should(BeTrue(), "rendered_at should be present") + } + }) + + It("Test policy with cacheTTLSeconds: 60 stores TTL in ConfigMap", func() { + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-60-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + CacheTTLSeconds: 60, // Cache for 60 seconds + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + labels: { + type: "merge" + value: {"ttl": "60"} + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "ttl-60-policy", namespace) + + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-60-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "comp", Type: "webservice"}}, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{Client: k8sClient, app: app} + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap contains ttl_seconds: 60 + cmName := "application-policies-" + namespace + "-ttl-60-app" + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm) + Expect(err).Should(BeNil()) + + for _, value := range cm.Data { + var record map[string]interface{} + err := json.Unmarshal([]byte(value), &record) + Expect(err).Should(BeNil()) + + ttl, ok := record["ttl_seconds"].(float64) + Expect(ok).Should(BeTrue()) + Expect(int32(ttl)).Should(Equal(int32(60))) + } + }) + + It("Test policy with cacheTTLSeconds not specified", func() { + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-default-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + // CacheTTLSeconds not specified - in tests it's 0, but CRD default is -1 in production + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + labels: { + type: "merge" + value: {"ttl": "default"} + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "ttl-default-policy", namespace) + + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-default-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "comp", Type: "webservice"}}, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{Client: k8sClient, app: app} + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap contains ttl_seconds: 0 (Go zero value when not specified in tests) + // Note: CRD default marker will set it to -1 in production when CRDs are regenerated + cmName := "application-policies-" + namespace + "-ttl-default-app" + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm) + Expect(err).Should(BeNil()) + + for _, value := range cm.Data { + var record map[string]interface{} + err := json.Unmarshal([]byte(value), &record) + Expect(err).Should(BeNil()) + + ttl, ok := record["ttl_seconds"].(float64) + Expect(ok).Should(BeTrue()) + Expect(int32(ttl)).Should(Equal(int32(0)), "Should be 0 (Go zero value) in tests") + } + }) + }) + + Context("Test context.prior support", func() { + It("Test context.prior is available to policy template on second render", func() { + // Create a policy that uses context.prior + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prior-context-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + CacheTTLSeconds: 0, // Always re-render so we can test prior + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true + +// Check if prior result exists +hasPrior: context.prior != _|_ + +transforms: { + labels: { + type: "merge" + value: { + if hasPrior { + "render-count": "incremental" + } + if !hasPrior { + "render-count": "first" + } + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "prior-context-policy", namespace) + + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "prior-test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "comp", Type: "webservice"}}, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + // First render - no prior context + handler := &AppHandler{Client: k8sClient, app: app} + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Check that label indicates first render + Expect(app.Labels["render-count"]).Should(Equal("first")) + + // Store ConfigMap name + cmName := app.Status.ApplicationPoliciesConfigMap + Expect(cmName).ShouldNot(BeEmpty()) + + // Clear in-memory cache to force re-render (TTL=0 means never cache) + globalPolicyCache.InvalidateAll() + + // Second render - should have prior context + app2 := app.DeepCopy() + app2.Status.ApplicationPoliciesConfigMap = cmName // Preserve ConfigMap reference + + handler2 := &AppHandler{Client: k8sClient, app: app2} + monCtx2 := monitorContext.NewTraceContext(ctx, "test2") + _, err = handler2.ApplyApplicationScopeTransforms(monCtx2, app2) + Expect(err).Should(BeNil()) + + // Check that label indicates incremental render (had prior) + Expect(app2.Labels["render-count"]).Should(Equal("incremental")) + }) }) }) diff --git a/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms_test.go.bak b/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms_test.go.bak new file mode 100644 index 000000000..8dbfc66d0 --- /dev/null +++ b/pkg/controller/core.oam.dev/v1beta1/application/policy_transforms_test.go.bak @@ -0,0 +1,2812 @@ +/* +Copyright 2021 The KubeVela Authors. + +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. +*/ + +package application + +import ( + "context" + "encoding/json" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + monitorContext "github.com/kubevela/pkg/monitor/context" + "github.com/oam-dev/kubevela/apis/core.oam.dev/common" + "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1" + "github.com/oam-dev/kubevela/pkg/oam/util" +) + +// Helper function to wait for PolicyDefinition to be retrievable +func waitForPolicyDef(ctx context.Context, name, ns string) { + Eventually(func() error { + pd := &v1beta1.PolicyDefinition{} + return k8sClient.Get(ctx, client.ObjectKey{Name: name, Namespace: ns}, pd) + }, "30s", "500ms").Should(Succeed()) + + // Additional wait to ensure appfile.LoadTemplate can find it + time.Sleep(100 * time.Millisecond) +} + +var _ = Describe("Test Application-scoped PolicyDefinition transforms", func() { + namespace := "policy-transform-test" + var ctx context.Context + + BeforeEach(func() { + // Set namespace in context for definition lookups + ctx = util.SetNamespaceInCtx(context.Background(), namespace) + + ns := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + } + Expect(k8sClient.Create(ctx, &ns)).Should(SatisfyAny(BeNil(), &util.AlreadyExistMatcher{})) + }) + + It("Test Application-scoped policy with spec merge transform", func() { + // Create a PolicyDefinition with Application scope + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "add-test-env", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: { + envName: string + envValue: string +} + +// Add environment variable to the first component +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + env: [{ + name: parameter.envName + value: parameter.envValue + }] + } + }] + } + } +} + +additionalContext: { + policyApplied: "add-test-env" + timestamp: "2024-01-01" +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "add-test-env", namespace) + waitForPolicyDef(ctx, "add-test-env", namespace) + + // Create an Application that uses this policy + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "my-component", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx"}`), + }, + }, + }, + Policies: []v1beta1.AppPolicy{ + { + Name: "test-policy", + Type: "add-test-env", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"envName":"TEST_VAR","envValue":"test-value"}`), + }, + }, + }, + }, + } + + // Create handler and apply transforms + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + resultCtx, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify the transform was applied + Expect(app.Spec.Components).Should(HaveLen(1)) + var props map[string]interface{} + Expect(json.Unmarshal(app.Spec.Components[0].Properties.Raw, &props)).Should(Succeed()) + Expect(props).Should(HaveKey("env")) + envs := props["env"].([]interface{}) + Expect(envs).Should(HaveLen(1)) + env := envs[0].(map[string]interface{}) + Expect(env["name"]).Should(Equal("TEST_VAR")) + Expect(env["value"]).Should(Equal("test-value")) + + // Verify additionalContext was stored + additionalCtx := getAdditionalContextFromCtx(resultCtx) + Expect(additionalCtx).ShouldNot(BeNil()) + Expect(additionalCtx["policyApplied"]).Should(Equal("add-test-env")) + Expect(additionalCtx["timestamp"]).Should(Equal("2024-01-01")) + }) + + It("Test Application-scoped policy with labels merge", func() { + // Create a PolicyDefinition that adds labels + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "add-labels", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: { + team: string + environment: string +} + +transforms: { + labels: { + type: "merge" + value: { + "team": parameter.team + "environment": parameter.environment + "managed-by": "kubevela" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "add-labels", namespace) + + // Create an Application with existing labels + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app-labels", + Namespace: namespace, + Labels: map[string]string{ + "existing": "label", + }, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "my-component", + Type: "webservice", + }, + }, + Policies: []v1beta1.AppPolicy{ + { + Name: "label-policy", + Type: "add-labels", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"team":"platform","environment":"production"}`), + }, + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify labels were merged + Expect(app.Labels).Should(HaveLen(4)) + Expect(app.Labels["existing"]).Should(Equal("label")) + Expect(app.Labels["team"]).Should(Equal("platform")) + Expect(app.Labels["environment"]).Should(Equal("production")) + Expect(app.Labels["managed-by"]).Should(Equal("kubevela")) + }) + + It("Test Application-scoped policy with enabled=false", func() { + // Create a PolicyDefinition with conditional application + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "conditional-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: { + applyPolicy: bool +} + +enabled: parameter.applyPolicy + +transforms: { + labels: { + type: "merge" + value: { + "should-not-appear": "true" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "conditional-policy", namespace) + + // Create an Application with applyPolicy=false + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app-conditional", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "my-component", + Type: "webservice", + }, + }, + Policies: []v1beta1.AppPolicy{ + { + Name: "conditional", + Type: "conditional-policy", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"applyPolicy":false}`), + }, + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify transform was NOT applied + Expect(app.Labels).ShouldNot(HaveKey("should-not-appear")) + }) + + It("Test Application-scoped policy with spec replace", func() { + // Create a PolicyDefinition that replaces the entire spec + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "replace-spec", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: { + newComponentName: string +} + +transforms: { + spec: { + type: "replace" + value: { + components: [{ + name: parameter.newComponentName + type: "webservice" + properties: { + image: "replaced-image" + } + }] + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "replace-spec", namespace) + + // Create an Application + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app-replace", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "original-component", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"original"}`), + }, + }, + }, + Policies: []v1beta1.AppPolicy{ + { + Name: "replace-policy", + Type: "replace-spec", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"newComponentName":"replaced-component"}`), + }, + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify spec was completely replaced + Expect(app.Spec.Components).Should(HaveLen(1)) + Expect(app.Spec.Components[0].Name).Should(Equal("replaced-component")) + var props map[string]interface{} + Expect(json.Unmarshal(app.Spec.Components[0].Properties.Raw, &props)).Should(Succeed()) + Expect(props["image"]).Should(Equal("replaced-image")) + }) + + It("Test non-Application-scoped policy is skipped", func() { + // Create a regular PolicyDefinition without Application scope + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "regular-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + // No Scope specified - should be treated as regular resource-generating policy + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +output: { + apiVersion: "v1" + kind: "ConfigMap" +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "regular-policy", namespace) + + // Create an Application + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app-skip", + Namespace: namespace, + Labels: map[string]string{ + "original": "value", + }, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "my-component", + Type: "webservice", + }, + }, + Policies: []v1beta1.AppPolicy{ + { + Name: "regular", + Type: "regular-policy", + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify app was not modified + Expect(app.Labels).Should(HaveLen(1)) + Expect(app.Labels["original"]).Should(Equal("value")) + }) +}) + +var _ = Describe("Test Global Policy Cache", func() { + namespace := "cache-test" + var ctx context.Context + + BeforeEach(func() { + // Set namespace in context for definition lookups + ctx = util.SetNamespaceInCtx(context.Background(), namespace) + + ns := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + } + Expect(k8sClient.Create(ctx, &ns)).Should(SatisfyAny(BeNil(), &util.AlreadyExistMatcher{})) + + // Clear cache before each test + globalPolicyCache.InvalidateAll() + }) + + It("Test cache basic operations", func() { + // Verify cache starts empty + Expect(globalPolicyCache.Size()).Should(Equal(0)) + + // Test that cache can be cleared + globalPolicyCache.InvalidateAll() + Expect(globalPolicyCache.Size()).Should(Equal(0)) + }) + + It("Test cache stores and retrieves rendered results", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "component", + Type: "webservice", + }, + }, + }, + } + + // Create mock rendered results + results := []RenderedPolicyResult{ + { + PolicyName: "policy1", + PolicyNamespace: namespace, + Enabled: true, + Transforms: &PolicyTransforms{ + Labels: &Transform{ + Type: "merge", + Value: map[string]interface{}{ + "test": "value", + }, + }, + }, + AdditionalContext: map[string]interface{}{ + "key": "value", + }, + }, + } + + // Set in cache + err := globalPolicyCache.Set(app, results, "test-hash") + Expect(err).Should(BeNil()) + + // Verify cache size + Expect(globalPolicyCache.Size()).Should(Equal(1)) + + // Get from cache + cached, hit, err := globalPolicyCache.Get(app, "test-hash") + Expect(err).Should(BeNil()) + Expect(hit).Should(BeTrue()) + Expect(cached).Should(HaveLen(1)) + Expect(cached[0].PolicyName).Should(Equal("policy1")) + Expect(cached[0].Enabled).Should(BeTrue()) + }) + + It("Test cache invalidation when app spec changes", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "component", + Type: "webservice", + }, + }, + }, + } + + results := []RenderedPolicyResult{ + { + PolicyName: "policy1", + PolicyNamespace: namespace, + Enabled: true, + }, + } + + // Cache with original spec + err := globalPolicyCache.Set(app, results, "hash1") + Expect(err).Should(BeNil()) + + // Verify cache hit + cached, hit, err := globalPolicyCache.Get(app, "hash1") + Expect(err).Should(BeNil()) + Expect(hit).Should(BeTrue()) + Expect(cached).Should(HaveLen(1)) + + // Modify app spec + app.Spec.Components = append(app.Spec.Components, common.ApplicationComponent{ + Name: "new-component", + Type: "worker", + }) + + // Cache should miss (spec hash changed) + cached, hit, err = globalPolicyCache.Get(app, "hash1") + Expect(err).Should(BeNil()) + Expect(hit).Should(BeFalse()) + Expect(cached).Should(BeNil()) + }) + + It("Test cache invalidation when global policy hash changes", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "component", + Type: "webservice", + }, + }, + }, + } + + results := []RenderedPolicyResult{ + { + PolicyName: "policy1", + PolicyNamespace: namespace, + Enabled: true, + }, + } + + // Cache with original policy hash + err := globalPolicyCache.Set(app, results, "old-policy-hash") + Expect(err).Should(BeNil()) + + // Verify cache hit with same hash + cached, hit, err := globalPolicyCache.Get(app, "old-policy-hash") + Expect(err).Should(BeNil()) + Expect(hit).Should(BeTrue()) + + // Try to get with different policy hash (policy changed) + cached, hit, err = globalPolicyCache.Get(app, "new-policy-hash") + Expect(err).Should(BeNil()) + Expect(hit).Should(BeFalse()) + Expect(cached).Should(BeNil()) + }) + + It("Test cache stores multiple policies", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "component", + Type: "webservice", + }, + }, + }, + } + + // Create multiple rendered results + results := []RenderedPolicyResult{ + { + PolicyName: "policy1", + PolicyNamespace: namespace, + Enabled: true, + Transforms: &PolicyTransforms{ + Labels: &Transform{ + Type: "merge", + Value: map[string]interface{}{ + "from-policy1": "value1", + }, + }, + }, + }, + { + PolicyName: "policy2", + PolicyNamespace: namespace, + Enabled: true, + Transforms: &PolicyTransforms{ + Labels: &Transform{ + Type: "merge", + Value: map[string]interface{}{ + "from-policy2": "value2", + }, + }, + }, + }, + { + PolicyName: "policy3", + PolicyNamespace: namespace, + Enabled: false, + SkipReason: "enabled=false", + }, + } + + // Cache all results + err := globalPolicyCache.Set(app, results, "multi-policy-hash") + Expect(err).Should(BeNil()) + + // Get from cache + cached, hit, err := globalPolicyCache.Get(app, "multi-policy-hash") + Expect(err).Should(BeNil()) + Expect(hit).Should(BeTrue()) + Expect(cached).Should(HaveLen(3)) + + // Verify all policies are cached correctly + Expect(cached[0].PolicyName).Should(Equal("policy1")) + Expect(cached[0].Enabled).Should(BeTrue()) + Expect(cached[1].PolicyName).Should(Equal("policy2")) + Expect(cached[1].Enabled).Should(BeTrue()) + Expect(cached[2].PolicyName).Should(Equal("policy3")) + Expect(cached[2].Enabled).Should(BeFalse()) + Expect(cached[2].SkipReason).Should(Equal("enabled=false")) + }) + + It("Test cache namespace invalidation", func() { + app1 := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "c1", Type: "webservice"}}, + }, + } + + app2 := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app2", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "c2", Type: "webservice"}}, + }, + } + + results := []RenderedPolicyResult{ + {PolicyName: "policy", PolicyNamespace: namespace, Enabled: true}, + } + + // Cache for both apps + err := globalPolicyCache.Set(app1, results, "hash1") + Expect(err).Should(BeNil()) + err = globalPolicyCache.Set(app2, results, "hash1") + Expect(err).Should(BeNil()) + + Expect(globalPolicyCache.Size()).Should(Equal(2)) + + // Invalidate namespace + globalPolicyCache.InvalidateForNamespace(namespace) + + // Both should be invalidated + Expect(globalPolicyCache.Size()).Should(Equal(0)) + }) + + It("Test cache cleanup stale entries", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + {Name: "component", Type: "webservice"}, + }, + }, + } + + results := []RenderedPolicyResult{ + {PolicyName: "policy", PolicyNamespace: namespace, Enabled: true}, + } + + // Cache results + err := globalPolicyCache.Set(app, results, "hash1") + Expect(err).Should(BeNil()) + + // Verify cache hit immediately + cached, hit, err := globalPolicyCache.Get(app, "hash1") + Expect(err).Should(BeNil()) + Expect(hit).Should(BeTrue()) + Expect(cached).Should(HaveLen(1)) + + // Note: We can't easily test TTL expiration in unit tests without time manipulation + // The TTL check happens in Get() and would require waiting 1 minute + // Integration tests should cover TTL behavior + }) + + It("Test InvalidateApplication removes specific app from cache", func() { + app1 := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "app1", Namespace: namespace}, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "c1", Type: "webservice"}}, + }, + } + + app2 := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "app2", Namespace: namespace}, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "c2", Type: "webservice"}}, + }, + } + + results := []RenderedPolicyResult{ + {PolicyName: "policy", PolicyNamespace: namespace, Enabled: true}, + } + + // Cache both apps + err := globalPolicyCache.Set(app1, results, "hash1") + Expect(err).Should(BeNil()) + err = globalPolicyCache.Set(app2, results, "hash1") + Expect(err).Should(BeNil()) + + Expect(globalPolicyCache.Size()).Should(Equal(2)) + + // Invalidate only app1 + globalPolicyCache.InvalidateApplication(namespace, "app1") + + Expect(globalPolicyCache.Size()).Should(Equal(1)) + + // app1 should miss + _, hit, _ := globalPolicyCache.Get(app1, "hash1") + Expect(hit).Should(BeFalse()) + + // app2 should still hit + _, hit, _ = globalPolicyCache.Get(app2, "hash1") + Expect(hit).Should(BeTrue()) + }) +}) + +var _ = Describe("Test Global PolicyDefinition Features", func() { + namespace := "global-policy-test" + var ctx context.Context + velaSystem := "vela-system" + + BeforeEach(func() { + // Set namespace in context for definition lookups + ctx = util.SetNamespaceInCtx(context.Background(), namespace) + + ns := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + } + Expect(k8sClient.Create(ctx, &ns)).Should(SatisfyAny(BeNil(), &util.AlreadyExistMatcher{})) + + velaSystemNs := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: velaSystem, + }, + } + Expect(k8sClient.Create(ctx, &velaSystemNs)).Should(SatisfyAny(BeNil(), &util.AlreadyExistMatcher{})) + + // Clear cache + globalPolicyCache.InvalidateAll() + }) + + It("Test global policy discovery from vela-system applies to all namespaces", func() { + // Create global policy in vela-system + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vela-system-global", + Namespace: velaSystem, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true + +transforms: { + labels: { + type: "merge" + value: { + "vela-system-global": "true" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "vela-system-global", velaSystem) + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Discover global policies from vela-system + policies, err := discoverGlobalPolicies(monCtx, k8sClient, velaSystem) + Expect(err).Should(BeNil()) + Expect(policies).Should(HaveLen(1)) + Expect(policies[0].Name).Should(Equal("vela-system-global")) + Expect(policies[0].Spec.Global).Should(BeTrue()) + Expect(policies[0].Spec.Priority).Should(Equal(int32(100))) + }) + + It("Test global policy discovery from namespace applies only to that namespace", func() { + // Create global policy in specific namespace + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "namespace-global", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 50, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true + +transforms: { + labels: { + type: "merge" + value: { + "namespace-global": "true" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "namespace-global", namespace) + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Discover from namespace + policies, err := discoverGlobalPolicies(monCtx, k8sClient, namespace) + Expect(err).Should(BeNil()) + Expect(policies).Should(HaveLen(1)) + Expect(policies[0].Name).Should(Equal("namespace-global")) + + // Discover from vela-system (should not include namespace policy) + velaSystemPolicies, err := discoverGlobalPolicies(monCtx, k8sClient, velaSystem) + Expect(err).Should(BeNil()) + // Should not include namespace-global policy + for _, p := range velaSystemPolicies { + Expect(p.Name).ShouldNot(Equal("namespace-global")) + } + }) + + It("Test priority ordering - higher priority runs first", func() { + // Create 3 policies with different priorities + policy1 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "low-priority", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 10, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{Template: `parameter: {}`}, + }, + }, + } + policy2 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "high-priority", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{Template: `parameter: {}`}, + }, + }, + } + policy3 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "medium-priority", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 50, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{Template: `parameter: {}`}, + }, + }, + } + + Expect(k8sClient.Create(ctx, policy1)).Should(Succeed()) + Expect(k8sClient.Create(ctx, policy2)).Should(Succeed()) + Expect(k8sClient.Create(ctx, policy3)).Should(Succeed()) + + monCtx := monitorContext.NewTraceContext(ctx, "test") + policies, err := discoverGlobalPolicies(monCtx, k8sClient, namespace) + Expect(err).Should(BeNil()) + Expect(policies).Should(HaveLen(3)) + + // Verify order: high-priority (100), medium-priority (50), low-priority (10) + Expect(policies[0].Name).Should(Equal("high-priority")) + Expect(policies[0].Spec.Priority).Should(Equal(int32(100))) + Expect(policies[1].Name).Should(Equal("medium-priority")) + Expect(policies[1].Spec.Priority).Should(Equal(int32(50))) + Expect(policies[2].Name).Should(Equal("low-priority")) + Expect(policies[2].Spec.Priority).Should(Equal(int32(10))) + }) + + It("Test alphabetical ordering for same priority", func() { + // Create 3 policies with same priority + policy1 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-c", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 50, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{Template: `parameter: {}`}, + }, + }, + } + policy2 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-a", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 50, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{Template: `parameter: {}`}, + }, + }, + } + policy3 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-b", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 50, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{Template: `parameter: {}`}, + }, + }, + } + + Expect(k8sClient.Create(ctx, policy1)).Should(Succeed()) + Expect(k8sClient.Create(ctx, policy2)).Should(Succeed()) + Expect(k8sClient.Create(ctx, policy3)).Should(Succeed()) + + monCtx := monitorContext.NewTraceContext(ctx, "test") + policies, err := discoverGlobalPolicies(monCtx, k8sClient, namespace) + Expect(err).Should(BeNil()) + Expect(policies).Should(HaveLen(3)) + + // Verify alphabetical order: policy-a, policy-b, policy-c + Expect(policies[0].Name).Should(Equal("policy-a")) + Expect(policies[1].Name).Should(Equal("policy-b")) + Expect(policies[2].Name).Should(Equal("policy-c")) + }) + + It("Test opt-out annotation prevents global policies", func() { + // Create global policy + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "global-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true + +transforms: { + labels: { + type: "merge" + value: { + "should-not-apply": "true" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "global-policy", namespace) + + // Create Application with opt-out annotation + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "opt-out-app", + Namespace: namespace, + Annotations: map[string]string{ + SkipGlobalPoliciesAnnotation: "true", + }, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "component", + Type: "webservice", + }, + }, + }, + } + + // Verify opt-out check + Expect(shouldSkipGlobalPolicies(app)).Should(BeTrue()) + + // Application without annotation + app2 := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "normal-app", + Namespace: namespace, + }, + } + Expect(shouldSkipGlobalPolicies(app2)).Should(BeFalse()) + }) + + It("Test validation prevents explicit reference of global policy", func() { + // Create global policy + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "global-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{Template: `parameter: {}`}, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "global-policy", namespace) + + // Create non-global policy for comparison + regularPolicyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "regular-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: false, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{Template: `parameter: {}`}, + }, + }, + } + Expect(k8sClient.Create(ctx, regularPolicyDef)).Should(Succeed()) + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Validation should fail for global policy + err := validateNotGlobalPolicy(monCtx, k8sClient, "global-policy", namespace) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(ContainSubstring("marked as Global")) + + // Validation should pass for regular policy + err = validateNotGlobalPolicy(monCtx, k8sClient, "regular-policy", namespace) + Expect(err).Should(BeNil()) + + // Validation should pass for non-existent policy + err = validateNotGlobalPolicy(monCtx, k8sClient, "non-existent", namespace) + Expect(err).Should(BeNil()) + }) +}) + +var _ = Describe("Test Application-scoped Policy Rendering", func() { + namespace := "rendering-test" + var ctx context.Context + + BeforeEach(func() { + // Set namespace in context for definition lookups + ctx = util.SetNamespaceInCtx(context.Background(), namespace) + + ns := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + } + Expect(k8sClient.Create(ctx, &ns)).Should(SatisfyAny(BeNil(), &util.AlreadyExistMatcher{})) + }) + + It("Test annotation transforms with merge", func() { + // Create a PolicyDefinition that adds annotations + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "add-annotations", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} + +transforms: { + annotations: { + type: "merge" + value: { + "policy.oam.dev/applied": "true" + "policy.oam.dev/version": "v1" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "add-annotations", namespace) + + // Create an Application with existing annotations + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app-annotations", + Namespace: namespace, + Annotations: map[string]string{ + "existing": "annotation", + }, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "component", + Type: "webservice", + }, + }, + Policies: []v1beta1.AppPolicy{ + { + Name: "annotation-policy", + Type: "add-annotations", + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify annotations were merged + Expect(app.Annotations).Should(HaveLen(3)) + Expect(app.Annotations["existing"]).Should(Equal("annotation")) + Expect(app.Annotations["policy.oam.dev/applied"]).Should(Equal("true")) + Expect(app.Annotations["policy.oam.dev/version"]).Should(Equal("v1")) + }) + + It("Test context.application access in CUE template", func() { + // Create a PolicyDefinition that uses context.application + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "context-aware-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +import "strings" + +parameter: {} + +// Access application metadata from context +enabled: true + +transforms: { + labels: { + type: "merge" + value: { + "app-name": context.application.metadata.name + "app-namespace": context.application.metadata.namespace + "app-name-upper": strings.ToUpper(context.application.metadata.name) + } + } +} + +additionalContext: { + originalAppName: context.application.metadata.name + componentCount: len(context.application.spec.components) +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "context-aware-policy", namespace) + + // Create an Application + app := &v1beta1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: v1beta1.ApplicationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + {Name: "component1", Type: "webservice"}, + {Name: "component2", Type: "worker"}, + }, + Policies: []v1beta1.AppPolicy{ + { + Name: "context-policy", + Type: "context-aware-policy", + }, + }, + }, + } + + // Create the Application first so it gets a UID (needed for ConfigMap OwnerReference) + Expect(k8sClient.Create(ctx, app)).Should(Succeed()) + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + resultCtx, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify labels from context + Expect(app.Labels["app-name"]).Should(Equal("my-test-app")) + Expect(app.Labels["app-namespace"]).Should(Equal(namespace)) + Expect(app.Labels["app-name-upper"]).Should(Equal("MY-TEST-APP")) + + // Verify additionalContext from context + additionalCtx := getAdditionalContextFromCtx(resultCtx) + Expect(additionalCtx).ShouldNot(BeNil()) + Expect(additionalCtx["originalAppName"]).Should(Equal("my-test-app")) + // CUE's len() returns int64, not float64 + Expect(additionalCtx["componentCount"]).Should(Equal(int64(2))) + }) + + It("Test renderPolicy function extracts all fields correctly", func() { + // Create a comprehensive PolicyDefinition + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "comprehensive-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: { + shouldApply: bool +} + +enabled: parameter.shouldApply + +transforms: { + labels: { + type: "merge" + value: { + "from-render": "true" + } + } +} + +additionalContext: { + rendered: true + policyName: "comprehensive-policy" +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "comprehensive-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + {Name: "component", Type: "webservice"}, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Test with enabled=true + policyRef := v1beta1.AppPolicy{ + Name: "test-policy", + Type: "comprehensive-policy", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"shouldApply":true}`), + }, + } + + result, err := handler.renderPolicy(monCtx, app, policyRef, policyDef) + Expect(err).Should(BeNil()) + Expect(result.PolicyName).Should(Equal("comprehensive-policy")) + Expect(result.PolicyNamespace).Should(Equal(namespace)) + Expect(result.Enabled).Should(BeTrue()) + Expect(result.Transforms).ShouldNot(BeNil()) + Expect(result.AdditionalContext).ShouldNot(BeNil()) + Expect(result.AdditionalContext["rendered"]).Should(Equal(true)) + Expect(result.AdditionalContext["policyName"]).Should(Equal("comprehensive-policy")) + + // Test with enabled=false + policyRefDisabled := v1beta1.AppPolicy{ + Name: "test-policy-disabled", + Type: "comprehensive-policy", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"shouldApply":false}`), + }, + } + + resultDisabled, err := handler.renderPolicy(monCtx, app, policyRefDisabled, policyDef) + Expect(err).Should(BeNil()) + Expect(resultDisabled.Enabled).Should(BeFalse()) + Expect(resultDisabled.SkipReason).Should(Equal("enabled=false")) + }) + + It("Test applyRenderedPolicyResult applies cached transforms correctly", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + {Name: "component", Type: "webservice"}, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Create a RenderedPolicyResult with transforms + renderedResult := RenderedPolicyResult{ + PolicyName: "cached-policy", + PolicyNamespace: namespace, + Enabled: true, + Transforms: &PolicyTransforms{ + Labels: &Transform{ + Type: "merge", + Value: map[string]interface{}{ + "cached": "true", + "source": "rendered-result", + }, + }, + }, + AdditionalContext: map[string]interface{}{ + "fromCache": true, + "timestamp": "2024-01-01", + }, + } + + // Apply the rendered result + resultCtx, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) + Expect(err).Should(BeNil()) + + // Verify labels were applied + Expect(app.Labels["cached"]).Should(Equal("true")) + Expect(app.Labels["source"]).Should(Equal("rendered-result")) + + // Verify additionalContext was stored + additionalCtx := getAdditionalContextFromCtx(resultCtx) + Expect(additionalCtx).ShouldNot(BeNil()) + Expect(additionalCtx["fromCache"]).Should(Equal(true)) + Expect(additionalCtx["timestamp"]).Should(Equal("2024-01-01")) + + // Verify status was recorded + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Name).Should(Equal("cached-policy")) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeTrue()) + }) + + It("Test applyRenderedPolicyResult skips disabled policies", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + {Name: "component", Type: "webservice"}, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Create a disabled RenderedPolicyResult + renderedResult := RenderedPolicyResult{ + PolicyName: "disabled-policy", + PolicyNamespace: namespace, + Enabled: false, + SkipReason: "enabled=false", + } + + // Apply the rendered result + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) + Expect(err).Should(BeNil()) + + // Verify no labels were applied + Expect(app.Labels).Should(BeEmpty()) + + // Verify status shows skipped + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Name).Should(Equal("disabled-policy")) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeFalse()) + Expect(app.Status.AppliedApplicationPolicies[0].Reason).Should(Equal("enabled=false")) + }) + + It("Test applyRenderedPolicyResult tracks label changes in status", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + {Name: "component", Type: "webservice"}, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Create a RenderedPolicyResult with label transforms + renderedResult := RenderedPolicyResult{ + PolicyName: "label-policy", + PolicyNamespace: namespace, + Enabled: true, + Transforms: &PolicyTransforms{ + Labels: &Transform{ + Type: "merge", + Value: map[string]interface{}{ + "added-by": "policy", + "environment": "test", + }, + }, + }, + } + + // Apply the rendered result + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) + Expect(err).Should(BeNil()) + + // Verify status tracks summary counts of what labels were added + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeTrue()) + Expect(app.Status.AppliedApplicationPolicies[0].LabelsCount).Should(Equal(2)) + // Full label details are stored in ConfigMap, status only has counts + Expect(app.Labels["added-by"]).Should(Equal("policy")) + Expect(app.Labels["environment"]).Should(Equal("test")) + }) + + It("Test applyRenderedPolicyResult tracks annotation changes in status", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + {Name: "component", Type: "webservice"}, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Create a RenderedPolicyResult with annotation transforms + renderedResult := RenderedPolicyResult{ + PolicyName: "annotation-policy", + PolicyNamespace: namespace, + Enabled: true, + Transforms: &PolicyTransforms{ + Annotations: &Transform{ + Type: "merge", + Value: map[string]interface{}{ + "policy.oam.dev/applied": "true", + "policy.oam.dev/version": "v1.0", + }, + }, + }, + } + + // Apply the rendered result + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) + Expect(err).Should(BeNil()) + + // Verify status tracks summary counts of what annotations were added + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeTrue()) + Expect(app.Status.AppliedApplicationPolicies[0].AnnotationsCount).Should(Equal(2)) + // Full annotation details are stored in ConfigMap, status only has counts + Expect(app.Annotations["policy.oam.dev/applied"]).Should(Equal("true")) + Expect(app.Annotations["policy.oam.dev/version"]).Should(Equal("v1.0")) + }) + + It("Test applyRenderedPolicyResult tracks additionalContext in status", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + {Name: "component", Type: "webservice"}, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Create a RenderedPolicyResult with additionalContext + renderedResult := RenderedPolicyResult{ + PolicyName: "context-policy", + PolicyNamespace: namespace, + Enabled: true, + AdditionalContext: map[string]interface{}{ + "policyApplied": "context-policy", + "timestamp": "2024-01-01", + "configHash": "abc123", + }, + } + + // Apply the rendered result + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) + Expect(err).Should(BeNil()) + + // Verify status tracks presence of additionalContext (full details in ConfigMap) + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeTrue()) + Expect(app.Status.AppliedApplicationPolicies[0].HasContext).Should(BeTrue()) + // Full context details are stored in ConfigMap, status only has boolean flag + }) + + It("Test applyRenderedPolicyResult tracks spec modification in status", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + {Name: "component", Type: "webservice"}, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Create a RenderedPolicyResult with spec transform + renderedResult := RenderedPolicyResult{ + PolicyName: "spec-policy", + PolicyNamespace: namespace, + Enabled: true, + Transforms: &PolicyTransforms{ + Spec: &Transform{ + Type: "merge", + Value: map[string]interface{}{}, + }, + }, + } + + // Apply the rendered result + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) + Expect(err).Should(BeNil()) + + // Verify status tracks spec modification + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].Applied).Should(BeTrue()) + Expect(app.Status.AppliedApplicationPolicies[0].SpecModified).Should(BeTrue()) + }) + + It("Test applyRenderedPolicyResult tracks all changes together", func() { + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: namespace, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + {Name: "component", Type: "webservice"}, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test") + + // Create a comprehensive RenderedPolicyResult + renderedResult := RenderedPolicyResult{ + PolicyName: "comprehensive-policy", + PolicyNamespace: namespace, + Enabled: true, + Transforms: &PolicyTransforms{ + Labels: &Transform{ + Type: "merge", + Value: map[string]interface{}{ + "team": "platform", + }, + }, + Annotations: &Transform{ + Type: "merge", + Value: map[string]interface{}{ + "policy.oam.dev/applied": "true", + }, + }, + Spec: &Transform{ + Type: "merge", + Value: map[string]interface{}{}, + }, + }, + AdditionalContext: map[string]interface{}{ + "applied": true, + }, + } + + // Apply the rendered result + _, _, err := handler.applyRenderedPolicyResult(monCtx, app, renderedResult, 1, 100) + Expect(err).Should(BeNil()) + + // Verify status tracks summary counts of all changes + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + policy := app.Status.AppliedApplicationPolicies[0] + Expect(policy.Applied).Should(BeTrue()) + Expect(policy.LabelsCount).Should(Equal(1)) + Expect(policy.AnnotationsCount).Should(Equal(1)) + Expect(policy.SpecModified).Should(BeTrue()) + Expect(policy.HasContext).Should(BeTrue()) + // Full details are stored in ConfigMap, status only has counts + Expect(app.Labels["team"]).Should(Equal("platform")) + Expect(app.Annotations["policy.oam.dev/applied"]).Should(Equal("true")) + }) + + Context("Test spec diff tracking with ConfigMap storage", func() { + It("Test spec diff tracking stores diffs in ConfigMap", func() { + // Create a global policy that modifies spec + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "modify-spec-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} + +enabled: true + +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + replicas: 3 + } + }] + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "modify-spec-policy", namespace) + + // Create Application with initial spec + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-diff-app", + Namespace: namespace, + UID: "test-uid-123", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main-component", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx","replicas":1}`), + }, + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test-trace") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap reference is set in status + Expect(app.Status.ApplicationPoliciesConfigMap).ShouldNot(BeEmpty()) + expectedCMName := "application-policies-" + namespace + "-test-diff-app" + Expect(app.Status.ApplicationPoliciesConfigMap).Should(Equal(expectedCMName)) + + // Verify ConfigMap exists + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: expectedCMName, + Namespace: namespace, + }, cm) + Expect(err).Should(BeNil()) + + // Verify sequence-prefixed key exists + Expect(cm.Data).Should(HaveKey("001-modify-spec-policy")) + + // Verify it's valid JSON + var diff map[string]interface{} + err = json.Unmarshal([]byte(cm.Data["001-modify-spec-policy"]), &diff) + Expect(err).Should(BeNil()) + + // Verify OwnerReference points to Application + Expect(cm.OwnerReferences).Should(HaveLen(1)) + Expect(cm.OwnerReferences[0].Name).Should(Equal("test-diff-app")) + Expect(cm.OwnerReferences[0].UID).Should(Equal(app.UID)) + Expect(*cm.OwnerReferences[0].Controller).Should(BeTrue()) + + // Verify status has summary information (sequence/priority are in ConfigMap) + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].SpecModified).Should(BeTrue()) + // Sequence and priority are in ConfigMap data, not in status + }) + + It("Test multiple policies create ordered diffs in ConfigMap", func() { + // Create 3 global policies with different priorities + policy1 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-first", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + cpu: "100m" + } + }] + } + } +} +`, + }, + }, + }, + } + + policy2 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-second", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 50, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + memory: "256Mi" + } + }] + } + } +} +`, + }, + }, + }, + } + + policy3 := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-third", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 10, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + replicas: 5 + } + }] + } + } +} +`, + }, + }, + }, + } + + Expect(k8sClient.Create(ctx, policy1)).Should(Succeed()) + Expect(k8sClient.Create(ctx, policy2)).Should(Succeed()) + Expect(k8sClient.Create(ctx, policy3)).Should(Succeed()) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multi-diff-app", + Namespace: namespace, + UID: "test-uid-456", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx"}`), + }, + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test-trace") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap has ordered keys + cm := &corev1.ConfigMap{} + expectedCMName := "application-policies-" + namespace + "-test-multi-diff-app" + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: expectedCMName, + Namespace: namespace, + }, cm) + Expect(err).Should(BeNil()) + + // Verify keys are in execution order (sequence prefix) + Expect(cm.Data).Should(HaveKey("001-policy-first")) + Expect(cm.Data).Should(HaveKey("002-policy-second")) + Expect(cm.Data).Should(HaveKey("003-policy-third")) + + // Verify status records (sequence/priority are in ConfigMap, not status) + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(3)) + Expect(app.Status.AppliedApplicationPolicies[0].Name).Should(Equal("policy-first")) + Expect(app.Status.AppliedApplicationPolicies[1].Name).Should(Equal("policy-second")) + Expect(app.Status.AppliedApplicationPolicies[2].Name).Should(Equal("policy-third")) + // Sequence and priority are stored in ConfigMap JSON data, not in status + }) + + It("Test ConfigMap is not created when no spec modifications", func() { + // Create policy that only adds labels (no spec change) + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "labels-only-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} + +enabled: true + +transforms: { + labels: { + type: "merge" + value: { + "team": "platform" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "labels-only-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-no-diff-app", + Namespace: namespace, + UID: "test-uid-789", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx"}`), + }, + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test-trace") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify policy was applied + Expect(app.Status.AppliedApplicationPolicies).Should(HaveLen(1)) + Expect(app.Status.AppliedApplicationPolicies[0].SpecModified).Should(BeFalse()) + Expect(app.Status.AppliedApplicationPolicies[0].LabelsCount).Should(Equal(1)) + Expect(app.Labels["team"]).Should(Equal("platform")) + + // ConfigMap IS created even without spec modifications (stores all transforms) + Expect(app.Status.ApplicationPoliciesConfigMap).ShouldNot(BeEmpty()) + expectedCMName := "application-policies-" + namespace + "-test-no-diff-app" + Expect(app.Status.ApplicationPoliciesConfigMap).Should(Equal(expectedCMName)) + }) + + It("Test spec diff contains meaningful change information", func() { + // Create a policy that makes multiple types of changes + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "complex-changes-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} + +enabled: true + +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + replicas: 3 + cpu: "200m" + memory: "512Mi" + env: [{ + name: "LOG_LEVEL" + value: "debug" + }] + } + }] + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "complex-changes-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-complex-diff-app", + Namespace: namespace, + UID: "test-uid-complex", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx","replicas":1}`), + }, + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + monCtx := monitorContext.NewTraceContext(ctx, "test-trace") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Get the ConfigMap with diffs + cm := &corev1.ConfigMap{} + expectedCMName := "application-policies-" + namespace + "-test-complex-diff-app" + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: expectedCMName, + Namespace: namespace, + }, cm) + Expect(err).Should(BeNil()) + + // Verify diff exists + Expect(cm.Data).Should(HaveKey("001-complex-changes-policy")) + diffJSON := cm.Data["001-complex-changes-policy"] + + // Parse the diff (JSON Merge Patch format) + var diff map[string]interface{} + err = json.Unmarshal([]byte(diffJSON), &diff) + Expect(err).Should(BeNil()) + + // Verify diff is not empty (contains actual changes) + Expect(diff).ShouldNot(BeEmpty()) + + // The diff should contain component changes + Expect(diff).Should(HaveKey("components")) + }) + + It("Test ConfigMap updates when policies change", func() { + // Create initial policy + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "updateable-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + spec: { + type: "merge" + value: { + components: [{ + properties: { + replicas: 2 + } + }] + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "updateable-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-update-app", + Namespace: namespace, + UID: "test-uid-update", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx","replicas":1}`), + }, + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + // First reconciliation + monCtx := monitorContext.NewTraceContext(ctx, "test-trace") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap was created + cm := &corev1.ConfigMap{} + expectedCMName := "application-policies-" + namespace + "-test-update-app" + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: expectedCMName, + Namespace: namespace, + }, cm) + Expect(err).Should(BeNil()) + Expect(cm.Data).Should(HaveKey("001-updateable-policy")) + + // Parse initial diff + var initialDiff map[string]interface{} + err = json.Unmarshal([]byte(cm.Data["001-updateable-policy"]), &initialDiff) + Expect(err).Should(BeNil()) + + // Second reconciliation with same policy should update ConfigMap + app2 := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-update-app", + Namespace: namespace, + UID: "test-uid-update", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "main", + Type: "webservice", + Properties: &runtime.RawExtension{ + Raw: []byte(`{"image":"nginx","replicas":1}`), + }, + }, + }, + }, + } + + handler2 := &AppHandler{ + Client: k8sClient, + app: app2, + } + + monCtx2 := monitorContext.NewTraceContext(ctx, "test-trace-2") + _, err = handler2.ApplyApplicationScopeTransforms(monCtx2, app2) + Expect(err).Should(BeNil()) + + // ConfigMap should still exist and be updated + cm2 := &corev1.ConfigMap{} + expectedCMName2 := "application-policies-" + namespace + "-test-update-app" + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: expectedCMName2, + Namespace: namespace, + }, cm2) + Expect(err).Should(BeNil()) + Expect(cm2.Data).Should(HaveKey("001-updateable-policy")) + }) + }) + + Context("Test Application hash-based cache invalidation", func() { + It("Test ConfigMap cache invalidates when Application spec changes", func() { + // Create a policy + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hash-test-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + CacheTTLSeconds: -1, // Never expire based on time + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + labels: { + type: "merge" + value: { + "cached": "true" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "hash-test-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hash-test-app", + Namespace: namespace, + UID: "hash-test-uid", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{ + { + Name: "comp1", + Type: "webservice", + }, + }, + }, + } + + handler := &AppHandler{ + Client: k8sClient, + app: app, + } + + // First application - creates ConfigMap with hash + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Get ConfigMap and extract hash + cmName := "application-policies-" + namespace + "-hash-test-app" + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm) + Expect(err).Should(BeNil()) + + // Extract original hash from ConfigMap data + var originalHash string + for _, value := range cm.Data { + var record map[string]interface{} + err := json.Unmarshal([]byte(value), &record) + Expect(err).Should(BeNil()) + if hash, ok := record["application_hash"].(string); ok { + originalHash = hash + break + } + } + Expect(originalHash).ShouldNot(BeEmpty()) + + // Modify Application spec - this should invalidate cache + app.Spec.Components = append(app.Spec.Components, common.ApplicationComponent{ + Name: "comp2", + Type: "worker", + }) + + // Re-apply policies + handler2 := &AppHandler{ + Client: k8sClient, + app: app, + } + monCtx2 := monitorContext.NewTraceContext(ctx, "test2") + _, err = handler2.ApplyApplicationScopeTransforms(monCtx2, app) + Expect(err).Should(BeNil()) + + // Get updated ConfigMap + cm2 := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm2) + Expect(err).Should(BeNil()) + + // Extract new hash - it should be different + var newHash string + for _, value := range cm2.Data { + var record map[string]interface{} + err := json.Unmarshal([]byte(value), &record) + Expect(err).Should(BeNil()) + if hash, ok := record["application_hash"].(string); ok { + newHash = hash + break + } + } + Expect(newHash).ShouldNot(BeEmpty()) + Expect(newHash).ShouldNot(Equal(originalHash), "Hash should change when spec changes") + }) + + It("Test ConfigMap cache invalidates when Application labels change", func() { + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "label-hash-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + CacheTTLSeconds: -1, + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + labels: { + type: "merge" + value: { + "test": "value" + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "label-hash-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "label-hash-app", + Namespace: namespace, + UID: "label-hash-uid", + Labels: map[string]string{ + "original": "label", + }, + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "comp", Type: "webservice"}}, + }, + } + + handler := &AppHandler{Client: k8sClient, app: app} + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Get original hash + cmName := "application-policies-" + namespace + "-label-hash-app" + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm) + Expect(err).Should(BeNil()) + + var originalHash string + for _, value := range cm.Data { + var record map[string]interface{} + json.Unmarshal([]byte(value), &record) + if hash, ok := record["application_hash"].(string); ok { + originalHash = hash + break + } + } + + // Change Application labels + app.Labels["new"] = "label" + handler2 := &AppHandler{Client: k8sClient, app: app} + monCtx2 := monitorContext.NewTraceContext(ctx, "test2") + _, err = handler2.ApplyApplicationScopeTransforms(monCtx2, app) + Expect(err).Should(BeNil()) + + // Get new hash + cm2 := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm2) + Expect(err).Should(BeNil()) + + var newHash string + for _, value := range cm2.Data { + var record map[string]interface{} + json.Unmarshal([]byte(value), &record) + if hash, ok := record["application_hash"].(string); ok { + newHash = hash + break + } + } + + Expect(newHash).ShouldNot(Equal(originalHash), "Hash should change when labels change") + }) + }) + + Context("Test TTL-based caching (cacheTTLSeconds)", func() { + It("Test policy with cacheTTLSeconds: -1 stores TTL in ConfigMap", func() { + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-never-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + CacheTTLSeconds: -1, // Never refresh + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + labels: { + type: "merge" + value: {"ttl": "never"} + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "ttl-never-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-never-app", + Namespace: namespace, + UID: "ttl-never-uid", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "comp", Type: "webservice"}}, + }, + } + + handler := &AppHandler{Client: k8sClient, app: app} + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap contains ttl_seconds: -1 + cmName := "application-policies-" + namespace + "-ttl-never-app" + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm) + Expect(err).Should(BeNil()) + + // Parse and verify TTL + for _, value := range cm.Data { + var record map[string]interface{} + err := json.Unmarshal([]byte(value), &record) + Expect(err).Should(BeNil()) + + ttl, ok := record["ttl_seconds"].(float64) + Expect(ok).Should(BeTrue(), "ttl_seconds should be present") + Expect(int32(ttl)).Should(Equal(int32(-1)), "TTL should be -1 (never refresh)") + + // Verify rendered_at timestamp exists + _, ok = record["rendered_at"].(string) + Expect(ok).Should(BeTrue(), "rendered_at should be present") + } + }) + + It("Test policy with cacheTTLSeconds: 60 stores TTL in ConfigMap", func() { + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-60-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + CacheTTLSeconds: 60, // Cache for 60 seconds + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + labels: { + type: "merge" + value: {"ttl": "60"} + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "ttl-60-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-60-app", + Namespace: namespace, + UID: "ttl-60-uid", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "comp", Type: "webservice"}}, + }, + } + + handler := &AppHandler{Client: k8sClient, app: app} + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap contains ttl_seconds: 60 + cmName := "application-policies-" + namespace + "-ttl-60-app" + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm) + Expect(err).Should(BeNil()) + + for _, value := range cm.Data { + var record map[string]interface{} + err := json.Unmarshal([]byte(value), &record) + Expect(err).Should(BeNil()) + + ttl, ok := record["ttl_seconds"].(float64) + Expect(ok).Should(BeTrue()) + Expect(int32(ttl)).Should(Equal(int32(60))) + } + }) + + It("Test policy defaults to cacheTTLSeconds: -1 when not specified", func() { + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-default-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + // CacheTTLSeconds not specified - should default to -1 + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true +transforms: { + labels: { + type: "merge" + value: {"ttl": "default"} + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "ttl-default-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ttl-default-app", + Namespace: namespace, + UID: "ttl-default-uid", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "comp", Type: "webservice"}}, + }, + } + + handler := &AppHandler{Client: k8sClient, app: app} + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Verify ConfigMap contains ttl_seconds: -1 (default) + cmName := "application-policies-" + namespace + "-ttl-default-app" + cm := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm) + Expect(err).Should(BeNil()) + + for _, value := range cm.Data { + var record map[string]interface{} + err := json.Unmarshal([]byte(value), &record) + Expect(err).Should(BeNil()) + + ttl, ok := record["ttl_seconds"].(float64) + Expect(ok).Should(BeTrue()) + Expect(int32(ttl)).Should(Equal(int32(-1)), "Should default to -1") + } + }) + }) + + Context("Test context.prior support", func() { + It("Test context.prior is available to policy template on second render", func() { + // Create a policy that uses context.prior + policyDef := &v1beta1.PolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prior-context-policy", + Namespace: namespace, + }, + Spec: v1beta1.PolicyDefinitionSpec{ + Global: true, + Priority: 100, + Scope: v1beta1.ApplicationScope, + CacheTTLSeconds: 0, // Always re-render so we can test prior + Schematic: &common.Schematic{ + CUE: &common.CUE{ + Template: ` +parameter: {} +enabled: true + +// Check if prior result exists +hasPrior: context.prior != _|_ + +transforms: { + labels: { + type: "merge" + value: { + if hasPrior { + "render-count": "incremental" + } + if !hasPrior { + "render-count": "first" + } + } + } +} +`, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, policyDef)).Should(Succeed()) + waitForPolicyDef(ctx, "prior-context-policy", namespace) + + app := &v1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prior-test-app", + Namespace: namespace, + UID: "prior-test-uid", + }, + Spec: v1beta1.ApplicationSpec{ + Components: []common.ApplicationComponent{{Name: "comp", Type: "webservice"}}, + }, + } + + // First render - no prior context + handler := &AppHandler{Client: k8sClient, app: app} + monCtx := monitorContext.NewTraceContext(ctx, "test") + _, err := handler.ApplyApplicationScopeTransforms(monCtx, app) + Expect(err).Should(BeNil()) + + // Check that label indicates first render + Expect(app.Labels["render-count"]).Should(Equal("first")) + + // Store ConfigMap name + cmName := app.Status.ApplicationPoliciesConfigMap + Expect(cmName).ShouldNot(BeEmpty()) + + // Second render - should have prior context + app2 := app.DeepCopy() + app2.Status.ApplicationPoliciesConfigMap = cmName // Preserve ConfigMap reference + + handler2 := &AppHandler{Client: k8sClient, app: app2} + monCtx2 := monitorContext.NewTraceContext(ctx, "test2") + _, err = handler2.ApplyApplicationScopeTransforms(monCtx2, app2) + Expect(err).Should(BeNil()) + + // Check that label indicates incremental render (had prior) + Expect(app2.Labels["render-count"]).Should(Equal("incremental")) + }) + }) +}) diff --git a/pkg/controller/core.oam.dev/v1beta1/application/suite_test.go b/pkg/controller/core.oam.dev/v1beta1/application/suite_test.go index 386cdaade..d4b2238fd 100644 --- a/pkg/controller/core.oam.dev/v1beta1/application/suite_test.go +++ b/pkg/controller/core.oam.dev/v1beta1/application/suite_test.go @@ -51,7 +51,9 @@ import ( "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1" "github.com/oam-dev/kubevela/pkg/appfile" + _ "github.com/oam-dev/kubevela/pkg/features" // Import to register feature gates "github.com/oam-dev/kubevela/pkg/multicluster" + utilfeature "k8s.io/apiserver/pkg/util/feature" // +kubebuilder:scaffold:imports ) @@ -77,6 +79,11 @@ func TestAPIs(t *testing.T) { var _ = BeforeSuite(func() { logf.SetLogger(zap.New(zap.UseDevMode(true), zap.WriteTo(GinkgoWriter))) rand.Seed(time.Now().UnixNano()) + + // Enable global policies feature gate for tests + Expect(utilfeature.DefaultMutableFeatureGate.Set("EnableGlobalPolicies=true")).ToNot(HaveOccurred()) + logf.Log.Info("Enabled EnableGlobalPolicies feature gate for tests") + By("bootstrapping test environment") var yamlPath string if _, set := os.LookupEnv("COMPATIBILITY_TEST"); set { diff --git a/pkg/oam/sedyYAJzl b/pkg/oam/sedyYAJzl new file mode 100644 index 000000000..4cff2c4e6 --- /dev/null +++ b/pkg/oam/sedyYAJzl @@ -0,0 +1,176 @@ +/* +Copyright 2019 The Crossplane Authors. + +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. +*/ + +package oam + +// Label key strings. +// AppConfig controller will add these labels into workloads. +const ( + // LabelAppName records the name of AppConfig + LabelAppName = "app.oam.dev/name" + // LabelAppRevision records the name of Application, it's equal to name of AppConfig created by Application + LabelAppRevision = "app.oam.dev/appRevision" + // LabelAppComponent records the name of Component + LabelAppComponent = "app.oam.dev/component" + // LabelReplicaKey records the replica key of Component + LabelReplicaKey = "app.oam.dev/replicaKey" + // LabelAppComponentRevision records the revision name of Component + LabelAppComponentRevision = "app.oam.dev/revision" + // LabelOAMResourceType whether a CR is workload or trait + LabelOAMResourceType = "app.oam.dev/resourceType" + // LabelAppRevisionHash records the Hash value of the application revision + LabelAppRevisionHash = "app.oam.dev/app-revision-hash" + // LabelAppNamespace records the namespace of Application + LabelAppNamespace = "app.oam.dev/namespace" + // LabelAppCluster records the cluster of Application + LabelAppCluster = "app.oam.dev/cluster" + // LabelAppUID records the uid of Application + LabelAppUID = "app.oam.dev/uid" + + // WorkloadTypeLabel indicates the type of the workloadDefinition + WorkloadTypeLabel = "workload.oam.dev/type" + // TraitTypeLabel indicates the type of the traitDefinition + TraitTypeLabel = "trait.oam.dev/type" + // TraitResource indicates which resource it is when a trait is composed by multiple resources in KubeVela + TraitResource = "trait.oam.dev/resource" + + // LabelComponentDefinitionName records the name of ComponentDefinition + LabelComponentDefinitionName = "componentdefinition.oam.dev/name" + // LabelTraitDefinitionName records the name of TraitDefinition + LabelTraitDefinitionName = "trait.oam.dev/name" + // LabelManageWorkloadTrait indicates if the trait will manage the lifecycle of the workload + LabelManageWorkloadTrait = "trait.oam.dev/manage-workload" + // LabelPolicyDefinitionName records the name of PolicyDefinition + LabelPolicyDefinitionName = "policydefinition.oam.dev/name" + // LabelWorkflowStepDefinitionName records the name of WorkflowStepDefinition + LabelWorkflowStepDefinitionName = "workflowstepdefinition.oam.dev/name" + + // LabelControllerRevisionComponent indicate which component the revision belong to + LabelControllerRevisionComponent = "controller.oam.dev/component" + + // LabelAddonName indicates the name of the corresponding Addon + LabelAddonName = "addons.oam.dev/name" + + // LabelAddonAuxiliaryName indicates the name of the auxiliary resource in addon app template + LabelAddonAuxiliaryName = "addons.oam.dev/auxiliary-name" + + // LabelAddonVersion indicates the version of the corresponding installed Addon + LabelAddonVersion = "addons.oam.dev/version" + + // LabelAddonRegistry indicates the name of addon-registry + LabelAddonRegistry = "addons.oam.dev/registry" + + // LabelNamespaceOfEnvName records the env name of namespace + LabelNamespaceOfEnvName = "namespace.oam.dev/env" + + // LabelNamespaceOfTargetName records the target name of namespace + LabelNamespaceOfTargetName = "namespace.oam.dev/target" + + // LabelControlPlaneNamespaceUsage mark the usage of the namespace in control plane cluster. + LabelControlPlaneNamespaceUsage = "usage.oam.dev/control-plane" + + // LabelRuntimeNamespaceUsage mark the usage of the namespace in runtime cluster. + // A control plane cluster can also be used as runtime cluster + LabelRuntimeNamespaceUsage = "usage.oam.dev/runtime" + + // LabelConfigType means the config type + LabelConfigType = "config.oam.dev/type" + + // LabelProject recorde the project the resource belong to + LabelProject = "core.oam.dev/project" + + // LabelResourceRules defines the configmap is representing the resource topology rules + LabelResourceRules = "rules.oam.dev/resources" + + // LabelResourceRuleFormat defines the resource format of the resource topology rules + LabelResourceRuleFormat = "rules.oam.dev/resource-format" + + // LabelControllerName indicates the controller name + LabelControllerName = "controller.oam.dev/name" + + // LabelPreCheck indicates if the target resource is for pre-check test + LabelPreCheck = "core.oam.dev/pre-check" +) + +const ( + // VelaNamespaceUsageEnv mark the usage of the namespace is used by env. + VelaNamespaceUsageEnv = "env" + // VelaNamespaceUsageTarget mark the usage of the namespace is used as delivery target. + VelaNamespaceUsageTarget = "target" +) + +const ( + // ResourceTypeTrait mark this K8s Custom Resource is an OAM trait + ResourceTypeTrait = "TRAIT" + // ResourceTypeWorkload mark this K8s Custom Resource is an OAM workload + ResourceTypeWorkload = "WORKLOAD" +) + +const ( + // AnnotationLastAppliedConfig records the previous configuration of a + // resource for use in a three-way diff during a patching apply + AnnotationLastAppliedConfig = "app.oam.dev/last-applied-configuration" + + // AnnotationLastAppliedTime indicates the last applied time + AnnotationLastAppliedTime = "app.oam.dev/last-applied-time" + + // AnnotationInplaceUpgrade indicates the workload should upgrade with the the same name + // the name of the workload instance should not changing along with the revision + AnnotationInplaceUpgrade = "app.oam.dev/inplace-upgrade" + + // AnnotationAppRevision indicates that the object is an application revision + // its controller should not try to reconcile it + AnnotationAppRevision = "app.oam.dev/app-revision" + + // AnnotationKubeVelaVersion is used to record current KubeVela version + AnnotationKubeVelaVersion = "oam.dev/kubevela-version" + + // AnnotationFilterAnnotationKeys is used to filter annotations passed to workload and trait, split by comma + AnnotationFilterAnnotationKeys = "filter.oam.dev/annotation-keys" + + // AnnotationFilterLabelKeys is used to filter labels passed to workload and trait, split by comma + AnnotationFilterLabelKeys = "filter.oam.dev/label-keys" + + // AnnotationDefinitionRevisionName is used to specify the name of DefinitionRevision in component/trait definition + AnnotationDefinitionRevisionName = "definitionrevision.oam.dev/name" + + // AnnotationLastAppliedConfiguration is kubectl annotations for 3-way merge + AnnotationLastAppliedConfiguration = "kubectl.kubernetes.io/last-applied-configuration" + + // AnnotationDeployVersion know the version number of the deployment. + AnnotationDeployVersion = "app.oam.dev/deployVersion" + + // AnnotationPublishVersion is annotation that record the application workflow version. + AnnotationPublishVersion = "app.oam.dev/publishVersion" + + // AnnotationAutoUpdate is annotation that let application auto update when it finds definition changes + AnnotationAutoUpdate = "app.oam.dev/autoUpdate" + + // AnnotationWorkflowName specifies the workflow name for execution. + AnnotationWorkflowName = "app.oam.dev/workflowName" + + // AnnotationWorkflowRestart triggers a workflow restart when set. Supported values: + // - "true": Immediate restart (sets restart time to current time + 1 second). + // Annotation is automatically removed after being processed. + // - RFC3339 timestamp (e.g., "2025-01-15T14:30:00Z"): One-time restart at specified time. + // Annotation is automatically removed after being processed. + // - Duration (e.g., "5m", "1h", "30s"): Recurring restart with minimum interval after each completion. + // Annotation persists; automatically reschedules after each workflow completion. + // All modes are GitOps-safe: the schedule is stored in status.workflowRestartScheduledAt. + AnnotationWorkflowRestart = "app.oam.dev/restart-workflow" + + // AnnotationAppName specifies the name for application in db. + // Note: the annotation is only created by velaUX, plea \ No newline at end of file diff --git a/references/cli/cli.go b/references/cli/cli.go index 9ef27e7d6..5ceacf9e0 100644 --- a/references/cli/cli.go +++ b/references/cli/cli.go @@ -104,6 +104,7 @@ func NewCommandWithIOStreams(ioStream util.IOStreams) *cobra.Command { NewExecCommand(commandArgs, "5", ioStream), RevisionCommandGroup(commandArgs, "6"), NewDebugCommand(commandArgs, "7", ioStream), + PolicyCommandGroup(commandArgs, "8", ioStream), // Continuous Delivery NewWorkflowCommand(commandArgs, "1", ioStream), diff --git a/references/cli/policy.go b/references/cli/policy.go new file mode 100644 index 000000000..97cf93626 --- /dev/null +++ b/references/cli/policy.go @@ -0,0 +1,828 @@ +/* +Copyright 2021 The KubeVela Authors. + +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. +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. +*/ + +package cli + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/fatih/color" + "github.com/olekukonko/tablewriter" + "github.com/pkg/errors" + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + "github.com/oam-dev/kubevela/apis/core.oam.dev/common" + "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1" + "github.com/oam-dev/kubevela/apis/types" + "github.com/oam-dev/kubevela/pkg/controller/core.oam.dev/v1beta1/application" + velacommon "github.com/oam-dev/kubevela/pkg/utils/common" + cmdutil "github.com/oam-dev/kubevela/pkg/utils/util" +) + +const ( + // Output format constants + outputFormatTable = "table" + outputFormatJSON = "json" + outputFormatYAML = "yaml" + outputFormatSummary = "summary" + outputFormatDiff = "diff" +) + +// PolicyCommandGroup creates the `policy` command group +func PolicyCommandGroup(c velacommon.Args, order string, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "policy", + Short: "Manage and debug Application-scoped policies.", + Long: "Commands for viewing and testing Application-scoped PolicyDefinitions (both global and explicit) applied to Applications.", + Annotations: map[string]string{ + types.TagCommandType: types.TypeApp, + types.TagCommandOrder: order, + }, + } + + cmd.AddCommand( + NewPolicyViewCommand(c, ioStreams), + NewPolicyDryRunCommand(c, ioStreams), + ) + + return cmd +} + +// NewPolicyViewCommand creates the `vela policy view` command +func NewPolicyViewCommand(c velacommon.Args, ioStreams cmdutil.IOStreams) *cobra.Command { + var outputFormat string + + cmd := &cobra.Command{ + Use: "view ", + Short: "View applied Application-scoped policies and their effects.", + Long: "View which Application-scoped policies (global and explicit) were applied to an Application and what changes they made.", + Example: ` # View policies applied to an Application + vela policy view my-app + + # View in JSON format + vela policy view my-app --output json + + # View in YAML format + vela policy view my-app --output yaml`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + appName := args[0] + namespace, err := cmd.Flags().GetString("namespace") + if err != nil { + return err + } + if namespace == "" { + namespace = "default" + } + + ctx := context.Background() + return runPolicyView(ctx, c, appName, namespace, outputFormat, ioStreams) + }, + } + + addNamespaceAndEnvArg(cmd) + cmd.Flags().StringVarP(&outputFormat, "output", "o", outputFormatTable, "Output format: table, json, yaml") + + return cmd +} + +// NewPolicyDryRunCommand creates the `vela policy dry-run` command +func NewPolicyDryRunCommand(c velacommon.Args, ioStreams cmdutil.IOStreams) *cobra.Command { + var ( + outputFormat string + policies []string + includeGlobalPolicies bool + includeAppPolicies bool + ) + + cmd := &cobra.Command{ + Use: "dry-run ", + Short: "Preview policy effects before applying.", + Long: `Simulate policy application to see what changes would be made. + +Modes: + - Isolated (--policies only): Test specified policies in isolation + - Additive (--policies + --include-global-policies): Test with existing globals + - Full (no --policies): Simulate complete policy chain + - Full + Extra (--policies + both flags): Everything plus test policies`, + Example: ` # Full simulation (all policies that would apply) + vela policy dry-run my-app + + # Test specific policy in isolation + vela policy dry-run my-app --policies inject-sidecar + + # Test new policy with existing globals + vela policy dry-run my-app --policies new-policy --include-global-policies + + # Show only metadata changes (labels, annotations, context) + vela policy dry-run my-app --output summary + + # Show unified diff + vela policy dry-run my-app --output diff + + # JSON output for CI/CD + vela policy dry-run my-app --output json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + appName := args[0] + namespace, err := cmd.Flags().GetString("namespace") + if err != nil { + return err + } + if namespace == "" { + namespace = "default" + } + + ctx := context.Background() + return runPolicyDryRun(ctx, c, appName, namespace, policies, includeGlobalPolicies, includeAppPolicies, outputFormat, ioStreams) + }, + } + + addNamespaceAndEnvArg(cmd) + cmd.Flags().StringVarP(&outputFormat, "output", "o", outputFormatTable, "Output format: table, summary, diff, json, yaml") + cmd.Flags().StringSliceVar(&policies, "policies", nil, "Comma-separated list of policies to test") + cmd.Flags().BoolVar(&includeGlobalPolicies, "include-global-policies", false, "Include existing global policies") + cmd.Flags().BoolVar(&includeAppPolicies, "include-app-policies", false, "Include policies from Application spec") + + return cmd +} + +// runPolicyView implements the view command logic +func runPolicyView(ctx context.Context, c velacommon.Args, appName, namespace, outputFormat string, ioStreams cmdutil.IOStreams) error { + cli, err := c.GetClient() + if err != nil { + return err + } + + // Get the Application + app := &v1beta1.Application{} + if err := cli.Get(ctx, client.ObjectKey{Name: appName, Namespace: namespace}, app); err != nil { + return errors.Wrapf(err, "failed to get Application %s/%s", namespace, appName) + } + + // Check if any policies were applied + if len(app.Status.AppliedApplicationPolicies) == 0 { + ioStreams.Info(fmt.Sprintf("No Application-scoped policies applied to Application '%s'\n", appName)) + ioStreams.Info("\nThis could be because:\n") + ioStreams.Info(" • No global policies exist in vela-system or the application namespace\n") + ioStreams.Info(" • No explicit Application-scoped policies in Application spec\n") + ioStreams.Info(" • Application has annotation: policy.oam.dev/skip-global: \"true\"\n") + ioStreams.Info(" • Global policies feature is disabled (feature gate not enabled)\n") + return nil + } + + // Get the diffs ConfigMap if it exists + var diffsConfigMap *corev1.ConfigMap + if app.Status.ApplicationPoliciesConfigMap != "" { + cm := &corev1.ConfigMap{} + err := cli.Get(ctx, client.ObjectKey{Name: app.Status.ApplicationPoliciesConfigMap, Namespace: namespace}, cm) + if err == nil { + diffsConfigMap = cm + } + } + + // Display based on output format + switch outputFormat { + case outputFormatJSON: + return outputPolicyViewJSON(app, diffsConfigMap, ioStreams) + case outputFormatYAML: + return outputPolicyViewYAML(app, diffsConfigMap, ioStreams) + case outputFormatTable: + return outputPolicyViewTable(app, diffsConfigMap, ioStreams) + default: + return fmt.Errorf("unknown output format: %s (supported: table, json, yaml)", outputFormat) + } +} + +// outputPolicyViewTable displays policy view in interactive table format +func outputPolicyViewTable(app *v1beta1.Application, diffsConfigMap *corev1.ConfigMap, ioStreams cmdutil.IOStreams) error { + policies := app.Status.AppliedApplicationPolicies + + // Count applied vs skipped + applied := 0 + skipped := 0 + for _, p := range policies { + if p.Applied { + applied++ + } else { + skipped++ + } + } + + // Display header + ioStreams.Info(fmt.Sprintf("Applied Application Policies: %s applied, %s skipped\n\n", + color.GreenString("%d", applied), + color.YellowString("%d", skipped))) + + // Create table + table := tablewriter.NewWriter(ioStreams.Out) + table.SetHeader([]string{"Seq", "Policy", "Namespace", "Source", "Priority", "Applied", "Spec", "Labels", "Annot.", "Context"}) + table.SetBorder(true) + table.SetAlignment(tablewriter.ALIGN_LEFT) + + for _, policy := range policies { + seq := "-" + source := "-" + priority := "-" + specChanges := "No" + labels := "0" + annot := "0" + ctx := "No" + + if policy.Applied { + // Try to get sequence, source, priority from ConfigMap if available + if diffsConfigMap != nil { + for key, value := range diffsConfigMap.Data { + if strings.HasSuffix(key, "-"+policy.Name) { + var record map[string]interface{} + if err := json.Unmarshal([]byte(value), &record); err == nil { + if seqVal, ok := record["sequence"].(float64); ok { + seq = fmt.Sprintf("%.0f", seqVal) + } + if srcVal, ok := record["source"].(string); ok { + source = srcVal + } + if priVal, ok := record["priority"].(float64); ok { + priority = fmt.Sprintf("%.0f", priVal) + } + } + break + } + } + } + + // Spec changes (from status summary) + if policy.SpecModified { + specChanges = "Yes" + } + + // Labels count (from status summary) + labels = fmt.Sprintf("%d", policy.LabelsCount) + + // Annotations count (from status summary) + annot = fmt.Sprintf("%d", policy.AnnotationsCount) + + // Context (from status summary) + if policy.HasContext { + ctx = "Yes" + } + } + + appliedStatus := "Yes" + if !policy.Applied { + appliedStatus = "No" + } + + table.Append([]string{ + seq, + policy.Name, + policy.Namespace, + source, + priority, + appliedStatus, + specChanges, + labels, + annot, + ctx, + }) + } + + table.Render() + + // Show skipped policies + var skippedPolicies []common.AppliedApplicationPolicy + for _, p := range policies { + if !p.Applied { + skippedPolicies = append(skippedPolicies, p) + } + } + + if len(skippedPolicies) > 0 { + ioStreams.Info(fmt.Sprintf("\nSkipped (%d):\n", len(skippedPolicies))) + for _, p := range skippedPolicies { + ioStreams.Info(fmt.Sprintf(" • %s: %s\n", p.Name, p.Reason)) + } + } + + // Show summary (using status summary counts) + totalLabels := 0 + totalAnnotations := 0 + specModCount := 0 + contextCount := 0 + + for _, p := range policies { + if p.Applied { + totalLabels += p.LabelsCount + totalAnnotations += p.AnnotationsCount + if p.SpecModified { + specModCount++ + } + if p.HasContext { + contextCount++ + } + } + } + + ioStreams.Info("\nSummary:\n") + ioStreams.Info(fmt.Sprintf(" Total Policies: %d (%d applied, %d skipped)\n", len(policies), applied, skipped)) + ioStreams.Info(fmt.Sprintf(" Spec Changes: %d policies\n", specModCount)) + ioStreams.Info(fmt.Sprintf(" Labels Added: %d total\n", totalLabels)) + ioStreams.Info(fmt.Sprintf(" Annotations: %d total\n", totalAnnotations)) + ioStreams.Info(fmt.Sprintf(" Context Data: %d policies\n", contextCount)) + + if diffsConfigMap != nil { + ioStreams.Info(fmt.Sprintf("\nView detailed diffs:\n kubectl get configmap %s -o json | jq '.data'\n", app.Status.ApplicationPoliciesConfigMap)) + } + + return nil +} + +// outputPolicyViewJSON outputs policy view in JSON format +func outputPolicyViewJSON(app *v1beta1.Application, diffsConfigMap *corev1.ConfigMap, ioStreams cmdutil.IOStreams) error { + output := map[string]interface{}{ + "application": app.Name, + "namespace": app.Namespace, + "appliedPolicies": app.Status.AppliedApplicationPolicies, + "policyDiffsConfigMap": app.Status.ApplicationPoliciesConfigMap, + } + + // Add summary + applied := 0 + skipped := 0 + specMod := 0 + totalLabels := 0 + totalAnnotations := 0 + + for _, p := range app.Status.AppliedApplicationPolicies { + if p.Applied { + applied++ + if p.SpecModified { + specMod++ + } + totalLabels += p.LabelsCount + totalAnnotations += p.AnnotationsCount + } else { + skipped++ + } + } + + output["summary"] = map[string]interface{}{ + "totalDiscovered": len(app.Status.AppliedApplicationPolicies), + "applied": applied, + "skipped": skipped, + "specModifications": specMod, + "labelsAdded": totalLabels, + "annotationsAdded": totalAnnotations, + } + + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return errors.Wrap(err, "failed to marshal to JSON") + } + + ioStreams.Info("%s\n", string(data)) + return nil +} + +// outputPolicyViewYAML outputs policy view in YAML format +func outputPolicyViewYAML(app *v1beta1.Application, diffsConfigMap *corev1.ConfigMap, ioStreams cmdutil.IOStreams) error { + output := map[string]interface{}{ + "application": app.Name, + "namespace": app.Namespace, + "appliedPolicies": app.Status.AppliedApplicationPolicies, + "policyDiffsConfigMap": app.Status.ApplicationPoliciesConfigMap, + } + + data, err := yaml.Marshal(output) + if err != nil { + return errors.Wrap(err, "failed to marshal to YAML") + } + + ioStreams.Info("%s\n", string(data)) + return nil +} + +// runPolicyDryRun implements the dry-run command logic +func runPolicyDryRun(ctx context.Context, c velacommon.Args, appName, namespace string, policies []string, includeGlobal, includeApp bool, outputFormat string, ioStreams cmdutil.IOStreams) error { + cli, err := c.GetClient() + if err != nil { + return err + } + + // Load the Application from cluster + app := &v1beta1.Application{} + if err := cli.Get(ctx, client.ObjectKey{Name: appName, Namespace: namespace}, app); err != nil { + return errors.Wrapf(err, "failed to get Application %s/%s", namespace, appName) + } + + // Determine dry-run mode based on flags + var mode application.PolicyDryRunMode + if len(policies) > 0 && !includeGlobal && !includeApp { + mode = application.DryRunModeIsolated + } else if len(policies) > 0 && includeGlobal { + mode = application.DryRunModeAdditive + } else { + mode = application.DryRunModeFull + } + + // Build simulation options + opts := application.PolicyDryRunOptions{ + Mode: mode, + SpecifiedPolicies: policies, + IncludeAppPolicies: includeApp || mode == application.DryRunModeFull, + } + + // Run simulation + ioStreams.Info("Dry-run Simulation\n") + ioStreams.Info("Application: %s (namespace: %s)\n", appName, namespace) + + modeStr := string(mode) + switch mode { + case application.DryRunModeIsolated: + modeStr = "Isolated (testing specified policies only)" + case application.DryRunModeAdditive: + modeStr = "Additive (specified policies + existing global policies)" + case application.DryRunModeFull: + modeStr = "Full simulation (all policies that would apply)" + } + ioStreams.Info("Mode: %s\n\n", modeStr) + + result, err := application.SimulatePolicyApplication(ctx, cli, app, opts) + if err != nil { + return errors.Wrap(err, "simulation failed") + } + + // Display results based on output format + switch outputFormat { + case outputFormatTable: + return outputDryRunTable(result, ioStreams) + case outputFormatSummary: + return outputDryRunSummary(result, ioStreams) + case outputFormatDiff: + return outputDryRunDiff(result, ioStreams) + case outputFormatJSON: + return outputDryRunJSON(result, ioStreams) + case outputFormatYAML: + return outputDryRunYAML(result, ioStreams) + default: + return fmt.Errorf("unknown output format: %s", outputFormat) + } +} +// outputDryRunTable outputs dry-run results in table format +func outputDryRunTable(result *application.PolicyDryRunResult, ioStreams cmdutil.IOStreams) error { + // Show execution plan + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + ioStreams.Info("Execution Plan\n") + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + + if len(result.ExecutionPlan) == 0 { + ioStreams.Info("No policies to apply\n\n") + } else { + for _, step := range result.ExecutionPlan { + ioStreams.Info(" %d. %-30s (%s, priority: %d) [%s]\n", + step.Sequence, step.PolicyName, step.PolicyNamespace, step.Priority, step.Source) + } + ioStreams.Info("\n") + } + + // Show warnings and errors + if len(result.Warnings) > 0 { + ioStreams.Info("%s:\n", color.YellowString("Warnings")) + for _, w := range result.Warnings { + ioStreams.Info(" • %s\n", w) + } + ioStreams.Info("\n") + } + + if len(result.Errors) > 0 { + ioStreams.Info("%s:\n", color.RedString("Errors")) + for _, e := range result.Errors { + ioStreams.Info(" • %s\n", e) + } + ioStreams.Info("\n") + return nil // Don't continue if there were errors + } + + // Apply policies + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + ioStreams.Info("Applying Policies...\n") + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + + for _, policyResult := range result.PolicyResults { + ioStreams.Info("[%d/%d] %s\n", policyResult.Sequence, len(result.PolicyResults), policyResult.PolicyName) + + if policyResult.Error != "" { + ioStreams.Info(" %s Error: %s\n\n", color.RedString("✗"), policyResult.Error) + continue + } + + if !policyResult.Enabled { + ioStreams.Info(" %s Skipped: %s\n\n", color.YellowString("⊘"), policyResult.SkipReason) + continue + } + + ioStreams.Info(" %s Policy enabled\n", color.GreenString("✓")) + ioStreams.Info("\n Changes:\n") + + specChangesStr := "No" + if policyResult.SpecModified { + specChangesStr = fmt.Sprintf("Yes (see diffs)") + } + ioStreams.Info(" Spec Modified: %s\n", specChangesStr) + ioStreams.Info(" Labels Added: %d\n", len(policyResult.AddedLabels)) + if len(policyResult.AddedLabels) > 0 { + for k, v := range policyResult.AddedLabels { + ioStreams.Info(" • %s: %s\n", k, v) + } + } + ioStreams.Info(" Annotations Added: %d\n", len(policyResult.AddedAnnotations)) + if len(policyResult.AddedAnnotations) > 0 { + for k, v := range policyResult.AddedAnnotations { + ioStreams.Info(" • %s: %s\n", k, v) + } + } + + contextStr := "None" + if policyResult.AdditionalContext != nil && len(policyResult.AdditionalContext.Raw) > 0 { + var contextMap map[string]interface{} + if err := json.Unmarshal(policyResult.AdditionalContext.Raw, &contextMap); err == nil { + var keys []string + for k := range contextMap { + keys = append(keys, k) + } + contextStr = strings.Join(keys, ", ") + } + } + ioStreams.Info(" Context Data: %s\n", contextStr) + ioStreams.Info("\n") + } + + // Summary + applied := 0 + skipped := 0 + specMod := 0 + totalLabels := 0 + totalAnnotations := 0 + + for _, pr := range result.PolicyResults { + if pr.Applied { + applied++ + if pr.SpecModified { + specMod++ + } + totalLabels += len(pr.AddedLabels) + totalAnnotations += len(pr.AddedAnnotations) + } else { + skipped++ + } + } + + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + ioStreams.Info("Summary\n") + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + + ioStreams.Info("Policies Applied: %d\n", applied) + ioStreams.Info("Policies Skipped: %d\n", skipped) + ioStreams.Info("Spec Modifications: %d\n", specMod) + ioStreams.Info("Labels Added: %d\n", totalLabels) + ioStreams.Info("Annotations Added: %d\n", totalAnnotations) + ioStreams.Info("\n") + + // Final state + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + ioStreams.Info("Final Application State\n") + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + + // Show labels + if len(result.Application.Labels) > 0 { + ioStreams.Info("Labels (%d total):\n", len(result.Application.Labels)) + for k, v := range result.Application.Labels { + // Find which policy added this label + var policyName string + for _, pr := range result.PolicyResults { + if pr.AddedLabels != nil { + if _, exists := pr.AddedLabels[k]; exists { + policyName = pr.PolicyName + break + } + } + } + if policyName != "" { + ioStreams.Info(" %-30s %s (%s)\n", k+":", v, policyName) + } else { + ioStreams.Info(" %-30s %s\n", k+":", v) + } + } + } else { + ioStreams.Info("Labels: (none)\n") + } + ioStreams.Info("\n") + + // Show annotations + if len(result.Application.Annotations) > 0 { + ioStreams.Info("Annotations (%d total):\n", len(result.Application.Annotations)) + for k, v := range result.Application.Annotations { + var policyName string + for _, pr := range result.PolicyResults { + if pr.AddedAnnotations != nil { + if _, exists := pr.AddedAnnotations[k]; exists { + policyName = pr.PolicyName + break + } + } + } + if policyName != "" { + ioStreams.Info(" %-30s %s (%s)\n", k+":", v, policyName) + } else { + ioStreams.Info(" %-30s %s\n", k+":", v) + } + } + } else { + ioStreams.Info("Annotations: (none)\n") + } + ioStreams.Info("\n") + + // Show application spec (YAML) + ioStreams.Info("Application Spec:\n") + specYAML, err := yaml.Marshal(result.Application.Spec) + if err != nil { + return errors.Wrap(err, "failed to marshal Application spec") + } + ioStreams.Info("%s\n", string(specYAML)) + + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + ioStreams.Info("This is a dry-run. No changes were applied to the cluster.\n\n") + + return nil +} + +// outputDryRunSummary outputs only metadata (labels, annotations, context) +func outputDryRunSummary(result *application.PolicyDryRunResult, ioStreams cmdutil.IOStreams) error { + applied := 0 + skipped := 0 + for _, pr := range result.PolicyResults { + if pr.Applied { + applied++ + } else { + skipped++ + } + } + + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + ioStreams.Info("Summary\n") + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + + ioStreams.Info("Policies Applied: %d\n", applied) + ioStreams.Info("Policies Skipped: %d\n", skipped) + ioStreams.Info("\n") + + // Show labels table + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + ioStreams.Info("Labels\n") + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + + if len(result.Application.Labels) > 0 { + table := tablewriter.NewWriter(ioStreams.Out) + table.SetHeader([]string{"Key", "Value", "Added By"}) + table.SetBorder(true) + + for k, v := range result.Application.Labels { + var policyName string + for _, pr := range result.PolicyResults { + if pr.AddedLabels != nil { + if _, exists := pr.AddedLabels[k]; exists { + policyName = pr.PolicyName + break + } + } + } + if policyName == "" { + policyName = "(existing)" + } + table.Append([]string{k, v, policyName}) + } + table.Render() + } else { + ioStreams.Info(" (none)\n") + } + ioStreams.Info("\n") + + // Show annotations table + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + ioStreams.Info("Annotations\n") + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + + if len(result.Application.Annotations) > 0 { + table := tablewriter.NewWriter(ioStreams.Out) + table.SetHeader([]string{"Key", "Value", "Added By"}) + table.SetBorder(true) + + for k, v := range result.Application.Annotations { + var policyName string + for _, pr := range result.PolicyResults { + if pr.AddedAnnotations != nil { + if _, exists := pr.AddedAnnotations[k]; exists { + policyName = pr.PolicyName + break + } + } + } + if policyName == "" { + policyName = "(existing)" + } + table.Append([]string{k, v, policyName}) + } + table.Render() + } else { + ioStreams.Info(" (none)\n") + } + ioStreams.Info("\n") + + return nil +} + +// outputDryRunDiff outputs unified diff format +func outputDryRunDiff(result *application.PolicyDryRunResult, ioStreams cmdutil.IOStreams) error { + // TODO: Implement unified diff output + // For now, just show JSON patches + ioStreams.Info("Policy Diffs:\n\n") + + if len(result.Diffs) == 0 { + ioStreams.Info("No spec modifications\n") + return nil + } + + for policyName, diff := range result.Diffs { + ioStreams.Info("Policy: %s\n", policyName) + ioStreams.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + ioStreams.Info("%s\n\n", string(diff)) + } + + return nil +} + +// outputDryRunJSON outputs JSON format +func outputDryRunJSON(result *application.PolicyDryRunResult, ioStreams cmdutil.IOStreams) error { + output := map[string]interface{}{ + "application": result.Application.Name, + "namespace": result.Application.Namespace, + "executionPlan": result.ExecutionPlan, + "policyResults": result.PolicyResults, + "warnings": result.Warnings, + "errors": result.Errors, + "finalSpec": result.Application.Spec, + "finalLabels": result.Application.Labels, + "finalAnnotations": result.Application.Annotations, + } + + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return errors.Wrap(err, "failed to marshal to JSON") + } + + ioStreams.Info("%s\n", string(data)) + return nil +} + +// outputDryRunYAML outputs YAML format +func outputDryRunYAML(result *application.PolicyDryRunResult, ioStreams cmdutil.IOStreams) error { + output := map[string]interface{}{ + "application": result.Application.Name, + "namespace": result.Application.Namespace, + "executionPlan": result.ExecutionPlan, + "policyResults": result.PolicyResults, + "warnings": result.Warnings, + "errors": result.Errors, + "finalSpec": result.Application.Spec, + "finalLabels": result.Application.Labels, + "finalAnnotations": result.Application.Annotations, + } + + data, err := yaml.Marshal(output) + if err != nil { + return errors.Wrap(err, "failed to marshal to YAML") + } + + ioStreams.Info("%s\n", string(data)) + return nil +}