From f1ed8854dc3fe92804cf1979354b8e4a0bed44d9 Mon Sep 17 00:00:00 2001 From: Hukumraj Singh Deora <54686422+LeelaChacha@users.noreply.github.com> Date: Mon, 15 Jan 2024 14:19:05 +0100 Subject: [PATCH] feat: Add Scaffold Command (#1859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add Scaffold Command * feat: Add Docs * feat: Add Docs * docs(scaffold.go): Fix Long Description * trigger build * docs(scaffold.go): Update Md Files * Update cmd/kyma/alpha/create/scaffold/scaffold.go Co-authored-by: Małgorzata Świeca * Update cmd/kyma/alpha/create/scaffold/scaffold.go Co-authored-by: Małgorzata Świeca * Update cmd/kyma/alpha/create/scaffold/scaffold.go Co-authored-by: Małgorzata Świeca * Update docs/gen-docs/kyma_alpha_create.md Co-authored-by: Małgorzata Świeca * Update cmd/kyma/alpha/create/scaffold/scaffold.go Co-authored-by: Małgorzata Świeca * docs(scaffold.go): Update Md Files * Merge Latest Changes * Fix E2E Test * scaffold.go: Fix Manifest and Default CR Generation * default_cr.go: Fix Lint * default_cr.go: Fix Metadata Case Sensitivity Issue * scaffold.go: Fix Empty Directory Issue * test-e2e-create-scaffold.yml: Add Newline At The End * opts.go: Fix Overwrite Validation * scaffold.go: Update Command Help * Update Docs * retrigger jobs * Refactoring * Refactoring * Refactoring * Refactoring * Refactoring * Refactoring * Refactoring * Refactoring * Refactoring * Add tests (WIP) * Add tests (WIP) * Add tests (WIP) * Add tests (WIP) * Add tests (WIP) * Add tests (WIP) * Add tests (WIP) * Review fix * Apply suggestions from code review Co-authored-by: Oleksandr Meteiko * Review fix * Review fix * Review fix * Review fix * Review fix * Review fix * Review fix * Review fix --------- Co-authored-by: Małgorzata Świeca Co-authored-by: Benjamin Lindner Co-authored-by: Tomasz Smelcerz Co-authored-by: Oleksandr Meteiko --- .../workflows/test-e2e-create-scaffold.yml | 38 ++ cmd/kyma/alpha/create/create.go | 2 + cmd/kyma/alpha/create/module/moduleconfig.go | 28 +- cmd/kyma/alpha/create/scaffold/opts.go | 105 +++++ cmd/kyma/alpha/create/scaffold/scaffold.go | 265 ++++++++++++ docs/gen-docs/kyma_alpha_create.md | 1 + docs/gen-docs/kyma_alpha_create_scaffold.md | 96 +++++ pkg/module/scaffold/default_cr.go | 29 ++ pkg/module/scaffold/manifest.go | 29 ++ pkg/module/scaffold/module_config.go | 30 ++ pkg/module/scaffold/scaffold.go | 40 ++ pkg/module/scaffold/security_config.go | 26 ++ pkg/module/scaffold/utils.go | 81 ++++ pkg/module/scaffold/utils_test.go | 248 +++++++++++ pkg/module/security_scan.go | 16 +- tests/e2e/Makefile | 6 +- .../create_scaffold/scaffold_suite_test.go | 13 + tests/e2e/create_scaffold/scaffold_test.go | 390 ++++++++++++++++++ 18 files changed, 1420 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/test-e2e-create-scaffold.yml create mode 100644 cmd/kyma/alpha/create/scaffold/opts.go create mode 100644 cmd/kyma/alpha/create/scaffold/scaffold.go create mode 100644 docs/gen-docs/kyma_alpha_create_scaffold.md create mode 100644 pkg/module/scaffold/default_cr.go create mode 100644 pkg/module/scaffold/manifest.go create mode 100644 pkg/module/scaffold/module_config.go create mode 100644 pkg/module/scaffold/scaffold.go create mode 100644 pkg/module/scaffold/security_config.go create mode 100644 pkg/module/scaffold/utils.go create mode 100644 pkg/module/scaffold/utils_test.go create mode 100644 tests/e2e/create_scaffold/scaffold_suite_test.go create mode 100644 tests/e2e/create_scaffold/scaffold_test.go diff --git a/.github/workflows/test-e2e-create-scaffold.yml b/.github/workflows/test-e2e-create-scaffold.yml new file mode 100644 index 000000000..156655dcc --- /dev/null +++ b/.github/workflows/test-e2e-create-scaffold.yml @@ -0,0 +1,38 @@ +name: TestSuite E2E. Scaffold Creation + +on: + push: + branches: + - main + - 'release-**' + pull_request: + branches: + - main + - 'release-**' + paths: + - 'go.mod' + - 'go.sum' + - '**.go' +jobs: + e2e: + name: "Run E2E tests" + runs-on: ubuntu-latest + steps: + - name: Checkout Kyma CLI + uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: 'go.mod' + cache-dependency-path: 'go.sum' + - name: Build Kyma CLI + run: | + make resolve validate build-linux + chmod +x ./bin/kyma-linux + ls -la ./bin + mv ./bin/kyma-linux /usr/local/bin/kyma + timeout-minutes: 10 + - name: Run create scaffold test + run: | + make -C tests/e2e test-create-scaffold + timeout-minutes: 3 diff --git a/cmd/kyma/alpha/create/create.go b/cmd/kyma/alpha/create/create.go index b7f81ce02..81c6f58ea 100755 --- a/cmd/kyma/alpha/create/create.go +++ b/cmd/kyma/alpha/create/create.go @@ -2,6 +2,7 @@ package create import ( "github.com/kyma-project/cli/cmd/kyma/alpha/create/module" + "github.com/kyma-project/cli/cmd/kyma/alpha/create/scaffold" "github.com/kyma-project/cli/internal/cli" "github.com/spf13/cobra" ) @@ -16,6 +17,7 @@ func NewCmd(o *cli.Options) *cobra.Command { } cmd.AddCommand(module.NewCmd(module.NewOptions(o))) + cmd.AddCommand(scaffold.NewCmd(scaffold.NewOptions(o))) return cmd } diff --git a/cmd/kyma/alpha/create/module/moduleconfig.go b/cmd/kyma/alpha/create/module/moduleconfig.go index cc1bd807e..deb3dbe3d 100644 --- a/cmd/kyma/alpha/create/module/moduleconfig.go +++ b/cmd/kyma/alpha/create/module/moduleconfig.go @@ -15,20 +15,20 @@ import ( ) type Config struct { - Name string `yaml:"name"` // required, the name of the Module - Version string `yaml:"version"` // required, the version of the Module - Channel string `yaml:"channel"` // required, channel that should be used in the ModuleTemplate - ManifestPath string `yaml:"manifest"` // required, reference to the manifests, must be a relative file name. - Mandatory bool `yaml:"mandatory"` // optional, default=false, indicates whether the module is mandatory to be installed on all clusters. - DefaultCRPath string `yaml:"defaultCR"` // optional, reference to a YAML file containing the default CR for the module, must be a relative file name. - ResourceName string `yaml:"resourceName"` // optional, default={NAME}-{CHANNEL}, the name for the ModuleTemplate that will be created - Namespace string `yaml:"namespace"` // optional, default=kcp-system, the namespace where the ModuleTemplate will be deployed - Security string `yaml:"security"` // optional, name of the security scanners config file - Internal bool `yaml:"internal"` // optional, default=false, determines whether the ModuleTemplate should have the internal flag or not - Beta bool `yaml:"beta"` // optional, default=false, determines whether the ModuleTemplate should have the beta flag or not - Labels map[string]string `yaml:"labels"` // optional, additional labels for the ModuleTemplate - Annotations map[string]string `yaml:"annotations"` // optional, additional annotations for the ModuleTemplate - CustomStateChecks []v1beta2.CustomStateCheck `yaml:"customStateCheck"` // optional, specifies custom state check for module + Name string `yaml:"name" comment:"required, the name of the Module"` + Version string `yaml:"version" comment:"required, the version of the Module"` + Channel string `yaml:"channel" comment:"required, channel that should be used in the ModuleTemplate"` + ManifestPath string `yaml:"manifest" comment:"required, reference to the manifests, must be a relative file name"` + Mandatory bool `yaml:"mandatory" comment:"optional, default=false, indicates whether the module is mandatory to be installed on all clusters"` + DefaultCRPath string `yaml:"defaultCR" comment:"optional, reference to a YAML file containing the default CR for the module, must be a relative file name"` + ResourceName string `yaml:"resourceName" comment:"optional, default={NAME}-{CHANNEL}, the name for the ModuleTemplate that will be created"` + Namespace string `yaml:"namespace" comment:"optional, default=kcp-system, the namespace where the ModuleTemplate will be deployed"` + Security string `yaml:"security" comment:"optional, name of the security scanners config file"` + Internal bool `yaml:"internal" comment:"optional, default=false, determines whether the ModuleTemplate should have the internal flag or not"` + Beta bool `yaml:"beta" comment:"optional, default=false, determines whether the ModuleTemplate should have the beta flag or not"` + Labels map[string]string `yaml:"labels" comment:"optional, additional labels for the ModuleTemplate"` + Annotations map[string]string `yaml:"annotations" comment:"optional, additional annotations for the ModuleTemplate"` + CustomStateChecks []v1beta2.CustomStateCheck `yaml:"customStateCheck" comment:"optional, specifies custom state check for module"` } const ( diff --git a/cmd/kyma/alpha/create/scaffold/opts.go b/cmd/kyma/alpha/create/scaffold/opts.go new file mode 100644 index 000000000..eeb2269a2 --- /dev/null +++ b/cmd/kyma/alpha/create/scaffold/opts.go @@ -0,0 +1,105 @@ +package scaffold + +import ( + "fmt" + "os" + "path" + + "github.com/kyma-project/cli/internal/cli" + "github.com/pkg/errors" +) + +// Options specifies the flags for the scaffold command +type Options struct { + *cli.Options + + Overwrite bool + Directory string + + ModuleConfigFile string + ManifestFile string + SecurityConfigFile string + DefaultCRFile string + + ModuleName string + ModuleVersion string + ModuleChannel string +} + +func (o *Options) securityConfigFileConfigured() bool { + return o.SecurityConfigFile != "" +} + +func (o *Options) defaultCRFileConfigured() bool { + return o.DefaultCRFile != "" +} + +var ( + errDirNotExists = errors.New("provided directory does not exist") + errNotDirectory = errors.New("provided path is not a directory") + errModuleConfigExists = errors.New("module config file already exists. use --overwrite flag to overwrite it") + errModuleNameEmpty = errors.New("--module-name flag must not be empty") + errModuleVersionEmpty = errors.New("--module-version flag must not be empty") + errModuleChannelEmpty = errors.New("--module-channel flag must not be empty") + errManifestFileEmpty = errors.New("--gen-manifest flag must not be empty") + errModuleConfigEmpty = errors.New("--module-config flag must not be empty") + errManifestCreation = errors.New("could not generate manifest") + errDefaultCRCreationFailed = errors.New("could not generate default CR") + errModuleConfigCreationFailed = errors.New("could not generate module config") + errSecurityConfigCreationFailed = errors.New("could not generate security config") +) + +// NewOptions creates options with default values +func NewOptions(o *cli.Options) *Options { + return &Options{Options: o} +} + +func (o *Options) Validate() error { + if o.ModuleName == "" { + return errModuleNameEmpty + } + + if o.ModuleVersion == "" { + return errModuleVersionEmpty + } + + if o.ModuleChannel == "" { + return errModuleChannelEmpty + } + + err := o.validateDirectory() + if err != nil { + return err + } + + if o.ModuleConfigFile == "" { + return errModuleConfigEmpty + } + + if o.ManifestFile == "" { + return errManifestFileEmpty + } + + return nil +} + +func (o *Options) validateDirectory() error { + fi, err := os.Stat(o.Directory) + + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%w: %s", errDirNotExists, o.Directory) + } + return err + } + + if !fi.IsDir() { + return fmt.Errorf("%w: %s", errNotDirectory, o.Directory) + } + + return nil +} + +func (o *Options) getCompleteFilePath(fileName string) string { + return path.Join(o.Directory, fileName) +} diff --git a/cmd/kyma/alpha/create/scaffold/scaffold.go b/cmd/kyma/alpha/create/scaffold/scaffold.go new file mode 100644 index 000000000..4de44a9a0 --- /dev/null +++ b/cmd/kyma/alpha/create/scaffold/scaffold.go @@ -0,0 +1,265 @@ +package scaffold + +import ( + "context" + "fmt" + + "github.com/kyma-project/cli/internal/cli" + scaffgen "github.com/kyma-project/cli/pkg/module/scaffold" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +const ( + defaultCRFlagName = "gen-default-cr" + defaultCRFlagDefault = "default-cr.yaml" + manifestFileFlagName = "gen-manifest" + manifestFileFlagDefault = "manifest.yaml" + moduleConfigFileFlagName = "module-config" + moduleConfigFileFlagDefault = "scaffold-module-config.yaml" + securityConfigFlagName = "gen-security-config" + securityConfigFlagDefault = "sec-scanners-config.yaml" +) + +type command struct { + cli.Command + opts *Options +} + +func NewCmd(o *Options) *cobra.Command { + c := command{ + Command: cli.Command{Options: o.Options}, + opts: o, + } + + cmd := &cobra.Command{ + Use: "scaffold [--module-name MODULE_NAME --module-version MODULE_VERSION --module-channel CHANNEL] [--directory MODULE_DIRECTORY] [flags]", + Short: "Generates necessary files required for module creation", + Long: `Scaffold generates or configures the necessary files for creating a new module in Kyma. This includes setting up +a basic directory structure and creating default files based on the provided flags. + +The command is designed to streamline the module creation process in Kyma, making it easier and more +efficient for developers to get started with new modules. It supports customization through various flags, +allowing for a tailored scaffolding experience according to the specific needs of the module being created. + +The command generates or uses the following files: + - Module Config: + Enabled: Always + Adjustable with flag: --module-config=VALUE + Generated when: The file doesn't exist or the --overwrite=true flag is provided + Default file name: scaffold-module-config.yaml + - Manifest: + Enabled: Always + Adjustable with flag: --gen-manifest=VALUE + Generated when: The file doesn't exist. If the file exists, it's name is used in the generated module configuration file + Default file name: manifest.yaml + - Default CR(s): + Enabled: When the flag --gen-default-cr is provided with or without value + Adjustable with flag: --gen-default-cr[=VALUE], if provided without an explicit VALUE, the default value is used + Generated when: The file doesn't exist. If the file exists, it's name is used in the generated module configuration file + Default file name: default-cr.yaml + - Security Scanners Config: + Enabled: When the flag --gen-security-config is provided with or without value + Adjustable with flag: --gen-security-config[=VALUE], if provided without an explicit VALUE, the default value is used + Generated when: The file doesn't exist. If the file exists, it's name is used in the generated module configuration file + Default file name: sec-scanners-config.yaml + +**NOTE:**: To protect the user from accidental file overwrites, this command by default doesn't overwrite any files. +Only the module config file may be force-overwritten when the --overwrite=true flag is used. + +You can specify the required fields of the module config using the following CLI flags: +--module-name=NAME +--module-version=VERSION +--module-channel=CHANNEL + +**NOTE:**: If the required fields aren't provided, the defaults are applied and the module-config.yaml is not ready to be used. You must manually edit the file to make it usable. +Also, edit the sec-scanners-config.yaml to be able to use it. +`, + Example: `Generate a minimal scaffold for a module - only a blank manifest file and module config file is generated using defaults + kyma alpha create scaffold +Generate a scaffold providing required values explicitly + kyma alpha create scaffold --module-name="kyma-project.io/module/testmodule" --module-version="0.1.1" --module-channel=fast +Generate a scaffold with a manifest file, default CR and security-scanners config for a module + kyma alpha create scaffold --gen-default-cr --gen-security-config +Generate a scaffold with a manifest file, default CR and security-scanners config for a module, overriding default values + kyma alpha create scaffold --gen-manifest="my-manifest.yaml" --gen-default-cr="my-cr.yaml" --gen-security-config="my-seccfg.yaml" + +`, + RunE: func(cobraCmd *cobra.Command, args []string) error { return c.Run(cobraCmd.Context()) }, + } + cmd.Flags().StringVar( + &o.ModuleName, "module-name", "kyma-project.io/module/mymodule", + "Specifies the module name in the generated module config file", + ) + cmd.Flags().StringVar( + &o.ModuleVersion, "module-version", "0.0.1", + "Specifies the module version in the generated module config file", + ) + cmd.Flags().StringVar( + &o.ModuleChannel, "module-channel", "regular", + "Specifies the module channel in the generated module config file", + ) + cmd.Flags().StringVar( + &o.ModuleConfigFile, moduleConfigFileFlagName, moduleConfigFileFlagDefault, + "Specifies the name for the generated module configuration file", + ) + cmd.Flags().Lookup(moduleConfigFileFlagName).NoOptDefVal = moduleConfigFileFlagDefault + + cmd.Flags().StringVar( + &o.ManifestFile, manifestFileFlagName, manifestFileFlagDefault, + "Specifies the manifest in the generated module config. A blank manifest file is generated if it doesn't exist", + ) + cmd.Flags().Lookup(manifestFileFlagName).NoOptDefVal = manifestFileFlagDefault + + cmd.Flags().StringVar( + &o.DefaultCRFile, defaultCRFlagName, "", + "Specifies the defaultCR in the generated module config. A blank defaultCR file is generated if it doesn't exist", + ) + cmd.Flags().Lookup(defaultCRFlagName).NoOptDefVal = defaultCRFlagDefault + + cmd.Flags().StringVar( + &o.SecurityConfigFile, securityConfigFlagName, "", + "Specifies the security file in the generated module config. A scaffold security config file is generated if it doesn't exist", + ) + cmd.Flags().Lookup(securityConfigFlagName).NoOptDefVal = securityConfigFlagDefault + + cmd.Flags().BoolVarP( + &o.Overwrite, "overwrite", "o", false, + "Specifies if the command overwrites an existing module configuration file", + ) + cmd.Flags().StringVarP( + &o.Directory, "directory", "d", "./", + "Specifies the directory where the scaffolding shall be generated", + ) + + return cmd +} + +func (cmd *command) Run(_ context.Context) error { + + if cmd.opts.CI { + cmd.Factory.NonInteractive = true + } + if cmd.opts.Verbose { + cmd.Factory.UseLogger = true + } + + l := cli.NewLogger(cmd.opts.Verbose).Sugar() + undo := zap.RedirectStdLog(l.Desugar()) + defer undo() + + if !cmd.opts.NonInteractive { + cli.AlphaWarn() + } + + cmd.NewStep("Validating") + if err := cmd.opts.Validate(); err != nil { + cmd.CurrentStep.Failuref("%s", err.Error()) + return fmt.Errorf("%w", err) + } + + sgen := cmd.scaffoldGeneratorFromOptions() + + moduleConfigExists, err := sgen.ModuleConfigFileExists() + if err != nil { + cmd.CurrentStep.Failuref("%s", err.Error()) + return fmt.Errorf("%w", err) + } + if moduleConfigExists && !cmd.opts.Overwrite { + cmd.CurrentStep.Failuref("%s", errModuleConfigExists.Error()) + return fmt.Errorf("%w", errModuleConfigExists) + } + cmd.CurrentStep.Success() + + manifestFileExists, err := sgen.ManifestFileExists() + if err != nil { + return err + } + cmd.NewStep("Configuring manifest file...\n") + if manifestFileExists { + cmd.CurrentStep.Successf("The manifest file exists, reusing: %s", sgen.ManifestFilePath()) + } else { + cmd.CurrentStep.Status("Generating the manifest file") + err := sgen.GenerateManifest() + if err != nil { + cmd.CurrentStep.Failuref("%s: %s", errManifestCreation.Error(), err.Error()) + return fmt.Errorf("%w: %s", errManifestCreation, err.Error()) + } + + cmd.CurrentStep.Successf("Generated a blank manifest file: %s", sgen.ManifestFilePath()) + } + + if cmd.opts.defaultCRFileConfigured() { + defaultCRFileExists, err := sgen.DefaultCRFileExists() + if err != nil { + return err + } + cmd.NewStep("Configuring defaultCR file...\n") + if defaultCRFileExists { + cmd.CurrentStep.Successf("The defaultCR file exists, reusing: %s", sgen.DefaultCRFilePath()) + } else { + cmd.CurrentStep.Status("Generating the default CR file") + err := sgen.GenerateDefaultCRFile() + if err != nil { + cmd.CurrentStep.Failuref("%s: %s", errDefaultCRCreationFailed.Error(), err.Error()) + return fmt.Errorf("%w: %s", errDefaultCRCreationFailed, err.Error()) + } + + cmd.CurrentStep.Successf("Generated a blank defaultCR file: %s", sgen.DefaultCRFilePath()) + } + } + + if cmd.opts.securityConfigFileConfigured() { + secCfgFileExists, err := sgen.SecurityConfigFileExists() + if err != nil { + return err + } + cmd.NewStep("Configuring security-scanners config file...\n") + if secCfgFileExists { + cmd.CurrentStep.Successf("The security-scanners config file exists, reusing: %s", sgen.SecurityConfigFilePath()) + } else { + cmd.CurrentStep.Status("Generating security-scanners config file") + err := sgen.GenerateSecurityConfigFile() + if err != nil { + cmd.CurrentStep.Failuref("%s: %s", errSecurityConfigCreationFailed.Error(), err.Error()) + return fmt.Errorf("%w: %s", errSecurityConfigCreationFailed, err.Error()) + } + + cmd.CurrentStep.Successf("Generated security-scanners config file - %s", sgen.SecurityConfigFilePath()) + } + } + + cmd.NewStep("Generating module config file...\n") + + err = sgen.GenerateModuleConfigFile() + if err != nil { + cmd.CurrentStep.Failuref("%s: %s", errModuleConfigCreationFailed.Error(), err.Error()) + return fmt.Errorf("%w: %s", errModuleConfigCreationFailed, err.Error()) + } + + cmd.CurrentStep.Successf("Generated module config file: %s", sgen.ModuleConfigFilePath()) + + return nil +} + +func (cmd *command) scaffoldGeneratorFromOptions() *scaffgen.Generator { + + toFullPath := func(file string) string { + if file == "" { + return "" + } + return cmd.opts.getCompleteFilePath(file) + } + + res := scaffgen.Generator{ + ModuleName: cmd.opts.ModuleName, + ModuleVersion: cmd.opts.ModuleVersion, + ModuleChannel: cmd.opts.ModuleChannel, + ModuleConfigFile: toFullPath(cmd.opts.ModuleConfigFile), + ManifestFile: toFullPath(cmd.opts.ManifestFile), + SecurityConfigFile: toFullPath(cmd.opts.SecurityConfigFile), + DefaultCRFile: toFullPath(cmd.opts.DefaultCRFile), + } + + return &res +} diff --git a/docs/gen-docs/kyma_alpha_create.md b/docs/gen-docs/kyma_alpha_create.md index 0e18a1d7c..d5e630632 100644 --- a/docs/gen-docs/kyma_alpha_create.md +++ b/docs/gen-docs/kyma_alpha_create.md @@ -23,4 +23,5 @@ Use this command to create resources on the Kyma cluster. * [kyma alpha](kyma_alpha.md) - Experimental commands * [kyma alpha create module](kyma_alpha_create_module.md) - Creates a module bundled as an OCI artifact +* [kyma alpha create scaffold](kyma_alpha_create_scaffold.md) - Generates necessary files required for module creation diff --git a/docs/gen-docs/kyma_alpha_create_scaffold.md b/docs/gen-docs/kyma_alpha_create_scaffold.md new file mode 100644 index 000000000..cbf8d2b1e --- /dev/null +++ b/docs/gen-docs/kyma_alpha_create_scaffold.md @@ -0,0 +1,96 @@ +--- +title: kyma alpha create scaffold +--- + +Generates necessary files required for module creation + +## Synopsis + +Scaffold generates or configures the necessary files for creating a new module in Kyma. This includes setting up +a basic directory structure and creating default files based on the provided flags. + +The command is designed to streamline the module creation process in Kyma, making it easier and more +efficient for developers to get started with new modules. It supports customization through various flags, +allowing for a tailored scaffolding experience according to the specific needs of the module being created. + +The command generates or uses the following files: + - Module Config: + Enabled: Always + Adjustable with flag: --module-config=VALUE + Generated when: The file doesn't exist or the --overwrite=true flag is provided + Default file name: scaffold-module-config.yaml + - Manifest: + Enabled: Always + Adjustable with flag: --gen-manifest=VALUE + Generated when: The file doesn't exist. If the file exists, it's name is used in the generated module configuration file + Default file name: manifest.yaml + - Default CR(s): + Enabled: When the flag --gen-default-cr is provided with or without value + Adjustable with flag: --gen-default-cr[=VALUE], if provided without an explicit VALUE, the default value is used + Generated when: The file doesn't exist. If the file exists, it's name is used in the generated module configuration file + Default file name: default-cr.yaml + - Security Scanners Config: + Enabled: When the flag --gen-security-config is provided with or without value + Adjustable with flag: --gen-security-config[=VALUE], if provided without an explicit VALUE, the default value is used + Generated when: The file doesn't exist. If the file exists, it's name is used in the generated module configuration file + Default file name: sec-scanners-config.yaml + +**NOTE:**: To protect the user from accidental file overwrites, this command by default doesn't overwrite any files. +Only the module config file may be force-overwritten when the --overwrite=true flag is used. + +You can specify the required fields of the module config using the following CLI flags: +--module-name=NAME +--module-version=VERSION +--module-channel=CHANNEL + +**NOTE:**: If the required fields aren't provided, the defaults are applied and the module-config.yaml is not ready to be used. You must manually edit the file to make it usable. +Also, edit the sec-scanners-config.yaml to be able to use it. + + +```bash +kyma alpha create scaffold [--module-name MODULE_NAME --module-version MODULE_VERSION --module-channel CHANNEL] [--directory MODULE_DIRECTORY] [flags] +``` + +## Examples + +```bash +Generate a minimal scaffold for a module - only a blank manifest file and module config file is generated using defaults + kyma alpha create scaffold +Generate a scaffold providing required values explicitly + kyma alpha create scaffold --module-name="kyma-project.io/module/testmodule" --module-version="0.1.1" --module-channel=fast +Generate a scaffold with a manifest file, default CR and security-scanners config for a module + kyma alpha create scaffold --gen-default-cr --gen-security-config +Generate a scaffold with a manifest file, default CR and security-scanners config for a module, overriding default values + kyma alpha create scaffold --gen-manifest="my-manifest.yaml" --gen-default-cr="my-cr.yaml" --gen-security-config="my-seccfg.yaml" + + +``` + +## Flags + +```bash + -d, --directory string Specifies the directory where the scaffolding shall be generated (default "./") + --gen-default-cr string[="default-cr.yaml"] Specifies the defaultCR in the generated module config. A blank defaultCR file is generated if it doesn't exist + --gen-manifest string[="manifest.yaml"] Specifies the manifest in the generated module config. A blank manifest file is generated if it doesn't exist (default "manifest.yaml") + --gen-security-config string[="sec-scanners-config.yaml"] Specifies the security file in the generated module config. A scaffold security config file is generated if it doesn't exist + --module-channel string Specifies the module channel in the generated module config file (default "regular") + --module-config string[="scaffold-module-config.yaml"] Specifies the name for the generated module configuration file (default "scaffold-module-config.yaml") + --module-name string Specifies the module name in the generated module config file (default "kyma-project.io/module/mymodule") + --module-version string Specifies the module version in the generated module config file (default "0.0.1") + -o, --overwrite Specifies if the command overwrites an existing module configuration file +``` + +## Flags inherited from parent commands + +```bash + --ci Enables the CI mode to run on CI/CD systems. It avoids any user interaction (such as no dialog prompts) and ensures that logs are formatted properly in log files (such as no spinners for CLI steps). + -h, --help Provides command help. + --kubeconfig string Path to the kubeconfig file. If undefined, Kyma CLI uses the KUBECONFIG environment variable, or falls back "/$HOME/.kube/config". + --non-interactive Enables the non-interactive shell mode (no colorized output, no spinner). + -v, --verbose Displays details of actions triggered by the command. +``` + +## See also + +* [kyma alpha create](kyma_alpha_create.md) - Creates resources on the Kyma cluster. + diff --git a/pkg/module/scaffold/default_cr.go b/pkg/module/scaffold/default_cr.go new file mode 100644 index 000000000..e5bbd1cc9 --- /dev/null +++ b/pkg/module/scaffold/default_cr.go @@ -0,0 +1,29 @@ +package scaffold + +import ( + "fmt" + "os" +) + +func (g *Generator) DefaultCRFilePath() string { + return g.DefaultCRFile +} + +func (g *Generator) DefaultCRFileExists() (bool, error) { + return g.fileExists(g.DefaultCRFilePath()) +} + +func (g *Generator) GenerateDefaultCRFile() error { + + blankContents := `# This is the file that contains the defaultCR for your module, which is the Custom Resource that will be created upon module enablement. +# Make sure this file contains *ONLY* the Custom Resource (not the Custom Resource Definition, which should be a part of your module manifest) + +` + filePath := g.DefaultCRFilePath() + err := os.WriteFile(filePath, []byte(blankContents), 0600) + if err != nil { + return fmt.Errorf("error while saving %s: %w", filePath, err) + } + + return nil +} diff --git a/pkg/module/scaffold/manifest.go b/pkg/module/scaffold/manifest.go new file mode 100644 index 000000000..333998229 --- /dev/null +++ b/pkg/module/scaffold/manifest.go @@ -0,0 +1,29 @@ +package scaffold + +import ( + "fmt" + "os" +) + +func (g *Generator) ManifestFilePath() string { + return g.ManifestFile +} + +func (g *Generator) ManifestFileExists() (bool, error) { + return g.fileExists(g.ManifestFilePath()) +} + +func (g *Generator) GenerateManifest() error { + + blankContents := `# This file holds the Manifest of your module, encompassing all resources installed in the cluster once the module is activated. +# It should include the Custom Resource Definition for your module's default CustomResource, if it exists. + +` + filePath := g.ManifestFilePath() + err := os.WriteFile(filePath, []byte(blankContents), 0600) + if err != nil { + return fmt.Errorf("error while saving %s: %w", filePath, err) + } + + return nil +} diff --git a/pkg/module/scaffold/module_config.go b/pkg/module/scaffold/module_config.go new file mode 100644 index 000000000..b5c14ec19 --- /dev/null +++ b/pkg/module/scaffold/module_config.go @@ -0,0 +1,30 @@ +package scaffold + +import ( + "github.com/kyma-project/cli/cmd/kyma/alpha/create/module" +) + +func (g *Generator) ModuleConfigFilePath() string { + return g.ModuleConfigFile +} + +func (g *Generator) ModuleConfigFileExists() (bool, error) { + return g.fileExists(g.ModuleConfigFilePath()) +} + +func (g *Generator) GenerateModuleConfigFile() error { + cfg := module.Config{ + Name: g.ModuleName, + Version: g.ModuleVersion, + Channel: g.ModuleChannel, + ManifestPath: g.ManifestFile, + Security: g.SecurityConfigFile, + DefaultCRPath: g.DefaultCRFile, + } + + if err := cfg.Validate(); err != nil { + return err + } + + return g.generateYamlFileFromObject(cfg, g.ModuleConfigFilePath()) +} diff --git a/pkg/module/scaffold/scaffold.go b/pkg/module/scaffold/scaffold.go new file mode 100644 index 000000000..d54a552db --- /dev/null +++ b/pkg/module/scaffold/scaffold.go @@ -0,0 +1,40 @@ +package scaffold + +import ( + "errors" + "fmt" + "os" +) + +type Generator struct { + ModuleName string + ModuleVersion string + ModuleChannel string + ModuleConfigFile string + ManifestFile string + SecurityConfigFile string + DefaultCRFile string +} + +func (g *Generator) fileExists(path string) (bool, error) { + if _, err := os.Stat(path); err == nil { + return true, nil + + } else if errors.Is(err, os.ErrNotExist) { + return false, nil + + } else { + return false, err + } +} + +func (g *Generator) generateYamlFileFromObject(obj interface{}, filePath string) error { + yamlVal := generateYaml(obj) + + err := os.WriteFile(filePath, []byte(yamlVal), 0600) + if err != nil { + return fmt.Errorf("error writing file: %w", err) + } + + return nil +} diff --git a/pkg/module/scaffold/security_config.go b/pkg/module/scaffold/security_config.go new file mode 100644 index 000000000..f7b539dec --- /dev/null +++ b/pkg/module/scaffold/security_config.go @@ -0,0 +1,26 @@ +package scaffold + +import ( + "github.com/kyma-project/cli/pkg/module" +) + +func (g *Generator) SecurityConfigFilePath() string { + return g.SecurityConfigFile +} + +func (g *Generator) SecurityConfigFileExists() (bool, error) { + return g.fileExists(g.SecurityConfigFilePath()) +} + +func (g *Generator) GenerateSecurityConfigFile() error { + cfg := module.SecurityScanCfg{ + ModuleName: g.ModuleName, + Protecode: []string{"europe-docker.pkg.dev/kyma-project/prod/myimage:1.2.3", + "europe-docker.pkg.dev/kyma-project/prod/external/ghcr.io/mymodule/anotherimage:4.5.6"}, + WhiteSource: module.WhiteSourceSecCfg{ + Exclude: []string{"**/test/**", "**/*_test.go"}, + }, + } + err := g.generateYamlFileFromObject(cfg, g.SecurityConfigFilePath()) + return err +} diff --git a/pkg/module/scaffold/utils.go b/pkg/module/scaffold/utils.go new file mode 100644 index 000000000..a9f9b98e3 --- /dev/null +++ b/pkg/module/scaffold/utils.go @@ -0,0 +1,81 @@ +package scaffold + +import ( + "fmt" + "reflect" + "strings" +) + +func generateYaml(obj interface{}) string { + reflectValue := reflect.ValueOf(obj) + var yamlBuilder strings.Builder + generateYamlWithComments(&yamlBuilder, reflectValue, 0, "") + return yamlBuilder.String() +} + +// generateYamlWithComments uses a "comment" tag in the struct definition to generate YAML with comments on corresponding lines. +// Note: Map support is missing! +func generateYamlWithComments(yamlBuilder *strings.Builder, obj reflect.Value, indentLevel int, commentPrefix string) { + t := obj.Type() + + indentPrefix := strings.Repeat(" ", indentLevel) + originalCommentPrefix := commentPrefix + for i := 0; i < t.NumField(); i++ { + commentPrefix = originalCommentPrefix + field := t.Field(i) + value := obj.Field(i) + yamlTag := field.Tag.Get("yaml") + commentTag := field.Tag.Get("comment") + + // comment-out non-required empty attributes + if value.IsZero() && !strings.Contains(commentTag, "required") { + commentPrefix = "# " + } + + if value.Kind() == reflect.Struct { + if commentTag == "" { + yamlBuilder.WriteString(fmt.Sprintf("%s%s%s:\n", commentPrefix, indentPrefix, yamlTag)) + } else { + yamlBuilder.WriteString(fmt.Sprintf("%s%s%s: # %s\n", commentPrefix, indentPrefix, yamlTag, commentTag)) + } + generateYamlWithComments(yamlBuilder, value, indentLevel+1, commentPrefix) + continue + } + + if value.Kind() == reflect.Slice { + if commentTag == "" { + yamlBuilder.WriteString(fmt.Sprintf("%s%s%s:\n", commentPrefix, indentPrefix, yamlTag)) + } else { + yamlBuilder.WriteString(fmt.Sprintf("%s%s%s: # %s\n", commentPrefix, indentPrefix, yamlTag, commentTag)) + } + + if value.Len() == 0 { + yamlBuilder.WriteString(fmt.Sprintf("%s%s -\n", commentPrefix, indentPrefix)) + } + for j := 0; j < value.Len(); j++ { + valueStr := getValueStr(value.Index(j)) + yamlBuilder.WriteString(fmt.Sprintf("%s%s - %s\n", "", indentPrefix, valueStr)) + } + continue + } + + valueStr := getValueStr(value) + if commentTag == "" { + yamlBuilder.WriteString(fmt.Sprintf("%s%s%s: %s\n", commentPrefix, indentPrefix, + yamlTag, valueStr)) + } else { + yamlBuilder.WriteString(fmt.Sprintf("%s%s%s: %s # %s\n", commentPrefix, indentPrefix, + yamlTag, valueStr, commentTag)) + } + } +} + +func getValueStr(value reflect.Value) string { + valueStr := "" + if value.Kind() == reflect.String { + valueStr = fmt.Sprintf("\"%v\"", value.Interface()) + } else { + valueStr = fmt.Sprintf("%v", value.Interface()) + } + return valueStr +} diff --git a/pkg/module/scaffold/utils_test.go b/pkg/module/scaffold/utils_test.go new file mode 100644 index 000000000..379c4b8cb --- /dev/null +++ b/pkg/module/scaffold/utils_test.go @@ -0,0 +1,248 @@ +package scaffold + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMarshalFlatWithoutCommentYaml(t *testing.T) { + type Subject struct { + Age int `yaml:"age"` + Name string `yaml:"name"` + Title string `yaml:"title"` + } + + s1 := Subject{ + Age: 22, + Name: "John", + } + + expected := stripLeadingNewline(` +age: 22 +name: "John" +# title: "" +`) + actual := generateYaml(s1) + require.Equal(t, expected, actual) +} + +func TestMarshalFlatWithSomeComment(t *testing.T) { + type Subject struct { + Age int `yaml:"age" comment:"the age of the user"` + Name string `yaml:"name" comment:"the name of the user"` + Title string `yaml:"title"` + Active bool `yaml:"active"` + } + + s1 := Subject{ + Name: "John", + Title: "Dr", + } + + expected := stripLeadingNewline(` +# age: 0 # the age of the user +name: "John" # the name of the user +title: "Dr" +# active: false +`) + actual := generateYaml(s1) + require.Equal(t, expected, actual) +} + +func TestMarshalFlatWithSomeRequiredCommentYaml(t *testing.T) { + type Subject struct { + Age int `yaml:"age" comment:"required, the age of the user"` + Name string `yaml:"name" comment:"required, the name of the user"` + Title string `yaml:"title" comment:"the title of the user"` + Active bool `yaml:"active" comment:"the active status of the user"` + } + + s1 := Subject{ + Name: "John", + Title: "Dr", + } + + // note: "age" is rendered uncommented because of the word "required" in the comment tag + expected := stripLeadingNewline(` +age: 0 # required, the age of the user +name: "John" # required, the name of the user +title: "Dr" # the title of the user +# active: false # the active status of the user +`) + actual := generateYaml(s1) + require.Equal(t, expected, actual) +} + +func TestMarshalNestedStruct(t *testing.T) { + type VeryNested struct { + ID string `yaml:"id" comment:"the id of the user"` + ExpirationDate string `yaml:"expirationDate"` // attribute with no comment + Department string `yaml:"department" comment:"one of: business, development, research, AI"` + } + type Nested struct { + Details VeryNested `yaml:"details"` // struct with no comment + Name string `yaml:"name" comment:"the name of the user"` + } + type Subject struct { + User Nested `yaml:"user" comment:"required, user data"` + Active bool `yaml:"active" comment:"required, the active flag of the user"` + } + + s1 := Subject{ + User: Nested{ + Details: VeryNested{ + ID: "123", + }, + Name: "John", + }, + Active: true, + } + + expected := stripLeadingNewline(` +user: # required, user data + details: + id: "123" # the id of the user +# expirationDate: "" +# department: "" # one of: business, development, research, AI + name: "John" # the name of the user +active: true # required, the active flag of the user +`) + actual := generateYaml(s1) + require.Equal(t, expected, actual) +} + +func TestMarshalSlice(t *testing.T) { + type Subject struct { + Age int `yaml:"age" comment:"required, the age of the user"` + Name string `yaml:"name" comment:"required, the name of the user"` + Nicknames []string `yaml:"nicknames"` + Friends []string `yaml:"friends"` + FavouriteFoods []string `yaml:"favouriteFoods" comment:"favourite foods of the user"` + FavouriteColors []string `yaml:"favouriteColors" comment:"favourite colors of the user"` + } + s1 := Subject{ + Name: "John", + Friends: []string{"Bob", "Alice"}, + FavouriteColors: []string{"Red", "Green", "Blue"}, + } + + expected := stripLeadingNewline(` +age: 0 # required, the age of the user +name: "John" # required, the name of the user +# nicknames: +# - +friends: + - "Bob" + - "Alice" +# favouriteFoods: # favourite foods of the user +# - +favouriteColors: # favourite colors of the user + - "Red" + - "Green" + - "Blue" +`) + actual := generateYaml(s1) + require.Equal(t, expected, actual) +} + +func TestMarshalNestedSlice(t *testing.T) { + type Nested struct { + Nicknames []string `yaml:"nicknames"` + Friends []string `yaml:"friends"` + FavouriteFoods []string `yaml:"favouriteFoods" comment:"favourite foods of the user"` + FavouriteColors []string `yaml:"favouriteColors" comment:"favourite colors of the user"` + } + type Subject struct { + Age int `yaml:"age" comment:"required, the age of the user"` + Name string `yaml:"name" comment:"required, the name of the user"` + Details Nested `yaml:"details" comment:"the details of the user"` + } + s1 := Subject{ + Name: "John", + Details: Nested{ + Friends: []string{"Bob", "Alice"}, + FavouriteColors: []string{"Red", "Green", "Blue"}, + }, + } + + expected := stripLeadingNewline(` +age: 0 # required, the age of the user +name: "John" # required, the name of the user +details: # the details of the user +# nicknames: +# - + friends: + - "Bob" + - "Alice" +# favouriteFoods: # favourite foods of the user +# - + favouriteColors: # favourite colors of the user + - "Red" + - "Green" + - "Blue" +`) + actual := generateYaml(s1) + require.Equal(t, expected, actual) +} + +func TestMarshalDoublyNestedSlice(t *testing.T) { + type DoublyNested struct { + Nicknames []string `yaml:"nicknames"` + Friends []string `yaml:"friends"` + FavouriteFoods []string `yaml:"favouriteFoods" comment:"favourite foods of the user"` + FavouriteColors []string `yaml:"favouriteColors" comment:"favourite colors of the user"` + } + type Nested struct { + Public DoublyNested `yaml:"public"` + Private DoublyNested `yaml:"private" comment:"private details of the user"` + } + type Subject struct { + Age int `yaml:"age" comment:"required, the age of the user"` + Name string `yaml:"name" comment:"required, the name of the user"` + Details Nested `yaml:"details" comment:"the details of the user"` + } + s1 := Subject{ + Name: "John", + Details: Nested{ + Public: DoublyNested{ + Friends: []string{"Bob", "Alice"}, + FavouriteColors: []string{"Red", "Green", "Blue"}, + }, + }, + } + + expected := stripLeadingNewline(` +age: 0 # required, the age of the user +name: "John" # required, the name of the user +details: # the details of the user + public: +# nicknames: +# - + friends: + - "Bob" + - "Alice" +# favouriteFoods: # favourite foods of the user +# - + favouriteColors: # favourite colors of the user + - "Red" + - "Green" + - "Blue" +# private: # private details of the user +# nicknames: +# - +# friends: +# - +# favouriteFoods: # favourite foods of the user +# - +# favouriteColors: # favourite colors of the user +# - +`) + actual := generateYaml(s1) + require.Equal(t, expected, actual) +} + +func stripLeadingNewline(val string) string { + return strings.TrimLeft(val, "\n") +} diff --git a/pkg/module/security_scan.go b/pkg/module/security_scan.go index 85d741204..80ee5625f 100644 --- a/pkg/module/security_scan.go +++ b/pkg/module/security_scan.go @@ -125,16 +125,16 @@ func getImageName(imageURL string) (string, string, error) { } type SecurityScanCfg struct { - ModuleName string `json:"module-name"` - Protecode []string `json:"protecode"` - WhiteSource WhiteSourceSecCfg `json:"whitesource"` - DevBranch string `json:"dev-branch"` - RcTag string `json:"rc-tag"` + ModuleName string `json:"module-name" yaml:"module-name" comment:"string, name of your module"` + Protecode []string `json:"protecode" yaml:"protecode" comment:"list, includes the images which must be scanned by the Protecode scanner (aka. Black Duck Binary Analysis)"` + WhiteSource WhiteSourceSecCfg `json:"whitesource" yaml:"whitesource" comment:"whitesource (aka. Mend) security scanner specific configuration"` + DevBranch string `json:"dev-branch" yaml:"dev-branch" comment:"string, name of the development branch"` + RcTag string `json:"rc-tag" yaml:"rc-tag" comment:"string, release candidate tag"` } type WhiteSourceSecCfg struct { - Language string `json:"language"` - SubProjects string `json:"subprojects"` - Exclude []string `json:"exclude"` + Language string `json:"language" yaml:"language" comment:"string, indicating the programming language the scanner has to analyze"` + SubProjects string `json:"subprojects" yaml:"subprojects" comment:"string, specifying any subprojects"` + Exclude []string `json:"exclude" yaml:"exclude" comment:"list, directories within the repository which should not be scanned"` } func parseSecurityScanConfig(securityConfigPath string) (*SecurityScanCfg, error) { diff --git a/tests/e2e/Makefile b/tests/e2e/Makefile index 3eed76896..705477fbf 100644 --- a/tests/e2e/Makefile +++ b/tests/e2e/Makefile @@ -41,4 +41,8 @@ test-same-version-module-creation: .PHONY: test-module-enabling-disabling test-module-enabling-disabling: - go test -ginkgo.v -ginkgo.focus "Kyma CLI deploy, enable and disable commands usage" \ No newline at end of file + go test -ginkgo.v -ginkgo.focus "Kyma CLI deploy, enable and disable commands usage" + +.PHONY: test-create-scaffold +test-create-scaffold: + go test ./create_scaffold -ginkgo.v -ginkgo.focus "Create Scaffold Command" diff --git a/tests/e2e/create_scaffold/scaffold_suite_test.go b/tests/e2e/create_scaffold/scaffold_suite_test.go new file mode 100644 index 000000000..e6ebe3f69 --- /dev/null +++ b/tests/e2e/create_scaffold/scaffold_suite_test.go @@ -0,0 +1,13 @@ +package scaffold_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCreateScaffold(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CreateScaffold Suite") +} diff --git a/tests/e2e/create_scaffold/scaffold_test.go b/tests/e2e/create_scaffold/scaffold_test.go new file mode 100644 index 000000000..ca0d489b6 --- /dev/null +++ b/tests/e2e/create_scaffold/scaffold_test.go @@ -0,0 +1,390 @@ +package scaffold_test + +import ( + "fmt" + "io/fs" + "os" + "os/exec" + "path" + + "github.com/kyma-project/cli/cmd/kyma/alpha/create/module" + "gopkg.in/yaml.v3" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const ( + markerFileData = "test-marker" +) + +var _ = Describe("Create Scaffold Command", Ordered, func() { + var initialDir string + var workDir string + var workDirCleanup func() + + setup := func() { + var err error + initialDir, err = os.Getwd() + Expect(err).To(BeNil()) + workDir, workDirCleanup = resolveWorkingDirectory() + err = os.Chdir(workDir) + Expect(err).To(BeNil()) + + } + teardown := func() { + err := os.Chdir(initialDir) + Expect(err).To(BeNil()) + workDirCleanup() + workDir = "" + initialDir = "" + + } + + Context("Given an empty directory", func() { + BeforeAll(func() { setup() }) + AfterAll(func() { teardown() }) + + var cmd createScaffoldCmd + It("When `kyma alpha create scaffold` command is invoked without any args", func() { + cmd = createScaffoldCmd{} + }) + + It("Then the command should succeed", func() { + Expect(cmd.execute()).To(Succeed()) + + By("And two files are generated") + Expect(filesIn(workDir)).Should(HaveLen(2)) + + By("And the manifest file is generated") + Expect(filesIn(workDir)).Should(ContainElement("manifest.yaml")) + + By("And the module config file is generated") + Expect(filesIn(workDir)).Should(ContainElement("scaffold-module-config.yaml")) + + By("And the module config contains expected entries") + actualModConf := moduleConfigFromFile(workDir, "scaffold-module-config.yaml") + expectedModConf := (&moduleConfigBuilder{}).defaults().get() + Expect(actualModConf).To(BeEquivalentTo(expectedModConf)) + }) + }) + + Context("Given a directory with an existing module configuration file", func() { + BeforeAll(func() { + setup() + Expect(createMarkerFile("scaffold-module-config.yaml")).To(Succeed()) + }) + AfterAll(func() { teardown() }) + + var cmd createScaffoldCmd + It("When `kyma alpha create scaffold` command is invoked without any args", func() { + cmd = createScaffoldCmd{} + }) + It("Then the command should fail", func() { + err := cmd.execute() + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(ContainSubstring("module config file already exists")) + + By("And no files should be generated") + Expect(filesIn(workDir)).Should(HaveLen(1)) + Expect(filesIn(workDir)).Should(ContainElement("scaffold-module-config.yaml")) + Expect(getMarkerFileData("scaffold-module-config.yaml")).Should(Equal(markerFileData)) + }) + }) + + Context("Given a directory with an existing module configuration file", func() { + BeforeAll(func() { + setup() + Expect(createMarkerFile("scaffold-module-config.yaml")).To(Succeed()) + }) + AfterAll(func() { teardown() }) + + var cmd createScaffoldCmd + It("When `kyma alpha create scaffold` command is invoked with --overwrite flag", func() { + cmd = createScaffoldCmd{ + overwrite: true, + } + }) + + It("Then the command should succeed", func() { + Expect(cmd.execute()).To(Succeed()) + + By("And two files are generated") + Expect(filesIn(workDir)).Should(HaveLen(2)) + + By("And the manifest file is generated") + Expect(filesIn(workDir)).Should(ContainElement("manifest.yaml")) + + By("And the module config file is generated") + Expect(filesIn(workDir)).Should(ContainElement("scaffold-module-config.yaml")) + + By("And the module config contains expected entries") + actualModConf := moduleConfigFromFile(workDir, "scaffold-module-config.yaml") + expectedModConf := (&moduleConfigBuilder{}).defaults().get() + Expect(actualModConf).To(BeEquivalentTo(expectedModConf)) + }) + }) + + Context("Given an empty directory", func() { + BeforeAll(func() { setup() }) + AfterAll(func() { teardown() }) + + var cmd createScaffoldCmd + It("When `kyma alpha create scaffold` command args override defaults", func() { + cmd = createScaffoldCmd{ + moduleName: "github.com/custom/module", + moduleVersion: "3.2.1", + moduleChannel: "custom", + moduleConfigFileFlag: "custom-module-config.yaml", + genManifestFlag: "custom-manifest.yaml", + genDefaultCRFlag: "custom-default-cr.yaml", + genSecurityScannersConfigFlag: "custom-security-scanners-config.yaml", + } + }) + It("Then the command should succeed", func() { + Expect(cmd.execute()).To(Succeed()) + + By("And four files are generated") + Expect(filesIn(workDir)).Should(HaveLen(4)) + + By("And the manifest file is generated") + Expect(filesIn(workDir)).Should(ContainElement("custom-manifest.yaml")) + + By("And the defaultCR file is generated") + Expect(filesIn(workDir)).Should(ContainElement("custom-default-cr.yaml")) + + By("And the security-scanners-config file is generated") + Expect(filesIn(workDir)).Should(ContainElement("custom-security-scanners-config.yaml")) + + By("And the module config file is generated") + Expect(filesIn(workDir)).Should(ContainElement("custom-module-config.yaml")) + + By("And the module config contains expected entries") + actualModConf := moduleConfigFromFile(workDir, "custom-module-config.yaml") + expectedModConf := cmd.toConfigBuilder().get() + Expect(actualModConf).To(BeEquivalentTo(expectedModConf)) + }) + }) + + Context("Given a directory with existing files", func() { + BeforeAll(func() { + setup() + Expect(createMarkerFile("custom-manifest.yaml")).To(Succeed()) + Expect(createMarkerFile("custom-default-cr.yaml")).To(Succeed()) + Expect(createMarkerFile("custom-security-scanners-config.yaml")).To(Succeed()) + + }) + AfterAll(func() { teardown() }) + + var cmd createScaffoldCmd + It("When `kyma alpha create scaffold` command is invoked with arguments that match existing files names", func() { + cmd = createScaffoldCmd{ + genManifestFlag: "custom-manifest.yaml", + genDefaultCRFlag: "custom-default-cr.yaml", + genSecurityScannersConfigFlag: "custom-security-scanners-config.yaml", + } + }) + It("Then the command should succeed", func() { + Expect(cmd.execute()).To(Succeed()) + + By("And there should be four files in the directory") + Expect(filesIn(workDir)).Should(HaveLen(4)) + + By("And the manifest file is reused (not generated)") + Expect(getMarkerFileData("custom-manifest.yaml")).Should(Equal(markerFileData)) + + By("And the defaultCR file is reused (not generated)") + Expect(getMarkerFileData("custom-default-cr.yaml")).Should(Equal(markerFileData)) + + By("And the security-scanners-config file is reused (not generated)") + Expect(getMarkerFileData("custom-security-scanners-config.yaml")).Should(Equal(markerFileData)) + + By("And the module config file is generated") + Expect(filesIn(workDir)).Should(ContainElement("scaffold-module-config.yaml")) + + By("And module config contains expected entries") + actualModConf := moduleConfigFromFile(workDir, "scaffold-module-config.yaml") + expectedModConf := cmd.toConfigBuilder().get() + Expect(actualModConf).To(BeEquivalentTo(expectedModConf)) + }) + }) +}) + +func getMarkerFileData(name string) string { + data, err := os.ReadFile(name) + Expect(err).To(BeNil()) + return string(data) +} + +func createMarkerFile(name string) error { + err := os.WriteFile(name, []byte(markerFileData), 0600) + return err +} + +func moduleConfigFromFile(dir, fileName string) *module.Config { + filePath := path.Join(dir, fileName) + data, err := os.ReadFile(filePath) + Expect(err).To(BeNil()) + res := module.Config{} + err = yaml.Unmarshal(data, &res) + Expect(err).To(BeNil()) + return &res +} + +func filesIn(dir string) []string { + fi, err := os.Stat(dir) + Expect(err).To(BeNil()) + Expect(fi.IsDir()).To(BeTrueBecause("The provided path should be a directory: %s", dir)) + + dirFs := os.DirFS(dir) + entries, err := fs.ReadDir(dirFs, ".") + Expect(err).To(BeNil()) + + res := []string{} + for _, ent := range entries { + if ent.Type().IsRegular() { + res = append(res, ent.Name()) + } + } + + return res +} + +func resolveWorkingDirectory() (path string, cleanup func()) { + path = os.Getenv("SCAFFOLD_DIR") + if len(path) > 0 { + cleanup = func() {} + } else { + var err error + path, err = os.MkdirTemp("", "create_scaffold_test") + if err != nil { + Fail(err.Error()) + } + cleanup = func() { + os.RemoveAll(path) + } + } + return +} + +type createScaffoldCmd struct { + moduleName string + moduleVersion string + moduleChannel string + moduleConfigFileFlag string + genDefaultCRFlag string + genSecurityScannersConfigFlag string + genManifestFlag string + overwrite bool +} + +func (cmd *createScaffoldCmd) execute() error { + var command *exec.Cmd + + args := []string{"alpha", "create", "scaffold"} + + if cmd.moduleName != "" { + args = append(args, fmt.Sprintf("--module-name=%s", cmd.moduleName)) + } + + if cmd.moduleVersion != "" { + args = append(args, fmt.Sprintf("--module-version=%s", cmd.moduleVersion)) + } + + if cmd.moduleChannel != "" { + args = append(args, fmt.Sprintf("--module-channel=%s", cmd.moduleChannel)) + } + + if cmd.moduleConfigFileFlag != "" { + args = append(args, fmt.Sprintf("--module-config=%s", cmd.moduleConfigFileFlag)) + } + + if cmd.genDefaultCRFlag != "" { + args = append(args, fmt.Sprintf("--gen-default-cr=%s", cmd.genDefaultCRFlag)) + } + + if cmd.genSecurityScannersConfigFlag != "" { + args = append(args, fmt.Sprintf("--gen-security-config=%s", cmd.genSecurityScannersConfigFlag)) + } + + if cmd.genManifestFlag != "" { + args = append(args, fmt.Sprintf("--gen-manifest=%s", cmd.genManifestFlag)) + } + + if cmd.overwrite { + args = append(args, "--overwrite=true") + } + + command = exec.Command("kyma", args...) + cmdOut, err := command.CombinedOutput() + + if err != nil { + return fmt.Errorf("create scaffold command failed with output: %s and error: %w", cmdOut, err) + } + return nil +} + +func (cmd *createScaffoldCmd) toConfigBuilder() *moduleConfigBuilder { + res := &moduleConfigBuilder{} + res.defaults() + if cmd.moduleName != "" { + res.withName(cmd.moduleName) + } + if cmd.moduleVersion != "" { + res.withVersion(cmd.moduleVersion) + } + if cmd.moduleChannel != "" { + res.withChannel(cmd.moduleChannel) + } + if cmd.genDefaultCRFlag != "" { + res.withDefaultCRPath(cmd.genDefaultCRFlag) + } + if cmd.genSecurityScannersConfigFlag != "" { + res.withSecurityScannersPath(cmd.genSecurityScannersConfigFlag) + } + if cmd.genManifestFlag != "" { + res.withManifestPath(cmd.genManifestFlag) + } + return res +} + +// moduleConfigBuilder is used to simplify module.Config creation for testing purposes +type moduleConfigBuilder struct { + module.Config +} + +func (mcb *moduleConfigBuilder) get() *module.Config { + res := mcb.Config + return &res +} +func (mcb *moduleConfigBuilder) withName(val string) *moduleConfigBuilder { + mcb.Name = val + return mcb +} +func (mcb *moduleConfigBuilder) withVersion(val string) *moduleConfigBuilder { + mcb.Version = val + return mcb +} +func (mcb *moduleConfigBuilder) withChannel(val string) *moduleConfigBuilder { + mcb.Channel = val + return mcb +} +func (mcb *moduleConfigBuilder) withManifestPath(val string) *moduleConfigBuilder { + mcb.ManifestPath = val + return mcb +} +func (mcb *moduleConfigBuilder) withDefaultCRPath(val string) *moduleConfigBuilder { + mcb.DefaultCRPath = val + return mcb +} +func (mcb *moduleConfigBuilder) withSecurityScannersPath(val string) *moduleConfigBuilder { + mcb.Security = val + return mcb +} +func (mcb *moduleConfigBuilder) defaults() *moduleConfigBuilder { + return mcb. + withName("kyma-project.io/module/mymodule"). + withVersion("0.0.1"). + withChannel("regular"). + withManifestPath("manifest.yaml") +}