github.com/oam-dev/kubevela@v1.9.11/pkg/addon/init.go (about)

     1  /*
     2  Copyright 2022 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 addon
    18  
    19  import (
    20  	"fmt"
    21  	"os"
    22  	"path"
    23  	"path/filepath"
    24  	"regexp"
    25  	"strings"
    26  
    27  	"cuelang.org/go/cue"
    28  	"cuelang.org/go/cue/cuecontext"
    29  	"cuelang.org/go/cue/format"
    30  	"cuelang.org/go/encoding/gocode/gocodec"
    31  	"github.com/fatih/color"
    32  	"k8s.io/klog/v2"
    33  	"sigs.k8s.io/yaml"
    34  
    35  	"github.com/oam-dev/kubevela/pkg/utils"
    36  )
    37  
    38  const (
    39  	// AddonNameRegex is the regex to validate addon names
    40  	AddonNameRegex = `^[a-z\d]+(-[a-z\d]+)*$`
    41  	// helmComponentDependency is the dependent addon of Helm Component
    42  	helmComponentDependency = "fluxcd"
    43  )
    44  
    45  // InitCmd contains the options to initialize an addon scaffold
    46  type InitCmd struct {
    47  	AddonName        string
    48  	NoSamples        bool
    49  	HelmRepoURL      string
    50  	HelmChartName    string
    51  	HelmChartVersion string
    52  	Path             string
    53  	Overwrite        bool
    54  	RefObjURLs       []string
    55  	// We use string instead of v1beta1.Application is because
    56  	// the cue formatter is having some problems: it will keep
    57  	// TypeMeta (instead of inlined).
    58  	AppTmpl     string
    59  	Metadata    Meta
    60  	Readme      string
    61  	Resources   []ElementFile
    62  	Schemas     []ElementFile
    63  	Views       []ElementFile
    64  	Definitions []ElementFile
    65  }
    66  
    67  // CreateScaffold creates an addon scaffold
    68  func (cmd *InitCmd) CreateScaffold() error {
    69  	var err error
    70  
    71  	if len(cmd.AddonName) == 0 || len(cmd.Path) == 0 {
    72  		return fmt.Errorf("addon name and path should not be empty")
    73  	}
    74  
    75  	err = CheckAddonName(cmd.AddonName)
    76  	if err != nil {
    77  		return err
    78  	}
    79  
    80  	err = cmd.createDirs()
    81  	if err != nil {
    82  		return fmt.Errorf("cannot create addon structure: %w", err)
    83  	}
    84  	// Delete created files if an error occurred afterwards.
    85  	defer func() {
    86  		if err != nil {
    87  			_ = os.RemoveAll(cmd.Path)
    88  		}
    89  	}()
    90  
    91  	cmd.createRequiredFiles()
    92  
    93  	if cmd.HelmChartName != "" && cmd.HelmChartVersion != "" && cmd.HelmRepoURL != "" {
    94  		klog.Info("Creating Helm component...")
    95  		err = cmd.createHelmComponent()
    96  		if err != nil {
    97  			return err
    98  		}
    99  	}
   100  
   101  	if len(cmd.RefObjURLs) > 0 {
   102  		klog.Info("Creating ref-objects URL component...")
   103  		err = cmd.createURLComponent()
   104  		if err != nil {
   105  			return err
   106  		}
   107  	}
   108  
   109  	if !cmd.NoSamples {
   110  		cmd.createSamples()
   111  	}
   112  
   113  	err = cmd.writeFiles()
   114  	if err != nil {
   115  		return err
   116  	}
   117  
   118  	// Print some instructions to get started.
   119  	fmt.Println("\nScaffold created in directory " +
   120  		color.New(color.Bold).Sprint(cmd.Path) + ". What to do next:\n" +
   121  		"- Check out our guide on how to build your own addon: " +
   122  		color.New(color.Bold, color.FgBlue).Sprint("https://kubevela.io/docs/platform-engineers/addon/intro") + "\n" +
   123  		"- Review and edit what we have generated in " + color.New(color.Bold).Sprint(cmd.Path) + "\n" +
   124  		"- To enable this addon, run: " +
   125  		color.New(color.FgGreen).Sprint("vela") + color.GreenString(" addon enable ") + color.New(color.Bold, color.FgGreen).Sprint(cmd.Path))
   126  
   127  	return nil
   128  }
   129  
   130  // CheckAddonName checks if an addon name is valid
   131  func CheckAddonName(addonName string) error {
   132  	if len(addonName) == 0 {
   133  		return fmt.Errorf("addon name should not be empty")
   134  	}
   135  
   136  	// Make sure addonName only contains lowercase letters, dashes, and numbers, e.g. some-addon
   137  	re := regexp.MustCompile(AddonNameRegex)
   138  	if !re.MatchString(addonName) {
   139  		return fmt.Errorf("addon name should only cocntain lowercase letters, dashes, and numbers, e.g. some-addon")
   140  	}
   141  
   142  	return nil
   143  }
   144  
   145  // createSamples creates sample files
   146  func (cmd *InitCmd) createSamples() {
   147  	// Sample Definition mytrait.cue
   148  	cmd.Definitions = append(cmd.Definitions, ElementFile{
   149  		Data: traitTemplate,
   150  		Name: "mytrait.cue",
   151  	})
   152  	// Sample Resource
   153  	cmd.Resources = append(cmd.Resources, ElementFile{
   154  		Data: resourceTemplate,
   155  		Name: "myresource.cue",
   156  	})
   157  	// Sample schema
   158  	cmd.Schemas = append(cmd.Schemas, ElementFile{
   159  		Data: schemaTemplate,
   160  		Name: "myschema.yaml",
   161  	})
   162  	// Sample View
   163  	cmd.Views = append(cmd.Views, ElementFile{
   164  		Data: strings.ReplaceAll(viewTemplate, "ADDON_NAME", cmd.AddonName),
   165  		Name: "my-view.cue",
   166  	})
   167  }
   168  
   169  // createRequiredFiles creates README.md, template.yaml and metadata.yaml
   170  func (cmd *InitCmd) createRequiredFiles() {
   171  	// README.md
   172  	cmd.Readme = strings.ReplaceAll(readmeTemplate, "ADDON_NAME", cmd.AddonName)
   173  
   174  	// template.cue
   175  	cmd.AppTmpl = appTemplate
   176  
   177  	// metadata.yaml
   178  	cmd.Metadata = Meta{
   179  		Name:         cmd.AddonName,
   180  		Version:      "1.0.0",
   181  		Description:  "An addon for KubeVela.",
   182  		Tags:         []string{"my-tag"},
   183  		Dependencies: []*Dependency{},
   184  		DeployTo:     nil,
   185  	}
   186  }
   187  
   188  // createHelmComponent creates a <addon-name-helm>.cue in /resources
   189  func (cmd *InitCmd) createHelmComponent() error {
   190  	// Make fluxcd a dependency, since it uses a helm component
   191  	cmd.Metadata.addDependency(helmComponentDependency)
   192  	// Make addon version same as chart version
   193  	cmd.Metadata.Version = cmd.HelmChartVersion
   194  
   195  	// Create a <addon-name-helm>.cue in resources
   196  	tmpl := helmComponentTmpl{}
   197  	tmpl.Type = "helm"
   198  	tmpl.Properties.RepoType = "helm"
   199  	if strings.HasPrefix(cmd.HelmRepoURL, "oci") {
   200  		tmpl.Properties.RepoType = "oci"
   201  	}
   202  	tmpl.Properties.URL = cmd.HelmRepoURL
   203  	tmpl.Properties.Chart = cmd.HelmChartName
   204  	tmpl.Properties.Version = cmd.HelmChartVersion
   205  	tmpl.Name = "addon-" + cmd.AddonName
   206  
   207  	str, err := toCUEResourceString(tmpl)
   208  	if err != nil {
   209  		return err
   210  	}
   211  
   212  	cmd.Resources = append(cmd.Resources, ElementFile{
   213  		Name: "helm.cue",
   214  		Data: str,
   215  	})
   216  
   217  	return nil
   218  }
   219  
   220  // createURLComponent creates a ref-object component containing URLs
   221  func (cmd *InitCmd) createURLComponent() error {
   222  	tmpl := refObjURLTmpl{Type: "ref-objects"}
   223  
   224  	for _, url := range cmd.RefObjURLs {
   225  		if !utils.IsValidURL(url) {
   226  			return fmt.Errorf("%s is not a valid url", url)
   227  		}
   228  
   229  		tmpl.Properties.URLs = append(tmpl.Properties.URLs, url)
   230  	}
   231  
   232  	str, err := toCUEResourceString(tmpl)
   233  	if err != nil {
   234  		return err
   235  	}
   236  
   237  	cmd.Resources = append(cmd.Resources, ElementFile{
   238  		Data: str,
   239  		Name: "from-url.cue",
   240  	})
   241  
   242  	return nil
   243  }
   244  
   245  // toCUEResourceString formats object to CUE string used in addons
   246  // nolint:staticcheck
   247  func toCUEResourceString(obj interface{}) (string, error) {
   248  	v, err := gocodec.New((*cue.Runtime)(cuecontext.New()), nil).Decode(obj)
   249  	if err != nil {
   250  		return "", err
   251  	}
   252  
   253  	bs, err := format.Node(v.Syntax())
   254  	if err != nil {
   255  		return "", err
   256  	}
   257  
   258  	// Append "output: " to the beginning of the string, like "output: {}"
   259  	bs = append([]byte("output: "), bs...)
   260  
   261  	return string(bs), nil
   262  }
   263  
   264  // addDependency adds a dependency into metadata.yaml
   265  func (m *Meta) addDependency(dep string) {
   266  	for _, d := range m.Dependencies {
   267  		if d.Name == dep {
   268  			return
   269  		}
   270  	}
   271  
   272  	m.Dependencies = append(m.Dependencies, &Dependency{Name: dep})
   273  }
   274  
   275  // createDirs creates the directory structure for an addon
   276  func (cmd *InitCmd) createDirs() error {
   277  	// Make sure addonDir is pointing to an empty directory, or does not exist at all
   278  	// so that we can create it later
   279  	_, err := os.Stat(cmd.Path)
   280  	if !os.IsNotExist(err) {
   281  		emptyDir, err := utils.IsEmptyDir(cmd.Path)
   282  		if err != nil {
   283  			return fmt.Errorf("we can't create directory %s. Make sure the name has not already been taken and you have the proper rights to write to it", cmd.Path)
   284  		}
   285  
   286  		if !emptyDir {
   287  			if !cmd.Overwrite {
   288  				return fmt.Errorf("directory %s is not empty. To avoid any data loss, please manually delete it first or use -f, then try again", cmd.Path)
   289  			}
   290  			klog.Warningf("Overwriting non-empty directory %s", cmd.Path)
   291  		}
   292  
   293  		// Now we are sure addonPath is en empty dir, (or the user want to overwrite), delete it
   294  		err = os.RemoveAll(cmd.Path)
   295  		if err != nil {
   296  			return err
   297  		}
   298  	}
   299  
   300  	// nolint:gosec
   301  	err = os.MkdirAll(cmd.Path, 0755)
   302  	if err != nil {
   303  		return err
   304  	}
   305  
   306  	dirs := []string{
   307  		path.Join(cmd.Path, ResourcesDirName),
   308  		path.Join(cmd.Path, DefinitionsDirName),
   309  		path.Join(cmd.Path, DefSchemaName),
   310  		path.Join(cmd.Path, ViewDirName),
   311  	}
   312  
   313  	for _, dir := range dirs {
   314  		// nolint:gosec
   315  		err = os.MkdirAll(dir, 0755)
   316  		if err != nil {
   317  			return err
   318  		}
   319  	}
   320  
   321  	return nil
   322  }
   323  
   324  // writeFiles writes addon to disk
   325  func (cmd *InitCmd) writeFiles() error {
   326  	var files []ElementFile
   327  
   328  	files = append(files, ElementFile{
   329  		Name: ReadmeFileName,
   330  		Data: cmd.Readme,
   331  	}, ElementFile{
   332  		Data: parameterTemplate,
   333  		Name: GlobalParameterFileName,
   334  	})
   335  
   336  	for _, v := range cmd.Resources {
   337  		files = append(files, ElementFile{
   338  			Data: v.Data,
   339  			Name: filepath.Join(ResourcesDirName, v.Name),
   340  		})
   341  	}
   342  	for _, v := range cmd.Views {
   343  		files = append(files, ElementFile{
   344  			Data: v.Data,
   345  			Name: filepath.Join(ViewDirName, v.Name),
   346  		})
   347  	}
   348  	for _, v := range cmd.Definitions {
   349  		files = append(files, ElementFile{
   350  			Data: v.Data,
   351  			Name: filepath.Join(DefinitionsDirName, v.Name),
   352  		})
   353  	}
   354  	for _, v := range cmd.Schemas {
   355  		files = append(files, ElementFile{
   356  			Data: v.Data,
   357  			Name: filepath.Join(DefSchemaName, v.Name),
   358  		})
   359  	}
   360  
   361  	// Prepare template.cue
   362  	files = append(files, ElementFile{
   363  		Data: cmd.AppTmpl,
   364  		Name: AppTemplateCueFileName,
   365  	})
   366  
   367  	// Prepare metadata.yaml
   368  	metaBytes, err := yaml.Marshal(cmd.Metadata)
   369  	if err != nil {
   370  		return err
   371  	}
   372  	files = append(files, ElementFile{
   373  		Data: string(metaBytes),
   374  		Name: MetadataFileName,
   375  	})
   376  
   377  	// Write files
   378  	for _, f := range files {
   379  		err := os.WriteFile(filepath.Join(cmd.Path, f.Name), []byte(f.Data), 0600)
   380  		if err != nil {
   381  			return err
   382  		}
   383  	}
   384  
   385  	return nil
   386  }
   387  
   388  // helmComponentTmpl is a template for a helm component .cue in an addon
   389  type helmComponentTmpl struct {
   390  	Name       string `json:"name"`
   391  	Type       string `json:"type"`
   392  	Properties struct {
   393  		RepoType string `json:"repoType"`
   394  		URL      string `json:"url"`
   395  		Chart    string `json:"chart"`
   396  		Version  string `json:"version"`
   397  	} `json:"properties"`
   398  }
   399  
   400  // refObjURLTmpl is a template for ref-objects containing URLs in an addon
   401  type refObjURLTmpl struct {
   402  	Type       string `json:"type"`
   403  	Properties struct {
   404  		URLs []string `json:"urls"`
   405  	} `json:"properties"`
   406  }
   407  
   408  const (
   409  	readmeTemplate = "# ADDON_NAME\n" +
   410  		"\n" +
   411  		"This is an addon template. Check how to build your own addon: https://kubevela.net/docs/platform-engineers/addon/intro\n" +
   412  		""
   413  	viewTemplate = `// We put VelaQL views in views directory.
   414  //
   415  // VelaQL(Vela Query Language) is a resource query language for KubeVela, 
   416  // used to query status of any extended resources in application-level.
   417  // Reference: https://kubevela.net/docs/platform-engineers/system-operation/velaql
   418  //
   419  // This VelaQL View queries the status of this addon.
   420  // Use this view to query by:
   421  //     vela ql --query 'my-view{addonName:ADDON_NAME}.status'
   422  // You should see 'running'.
   423  
   424  import (
   425  	"vela/ql"
   426  )
   427  
   428  app: ql.#Read & {
   429  	value: {
   430  		kind:       "Application"
   431  		apiVersion: "core.oam.dev/v1beta1"
   432  		metadata: {
   433  			name:      "addon-" + parameter.addonName
   434  			namespace: "vela-system"
   435  		}
   436  	}
   437  }
   438  
   439  parameter: {
   440  	addonName: *"ADDON_NAME" | string
   441  }
   442  
   443  status: app.value.status.status
   444  `
   445  	traitTemplate = `// We put Definitions in definitions directory.
   446  // References:
   447  // - https://kubevela.net/docs/platform-engineers/cue/definition-edit
   448  // - https://kubevela.net/docs/platform-engineers/addon/intro#definitions-directoryoptional
   449  "mytrait": {
   450  	alias: "mt"
   451  	annotations: {}
   452  	attributes: {
   453  		appliesToWorkloads: [
   454  			"deployments.apps",
   455  			"replicasets.apps",
   456  			"statefulsets.apps",
   457  		]
   458  		conflictsWith: []
   459  		podDisruptive:   false
   460  		workloadRefPath: ""
   461  	}
   462  	description: "My trait description."
   463  	labels: {}
   464  	type: "trait"
   465  }
   466  template: {
   467  	parameter: {param: ""}
   468  	outputs: {sample: {}}
   469  }
   470  `
   471  	resourceTemplate = `// We put Components in resources directory.
   472  // References:
   473  // - https://kubevela.net/docs/end-user/components/references
   474  // - https://kubevela.net/docs/platform-engineers/addon/intro#resources-directoryoptional
   475  output: {
   476  	type: "k8s-objects"
   477  	properties: {
   478  		objects: [
   479  			{
   480  				// This creates a plain old Kubernetes namespace
   481  				apiVersion: "v1"
   482  				kind:       "Namespace"
   483  				// We can use the parameter defined in parameter.cue like this.
   484  				metadata: name: parameter.myparam
   485  			},
   486  		]
   487  	}
   488  }
   489  `
   490  	parameterTemplate = `// parameter.cue is used to store addon parameters.
   491  //
   492  // You can use these parameters in template.cue or in resources/ by 'parameter.myparam'
   493  //
   494  // For example, you can use parameters to allow the user to customize
   495  // container images, ports, and etc.
   496  parameter: {
   497  	// +usage=Custom parameter description
   498  	myparam: *"myns" | string
   499  }
   500  `
   501  	schemaTemplate = `# We put UI Schemas that correspond to Definitions in schemas directory.
   502  # References:
   503  # - https://kubevela.net/docs/platform-engineers/addon/intro#schemas-directoryoptional
   504  # - https://kubevela.net/docs/reference/ui-schema
   505  - jsonKey: myparam
   506    label: MyParam
   507    validate:
   508      required: true
   509  `
   510  	appTemplate = `package main
   511  output: {
   512  	apiVersion: "core.oam.dev/v1beta1"
   513  	kind:       "Application"
   514  	spec: {
   515  		components: []
   516  		policies: []
   517  	}
   518  }
   519  `
   520  )