Files
kubevela/references/cli/policy.go
Brian Kane 38dea0b56c feat: application-scoped policies (#7067)
Introduces application-scoped policies and global auto-applied policies
for KubeVela.

Key changes:
- PolicyDefinition gains `scope`, `global`, and `priority` fields
- Global policies (global=true, scope=Application) are auto-applied to
  every Application in their namespace (and vela-system globals apply
  cluster-wide) without being listed in spec.policies
- PolicyScopeIndex: in-memory singleton index of PolicyDefinition
  metadata, bootstrapped at startup and kept live via watch events.
  Follows KubeVela's 2-step lookup (local namespace → vela-system)
- ApplicationPolicyCache: per-app cache of rendered policy results,
  invalidated by spec hash, revision hash, or TTL; cleared on deletion
- Policy rendering pipeline extended to inject global policies before
  user-specified ones, respecting priority ordering
- Appfile.Context carries context.Context from controller into rendering
- Feature gates: EnableApplicationScopedPolicies and EnableGlobalPolicies
  (both Alpha, default false); admission webhook warns when a
  PolicyDefinition targets a disabled gate

Signed-off-by: Brian Kane <briankane1@gmail.com>
2026-03-19 07:58:15 -07:00

989 lines
34 KiB
Go

/*
Copyright 2026 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 cli
import (
"context"
"encoding/json"
"fmt"
"os"
"path"
"strings"
"github.com/aryann/difflib"
"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"
v1beta1 "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 (
outputFormatTable = "table"
outputFormatJSON = "json"
outputFormatYAML = "yaml"
outputFormatSummary = "summary"
)
// 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 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
var wide, outcome, details bool
var policyFilter string
cmd := &cobra.Command{
Use: "view <app-name>",
Short: "View applied Application-scoped policies and their effects.",
Long: "View which Application-scoped policies were applied to an Application and what changes they made.",
Example: ` # View policies applied to an Application
vela policy view my-app
# Wide output (includes labels/annotations/context columns)
vela policy view my-app --wide
# Include per-policy details (context, labels, annotations, spec diff)
vela policy view my-app --details
# Include outcome (final spec/labels/annotations/context)
vela policy view my-app --outcome
# Filter to a specific policy
vela policy view my-app -p governance
# Include outcome in JSON output
vela policy view my-app --output json --outcome`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
namespace, err := cmd.Flags().GetString("namespace")
if err != nil {
return err
}
if namespace == "" {
namespace = "default"
}
return runPolicyView(context.Background(), c, args[0], namespace, outputFormat, wide, details, outcome, policyFilter, ioStreams)
},
}
addNamespaceAndEnvArg(cmd)
cmd.Flags().StringVarP(&outputFormat, "output", "o", outputFormatTable, "Output format: table, json, yaml")
cmd.Flags().BoolVar(&wide, "wide", false, "Show additional columns (labels, annotations, context)")
cmd.Flags().BoolVar(&details, "details", false, "Include per-policy output details (context, labels, annotations, spec transforms)")
cmd.Flags().BoolVar(&outcome, "outcome", false, "Include final spec, labels, annotations and context in output")
cmd.Flags().StringVarP(&policyFilter, "policy", "p", "", "Filter output to policies matching a name or glob pattern (e.g. \"add-env-*\")")
return cmd
}
// policyOutputSpec holds spec snapshots nested under output.spec in the ConfigMap.
type policyOutputSpec struct {
Before *json.RawMessage `json:"before,omitempty"`
After *json.RawMessage `json:"after,omitempty"`
}
// policyOutput is the per-policy output block stored in the ConfigMap.
type policyOutput struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
Ctx map[string]interface{} `json:"ctx,omitempty"`
Spec *policyOutputSpec `json:"spec,omitempty"`
}
type policyOutputWrapper struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Priority int32 `json:"priority,omitempty"`
Output policyOutput `json:"output,omitempty"`
DefinitionRevisionName string `json:"definitionRevisionName,omitempty"`
Revision int64 `json:"revision,omitempty"`
RevisionHash string `json:"revisionHash,omitempty"`
}
type policyInfoRecord struct {
RenderedAt string `json:"rendered_at"`
}
func printSpecDiff(before, after, indent string, ioStreams cmdutil.IOStreams) {
// Convert JSON to YAML for readable diff
var beforeObj, afterObj interface{}
beforeYAML, afterYAML := before, after
if err := json.Unmarshal([]byte(before), &beforeObj); err == nil {
if b, err := yaml.Marshal(beforeObj); err == nil {
beforeYAML = string(b)
}
}
if err := json.Unmarshal([]byte(after), &afterObj); err == nil {
if b, err := yaml.Marshal(afterObj); err == nil {
afterYAML = string(b)
}
}
diffs := difflib.Diff(strings.Split(beforeYAML, "\n"), strings.Split(afterYAML, "\n"))
anyChange := false
for _, d := range diffs {
if d.Delta != difflib.Common {
anyChange = true
break
}
}
if !anyChange {
fmt.Fprintf(ioStreams.Out, "%s(no spec changes)\n", indent)
return
}
for _, d := range diffs {
switch d.Delta {
case difflib.LeftOnly:
fmt.Fprintf(ioStreams.Out, "%s\n", color.RedString("%s- %s", indent, d.Payload))
case difflib.RightOnly:
fmt.Fprintf(ioStreams.Out, "%s\n", color.GreenString("%s+ %s", indent, d.Payload))
case difflib.Common:
fmt.Fprintf(ioStreams.Out, "%s %s\n", indent, d.Payload)
}
}
}
// buildPolicyDetailsFromConfigMap parses per-policy entries from a ConfigMap into the
// same map[string]map[string]any shape that PolicyDryRunResult.PolicyDetails uses.
// This allows view --details to call printPolicyDetails just like dry-run does.
func buildPolicyDetailsFromConfigMap(cm *corev1.ConfigMap) map[string]map[string]any {
if cm == nil {
return nil
}
result := map[string]map[string]any{}
for key, val := range cm.Data {
if key == "info" || key == "rendered_spec" || key == "applied_spec" || key == "metadata" {
continue
}
var wrapper policyOutputWrapper
if err := json.Unmarshal([]byte(val), &wrapper); err != nil {
continue
}
// Parse the nested output block
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(val), &raw); err == nil {
if outputRaw, ok := raw["output"]; ok {
_ = json.Unmarshal(outputRaw, &wrapper.Output)
}
}
// Build the details map in the same shape as PolicyDryRunResult.PolicyDetails
outputMap := map[string]interface{}{}
if len(wrapper.Output.Labels) > 0 {
// Convert map[string]string to map[string]interface{} for consistent handling
labelsAny := make(map[string]interface{}, len(wrapper.Output.Labels))
for k, v := range wrapper.Output.Labels {
labelsAny[k] = v
}
outputMap["labels"] = labelsAny
}
if len(wrapper.Output.Annotations) > 0 {
annotationsAny := make(map[string]interface{}, len(wrapper.Output.Annotations))
for k, v := range wrapper.Output.Annotations {
annotationsAny[k] = v
}
outputMap["annotations"] = annotationsAny
}
if len(wrapper.Output.Ctx) > 0 {
outputMap["ctx"] = wrapper.Output.Ctx
}
if wrapper.Output.Spec != nil && wrapper.Output.Spec.Before != nil && wrapper.Output.Spec.After != nil {
var beforeObj, afterObj interface{}
_ = json.Unmarshal(*wrapper.Output.Spec.Before, &beforeObj)
_ = json.Unmarshal(*wrapper.Output.Spec.After, &afterObj)
outputMap["spec"] = map[string]interface{}{
"before": beforeObj,
"after": afterObj,
}
}
details := map[string]any{
"output": outputMap,
"priority": wrapper.Priority,
}
if wrapper.DefinitionRevisionName != "" {
details["definitionRevisionName"] = wrapper.DefinitionRevisionName
details["revision"] = wrapper.Revision
details["revisionHash"] = wrapper.RevisionHash
}
result[wrapper.Name] = details
}
return result
}
// filterPolicies returns only the policies whose names match the given glob pattern.
func filterPolicies(policies []common.AppliedApplicationPolicy, pattern string) []common.AppliedApplicationPolicy {
var filtered []common.AppliedApplicationPolicy
for _, p := range policies {
if matched, _ := path.Match(pattern, p.Name); matched {
filtered = append(filtered, p)
}
}
return filtered
}
// NewPolicyDryRunCommand creates the `vela policy dry-run` command
func NewPolicyDryRunCommand(c velacommon.Args, ioStreams cmdutil.IOStreams) *cobra.Command {
var outputFormat string
var file, policyFilter string
var details, outcome bool
cmd := &cobra.Command{
Use: "dry-run [app-name]",
Short: "Preview what policies would do to an Application without applying changes.",
Long: "Simulates the full policy application using the same code path as the controller. No changes are made to the cluster.",
Example: ` # Simulate policies for a live Application (fetched from cluster)
vela policy dry-run my-app
# Test policies using a local Application file
vela policy dry-run -f app.yaml
# Focus on a specific policy
vela policy dry-run -f app.yaml -p my-policy
# Summary view (labels and annotations only)
vela policy dry-run my-app --output summary
# JSON output for CI/CD
vela policy dry-run -f app.yaml --output json
# Include per-policy output details
vela policy dry-run -f app.yaml --details
# Include outcome (final spec/labels/annotations/context)
vela policy dry-run -f app.yaml --outcome`,
Args: cobra.RangeArgs(0, 1),
RunE: func(cmd *cobra.Command, args []string) error {
namespace, err := cmd.Flags().GetString("namespace")
if err != nil {
return err
}
if namespace == "" {
namespace = "default"
}
if file != "" {
return runPolicyDryRunFile(context.Background(), c, file, namespace, outputFormat, policyFilter, details, outcome, ioStreams)
}
if len(args) == 0 {
return fmt.Errorf("must provide either an app name or -f <file>")
}
return runPolicyDryRun(context.Background(), c, args[0], namespace, outputFormat, policyFilter, details, outcome, ioStreams)
},
}
addNamespaceAndEnvArg(cmd)
cmd.Flags().StringVarP(&outputFormat, "output", "o", outputFormatTable, "Output format: table, summary, json, yaml")
cmd.Flags().StringVarP(&file, "file", "f", "", "Path to a local Application YAML file")
cmd.Flags().StringVarP(&policyFilter, "policy", "p", "", "Filter output to policies matching a name or glob pattern (e.g. \"add-env-*\")")
cmd.Flags().BoolVar(&details, "details", false, "Include per-policy output details (context, labels, annotations, spec transforms)")
cmd.Flags().BoolVar(&outcome, "outcome", false, "Include outcome (final spec, labels, annotations and context) in output")
return cmd
}
// runPolicyView implements the view command logic
func runPolicyView(ctx context.Context, c velacommon.Args, appName, namespace, outputFormat string, wide, details, outcome bool, policyFilter string, ioStreams cmdutil.IOStreams) error {
cli, err := c.GetClient()
if err != nil {
return err
}
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)
}
if len(app.Status.AppliedApplicationPolicies) == 0 && len(app.Spec.Policies) == 0 {
fmt.Fprintf(ioStreams.Out, "No policies found on '%s'\n", appName)
return nil
}
// Fetch the diffs ConfigMap if available
var diffsConfigMap *corev1.ConfigMap
if app.Status.ApplicationPoliciesConfigMap != "" {
cm := &corev1.ConfigMap{}
if err := cli.Get(ctx, client.ObjectKey{Name: app.Status.ApplicationPoliciesConfigMap, Namespace: namespace}, cm); err == nil {
diffsConfigMap = cm
}
}
// Apply policy filter
policies := app.Status.AppliedApplicationPolicies
if policyFilter != "" {
policies = filterPolicies(policies, policyFilter)
if len(policies) == 0 {
return fmt.Errorf("no policies matching %q found", policyFilter)
}
}
// Build policyDetails from ConfigMap when --details is requested
var policyDetails map[string]map[string]any
if details {
policyDetails = buildPolicyDetailsFromConfigMap(diffsConfigMap)
}
switch outputFormat {
case outputFormatJSON:
return outputPolicyViewJSON(app, policies, diffsConfigMap, details, outcome, policyDetails, ioStreams)
case outputFormatYAML:
return outputPolicyViewYAML(app, policies, diffsConfigMap, details, outcome, policyDetails, ioStreams)
default:
return outputPolicyViewTable(app, policies, diffsConfigMap, wide, details, outcome, policyDetails, ioStreams)
}
}
func outputPolicyViewTable(app *v1beta1.Application, policies []common.AppliedApplicationPolicy, diffsConfigMap *corev1.ConfigMap, wide, details, outcome bool, policyDetails map[string]map[string]any, ioStreams cmdutil.IOStreams) error {
// Build type map from spec.policies for cross-referencing global policies
specPolicyType := make(map[string]string)
for _, p := range app.Spec.Policies {
specPolicyType[p.Name] = p.Type
}
// Resolve type for each applied policy
pTypes := make([]string, len(policies))
for i, p := range policies {
pTypes[i] = p.Type
if pTypes[i] == "" {
pTypes[i] = specPolicyType[p.Name]
}
}
printPolicyTable(policies, pTypes, wide, ioStreams)
// Rendered at timestamp from ConfigMap info block
if diffsConfigMap != nil {
if infoRaw, ok := diffsConfigMap.Data["info"]; ok {
var info policyInfoRecord
if err := json.Unmarshal([]byte(infoRaw), &info); err == nil && info.RenderedAt != "" {
fmt.Fprintf(ioStreams.Out, "Last rendered: %s\n\n", info.RenderedAt)
}
}
}
if outcome {
oSpec, oLabels, oAnnotations, oCtx := extractOutcomeFromConfigMap(diffsConfigMap, app.Spec, app.Labels, app.Annotations)
printOutcomeBlock(oSpec, oLabels, oAnnotations, oCtx, ioStreams)
}
if details {
printPolicyDetails(policies, policyDetails, ioStreams)
}
return nil
}
// printPolicyTable renders the shared policy table used by both view and dry-run.
// types is a parallel slice of resolved policy type strings (may be empty strings).
func printPolicyTable(policies []common.AppliedApplicationPolicy, types []string, wide bool, ioStreams cmdutil.IOStreams) {
table := tablewriter.NewWriter(ioStreams.Out)
if wide {
table.SetHeader([]string{"#", "Policy", "Type", "Namespace", "Source", "Enabled", "Error", "Labels", "Annotations", "Context", "Spec Modified"})
} else {
table.SetHeader([]string{"#", "Policy", "Type", "Namespace", "Source", "Enabled"})
}
table.SetBorder(true)
table.SetAlignment(tablewriter.ALIGN_LEFT)
for i, p := range policies {
enabledStr := color.GreenString("Yes")
if !p.Applied {
enabledStr = color.YellowString("No")
}
pType := ""
if i < len(types) {
pType = types[i]
}
row := []string{
fmt.Sprintf("%d", i+1),
p.Name,
pType,
p.Namespace,
p.Source,
enabledStr,
}
if wide {
row = append(row,
boolStr(p.Error),
fmt.Sprintf("%d", p.LabelsCount),
fmt.Sprintf("%d", p.AnnotationsCount),
boolStr(p.HasContext),
boolStr(p.SpecModified),
)
}
table.Append(row)
}
table.Render()
fmt.Fprintln(ioStreams.Out)
}
// printPolicyDetails renders the per-policy details section. It is shared between
// `vela policy view --details` (reading from ConfigMap) and `vela policy dry-run --details`
// (reading from PolicyDryRunResult.PolicyDetails).
func printPolicyDetails(policies []common.AppliedApplicationPolicy, policyDetails map[string]map[string]any, ioStreams cmdutil.IOStreams) {
if len(policyDetails) == 0 {
return
}
fmt.Fprintf(ioStreams.Out, "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
fmt.Fprintf(ioStreams.Out, "Per-Policy Details\n")
fmt.Fprintf(ioStreams.Out, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
for i, p := range policies {
header := fmt.Sprintf("Policy %d: %s", i+1, p.Name)
if p.Source != "" {
header += fmt.Sprintf(" [%s]", p.Source)
}
if !p.Applied {
disabledHeader := header + " (disabled)"
fmt.Fprintf(ioStreams.Out, "%s\n%s\n", color.YellowString(disabledHeader), strings.Repeat("─", len(disabledHeader)))
fmt.Fprintf(ioStreams.Out, " (policy was not applied)\n\n")
continue
}
details, ok := policyDetails[p.Name]
if !ok {
continue
}
fmt.Fprintf(ioStreams.Out, "%s\n%s\n", color.CyanString(header), strings.Repeat("─", len(header)))
// Version / priority metadata
if v, ok := details["definitionRevisionName"].(string); ok && v != "" {
rev, _ := details["revision"].(int64)
hash, _ := details["revisionHash"].(string)
fmt.Fprintf(ioStreams.Out, " Version: %s (revision %d, hash: %s)\n", v, rev, hash)
}
if pri, ok := details["priority"]; ok {
fmt.Fprintf(ioStreams.Out, " Priority: %v\n", pri)
}
output, _ := details["output"].(map[string]interface{})
if output != nil {
if ctx, ok := output["ctx"].(map[string]interface{}); ok && len(ctx) > 0 {
fmt.Fprintf(ioStreams.Out, " %s\n", color.CyanString("Context:"))
ctxYAML, _ := yaml.Marshal(ctx)
fmt.Fprintf(ioStreams.Out, "%s\n", indentLines(string(ctxYAML), " "))
}
if rawLabels, ok := output["labels"].(map[string]interface{}); ok && len(rawLabels) > 0 {
fmt.Fprintf(ioStreams.Out, " %s\n", color.CyanString(fmt.Sprintf("Labels (%d):", len(rawLabels))))
for k, v := range rawLabels {
fmt.Fprintf(ioStreams.Out, " %-40s %v\n", k+":", v)
}
fmt.Fprintln(ioStreams.Out)
}
if rawAnnotations, ok := output["annotations"].(map[string]interface{}); ok && len(rawAnnotations) > 0 {
fmt.Fprintf(ioStreams.Out, " %s\n", color.CyanString(fmt.Sprintf("Annotations (%d):", len(rawAnnotations))))
for k, v := range rawAnnotations {
fmt.Fprintf(ioStreams.Out, " %-40s %v\n", k+":", v)
}
fmt.Fprintln(ioStreams.Out)
}
if specBlock, ok := output["spec"].(map[string]interface{}); ok {
before, hasBefore := specBlock["before"]
after, hasAfter := specBlock["after"]
if hasBefore && hasAfter {
beforeJSON, _ := json.Marshal(before)
afterJSON, _ := json.Marshal(after)
fmt.Fprintf(ioStreams.Out, " %s\n", color.CyanString("Spec Diff:"))
printSpecDiff(string(beforeJSON), string(afterJSON), " ", ioStreams)
}
}
}
fmt.Fprintln(ioStreams.Out)
}
}
// printOutcomeBlock renders the Outcome section for table output.
func printOutcomeBlock(spec v1beta1.ApplicationSpec, labels, annotations map[string]string, finalCtx map[string]interface{}, ioStreams cmdutil.IOStreams) {
fmt.Fprintf(ioStreams.Out, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
fmt.Fprintf(ioStreams.Out, "Outcome\n")
fmt.Fprintf(ioStreams.Out, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
if len(labels) > 0 {
printSubheader(fmt.Sprintf("Labels (%d)", len(labels)), ioStreams)
for k, v := range labels {
fmt.Fprintf(ioStreams.Out, " %-40s %s\n", k+":", v)
}
fmt.Fprintln(ioStreams.Out)
}
if len(annotations) > 0 {
printSubheader(fmt.Sprintf("Annotations (%d)", len(annotations)), ioStreams)
for k, v := range annotations {
fmt.Fprintf(ioStreams.Out, " %-40s %s\n", k+":", v)
}
fmt.Fprintln(ioStreams.Out)
}
if len(finalCtx) > 0 {
printSubheader("Context", ioStreams)
ctxYAML, _ := yaml.Marshal(finalCtx)
fmt.Fprintf(ioStreams.Out, "%s\n", indentLines(string(ctxYAML), " "))
}
printSubheader("Spec", ioStreams)
specYAML, _ := yaml.Marshal(spec)
fmt.Fprintf(ioStreams.Out, "%s\n", indentLines(string(specYAML), " "))
}
func outputPolicyViewJSON(app *v1beta1.Application, policies []common.AppliedApplicationPolicy, cm *corev1.ConfigMap, details, outcome bool, policyDetails map[string]map[string]any, ioStreams cmdutil.IOStreams) error {
var detailsArg map[string]map[string]any
if details {
detailsArg = policyDetails
}
oSpec, oLabels, oAnnotations, oCtx := extractOutcomeFromConfigMap(cm, app.Spec, app.Labels, app.Annotations)
out := buildPolicyOutput(app.Name, app.Namespace, policies, oSpec, oLabels, oAnnotations, oCtx, outcome, nil, detailsArg)
data, err := json.MarshalIndent(out, "", " ")
if err != nil {
return errors.Wrap(err, "failed to marshal JSON")
}
fmt.Fprintf(ioStreams.Out, "%s\n", data)
return nil
}
func outputPolicyViewYAML(app *v1beta1.Application, policies []common.AppliedApplicationPolicy, cm *corev1.ConfigMap, details, outcome bool, policyDetails map[string]map[string]any, ioStreams cmdutil.IOStreams) error {
var detailsArg map[string]map[string]any
if details {
detailsArg = policyDetails
}
oSpec, oLabels, oAnnotations, oCtx := extractOutcomeFromConfigMap(cm, app.Spec, app.Labels, app.Annotations)
out := buildPolicyOutput(app.Name, app.Namespace, policies, oSpec, oLabels, oAnnotations, oCtx, outcome, nil, detailsArg)
data, err := yaml.Marshal(out)
if err != nil {
return errors.Wrap(err, "failed to marshal YAML")
}
fmt.Fprintf(ioStreams.Out, "%s\n", data)
return nil
}
// extractMetadataFromConfigMap parses the "metadata" key from the ConfigMap (merged labels, annotations, context).
func extractMetadataFromConfigMap(cm *corev1.ConfigMap) map[string]interface{} {
if cm == nil {
return nil
}
metaRaw, ok := cm.Data["metadata"]
if !ok {
return nil
}
var meta map[string]interface{}
if err := json.Unmarshal([]byte(metaRaw), &meta); err != nil {
return nil
}
return meta
}
// extractOutcomeFromConfigMap reads the policy-applied outcome (labels, annotations, context, spec) from the ConfigMap.
// labels and annotations reflect only what policies contributed — not controller-added values.
// Falls back to the provided app spec/labels/annotations if the ConfigMap is unavailable.
func extractOutcomeFromConfigMap(cm *corev1.ConfigMap, fallbackSpec v1beta1.ApplicationSpec, fallbackLabels, fallbackAnnotations map[string]string) (v1beta1.ApplicationSpec, map[string]string, map[string]string, map[string]interface{}) {
spec := fallbackSpec
if cm == nil {
return spec, fallbackLabels, fallbackAnnotations, nil
}
// applied_spec is app.Spec after policy application
if specRaw, ok := cm.Data["applied_spec"]; ok {
var s v1beta1.ApplicationSpec
if err := json.Unmarshal([]byte(specRaw), &s); err == nil {
spec = s
}
}
// metadata contains only policy-contributed labels/annotations/context — always use it when present,
// even if empty (an empty map means no policies added labels/annotations).
meta := extractMetadataFromConfigMap(cm)
if meta == nil {
return spec, fallbackLabels, fallbackAnnotations, nil
}
labels := make(map[string]string)
if l, ok := meta["labels"].(map[string]interface{}); ok {
for k, v := range l {
if s, ok := v.(string); ok {
labels[k] = s
}
}
}
annotations := make(map[string]string)
if a, ok := meta["annotations"].(map[string]interface{}); ok {
for k, v := range a {
if s, ok := v.(string); ok {
annotations[k] = s
}
}
}
var finalCtx map[string]interface{}
if ctx, ok := meta["context"].(map[string]interface{}); ok && len(ctx) > 0 {
finalCtx = ctx
}
return spec, labels, annotations, finalCtx
}
// runPolicyDryRun implements the dry-run command logic (cluster mode)
func runPolicyDryRun(ctx context.Context, c velacommon.Args, appName, namespace, outputFormat, policyFilter string, details, outcome bool, ioStreams cmdutil.IOStreams) error {
cli, err := c.GetClient()
if err != nil {
return err
}
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)
}
if outputFormat == outputFormatTable || outputFormat == "" {
fmt.Fprintf(ioStreams.Out, "Dry-run: %s/%s\n\n", namespace, appName)
}
result, err := application.SimulatePolicyApplication(ctx, cli, app)
if err != nil {
return errors.Wrap(err, "simulation failed")
}
return outputDryRunResult(result, outputFormat, policyFilter, details, outcome, ioStreams)
}
// runPolicyDryRunFile implements the dry-run command logic (file mode)
func runPolicyDryRunFile(ctx context.Context, c velacommon.Args, filePath, namespace, outputFormat, policyFilter string, details, outcome bool, ioStreams cmdutil.IOStreams) error {
data, err := os.ReadFile(filePath) //nolint:gosec // filePath is supplied by the user via CLI flag
if err != nil {
return errors.Wrapf(err, "failed to read file %s", filePath)
}
app := &v1beta1.Application{}
if err := yaml.Unmarshal(data, app); err != nil {
return errors.Wrapf(err, "failed to parse Application from %s", filePath)
}
if app.Namespace == "" {
app.Namespace = namespace
}
cli, err := c.GetClient()
if err != nil {
return err
}
if outputFormat == outputFormatTable || outputFormat == "" {
fmt.Fprintf(ioStreams.Out, "Dry-run: %s (from file)\n\n", filePath)
}
result, err := application.SimulatePolicyApplication(ctx, cli, app)
if err != nil {
return errors.Wrap(err, "simulation failed")
}
return outputDryRunResult(result, outputFormat, policyFilter, details, outcome, ioStreams)
}
func outputDryRunResult(result *application.PolicyDryRunResult, outputFormat, policyFilter string, details, outcome bool, ioStreams cmdutil.IOStreams) error {
if policyFilter != "" {
result = filterDryRunResult(result, policyFilter)
if result == nil {
return fmt.Errorf("no policies matching %q found in simulation results", policyFilter)
}
}
switch outputFormat {
case outputFormatSummary:
return outputDryRunSummary(result, ioStreams)
case outputFormatJSON:
return outputDryRunJSON(result, details, outcome, ioStreams)
case outputFormatYAML:
return outputDryRunYAML(result, details, outcome, ioStreams)
default:
return outputDryRunTable(result, details, outcome, ioStreams)
}
}
func outputDryRunTable(result *application.PolicyDryRunResult, details, outcome bool, ioStreams cmdutil.IOStreams) error {
if len(result.Errors) > 0 {
fmt.Fprintf(ioStreams.Out, "%s\n", color.RedString("Errors:"))
for _, e := range result.Errors {
fmt.Fprintf(ioStreams.Out, " • %s\n", e)
}
fmt.Fprintln(ioStreams.Out)
}
fmt.Fprintf(ioStreams.Out, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
fmt.Fprintf(ioStreams.Out, "Policy Results\n")
fmt.Fprintf(ioStreams.Out, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
if len(result.PolicyResults) == 0 {
fmt.Fprintf(ioStreams.Out, "No Application-scoped policies applied.\n\n")
} else {
types := make([]string, len(result.PolicyResults))
for i, p := range result.PolicyResults {
types[i] = p.Type
}
printPolicyTable(result.PolicyResults, types, true, ioStreams)
}
if outcome {
printOutcomeBlock(result.Application.Spec, result.Application.Labels, result.Application.Annotations, result.FinalContext, ioStreams)
}
if details {
printPolicyDetails(result.PolicyResults, result.PolicyDetails, ioStreams)
}
fmt.Fprintf(ioStreams.Out, "This is a dry-run. No changes were applied to the cluster.\n")
return nil
}
func outputDryRunSummary(result *application.PolicyDryRunResult, ioStreams cmdutil.IOStreams) error {
applied, skipped := countApplied(result.PolicyResults)
fmt.Fprintf(ioStreams.Out, "Policies: %d enabled, %d disabled\n\n", applied, skipped)
printLabelsAnnotations(result.Application.Labels, result.Application.Annotations, ioStreams)
return nil
}
func outputDryRunJSON(result *application.PolicyDryRunResult, details, outcome bool, ioStreams cmdutil.IOStreams) error {
var policyDetails map[string]map[string]any
if details {
policyDetails = result.PolicyDetails
}
out := buildPolicyOutput(result.Application.Name, result.Application.Namespace, result.PolicyResults, result.Application.Spec, result.Application.Labels, result.Application.Annotations, result.FinalContext, outcome, result.Errors, policyDetails)
data, err := json.MarshalIndent(out, "", " ")
if err != nil {
return errors.Wrap(err, "failed to marshal JSON")
}
fmt.Fprintf(ioStreams.Out, "%s\n", data)
return nil
}
func outputDryRunYAML(result *application.PolicyDryRunResult, details, outcome bool, ioStreams cmdutil.IOStreams) error {
var policyDetails map[string]map[string]any
if details {
policyDetails = result.PolicyDetails
}
out := buildPolicyOutput(result.Application.Name, result.Application.Namespace, result.PolicyResults, result.Application.Spec, result.Application.Labels, result.Application.Annotations, result.FinalContext, outcome, result.Errors, policyDetails)
data, err := yaml.Marshal(out)
if err != nil {
return errors.Wrap(err, "failed to marshal YAML")
}
fmt.Fprintf(ioStreams.Out, "%s\n", data)
return nil
}
// buildPolicyOutput constructs the shared JSON/YAML output for both `vela policy view` and
// `vela policy dry-run`. errors and policyDetails are nil for the live view case.
func buildPolicyOutput(appName, namespace string, policies []common.AppliedApplicationPolicy, spec v1beta1.ApplicationSpec, labels, annotations map[string]string, finalCtx map[string]interface{}, outcome bool, errs []string, policyDetails map[string]map[string]any) map[string]any {
applied, skipped := countApplied(policies)
totalLabels, totalAnnotations, specMod, _ := summarize(policies)
// Merge status entries with rich detail data into a single policies list.
merged := make([]map[string]any, 0, len(policies))
for i, p := range policies {
entry := map[string]any{
"order": i + 1,
"name": p.Name,
"type": p.Type,
"namespace": p.Namespace,
"source": p.Source,
"applied": p.Applied,
}
if p.Error {
entry["error"] = true
}
if p.Message != "" {
entry["message"] = p.Message
}
if p.SpecModified {
entry["specModified"] = true
}
if p.LabelsCount > 0 {
entry["labelsCount"] = p.LabelsCount
}
if p.AnnotationsCount > 0 {
entry["annotationsCount"] = p.AnnotationsCount
}
if p.HasContext {
entry["hasContext"] = true
}
if p.DefinitionRevisionName != "" {
entry["definitionRevisionName"] = p.DefinitionRevisionName
}
if p.Revision > 0 {
entry["revision"] = p.Revision
}
if p.RevisionHash != "" {
entry["revisionHash"] = p.RevisionHash
}
// Merge rich output from policyDetails when available (--details mode).
if details, ok := policyDetails[p.Name]; ok {
if priority, ok := details["priority"]; ok {
entry["priority"] = priority
}
if output, ok := details["output"]; ok {
entry["output"] = output
}
}
merged = append(merged, entry)
}
out := map[string]any{
"application": appName,
"namespace": namespace,
"policies": merged,
"summary": map[string]any{
"enabled": applied,
"disabled": skipped,
"specModifications": specMod,
"labelsAdded": totalLabels,
"annotationsAdded": totalAnnotations,
},
}
if outcome {
outcomeBlock := map[string]any{
"spec": spec,
"labels": labels,
"annotations": annotations,
}
if len(finalCtx) > 0 {
outcomeBlock["context"] = finalCtx
}
out["outcome"] = outcomeBlock
}
if len(errs) > 0 {
out["errors"] = errs
}
return out
}
// filterDryRunResult returns a shallow copy of result with PolicyResults and PolicyDetails
// filtered to policies whose names match the given glob pattern (e.g. "add-env-*").
// Returns nil if no policies match.
func filterDryRunResult(result *application.PolicyDryRunResult, pattern string) *application.PolicyDryRunResult {
filtered := filterPolicies(result.PolicyResults, pattern)
if len(filtered) == 0 {
return nil
}
out := *result
out.PolicyResults = filtered
if result.PolicyDetails != nil {
out.PolicyDetails = map[string]map[string]any{}
for _, p := range filtered {
if d, ok := result.PolicyDetails[p.Name]; ok {
out.PolicyDetails[p.Name] = d
}
}
}
return &out
}
// helpers
func countApplied(policies []common.AppliedApplicationPolicy) (applied, skipped int) {
for _, p := range policies {
if p.Applied {
applied++
} else {
skipped++
}
}
return
}
func summarize(policies []common.AppliedApplicationPolicy) (totalLabels, totalAnnotations, specMod, ctxCount int) {
for _, p := range policies {
if !p.Applied {
continue
}
totalLabels += p.LabelsCount
totalAnnotations += p.AnnotationsCount
if p.SpecModified {
specMod++
}
if p.HasContext {
ctxCount++
}
}
return
}
func boolStr(b bool) string {
if b {
return "Yes"
}
return "No"
}
func printLabelsAnnotations(labels, annotations map[string]string, ioStreams cmdutil.IOStreams) {
if len(labels) > 0 {
fmt.Fprintf(ioStreams.Out, "Labels (%d):\n", len(labels))
for k, v := range labels {
fmt.Fprintf(ioStreams.Out, " %-40s %s\n", k+":", v)
}
fmt.Fprintln(ioStreams.Out)
}
if len(annotations) > 0 {
fmt.Fprintf(ioStreams.Out, "Annotations (%d):\n", len(annotations))
for k, v := range annotations {
fmt.Fprintf(ioStreams.Out, " %-40s %s\n", k+":", v)
}
fmt.Fprintln(ioStreams.Out)
}
}
func printSubheader(title string, ioStreams cmdutil.IOStreams) {
fmt.Fprintf(ioStreams.Out, "%s\n%s\n", color.CyanString(title), strings.Repeat("─", len(title)))
}
func indentLines(s, prefix string) string {
lines := strings.Split(s, "\n")
for i, l := range lines {
if l != "" {
lines[i] = prefix + l
}
}
return strings.Join(lines, "\n")
}