mirror of
https://github.com/kubevela/kubevela.git
synced 2026-05-09 10:56:53 +00:00
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>
989 lines
34 KiB
Go
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")
|
|
}
|