From ace23f1c6f63795bdc2d5742cf6081e35c2bb5aa Mon Sep 17 00:00:00 2001 From: Charlie Chiang Date: Mon, 18 Jul 2022 10:40:42 +0800 Subject: [PATCH] Feat: enhance addon init (#4370) Signed-off-by: Charlie Chiang --- pkg/addon/addon.go | 2 +- pkg/addon/create.go | 378 ------------------------- pkg/addon/create_test.go | 171 ------------ pkg/addon/init.go | 523 +++++++++++++++++++++++++++++++++++ pkg/addon/init_test.go | 109 ++++++++ pkg/addon/type.go | 2 +- pkg/addon/utils_test.go | 12 +- references/cli/addon.go | 72 +++-- references/cli/addon_test.go | 6 +- 9 files changed, 689 insertions(+), 586 deletions(-) delete mode 100644 pkg/addon/create.go delete mode 100644 pkg/addon/create_test.go create mode 100644 pkg/addon/init.go create mode 100644 pkg/addon/init_test.go diff --git a/pkg/addon/addon.go b/pkg/addon/addon.go index 3b1569079..723f18aeb 100644 --- a/pkg/addon/addon.go +++ b/pkg/addon/addon.go @@ -80,7 +80,7 @@ import ( const ( // ReadmeFileName is the addon readme file name - ReadmeFileName string = "readme.md" + ReadmeFileName string = "README.md" // MetadataFileName is the addon meatadata.yaml file name MetadataFileName string = "metadata.yaml" diff --git a/pkg/addon/create.go b/pkg/addon/create.go deleted file mode 100644 index 7504e67ce..000000000 --- a/pkg/addon/create.go +++ /dev/null @@ -1,378 +0,0 @@ -/* -Copyright 2022 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 addon - -import ( - "fmt" - "os" - "path" - "regexp" - "strings" - - "github.com/fatih/color" - - "cuelang.org/go/cue" - "cuelang.org/go/cue/format" - "cuelang.org/go/encoding/gocode/gocodec" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" - - "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1" - "github.com/oam-dev/kubevela/apis/types" - "github.com/oam-dev/kubevela/pkg/utils" -) - -// CreateAddonFromHelmChart creates an addon scaffold from a Helm Chart, with a Helm component inside -func CreateAddonFromHelmChart(addonName, addonPath, helmRepoURL, chartName, chartVersion string) error { - if len(addonName) == 0 || len(helmRepoURL) == 0 || len(chartName) == 0 || len(chartVersion) == 0 { - return fmt.Errorf("addon addonPath, helm URL, chart name, and chart verion should not be empty") - } - - // Currently, we do not check whether the Helm Chart actually exists, because it is just a scaffold. - // The user can still edit it after creation. - // Also, if the user is offline, we cannot check whether the Helm Chart exists. - // TODO(charlie0129): check whether the Helm Chart exists (if the user wants) - - // Make sure url is valid - isValidURL := utils.IsValidURL(helmRepoURL) - if !isValidURL { - return fmt.Errorf("invalid helm repo url %s", helmRepoURL) - } - - err := preAddonCreation(addonName, addonPath) - if err != nil { - return err - } - - // Create files like template.yaml, README.md, and etc. - err = createFilesFromHelmChart(addonName, addonPath, helmRepoURL, chartName, chartVersion) - if err != nil { - return fmt.Errorf("cannot create addon files: %w", err) - } - - postAddonCreation(addonPath) - - return nil -} - -// CreateAddonSample creates an empty addon scaffold, with some required files -func CreateAddonSample(addonName, addonPath string) error { - if len(addonName) == 0 || len(addonPath) == 0 { - return fmt.Errorf("addon name and addon path should not be empty") - } - - err := preAddonCreation(addonName, addonPath) - if err != nil { - return err - } - - err = createSampleFiles(addonName, addonPath) - if err != nil { - return err - } - - postAddonCreation(addonPath) - - return nil -} - -// preAddonCreation is executed before creating an addon scaffold -// It makes sure that user-provided info is valid. -func preAddonCreation(addonName, addonPath string) error { - if len(addonName) == 0 || len(addonPath) == 0 { - return fmt.Errorf("addon name and addonPath should not be empty") - } - - // Make sure addon name is valid - err := CheckAddonName(addonName) - if err != nil { - return err - } - - // Create dirs - err = createAddonDirs(addonPath) - if err != nil { - return fmt.Errorf("cannot create addon structure: %w", err) - } - - return nil -} - -// postAddonCreation is after before creating an addon scaffold -// It prints some instructions to get started. -func postAddonCreation(addonPath string) { - fmt.Println("Scaffold created in directory " + - color.New(color.Bold).Sprint(addonPath) + ". What to do next:\n" + - "- Check out our guide on how to build your own addon: " + - color.BlueString("https://kubevela.io/docs/platform-engineers/addon/intro") + "\n" + - "- Review and edit what we have generated in " + color.New(color.Bold).Sprint(addonPath) + "\n" + - "- To enable the addon, run: " + - color.New(color.FgGreen).Sprint("vela") + color.GreenString(" addon enable ") + color.New(color.Bold, color.FgGreen).Sprint(addonPath)) -} - -// CheckAddonName checks if an addon name is valid -func CheckAddonName(addonName string) error { - if len(addonName) == 0 { - return fmt.Errorf("addon name should not be empty") - } - - // Make sure addonName only contains lowercase letters, dashes, and numbers, e.g. some-addon - re := regexp.MustCompile(`^[a-z\d]+(-[a-z\d]+)*$`) - if !re.MatchString(addonName) { - return fmt.Errorf("addon name should only cocntain lowercase letters, dashes, and numbers, e.g. some-addon") - } - - return nil -} - -// createFilesFromHelmChart creates the file structure for a Helm Chart addon, -// including template.yaml, readme.md, metadata.yaml, and .cue. -func createFilesFromHelmChart(addonName, addonPath, helmRepoURL, chartName, chartVersion string) error { - // Generate template.yaml with an empty Application - applicationTemplate := v1beta1.Application{ - TypeMeta: v1.TypeMeta{ - APIVersion: v1beta1.SchemeGroupVersion.String(), - Kind: "Application", - }, - ObjectMeta: v1.ObjectMeta{ - Name: addonName, - Namespace: types.DefaultKubeVelaNS, - }, - } - - applicationTemplateBytes, err := yaml.Marshal(applicationTemplate) - if err != nil { - return err - } - - // Generate metadata.yaml with `fluxcd` as a dependency because we are using helm. - // However, this may change in the future, possibly with `argocd`. - metadataTemplate := Meta{ - Name: addonName, - Version: chartVersion, - Description: "An addon for KubeVela.", - Tags: []string{chartVersion}, - Dependencies: []*Dependency{{Name: "fluxcd"}}, - } - metadataTemplateBytes, err := yaml.Marshal(metadataTemplate) - if err != nil { - return err - } - - // Write template.yaml, readme.md, and metadata.yaml - err = writeRequiredFiles(addonPath, - applicationTemplateBytes, - []byte(strings.ReplaceAll(readmeTemplate, "ADDON_NAME", addonName)), - metadataTemplateBytes) - if err != nil { - return err - } - - // Write addonName.cue, containing the helm chart - addonResourcePath := path.Join(addonPath, ResourcesDirName, addonName+".cue") - resourceTmpl := HelmCUETemplate{} - resourceTmpl.Output.Type = "helm" - resourceTmpl.Output.Properties.RepoType = "helm" - resourceTmpl.Output.Properties.URL = helmRepoURL - resourceTmpl.Output.Properties.Chart = chartName - resourceTmpl.Output.Properties.Version = chartVersion - err = writeHelmCUETemplate(resourceTmpl, addonResourcePath) - if err != nil { - return err - } - - return nil -} - -// createSampleFiles creates the file structure for an empty addon -func createSampleFiles(addonName, addonPath string) error { - // Generate metadata.yaml - metadataTemplate := Meta{ - Name: addonName, - Version: "1.0.0", - Description: "An addon for KubeVela.", - Tags: []string{}, - Dependencies: []*Dependency{}, - } - metadataTemplateBytes, err := yaml.Marshal(metadataTemplate) - if err != nil { - return err - } - - // Generate template.yaml - applicationTemplate := v1beta1.Application{ - TypeMeta: v1.TypeMeta{ - APIVersion: v1beta1.SchemeGroupVersion.String(), - Kind: "Application", - }, - ObjectMeta: v1.ObjectMeta{ - Name: addonName, - Namespace: types.DefaultKubeVelaNS, - }, - } - applicationTemplateBytes, err := yaml.Marshal(applicationTemplate) - if err != nil { - return err - } - - err = writeRequiredFiles(addonPath, - applicationTemplateBytes, - []byte(strings.ReplaceAll(readmeTemplate, "ADDON_NAME", addonName)), - metadataTemplateBytes) - if err != nil { - return err - } - - return nil -} - -// writeRequiredFiles creates required files for an addon, -// including template.yaml, readme.md, and metadata.yaml -func writeRequiredFiles(addonPath string, tmplContent, readmeContent, metadataContent []byte) error { - // Write template.yaml - templateFilePath := path.Join(addonPath, TemplateFileName) - err := os.WriteFile(templateFilePath, - tmplContent, - 0644) - if err != nil { - return fmt.Errorf("cannot write %s: %w", templateFilePath, err) - } - - // Write README.md - readmeFilePath := path.Join(addonPath, ReadmeFileName) - err = os.WriteFile(readmeFilePath, - readmeContent, - 0644) - if err != nil { - return fmt.Errorf("cannot write %s: %w", readmeFilePath, err) - } - - // Write metadata.yaml - metadataFilePath := path.Join(addonPath, MetadataFileName) - err = os.WriteFile(metadataFilePath, - metadataContent, - 0644) - if err != nil { - return fmt.Errorf("cannot write %s: %w", metadataFilePath, err) - } - - return nil -} - -// createAddonDirs creates the directory structure for an addon -func createAddonDirs(addonDir string) error { - // Make sure addonDir is pointing to an empty directory, or does not exist at all - // so that we can create it later - _, err := os.Stat(addonDir) - if !os.IsNotExist(err) { - emptyDir, err := utils.IsEmptyDir(addonDir) - if err != nil { - 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", addonDir) - } - - if !emptyDir { - return fmt.Errorf("directory %s is not empty. To avoid any data loss, please manually delete it first, then try again", addonDir) - } - - // Now we are sure addonPath is en empty dir, delete it - err = os.Remove(addonDir) - if err != nil { - return err - } - } - - // nolint:gosec - err = os.MkdirAll(addonDir, 0755) - if err != nil { - return err - } - - dirs := []string{ - path.Join(addonDir, ResourcesDirName), - path.Join(addonDir, DefinitionsDirName), - path.Join(addonDir, DefSchemaName), - } - - for _, dir := range dirs { - // nolint:gosec - err = os.MkdirAll(dir, 0755) - if err != nil { - return err - } - } - - return nil -} - -// writeHelmCUETemplate writes a cue, with a helm component inside, intended as addon resource -func writeHelmCUETemplate(tmpl HelmCUETemplate, filePath string) error { - r := cue.Runtime{} - v, err := gocodec.New(&r, nil).Decode(tmpl) - if err != nil { - return err - } - - // Use `output` value - v = v.Lookup("output") - // Format output - bs, err := format.Node(v.Syntax()) - if err != nil { - return err - } - - // Append "output: " to the beginning of the string, like "output: {}" - bs = append([]byte("output: "), bs...) - err = os.WriteFile(filePath, bs, 0644) - if err != nil { - return fmt.Errorf("cannot write %s: %w", filePath, err) - } - - return nil -} - -// HelmCUETemplate is a template for a helm component .cue in an addon -type HelmCUETemplate struct { - Output struct { - Type string `json:"type"` - Properties struct { - RepoType string `json:"repoType"` - URL string `json:"url"` - Chart string `json:"chart"` - Version string `json:"version"` - } `json:"properties"` - } `json:"output"` -} - -const ( - readmeTemplate = "# ADDON_NAME\n" + - "\n" + - "This is an addon template. Check how to build your own addon: https://kubevela.net/docs/platform-engineers/addon/intro\n" + - "\n" + - "## Directory Structure\n" + - "\n" + - "- `template.yaml`: contains the basic app, you can add some component and workflow to meet your requirements. Other files in `resources/` and `definitions/` will be rendered as Components and appended in `spec.components`\n" + - "- `metadata.yaml`: contains addon metadata information.\n" + - "- `definitions/`: contains the X-Definition yaml/cue files. These file will be rendered as KubeVela Component in `template.yaml`\n" + - "- `resources/`:\n" + - " - `parameter.cue` to expose parameters. It will be converted to JSON schema and rendered in UI forms.\n" + - " - All other files will be rendered as KubeVela Components. It can be one of the two types:\n" + - " - YAML file that contains only one resource. This will be rendered as a `raw` component\n" + - " - CUE template file that can read user input as `parameter.XXX` as defined `parameter.cue`.\n" + - " Basically the CUE template file will be combined with `parameter.cue` to render a resource.\n" + - " **You can specify the type and trait in this format**\n" + - "" -) diff --git a/pkg/addon/create_test.go b/pkg/addon/create_test.go deleted file mode 100644 index 92216312a..000000000 --- a/pkg/addon/create_test.go +++ /dev/null @@ -1,171 +0,0 @@ -/* -Copyright 2022 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 addon - -import ( - "os" - "path" - "testing" - - "gotest.tools/assert" -) - -func TestCheckAddonName(t *testing.T) { - var err error - - err = CheckAddonName("") - assert.ErrorContains(t, err, "should not be empty") - - invalidNames := []string{ - "-addon", - "addon-", - "Caps", - "=", - ".", - } - for _, name := range invalidNames { - err = CheckAddonName(name) - assert.ErrorContains(t, err, "should only") - } - - validNames := []string{ - "addon-name", - "3-addon-name", - "addon-name-3", - "addon", - } - for _, name := range validNames { - err = CheckAddonName(name) - assert.NilError(t, err) - } -} - -func TestWriteHelmCUETemplate(t *testing.T) { - resourceTmpl := HelmCUETemplate{} - resourceTmpl.Output.Type = "helm" - resourceTmpl.Output.Properties.RepoType = "helm" - resourceTmpl.Output.Properties.URL = "https://charts.bitnami.com/bitnami" - resourceTmpl.Output.Properties.Chart = "bitnami/nginx" - resourceTmpl.Output.Properties.Version = "12.0.4" - err := writeHelmCUETemplate(resourceTmpl, "test.cue") - assert.NilError(t, err) - defer func() { - _ = os.Remove("test.cue") - }() - data, err := os.ReadFile("test.cue") - assert.NilError(t, err) - expected := `output: { - type: "helm" - properties: { - url: "https://charts.bitnami.com/bitnami" - repoType: "helm" - chart: "bitnami/nginx" - version: "12.0.4" - } -}` - assert.Equal(t, string(data), expected) -} - -func TestCreateAddonFromHelmChart(t *testing.T) { - err := CreateAddonFromHelmChart("", "", "", "bitnami/nginx", "12.0.4") - assert.ErrorContains(t, err, "should not be empty") - - checkFiles := func(base string) { - fileList := []string{ - "definitions", - path.Join("resources", base+".cue"), - "schemas", - MetadataFileName, - ReadmeFileName, - TemplateFileName, - } - for _, file := range fileList { - _, err = os.Stat(path.Join(base, file)) - assert.NilError(t, err) - } - } - - // Empty dir already exists - _ = os.MkdirAll("test-addon", 0755) - err = CreateAddonFromHelmChart("test-addon", "./test-addon", "https://charts.bitnami.com/bitnami", "bitnami/nginx", "12.0.4") - checkFiles("test-addon") - defer func() { - _ = os.RemoveAll("test-addon") - }() - - // Non-empty dir already exists - err = CreateAddonFromHelmChart("test-addon", "test-addon", "https://charts.bitnami.com/bitnami", "bitnami/nginx", "12.0.4") - assert.ErrorContains(t, err, "not empty") - - // Name already taken - err = os.WriteFile("already-taken", []byte{}, 0644) - assert.NilError(t, err) - defer func() { - _ = os.Remove("already-taken") - }() - err = CreateAddonFromHelmChart("already-taken", "already-taken", "https://charts.bitnami.com/bitnami", "bitnami/nginx", "12.0.4") - assert.ErrorContains(t, err, "can't create") - - // Invalid addon name - err = CreateAddonFromHelmChart("/", "./a", "https://charts.bitnami.com/bitnami", "bitnami/nginx", "12.0.4") - assert.ErrorContains(t, err, "should only") - - // Invalid URL - err = CreateAddonFromHelmChart("invalid-url", "invalid-url", "invalid-url", "bitnami/nginx", "12.0.4") - assert.ErrorContains(t, err, "invalid helm repo url") -} - -func TestCreateAddonSample(t *testing.T) { - checkFiles := func(base string) { - fileList := []string{ - "definitions", - "resources", - "schemas", - MetadataFileName, - ReadmeFileName, - TemplateFileName, - } - for _, file := range fileList { - _, err := os.Stat(path.Join(base, file)) - assert.NilError(t, err) - } - } - - // Normal creation - err := CreateAddonSample("test-addon", "test-addon") - assert.NilError(t, err) - checkFiles("test-addon") - - // Non-empty dir already exists - err = CreateAddonSample("test-addon", "test-addon") - assert.ErrorContains(t, err, "directory") - - defer func() { - _ = os.RemoveAll("test-addon") - }() - - err = CreateAddonSample("", "") - assert.ErrorContains(t, err, "empty") -} - -func TestPreAddonCreation(t *testing.T) { - err := preAddonCreation("", "") - assert.ErrorContains(t, err, "empty") - - err = preAddonCreation("=", "a") - assert.ErrorContains(t, err, "name") -} diff --git a/pkg/addon/init.go b/pkg/addon/init.go new file mode 100644 index 000000000..ae4047230 --- /dev/null +++ b/pkg/addon/init.go @@ -0,0 +1,523 @@ +/* +Copyright 2022 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 addon + +import ( + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/format" + "cuelang.org/go/encoding/gocode/gocodec" + "github.com/fatih/color" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + "sigs.k8s.io/yaml" + + "github.com/oam-dev/kubevela/apis/core.oam.dev/common" + "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1" + "github.com/oam-dev/kubevela/apis/types" + "github.com/oam-dev/kubevela/pkg/utils" +) + +const ( + // AddonNameRegex is the regex to validate addon names + AddonNameRegex = `^[a-z\d]+(-[a-z\d]+)*$` + helmComponentDependency = "fluxcd" +) + +// InitCmd contains the options to initialize an addon scaffold +type InitCmd struct { + AddonName string + NoSamples bool + HelmRepoURL string + HelmChartName string + HelmChartVersion string + Path string + Overwrite bool + RefObjURLs []string + AppTmpl v1beta1.Application + Metadata Meta + Readme string + Resources []ElementFile + Schemas []ElementFile + Views []ElementFile + Definitions []ElementFile +} + +// CreateScaffold creates an addon scaffold +func (cmd *InitCmd) CreateScaffold() error { + var err error + + if len(cmd.AddonName) == 0 || len(cmd.Path) == 0 { + return fmt.Errorf("addon name and path should not be empty") + } + + err = CheckAddonName(cmd.AddonName) + if err != nil { + return err + } + + err = cmd.createDirs() + if err != nil { + return fmt.Errorf("cannot create addon structure: %w", err) + } + // Delete created files if an error occurred afterwards. + defer func() { + if err != nil { + _ = os.RemoveAll(cmd.Path) + } + }() + + cmd.createRequiredFiles() + + if cmd.HelmChartName != "" && cmd.HelmChartVersion != "" && cmd.HelmRepoURL != "" { + klog.Info("Creating Helm component...") + err = cmd.createHelmComponent() + if err != nil { + return err + } + } + + if len(cmd.RefObjURLs) > 0 { + klog.Info("Creating ref-objects URL component...") + err = cmd.createURLComponent() + if err != nil { + return err + } + } + + if !cmd.NoSamples { + cmd.createSamples() + } + + err = cmd.writeFiles() + if err != nil { + return err + } + + // Print some instructions to get started. + fmt.Println("\nScaffold created in directory " + + color.New(color.Bold).Sprint(cmd.Path) + ". What to do next:\n" + + "- Check out our guide on how to build your own addon: " + + color.New(color.Bold, color.FgBlue).Sprint("https://kubevela.io/docs/platform-engineers/addon/intro") + "\n" + + "- Review and edit what we have generated in " + color.New(color.Bold).Sprint(cmd.Path) + "\n" + + "- To enable this addon, run: " + + color.New(color.FgGreen).Sprint("vela") + color.GreenString(" addon enable ") + color.New(color.Bold, color.FgGreen).Sprint(cmd.Path)) + + return nil +} + +// CheckAddonName checks if an addon name is valid +func CheckAddonName(addonName string) error { + if len(addonName) == 0 { + return fmt.Errorf("addon name should not be empty") + } + + // Make sure addonName only contains lowercase letters, dashes, and numbers, e.g. some-addon + re := regexp.MustCompile(AddonNameRegex) + if !re.MatchString(addonName) { + return fmt.Errorf("addon name should only cocntain lowercase letters, dashes, and numbers, e.g. some-addon") + } + + return nil +} + +// createSamples creates sample files +func (cmd *InitCmd) createSamples() { + // Sample Definition mytrait.cue + cmd.Definitions = append(cmd.Definitions, ElementFile{ + Data: traitTemplate, + Name: "mytrait.cue", + }) + // Sample Resource + cmd.Resources = append(cmd.Resources, ElementFile{ + Data: resourceTemplate, + Name: "myresource.cue", + }, ElementFile{ + Data: parameterTemplate, + Name: "parameter.cue", + }) + // Sample schema + cmd.Schemas = append(cmd.Schemas, ElementFile{ + Data: schemaTemplate, + Name: "myschema.yaml", + }) + // Sample View + cmd.Views = append(cmd.Views, ElementFile{ + Data: strings.ReplaceAll(viewTemplate, "ADDON_NAME", cmd.AddonName), + Name: "my-view.cue", + }) +} + +// createRequiredFiles creates README.md, template.yaml and metadata.yaml +func (cmd *InitCmd) createRequiredFiles() { + // README.md + cmd.Readme = strings.ReplaceAll(readmeTemplate, "ADDON_NAME", cmd.AddonName) + + // template.yaml + cmd.AppTmpl = v1beta1.Application{ + TypeMeta: v1.TypeMeta{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Kind: "Application", + }, + ObjectMeta: v1.ObjectMeta{ + Name: cmd.AddonName, + Namespace: types.DefaultKubeVelaNS, + }, + Spec: v1beta1.ApplicationSpec{ + // Prevent nulls after serialization + Components: []common.ApplicationComponent{}, + }, + } + + // metadata.yaml + cmd.Metadata = Meta{ + Name: cmd.AddonName, + Version: "1.0.0", + Description: "An addon for KubeVela.", + Tags: []string{"my-tag"}, + Dependencies: []*Dependency{}, + DeployTo: &DeployTo{ + RuntimeCluster: false, + }, + } +} + +// createHelmComponent creates a .cue in /resources +func (cmd *InitCmd) createHelmComponent() error { + // Make fluxcd a dependency, since it uses a helm component + cmd.Metadata.addDependency(helmComponentDependency) + // Make addon version same as chart version + cmd.Metadata.Version = cmd.HelmChartVersion + + // Create a .cue in resources + tmpl := helmComponentTmpl{} + tmpl.Type = "helm" + tmpl.Properties.RepoType = "helm" + tmpl.Properties.URL = cmd.HelmRepoURL + tmpl.Properties.Chart = cmd.HelmChartName + tmpl.Properties.Version = cmd.HelmChartVersion + + str, err := toCUEResourceString(tmpl) + if err != nil { + return err + } + + cmd.Resources = append(cmd.Resources, ElementFile{ + Name: "helm.cue", + Data: str, + }) + + return nil +} + +// createURLComponent creates a ref-object component containing URLs +func (cmd *InitCmd) createURLComponent() error { + tmpl := refObjURLTmpl{Type: "ref-objects"} + + for _, url := range cmd.RefObjURLs { + if !utils.IsValidURL(url) { + return fmt.Errorf("%s is not a valid url", url) + } + + tmpl.Properties.URLs = append(tmpl.Properties.URLs, url) + } + + str, err := toCUEResourceString(tmpl) + if err != nil { + return err + } + + cmd.Resources = append(cmd.Resources, ElementFile{ + Data: str, + Name: "from-url.cue", + }) + + return nil +} + +// toCUEResourceString formats object to CUE string used in addons +func toCUEResourceString(obj interface{}) (string, error) { + r := cue.Runtime{} + v, err := gocodec.New(&r, nil).Decode(obj) + if err != nil { + return "", err + } + + bs, err := format.Node(v.Syntax()) + if err != nil { + return "", err + } + + // Append "output: " to the beginning of the string, like "output: {}" + bs = append([]byte("output: "), bs...) + + return string(bs), nil +} + +// addDependency adds a dependency into metadata.yaml +func (m *Meta) addDependency(dep string) { + for _, d := range m.Dependencies { + if d.Name == dep { + return + } + } + + m.Dependencies = append(m.Dependencies, &Dependency{Name: dep}) +} + +// createDirs creates the directory structure for an addon +func (cmd *InitCmd) createDirs() error { + // Make sure addonDir is pointing to an empty directory, or does not exist at all + // so that we can create it later + _, err := os.Stat(cmd.Path) + if !os.IsNotExist(err) { + emptyDir, err := utils.IsEmptyDir(cmd.Path) + if err != nil { + 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) + } + + if !emptyDir { + if !cmd.Overwrite { + 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) + } + klog.Warningf("Overwriting non-empty directory %s", cmd.Path) + } + + // Now we are sure addonPath is en empty dir, (or the user want to overwrite), delete it + err = os.RemoveAll(cmd.Path) + if err != nil { + return err + } + } + + // nolint:gosec + err = os.MkdirAll(cmd.Path, 0755) + if err != nil { + return err + } + + dirs := []string{ + path.Join(cmd.Path, ResourcesDirName), + path.Join(cmd.Path, DefinitionsDirName), + path.Join(cmd.Path, DefSchemaName), + path.Join(cmd.Path, ViewDirName), + } + + for _, dir := range dirs { + // nolint:gosec + err = os.MkdirAll(dir, 0755) + if err != nil { + return err + } + } + + return nil +} + +// writeFiles writes addon to disk +func (cmd *InitCmd) writeFiles() error { + var files []ElementFile + + files = append(files, ElementFile{ + Name: ReadmeFileName, + Data: cmd.Readme, + }) + + for _, v := range cmd.Resources { + files = append(files, ElementFile{ + Data: v.Data, + Name: filepath.Join(ResourcesDirName, v.Name), + }) + } + for _, v := range cmd.Views { + files = append(files, ElementFile{ + Data: v.Data, + Name: filepath.Join(ViewDirName, v.Name), + }) + } + for _, v := range cmd.Definitions { + files = append(files, ElementFile{ + Data: v.Data, + Name: filepath.Join(DefinitionsDirName, v.Name), + }) + } + for _, v := range cmd.Schemas { + files = append(files, ElementFile{ + Data: v.Data, + Name: filepath.Join(DefSchemaName, v.Name), + }) + } + + // Prepare template.yaml + tmplBytes, err := yaml.Marshal(cmd.AppTmpl) + if err != nil { + return err + } + files = append(files, ElementFile{ + Data: string(tmplBytes), + Name: TemplateFileName, + }) + + // Prepare metadata.yaml + metaBytes, err := yaml.Marshal(cmd.Metadata) + if err != nil { + return err + } + files = append(files, ElementFile{ + Data: string(metaBytes), + Name: MetadataFileName, + }) + + // Write files + for _, f := range files { + err := os.WriteFile(filepath.Join(cmd.Path, f.Name), []byte(f.Data), 0644) + if err != nil { + return err + } + } + + return nil +} + +// helmComponentTmpl is a template for a helm component .cue in an addon +type helmComponentTmpl struct { + Type string `json:"type"` + Properties struct { + RepoType string `json:"repoType"` + URL string `json:"url"` + Chart string `json:"chart"` + Version string `json:"version"` + } `json:"properties"` +} + +// refObjURLTmpl is a template for ref-objects containing URLs in an addon +type refObjURLTmpl struct { + Type string `json:"type"` + Properties struct { + URLs []string `json:"urls"` + } `json:"properties"` +} + +const ( + readmeTemplate = "# ADDON_NAME\n" + + "\n" + + "This is an addon template. Check how to build your own addon: https://kubevela.net/docs/platform-engineers/addon/intro\n" + + "" + viewTemplate = `// We put VelaQL views in views directory. +// +// VelaQL(Vela Query Language) is a resource query language for KubeVela, +// used to query status of any extended resources in application-level. +// Reference: https://kubevela.net/docs/platform-engineers/system-operation/velaql +// +// This VelaQL View querys the status of this addon. +// Use this view to query by: +// vela ql --query 'my-view{addonName:ADDON_NAME}.status' +// You should see 'running'. + +import ( + "vela/ql" +) + +app: ql.#Read & { + value: { + kind: "Application" + apiVersion: "core.oam.dev/v1beta1" + metadata: { + name: "addon-" + parameter.addonName + namespace: "vela-system" + } + } +} + +parameter: { + addonName: *"ADDON_NAME" | string +} + +status: app.value.status.status +` + traitTemplate = `// We put Definitions in definitions directory. +// References: +// - https://kubevela.net/docs/platform-engineers/cue/definition-edit +// - https://kubevela.net/docs/platform-engineers/addon/intro#definitions-directoryoptional +"mytrait": { + alias: "mt" + annotations: {} + attributes: { + appliesToWorkloads: [ + "deployments.apps", + "replicasets.apps", + "statefulsets.apps", + ] + conflictsWith: [] + podDisruptive: false + workloadRefPath: "" + } + description: "My trait description." + labels: {} + type: "trait" +} +template: { + parameter: {param: ""} + outputs: {sample: {}} +} +` + resourceTemplate = `// We put Components in resources directory. +// References: +// - https://kubevela.net/docs/end-user/components/references +// - https://kubevela.net/docs/platform-engineers/addon/intro#resources-directoryoptional +output: { + type: "k8s-objects" + properties: { + objects: [ + { + // This creates a plain old Kubernetes namespace + apiVersion: "v1" + kind: "Namespace" + // We can use the parameter defined in parameter.cue like this. + metadata: name: parameter.myparam + }, + ] + } +} +` + parameterTemplate = `// parameter.cue is used to store addon parameters. +// +// You can use these parameters in other resources by 'parameter.myparam' +// +// For example, you can use parameters to allow the user to customize +// container images, ports, and etc. +parameter: { + // +usage=Custom parameter description + myparam: *"myns" | string +} +` + schemaTemplate = `# We put UI Schemas that correspond to Definitions in schemas directory. +# References: +# - https://kubevela.net/docs/platform-engineers/addon/intro#schemas-directoryoptional +# - https://kubevela.net/docs/reference/ui-schema +- jsonKey: myparam + label: MyParam + validate: + required: true +` +) diff --git a/pkg/addon/init_test.go b/pkg/addon/init_test.go new file mode 100644 index 000000000..a0b39be02 --- /dev/null +++ b/pkg/addon/init_test.go @@ -0,0 +1,109 @@ +/* +Copyright 2022 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 addon + +import ( + "os" + "path/filepath" + "testing" + + "gotest.tools/assert" +) + +func TestCheckAddonName(t *testing.T) { + var err error + + err = CheckAddonName("") + assert.ErrorContains(t, err, "should not be empty") + + invalidNames := []string{ + "-addon", + "addon-", + "Caps", + "=", + ".", + } + for _, name := range invalidNames { + err = CheckAddonName(name) + assert.ErrorContains(t, err, "should only") + } + + validNames := []string{ + "addon-name", + "3-addon-name", + "addon-name-3", + "addon", + } + for _, name := range validNames { + err = CheckAddonName(name) + assert.NilError(t, err) + } +} + +func TestInitCmd_CreateScaffold(t *testing.T) { + var err error + + // empty addon name or path + cmd := InitCmd{} + err = cmd.CreateScaffold() + assert.ErrorContains(t, err, "be empty") + + // invalid addon name + cmd = InitCmd{ + AddonName: "-name", + Path: "name", + } + err = cmd.CreateScaffold() + assert.ErrorContains(t, err, "should only") + + // dir already exists + cmd = InitCmd{ + AddonName: "name", + Path: "testdata", + } + err = cmd.CreateScaffold() + assert.ErrorContains(t, err, "cannot create") + + // with helm component + cmd = InitCmd{ + AddonName: "with-helm", + Path: "with-helm", + HelmRepoURL: "https://charts.bitnami.com/bitnami", + HelmChartVersion: "12.0.0", + HelmChartName: "nginx", + } + err = cmd.CreateScaffold() + assert.NilError(t, err) + defer os.RemoveAll("with-helm") + _, err = os.Stat(filepath.Join("with-helm", ResourcesDirName, "helm.cue")) + assert.NilError(t, err) + + // with ref-obj + cmd = InitCmd{ + AddonName: "with-refobj", + Path: "with-refobj", + RefObjURLs: []string{"https:"}, + } + err = cmd.CreateScaffold() + assert.ErrorContains(t, err, "not a valid url") + cmd.RefObjURLs[0] = "https://some.com" + err = cmd.CreateScaffold() + assert.NilError(t, err) + defer os.RemoveAll("with-refobj") + _, err = os.Stat(filepath.Join("with-refobj", ResourcesDirName, "from-url.cue")) + assert.NilError(t, err) +} diff --git a/pkg/addon/type.go b/pkg/addon/type.go index a809d45e9..150695442 100644 --- a/pkg/addon/type.go +++ b/pkg/addon/type.go @@ -92,7 +92,7 @@ type Meta struct { // DeployTo defines where the addon to deploy to type DeployTo struct { // This field keep the compatible for older case - LegacyRuntimeCluster bool `json:"runtime_cluster"` + LegacyRuntimeCluster bool `json:"runtime_cluster,omitempty"` DisableControlPlane bool `json:"disableControlPlane"` RuntimeCluster bool `json:"runtimeCluster"` } diff --git a/pkg/addon/utils_test.go b/pkg/addon/utils_test.go index 0cdfebe2d..76930682d 100644 --- a/pkg/addon/utils_test.go +++ b/pkg/addon/utils_test.go @@ -234,7 +234,11 @@ func TestIsAddonDir(t *testing.T) { assert.Contains(t, err.Error(), "missing") // Pass all checks - err = CreateAddonSample("testaddon2", filepath.Join("testdata", "testaddon2")) + cmd := InitCmd{ + Path: filepath.Join("testdata", "testaddon2"), + AddonName: "testaddon2", + } + err = cmd.CreateScaffold() assert.NoError(t, err) defer func() { _ = os.RemoveAll(filepath.Join("testdata", "testaddon2")) @@ -252,7 +256,11 @@ func TestMakeChart(t *testing.T) { assert.Contains(t, err.Error(), "not an addon dir") // Valid addon dir - err = CreateAddonSample("testaddon2", filepath.Join("testdata", "testaddon")) + cmd := InitCmd{ + Path: filepath.Join("testdata", "testaddon"), + AddonName: "testaddon", + } + err = cmd.CreateScaffold() assert.NoError(t, err) defer func() { _ = os.RemoveAll(filepath.Join("testdata", "testaddon")) diff --git a/references/cli/addon.go b/references/cli/addon.go index 06a59fa4b..25b812464 100644 --- a/references/cli/addon.go +++ b/references/cli/addon.go @@ -96,7 +96,7 @@ func NewAddonCommand(c common.Args, order string, ioStreams cmdutil.IOStreams) * NewAddonRegistryCommand(c, ioStreams), NewAddonUpgradeCommand(c, ioStreams), NewAddonPackageCommand(c), - NewAddonCreateCommand(), + NewAddonInitCommand(), NewAddonPushCommand(c), ) return cmd @@ -376,33 +376,35 @@ func NewAddonStatusCommand(c common.Args, ioStream cmdutil.IOStreams) *cobra.Com return cmd } -// NewAddonCreateCommand creates an addon scaffold -func NewAddonCreateCommand() *cobra.Command { - var helmRepoURL string - var chartName string - var chartVersion string - var path string +// NewAddonInitCommand creates an addon scaffold +func NewAddonInitCommand() *cobra.Command { + var ( + helmRepoURL string + chartName string + chartVersion string + urls []string + path string + noSample bool + overwrite bool + ) cmd := &cobra.Command{ Use: "init", Short: "create an addon scaffold", - Long: "Create an addon scaffold for quick starting. A Helm Component is generated if you provide Chart-related parameters.", - Example: ` vela addon init mongodb --helm-repo-url=https://marketplace.azurecr.io/helm/v1/repo --chart=mongodb --version=12.1.16 -will create something like this: - mongodb/ - ├── definitions - ├── metadata.yaml - ├── readme.md - ├── resources - │ └── mongodb.cue - ├── schemas - └── template.yaml + Long: "Create an addon scaffold for quick starting.", + Example: ` Store the scaffold in a different directory: + vela addon init mongodb -p path/to/addon -If you want to store the scaffold in a different directory, you can use the -p/--path flag: - vela addon init mongodb -p ./some/repo --helm-repo-url=https://marketplace.azurecr.io/helm/v1/repo --chart=mongodb --version=12.1.16 + Add a Helm component: + vela addon init mongodb --helm-repo https://marketplace.azurecr.io/helm/v1/repo --chart mongodb --chart-version 12.1.16 -If you don't want the Helm component, just omit the three Chart-related parameters. We will create an empty scaffold for you. - vela addon init mongodb`, + Add resources from URL using ref-objects component + vela addon init my-addon --url https://domain.com/resource.yaml + + Use --no-samples options to skip creating sample files + vela addon init my-addon --no-sample + + You can combine all the options together.`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) < 1 { return fmt.Errorf("an addon name is required") @@ -421,19 +423,29 @@ If you don't want the Helm component, just omit the three Chart-related paramete return fmt.Errorf("addon name or path should not be empty") } - // If the user specified all Chart-related info, use the addon template with a Chart in it. - if helmRepoURL != "" && chartName != "" && chartVersion != "" { - return pkgaddon.CreateAddonFromHelmChart(args[0], addonPath, helmRepoURL, chartName, chartVersion) + initCmd := pkgaddon.InitCmd{ + AddonName: addonName, + HelmChartName: chartName, + HelmChartVersion: chartVersion, + HelmRepoURL: helmRepoURL, + Path: addonPath, + RefObjURLs: urls, + NoSamples: noSample, + Overwrite: overwrite, } - return pkgaddon.CreateAddonSample(addonName, addonPath) + return initCmd.CreateScaffold() }, } - cmd.Flags().StringVar(&helmRepoURL, "helm-repo-url", "", "URL that points to a Helm repo") - cmd.Flags().StringVar(&chartName, "chart", "", "Helm Chart name") - cmd.Flags().StringVar(&chartVersion, "version", "", "version of the Chart") - cmd.Flags().StringVarP(&path, "path", "p", "", "path to the addon directory (default is ./)") + f := cmd.Flags() + f.StringVar(&helmRepoURL, "helm-repo", "", "URL that points to a Helm repo") + f.StringVar(&chartName, "chart", "", "Helm Chart name") + f.StringVar(&chartVersion, "chart-version", "", "version of the Chart") + f.StringVarP(&path, "path", "p", "", "path to the addon directory (default is ./)") + f.StringArrayVarP(&urls, "url", "u", []string{}, "add URL resources using ref-object component") + f.BoolVarP(&noSample, "no-samples", "", false, "do not generate sample files") + f.BoolVarP(&overwrite, "force", "f", false, "overwrite existing addon files") return cmd } diff --git a/references/cli/addon_test.go b/references/cli/addon_test.go index dfdb133f6..f37bee2de 100644 --- a/references/cli/addon_test.go +++ b/references/cli/addon_test.go @@ -421,16 +421,16 @@ func TestGenerateParameterString(t *testing.T) { } func TestNewAddonCreateCommand(t *testing.T) { - cmd := NewAddonCreateCommand() + cmd := NewAddonInitCommand() cmd.SetArgs([]string{}) err := cmd.Execute() assert.ErrorContains(t, err, "required") - cmd.SetArgs([]string{"--chart", "a", "--helm-repo-url", "https://some.com", "--version", "c"}) + cmd.SetArgs([]string{"--chart", "a", "--helm-repo", "https://some.com", "--chart-version", "c"}) err = cmd.Execute() assert.ErrorContains(t, err, "required") - cmd.SetArgs([]string{"test-addon", "--chart", "a", "--helm-repo-url", "https://some.com", "--version", "c"}) + cmd.SetArgs([]string{"test-addon", "--chart", "a", "--helm-repo", "https://some.com", "--chart-version", "c"}) err = cmd.Execute() assert.NilError(t, err) _ = os.RemoveAll("test-addon")