Feat: enhance addon init (#4370)

Signed-off-by: Charlie Chiang <charlie_c_0129@outlook.com>
This commit is contained in:
Charlie Chiang
2022-07-18 10:40:42 +08:00
committed by GitHub
parent 557f7197b5
commit ace23f1c6f
9 changed files with 689 additions and 586 deletions
+1 -1
View File
@@ -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"
-378
View File
@@ -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 <addon-nam>.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" +
""
)
-171
View File
@@ -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")
}
+523
View File
@@ -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 <addon-name-helm>.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 <addon-name-helm>.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
`
)
+109
View File
@@ -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)
}
+1 -1
View File
@@ -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"`
}
+10 -2
View File
@@ -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"))
+42 -30
View File
@@ -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 ./<addon-name>)")
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 ./<addon-name>)")
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
}
+3 -3
View File
@@ -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")