github.com/oam-dev/kubevela@v1.9.11/references/cli/dryrun.go (about)

     1  /*
     2  Copyright 2021 The KubeVela Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package cli
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"encoding/json"
    23  	"fmt"
    24  	"os"
    25  	"path/filepath"
    26  	"strings"
    27  
    28  	wfv1alpha1 "github.com/kubevela/workflow/api/v1alpha1"
    29  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    30  	"k8s.io/client-go/kubernetes/scheme"
    31  
    32  	"github.com/pkg/errors"
    33  	"github.com/spf13/cobra"
    34  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    35  	"sigs.k8s.io/controller-runtime/pkg/client"
    36  	"sigs.k8s.io/yaml"
    37  
    38  	apicommon "github.com/oam-dev/kubevela/apis/core.oam.dev/common"
    39  	"github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha1"
    40  	corev1beta1 "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
    41  	"github.com/oam-dev/kubevela/apis/types"
    42  	"github.com/oam-dev/kubevela/pkg/appfile/dryrun"
    43  	pkgdef "github.com/oam-dev/kubevela/pkg/definition"
    44  	"github.com/oam-dev/kubevela/pkg/oam"
    45  	oamutil "github.com/oam-dev/kubevela/pkg/oam/util"
    46  	"github.com/oam-dev/kubevela/pkg/utils"
    47  	"github.com/oam-dev/kubevela/pkg/utils/common"
    48  	cmdutil "github.com/oam-dev/kubevela/pkg/utils/util"
    49  	"github.com/oam-dev/kubevela/pkg/workflow/step"
    50  )
    51  
    52  // DryRunCmdOptions contains dry-run cmd options
    53  type DryRunCmdOptions struct {
    54  	cmdutil.IOStreams
    55  	ApplicationFiles     []string
    56  	DefinitionFile       string
    57  	OfflineMode          bool
    58  	MergeStandaloneFiles bool
    59  	DefinitionNamespace  string
    60  }
    61  
    62  // NewDryRunCommand creates `dry-run` command
    63  func NewDryRunCommand(c common.Args, order string, ioStreams cmdutil.IOStreams) *cobra.Command {
    64  	o := &DryRunCmdOptions{IOStreams: ioStreams}
    65  	cmd := &cobra.Command{
    66  		Use:                   "dry-run",
    67  		DisableFlagsInUseLine: true,
    68  		Short:                 "Dry Run an application, and output the K8s resources as result to stdout.",
    69  		Long: `Dry-run application locally, render the Kubernetes resources as result to stdout.
    70  	vela dry-run -d /definition/directory/or/file/ -f /path/to/app.yaml
    71  
    72  You can also specify a remote url for app:
    73  	vela dry-run -d /definition/directory/or/file/ -f https://remote-host/app.yaml
    74  
    75  And more, you can specify policy and workflow with application file:
    76  	vela dry-run -d /definition/directory/or/file/ -f /path/to/app.yaml -f /path/to/policy.yaml -f /path/to/workflow.yaml, OR
    77  	vela dry-run -d /definition/directory/or/file/ -f /path/to/app.yaml,/path/to/policy.yaml,/path/to/workflow.yaml
    78  
    79  Additionally, if the provided policy and workflow files are not referenced by application file, warning message will show up
    80  and those files will be ignored. You can use "merge" flag to make those standalone files effective:
    81  	vela dry-run -d /definition/directory/or/file/ -f /path/to/app.yaml,/path/to/policy.yaml,/path/to/workflow.yaml --merge
    82  
    83  Limitation:
    84  	1. Only support one object per file(yaml) for "-f" flag. More support will be added in the future improvement.
    85  	2. Dry Run with policy and workflow will only take override/topology policies and deploy workflow step into considerations. Other workflow step will be ignored.
    86  `,
    87  		Example: `
    88  # dry-run application 
    89  vela dry-run -f app.yaml
    90  
    91  # dry-run application with policy and workflow
    92  vela dry-run -f app.yaml -f policy.yaml -f workflow.yaml
    93  `,
    94  		Annotations: map[string]string{
    95  			types.TagCommandType:  types.TypeApp,
    96  			types.TagCommandOrder: order,
    97  		},
    98  		RunE: func(cmd *cobra.Command, args []string) error {
    99  			namespace, err := GetFlagNamespaceOrEnv(cmd, c)
   100  			if err != nil {
   101  				// We need to return an error only if not in offline mode
   102  				if !o.OfflineMode {
   103  					return err
   104  				}
   105  
   106  				// Set the namespace to default to match behavior of `GetFlagNamespaceOrEnv`
   107  				namespace = types.DefaultAppNamespace
   108  			}
   109  
   110  			buff, err := DryRunApplication(o, c, namespace)
   111  			if err != nil {
   112  				return err
   113  			}
   114  			o.Info(buff.String())
   115  			return nil
   116  		},
   117  	}
   118  
   119  	cmd.Flags().StringSliceVarP(&o.ApplicationFiles, "file", "f", []string{"app.yaml"}, "application related file names")
   120  	cmd.Flags().StringVarP(&o.DefinitionFile, "definition", "d", "", "specify a definition file or directory, it will only be used in dry-run rather than applied to K8s cluster")
   121  	cmd.Flags().BoolVar(&o.OfflineMode, "offline", false, "Run `dry-run` in offline / local mode, all validation steps will be skipped")
   122  	cmd.Flags().BoolVar(&o.MergeStandaloneFiles, "merge", false, "Merge standalone files to produce dry-run results")
   123  	cmd.Flags().StringVarP(&o.DefinitionNamespace, "definition-namespace", "x", "", "Specify which namespace the definition locates. (default \"vela-system\")")
   124  	addNamespaceAndEnvArg(cmd)
   125  	cmd.SetOut(ioStreams.Out)
   126  	return cmd
   127  }
   128  
   129  // DryRunApplication will dry-run an application and return the render result
   130  func DryRunApplication(cmdOption *DryRunCmdOptions, c common.Args, namespace string) (bytes.Buffer, error) {
   131  	var err error
   132  	var buff = bytes.Buffer{}
   133  
   134  	var objs []*unstructured.Unstructured
   135  	if cmdOption.DefinitionFile != "" {
   136  		objs, err = ReadDefinitionsFromFile(cmdOption.DefinitionFile, cmdOption.IOStreams)
   137  		if err != nil {
   138  			return buff, err
   139  		}
   140  	}
   141  
   142  	// Load a kubernetes client
   143  	var newClient client.Client
   144  	if cmdOption.OfflineMode {
   145  		// We will load a fake client with all the objects present in the definitions file preloaded
   146  		objs = includeBuiltinWorkflowStepDefinition(objs)
   147  		newClient, err = c.GetFakeClient(objs)
   148  	} else {
   149  		// Load an actual client here
   150  		newClient, err = c.GetClient()
   151  	}
   152  	if err != nil {
   153  		return buff, err
   154  	}
   155  
   156  	pd, err := c.GetPackageDiscover()
   157  	if err != nil {
   158  		return buff, err
   159  	}
   160  	config, err := c.GetConfig()
   161  	if err != nil {
   162  		return buff, err
   163  	}
   164  
   165  	dryRunOpt := dryrun.NewDryRunOption(newClient, config, pd, objs, false)
   166  	ctx := oamutil.SetNamespaceInCtx(context.Background(), namespace)
   167  	ctx = oamutil.SetXDefinitionNamespaceInCtx(ctx, cmdOption.DefinitionNamespace)
   168  
   169  	// Perform validation only if not in offline mode
   170  	if !cmdOption.OfflineMode {
   171  		for _, applicationFile := range cmdOption.ApplicationFiles {
   172  			err = dryRunOpt.ValidateApp(ctx, applicationFile)
   173  			if err != nil {
   174  				return buff, errors.WithMessagef(err, "validate application: %s by dry-run", applicationFile)
   175  			}
   176  		}
   177  	}
   178  
   179  	app, err := readApplicationFromFiles(cmdOption, &buff)
   180  	if err != nil {
   181  		return buff, errors.WithMessagef(err, "read application files: %s", cmdOption.ApplicationFiles)
   182  	}
   183  	err = dryRunOpt.ExecuteDryRunWithPolicies(ctx, app, &buff)
   184  	if err != nil {
   185  		return buff, err
   186  	}
   187  	return buff, nil
   188  }
   189  
   190  func readObj(path string) (*unstructured.Unstructured, error) {
   191  	switch {
   192  	case strings.HasSuffix(path, CUEExtension):
   193  		def := pkgdef.Definition{Unstructured: unstructured.Unstructured{}}
   194  		defBytes, err := os.ReadFile(filepath.Clean(path))
   195  		if err != nil {
   196  			return nil, err
   197  		}
   198  		if err := def.FromCUEString(string(defBytes), nil); err != nil {
   199  			return nil, errors.Wrapf(err, "failed to parse CUE for definition")
   200  		}
   201  		obj := &unstructured.Unstructured{Object: def.UnstructuredContent()}
   202  		return obj, nil
   203  	default:
   204  		obj := &unstructured.Unstructured{}
   205  		err := common.ReadYamlToObject(path, obj)
   206  		if err != nil {
   207  			return nil, err
   208  		}
   209  		return obj, nil
   210  	}
   211  }
   212  
   213  // ReadDefinitionsFromFile will read objects from file or dir in the format of yaml
   214  func ReadDefinitionsFromFile(path string, io cmdutil.IOStreams) ([]*unstructured.Unstructured, error) {
   215  	fi, err := os.Stat(path)
   216  	if err != nil {
   217  		return nil, err
   218  	}
   219  	if !fi.IsDir() {
   220  		obj, err := readObj(path)
   221  		if err != nil {
   222  			return nil, err
   223  		}
   224  		return []*unstructured.Unstructured{obj}, nil
   225  	}
   226  
   227  	var objs []*unstructured.Unstructured
   228  	err = filepath.WalkDir(path, func(path string, e os.DirEntry, err error) error {
   229  		if e == nil {
   230  			io.Errorf("failed to walk nil dir entry %s", path)
   231  			return nil
   232  		}
   233  		if err != nil {
   234  			io.Errorf("failed to walk dir %s: %v", path, err)
   235  			return nil
   236  		}
   237  		if e.IsDir() {
   238  			return nil
   239  		}
   240  		fileType := filepath.Ext(e.Name())
   241  		if fileType != YAMLExtension && fileType != YMLExtension && fileType != CUEExtension {
   242  			return nil
   243  		}
   244  		obj, err := readObj(path)
   245  		if err != nil {
   246  			return err
   247  		}
   248  		objs = append(objs, obj)
   249  		return nil
   250  	})
   251  	if err != nil {
   252  		return nil, err
   253  	}
   254  	return objs, nil
   255  }
   256  
   257  func readApplicationFromFile(filename string) (*corev1beta1.Application, error) {
   258  	fileContent, err := utils.ReadRemoteOrLocalPath(filename, true)
   259  	if err != nil {
   260  		return nil, err
   261  	}
   262  
   263  	fileType := filepath.Ext(filename)
   264  	switch fileType {
   265  	case YAMLExtension, YMLExtension:
   266  		fileContent, err = yaml.YAMLToJSON(fileContent)
   267  		if err != nil {
   268  			return nil, err
   269  		}
   270  	}
   271  
   272  	app := new(corev1beta1.Application)
   273  	err = json.Unmarshal(fileContent, app)
   274  	return app, err
   275  }
   276  
   277  func readApplicationFromFiles(cmdOption *DryRunCmdOptions, buff *bytes.Buffer) (*corev1beta1.Application, error) {
   278  	var app *corev1beta1.Application
   279  	var policies []*v1alpha1.Policy
   280  	var wf *wfv1alpha1.Workflow
   281  	policyNameMap := make(map[string]struct{})
   282  
   283  	for _, filename := range cmdOption.ApplicationFiles {
   284  		fileContent, err := utils.ReadRemoteOrLocalPath(filename, true)
   285  		if err != nil {
   286  			return nil, err
   287  		}
   288  
   289  		fileType := filepath.Ext(filename)
   290  		switch fileType {
   291  		case YAMLExtension, YMLExtension:
   292  			// only support one object in one yaml file
   293  			fileContent, err = yaml.YAMLToJSON(fileContent)
   294  			if err != nil {
   295  				return nil, err
   296  			}
   297  			decode := scheme.Codecs.UniversalDeserializer().Decode
   298  			// cannot guarantee get the object, but gkv is enough
   299  			_, gkv, _ := decode(fileContent, nil, nil)
   300  
   301  			jsonFileContent, err := yaml.YAMLToJSON(fileContent)
   302  			if err != nil {
   303  				return nil, err
   304  			}
   305  
   306  			switch *gkv {
   307  			case corev1beta1.ApplicationKindVersionKind:
   308  				if app != nil {
   309  					return nil, errors.New("more than one applications provided")
   310  				}
   311  				app = new(corev1beta1.Application)
   312  				err = json.Unmarshal(jsonFileContent, app)
   313  				if err != nil {
   314  					return nil, err
   315  				}
   316  			case v1alpha1.PolicyGroupVersionKind:
   317  				policy := new(v1alpha1.Policy)
   318  				err = json.Unmarshal(jsonFileContent, policy)
   319  				if err != nil {
   320  					return nil, err
   321  				}
   322  				policies = append(policies, policy)
   323  			case v1alpha1.WorkflowGroupVersionKind:
   324  				if wf != nil {
   325  					return nil, errors.New("more than one external workflow provided")
   326  				}
   327  				wf = new(wfv1alpha1.Workflow)
   328  				err = json.Unmarshal(jsonFileContent, wf)
   329  				if err != nil {
   330  					return nil, err
   331  				}
   332  			default:
   333  				return nil, fmt.Errorf("file %s is not application, policy or workflow", filename)
   334  			}
   335  		}
   336  	}
   337  
   338  	// only allow one application
   339  	if app == nil {
   340  		return nil, errors.New("no application provided")
   341  	}
   342  
   343  	// workflow not referenced by application
   344  	if !cmdOption.MergeStandaloneFiles {
   345  		if wf != nil &&
   346  			((app.Spec.Workflow != nil && app.Spec.Workflow.Ref != wf.Name) || app.Spec.Workflow == nil) {
   347  			fmt.Fprintf(buff, "WARNING: workflow %s not referenced by application\n\n", wf.Name)
   348  		}
   349  	} else {
   350  		if wf != nil {
   351  			app.Spec.Workflow = &corev1beta1.Workflow{
   352  				Ref:   "",
   353  				Steps: wf.Steps,
   354  			}
   355  		}
   356  		err := getPolicyNameFromWorkflow(wf, policyNameMap)
   357  		if err != nil {
   358  			return nil, err
   359  		}
   360  	}
   361  
   362  	for _, policy := range policies {
   363  		// check standalone policies
   364  		if _, exist := policyNameMap[policy.Name]; !exist && !cmdOption.MergeStandaloneFiles {
   365  			fmt.Fprintf(buff, "WARNING: policy %s not referenced by application\n\n", policy.Name)
   366  			continue
   367  		}
   368  		app.Spec.Policies = append(app.Spec.Policies, corev1beta1.AppPolicy{
   369  			Name:       policy.Name,
   370  			Type:       policy.Type,
   371  			Properties: policy.Properties,
   372  		})
   373  	}
   374  	return app, nil
   375  }
   376  
   377  func getPolicyNameFromWorkflow(wf *wfv1alpha1.Workflow, policyNameMap map[string]struct{}) error {
   378  
   379  	checkPolicy := func(wfsb wfv1alpha1.WorkflowStepBase, policyNameMap map[string]struct{}) error {
   380  		workflowStepSpec := &step.DeployWorkflowStepSpec{}
   381  		if err := utils.StrictUnmarshal(wfsb.Properties.Raw, workflowStepSpec); err != nil {
   382  			return err
   383  		}
   384  		for _, p := range workflowStepSpec.Policies {
   385  			policyNameMap[p] = struct{}{}
   386  		}
   387  		return nil
   388  	}
   389  
   390  	if wf == nil {
   391  		return nil
   392  	}
   393  
   394  	for _, wfs := range wf.Steps {
   395  		if wfs.Type == step.DeployWorkflowStep {
   396  			err := checkPolicy(wfs.WorkflowStepBase, policyNameMap)
   397  			if err != nil {
   398  				return err
   399  			}
   400  			for _, sub := range wfs.SubSteps {
   401  				if sub.Type == step.DeployWorkflowStep {
   402  					err = checkPolicy(sub, policyNameMap)
   403  					if err != nil {
   404  						return err
   405  					}
   406  				}
   407  			}
   408  
   409  		}
   410  	}
   411  	return nil
   412  }
   413  
   414  // includeBuiltinWorkflowStepDefinition adds builtin workflow step definition to the given objects
   415  // A few builtin workflow steps have cue definition. They should be included when building offline fake client.
   416  func includeBuiltinWorkflowStepDefinition(objs []*unstructured.Unstructured) []*unstructured.Unstructured {
   417  	deployUnstructured, _ := oamutil.Object2Unstructured(deployDefinition)
   418  	return append(objs, deployUnstructured)
   419  }
   420  
   421  // deployDefinition is the definition of deploy step
   422  // Copied it here to make dry-run work in offline mode.
   423  var deployDefinition = &corev1beta1.WorkflowStepDefinition{
   424  	TypeMeta: metav1.TypeMeta{
   425  		Kind:       corev1beta1.WorkflowStepDefinitionKind,
   426  		APIVersion: corev1beta1.SchemeGroupVersion.String(),
   427  	},
   428  	ObjectMeta: metav1.ObjectMeta{
   429  		Name:      "deploy",
   430  		Namespace: oam.SystemDefinitionNamespace,
   431  	},
   432  	Spec: corev1beta1.WorkflowStepDefinitionSpec{
   433  		Schematic: &apicommon.Schematic{
   434  			CUE: &apicommon.CUE{Template: `
   435  import (
   436  	"vela/op"
   437  )
   438  
   439  "deploy": {
   440  	type: "workflow-step"
   441  	annotations: {
   442  		"category": "Application Delivery"
   443  	}
   444  	labels: {
   445  		"scope": "Application"
   446  	}
   447  	description: "A powerful and unified deploy step for components multi-cluster delivery with policies."
   448  }
   449  // Ignore the template field for it's useless in dry-run.
   450  template: {}`,
   451  			},
   452  		},
   453  	},
   454  }