From 5366a39794300e579600e316d9c7dd289a5b8847 Mon Sep 17 00:00:00 2001 From: varshaprasad96 Date: Tue, 21 Apr 2020 18:46:13 -0700 Subject: [PATCH] [gen-csv] Add interactive prompts to generate csv This PR introduces the feature to provide interactive prompts to the user to obtain csv metadata. --- changelog/fragments/interactive-csv-cmd.yaml | 3 + cmd/operator-sdk/generate/csv.go | 57 +++++---- hack/tests/subcommand-generate-csv.sh | 4 +- internal/generate/olm-catalog/csv.go | 28 ++++- .../generate/olm-catalog/csv_cmd_prompt.go | 91 ++++++++++++++ internal/generate/olm-catalog/csv_go_test.go | 83 ++++++++++--- ...cached-operator.clusterserviceversion.yaml | 2 +- .../util/projutil/interactive_promt_util.go | 113 ++++++++++++++++++ .../projutil/interactive_promt_util_test.go | 95 +++++++++++++++ .../en/docs/cli/operator-sdk_generate_csv.md | 1 + 10 files changed, 433 insertions(+), 44 deletions(-) create mode 100644 changelog/fragments/interactive-csv-cmd.yaml create mode 100644 internal/generate/olm-catalog/csv_cmd_prompt.go create mode 100644 internal/util/projutil/interactive_promt_util.go create mode 100644 internal/util/projutil/interactive_promt_util_test.go diff --git a/changelog/fragments/interactive-csv-cmd.yaml b/changelog/fragments/interactive-csv-cmd.yaml new file mode 100644 index 0000000000..56402f016a --- /dev/null +++ b/changelog/fragments/interactive-csv-cmd.yaml @@ -0,0 +1,3 @@ +entries: + - description: Add flag `--interactive` to the command `operator-sdk generate csv` in order to enable working with interactive prompts while generating CSV. + kind: "addition" diff --git a/cmd/operator-sdk/generate/csv.go b/cmd/operator-sdk/generate/csv.go index 2f2b586e1b..eac6454780 100644 --- a/cmd/operator-sdk/generate/csv.go +++ b/cmd/operator-sdk/generate/csv.go @@ -27,17 +27,19 @@ import ( ) type csvCmd struct { - csvVersion string - csvChannel string - fromVersion string - operatorName string - outputDir string - deployDir string - apisDir string - crdDir string - updateCRDs bool - defaultChannel bool - makeManifests bool + csvVersion string + csvChannel string + fromVersion string + operatorName string + outputDir string + deployDir string + apisDir string + crdDir string + interactivelevel projutil.InteractiveLevel + updateCRDs bool + defaultChannel bool + makeManifests bool + interactive bool } //nolint:lll @@ -171,6 +173,16 @@ Flags that change project default paths: c.updateCRDs = false } + // Check if the user has any specific preference to enable / disable interactive prompts. + // Default behaviour is to disable the prompts. + if cmd.Flags().Changed("interactive") { + if c.interactive { + c.interactivelevel = projutil.InteractiveOnAll + } else { + c.interactivelevel = projutil.InteractiveHardOff + } + } + if err := c.run(); err != nil { log.Fatal(err) } @@ -230,6 +242,9 @@ Flags that change project default paths: "directory. This directory is intended to be used for your latest bundle manifests. "+ "The default location is deploy/olm-catalog//manifests. "+ "If --output-dir is set, the directory will be /manifests") + cmd.Flags().BoolVar(&c.interactive, "interactive", false, + "When set, will enable the interactive command prompt feature to fill the UI "+ + "metadata fields in CSV") return cmd } @@ -250,16 +265,18 @@ func (c csvCmd) run() error { if c.operatorName == "" { c.operatorName = filepath.Base(projutil.MustGetwd()) } + csv := gencatalog.BundleGenerator{ - OperatorName: c.operatorName, - CSVVersion: c.csvVersion, - FromVersion: c.fromVersion, - UpdateCRDs: c.updateCRDs, - MakeManifests: c.makeManifests, - DeployDir: c.deployDir, - ApisDir: c.apisDir, - CRDsDir: c.crdDir, - OutputDir: c.outputDir, + OperatorName: c.operatorName, + CSVVersion: c.csvVersion, + FromVersion: c.fromVersion, + UpdateCRDs: c.updateCRDs, + MakeManifests: c.makeManifests, + DeployDir: c.deployDir, + ApisDir: c.apisDir, + CRDsDir: c.crdDir, + OutputDir: c.outputDir, + InteractivePreference: c.interactivelevel, } if err := csv.Generate(); err != nil { diff --git a/hack/tests/subcommand-generate-csv.sh b/hack/tests/subcommand-generate-csv.sh index 3cacf97221..cc002b3a02 100755 --- a/hack/tests/subcommand-generate-csv.sh +++ b/hack/tests/subcommand-generate-csv.sh @@ -41,8 +41,8 @@ function check_crd_files() { } function generate_csv() { - echo "operator-sdk generate csv --operator-name $OPERATOR_NAME $@" - operator-sdk generate csv --operator-name $OPERATOR_NAME $@ + echo "operator-sdk generate csv --operator-name $OPERATOR_NAME --interactive=false $@" + operator-sdk generate csv --operator-name $OPERATOR_NAME --interactive=false $@ } pushd "$TEST_DIR" > /dev/null diff --git a/internal/generate/olm-catalog/csv.go b/internal/generate/olm-catalog/csv.go index 541e335ac6..852e9f1c21 100644 --- a/internal/generate/olm-catalog/csv.go +++ b/internal/generate/olm-catalog/csv.go @@ -56,13 +56,15 @@ type BundleGenerator struct { CSVVersion string // These directories specify where to retrieve manifests from. DeployDir, ApisDir, CRDsDir string + // Interactivepreference refers to the user preference to enable/disable + // interactive prompts. + InteractivePreference projutil.InteractiveLevel // updateCRDs directs the generator to also add CustomResourceDefinition // manifests to the bundle. UpdateCRDs bool // makeManifests directs the generator to use 'manifests' as the bundle // dir name. MakeManifests bool - // noUpdate is for testing the generator's update capabilities. noUpdate bool // fromBundleDir is set if the generator needs to update from @@ -71,6 +73,9 @@ type BundleGenerator struct { // toBundleDir is the bundle directory filepath where the CSV will be generated // This is set according to the generator's OutputDir toBundleDir string + // Subcommand includes the list of csv metadata fields which the user + // provides to the interactive prompts which appear while generating csv. + interactiveCSVCmd interactiveCSVCmd } // getBundleDirs gets directory names of the new bundle and, if it exists, @@ -180,6 +185,14 @@ func (g *BundleGenerator) setDefaults() { // olmapiv1alpha1.ClusterServiceVersion instead of writing to a template. func (g BundleGenerator) Generate() error { g.setDefaults() + + csvPath := g.getCSVPath(g.OperatorName) + + if (g.InteractivePreference == projutil.InteractiveSoftOff && !isFileExist(csvPath)) || + g.InteractivePreference == projutil.InteractiveOnAll { + g.interactiveCSVCmd.generateInteractivePrompt() + } + fileMap, err := g.generateCSV() if err != nil { return err @@ -195,13 +208,14 @@ func (g BundleGenerator) Generate() error { } } - if err = os.MkdirAll(g.toBundleDir, fileutil.DefaultDirFileMode); err != nil { + if err := os.MkdirAll(g.toBundleDir, fileutil.DefaultDirFileMode); err != nil { return fmt.Errorf("error mkdir %s: %v", g.toBundleDir, err) } + for fileName, b := range fileMap { path := filepath.Join(g.toBundleDir, fileName) log.Debugf("CSV generator writing %s", path) - if err = ioutil.WriteFile(path, b, fileutil.DefaultFileMode); err != nil { + if err := ioutil.WriteFile(path, b, fileutil.DefaultFileMode); err != nil { return fmt.Errorf("error writing bundle file: %v", err) } } @@ -220,6 +234,11 @@ func getCSVFileNameLegacy(name, version string) string { return getCSVName(strings.ToLower(name), version) + csvYamlFileExt } +// getCSVPath returns the location of CSV in the project. +func (g BundleGenerator) getCSVPath(operatorName string) string { + return filepath.Join(g.fromBundleDir, getCSVFileName(operatorName)) +} + func (g BundleGenerator) generateCSV() (fileMap map[string][]byte, err error) { // Get current CSV to update, otherwise start with a fresh CSV. var csv *olmapiv1alpha1.ClusterServiceVersion @@ -243,6 +262,9 @@ func (g BundleGenerator) generateCSV() (fileMap map[string][]byte, err error) { return nil, err } + // populate the csv with the metadata obtained from the user. + g.interactiveCSVCmd.addUImetadata(csv) + path := "" if g.MakeManifests { path = getCSVFileName(g.OperatorName) diff --git a/internal/generate/olm-catalog/csv_cmd_prompt.go b/internal/generate/olm-catalog/csv_cmd_prompt.go new file mode 100644 index 0000000000..c5a49997cb --- /dev/null +++ b/internal/generate/olm-catalog/csv_cmd_prompt.go @@ -0,0 +1,91 @@ +// Copyright 2020 The Operator-SDK 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 olmcatalog + +import ( + "strings" + + olmapiv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/operator-framework/operator-sdk/internal/util/projutil" +) + +// InteractiveCSVCmd includes the list of CSV fields which would be asked +// to the user while generating CSV. +type interactiveCSVCmd struct { + // DisplayName is the name of the crd. + DisplayName string + // Keyword is a list of keywords describing the operator. + Keywords []string + // Description of the operator. Can include the features, limitations or + // use-cases of the operator. + Description string + // Name of the publishing entity behind the operator. + ProviderName string + // URL related to the publishing entity behind the operator. + ProviderURL string + // Maintainers is the list of organizational entities maintaining the operator. + Maintainers []string +} + +// generateInteractivePrompt generates the prompts for user to provide input to the CSV +// fields. +func (s *interactiveCSVCmd) generateInteractivePrompt() { + s.DisplayName = projutil.GetRequiredInput("Display name for the operator") + s.Keywords = projutil.GetStringArray("Comma-separated list of keywords for your operator") + s.Description = projutil.GetRequiredInput("Description for the operator") + s.ProviderName = projutil.GetRequiredInput("Provider's name for the operator") + s.ProviderURL = projutil.GetOptionalInput("Any relevant URL for the provider name") + s.Maintainers = projutil.GetStringArray("Comma-separated list of maintainers and their emails" + + " (e.g. 'name1:email1, name2:email2')") +} + +// addUImetadata populates the CSV with the data obtained from the interactive +// prompts which appear while generating CSV. +func (s *interactiveCSVCmd) addUImetadata(csv *olmapiv1alpha1.ClusterServiceVersion) { + if s.DisplayName != "" { + csv.Spec.DisplayName = s.DisplayName + } + + if len(s.Keywords) != 0 { + csv.Spec.Keywords = s.Keywords + } + + if s.Description != "" { + csv.Spec.Description = s.Description + } + + if len(s.Maintainers) != 0 { + maintainers := make([]olmapiv1alpha1.Maintainer, 0) + for _, entity := range s.Maintainers { + entityDetails := strings.Split(entity, ":") + if len(entityDetails) == 2 { + m := olmapiv1alpha1.Maintainer{} + m.Name, m.Email = entityDetails[0], entityDetails[1] + maintainers = append(maintainers, m) + } + } + csv.Spec.Maintainers = maintainers + } + + if s.ProviderName != "" { + provider := olmapiv1alpha1.AppLink{} + provider.Name = s.ProviderName + if s.ProviderURL != "" { + provider.URL = s.ProviderURL + } + csv.Spec.Provider = provider + } + +} diff --git a/internal/generate/olm-catalog/csv_go_test.go b/internal/generate/olm-catalog/csv_go_test.go index eda7a13549..475a64051c 100644 --- a/internal/generate/olm-catalog/csv_go_test.go +++ b/internal/generate/olm-catalog/csv_go_test.go @@ -95,18 +95,20 @@ func TestGoCSVNewWithInputsToOutput(t *testing.T) { csvVersion := "0.0.1" g := BundleGenerator{ - OperatorName: testProjectName, - DeployDir: "config", - ApisDir: "api", - CRDsDir: filepath.Join("config", "crds"), - OutputDir: outputDir, - CSVVersion: csvVersion, - FromVersion: "", - UpdateCRDs: false, - MakeManifests: false, + OperatorName: testProjectName, + DeployDir: "config", + ApisDir: "api", + CRDsDir: filepath.Join("config", "crds"), + OutputDir: outputDir, + CSVVersion: csvVersion, + FromVersion: "", + UpdateCRDs: false, + MakeManifests: false, + InteractivePreference: projutil.InteractiveHardOff, } g.noUpdate = true + if err := g.Generate(); err != nil { t.Fatalf("Failed to execute CSV generator: %v", err) } @@ -151,16 +153,18 @@ func TestGoCSVUpgradeWithInputsToOutput(t *testing.T) { } g := BundleGenerator{ - OperatorName: testProjectName, - DeployDir: "config", - ApisDir: "api", - CRDsDir: filepath.Join("config", "crds"), - OutputDir: outputDir, - CSVVersion: csvVersion, - FromVersion: fromVersion, - UpdateCRDs: false, - MakeManifests: false, + OperatorName: testProjectName, + DeployDir: "config", + ApisDir: "api", + CRDsDir: filepath.Join("config", "crds"), + OutputDir: outputDir, + CSVVersion: csvVersion, + FromVersion: fromVersion, + UpdateCRDs: false, + MakeManifests: false, + InteractivePreference: projutil.InteractiveHardOff, } + if err := g.Generate(); err != nil { t.Fatalf("Failed to execute CSV generator: %v", err) } @@ -392,6 +396,49 @@ func TestGoCSVNewWithEmptyDeployDir(t *testing.T) { } } +func TestCSVPrompt(t *testing.T) { + cleanupFunc := chDirWithCleanup(t, testGoDataDir) + defer cleanupFunc() + + s := interactiveCSVCmd{ + DisplayName: "Memcached Application", + Keywords: []string{"memcached", "app"}, + Description: "Main enterprise application providing business critical features with " + + "high availability and no manual intervention.", + ProviderName: "Example", + ProviderURL: "www.example.com", + Maintainers: []string{"Some Corp:corp@example.com"}, + } + + g := BundleGenerator{ + OperatorName: testProjectName, + DeployDir: "deploy", + ApisDir: filepath.Join("pkg", "apis"), + CRDsDir: filepath.Join("deploy", "crds_v1beta1"), + OutputDir: "deploy", + CSVVersion: "0.0.2", + FromVersion: "", + UpdateCRDs: false, + MakeManifests: true, + interactiveCSVCmd: s, + } + + g.setDefaults() + fileMap, err := g.generateCSV() + if err != nil { + t.Fatalf("Failed to execute CSV generator: %v", err) + } + + csvExpFile := getCSVFileName(testProjectName) + csvExpBytes := readFile(t, filepath.Join(OLMCatalogDir, testProjectName, "manifests", csvExpFile)) + if b, ok := fileMap[csvExpFile]; !ok { + t.Errorf("Failed to generate CSV for version %s", csvVersion) + } else { + assert.Equal(t, string(csvExpBytes), string(b)) + } + +} + func TestUpdateCSVVersion(t *testing.T) { cleanupFunc := chDirWithCleanup(t, testGoDataDir) defer cleanupFunc() diff --git a/internal/generate/testdata/go/deploy/olm-catalog/memcached-operator/manifests/memcached-operator.clusterserviceversion.yaml b/internal/generate/testdata/go/deploy/olm-catalog/memcached-operator/manifests/memcached-operator.clusterserviceversion.yaml index 063f57f480..2d21c43be4 100644 --- a/internal/generate/testdata/go/deploy/olm-catalog/memcached-operator/manifests/memcached-operator.clusterserviceversion.yaml +++ b/internal/generate/testdata/go/deploy/olm-catalog/memcached-operator/manifests/memcached-operator.clusterserviceversion.yaml @@ -90,7 +90,7 @@ spec: fieldPath: metadata.name - name: OPERATOR_NAME value: memcached-operator - image: quay.io/example/memcached-operator:v0.0.2 + image: quay.io/example/memcached-operator:v0.0.3 imagePullPolicy: Never name: memcached-operator resources: {} diff --git a/internal/util/projutil/interactive_promt_util.go b/internal/util/projutil/interactive_promt_util.go new file mode 100644 index 0000000000..621a261c1c --- /dev/null +++ b/internal/util/projutil/interactive_promt_util.go @@ -0,0 +1,113 @@ +// Copyright 2020 The Operator-SDK 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 projutil + +import ( + "bufio" + "fmt" + "io" + "log" + "os" + "strings" +) + +// InteractiveLevel captures the user preference on the generation of interactive +// commands. +type InteractiveLevel int + +const ( + // User has not turned interactive mode on or off, default to off. + InteractiveSoftOff InteractiveLevel = iota + // User has explicitly turned interactive mode off. + InteractiveHardOff + // User only explicitly turned interactive mode on. + InteractiveOnAll +) + +func printMessage(msg string, isOptional bool) { + fmt.Println() + if isOptional { + fmt.Print(strings.TrimSpace(msg) + " (optional): " + "\n" + "> ") + } else { + fmt.Print(strings.TrimSpace(msg) + " (required): " + "\n" + "> ") + } +} + +func GetRequiredInput(msg string) string { + return getRequiredInput(os.Stdin, msg) +} + +func getRequiredInput(rd io.Reader, msg string) string { + reader := bufio.NewReader(rd) + + for { + printMessage(msg, false) + value := readInput(reader) + if value != "" { + return value + } + fmt.Printf("Input is required. ") + } +} + +func GetOptionalInput(msg string) string { + printMessage(msg, true) + value := readInput(bufio.NewReader(os.Stdin)) + return value +} + +func GetStringArray(msg string) []string { + return getStringArray(os.Stdin, msg) +} + +func getStringArray(rd io.Reader, msg string) []string { + reader := bufio.NewReader(rd) + for { + printMessage(msg, false) + value := readArray(reader) + if len(value) != 0 && len(value[0]) != 0 { + return value + } + fmt.Printf("No list provided. ") + } +} + +// readstdin reads a line from stdin and returns the value. +func readLine(reader *bufio.Reader) string { + text, err := reader.ReadString('\n') + if err != nil { + log.Fatalf("Error when reading input: %v", err) + } + return strings.TrimSpace(text) +} + +func readInput(reader *bufio.Reader) string { + for { + text := readLine(reader) + return text + } +} + +// readArray parses the line from stdin, returns an array +// of words. +func readArray(reader *bufio.Reader) []string { + arr := make([]string, 0) + text := readLine(reader) + + for _, words := range strings.Split(text, ",") { + arr = append(arr, strings.TrimSpace(words)) + } + return arr +} diff --git a/internal/util/projutil/interactive_promt_util_test.go b/internal/util/projutil/interactive_promt_util_test.go new file mode 100644 index 0000000000..4521f3b308 --- /dev/null +++ b/internal/util/projutil/interactive_promt_util_test.go @@ -0,0 +1,95 @@ +// Copyright 2020 The Operator-SDK 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 projutil + +import ( + "bytes" + "reflect" + "testing" +) + +func TestUserInput(t *testing.T) { + type args struct { + msg string + content []byte + } + + tests := []struct { + name string + args args + want string + }{ + { + name: "test when user provides input to the command", + args: args{ + msg: "Enter a word: ", + content: []byte("Memcached Operator\n"), + }, + want: "Memcached Operator", + }, + { + name: "test when user does not provide input and prompt appears again", + args: args{ + msg: "Enter a word: ", + content: []byte("\nMemcached Operator\n"), + }, + want: "Memcached Operator", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getRequiredInput(bytes.NewBuffer(tt.args.content), tt.args.msg); got != tt.want { + t.Errorf("GetRequiredInput() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUserInputStringArray(t *testing.T) { + type args struct { + msg string + content []byte + } + + tests := []struct { + name string + args args + want []string + }{ + { + name: "test when user provides input to the command", + args: args{ + msg: "Enter list of words", + content: []byte("app, memcached-operator \n"), + }, + want: []string{"app", "memcached-operator"}, + }, + { + name: "test when user does not provide input and prompt appears again", + args: args{ + msg: "Enter list of words", + content: []byte("\noperator, app\n"), + }, + want: []string{"operator", "app"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getStringArray(bytes.NewBuffer(tt.args.content), tt.args.msg); !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetRequiredInput() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/website/content/en/docs/cli/operator-sdk_generate_csv.md b/website/content/en/docs/cli/operator-sdk_generate_csv.md index d163da75b2..44b0eb8dba 100644 --- a/website/content/en/docs/cli/operator-sdk_generate_csv.md +++ b/website/content/en/docs/cli/operator-sdk_generate_csv.md @@ -131,6 +131,7 @@ operator-sdk generate csv [flags] --csv-version string Semantic version of the CSV. This flag must be set if a package manifest exists --deploy-dir string Project relative path to root directory for operator manifests (Deployment and RBAC) (default "deploy") -h, --help help for csv + --interactive When set, will enable the interactive command prompt feature to fill the UI metadata fields in CSV --make-manifests When set, the generator will create or update a CSV manifest in a 'manifests' directory. This directory is intended to be used for your latest bundle manifests. The default location is deploy/olm-catalog//manifests. If --output-dir is set, the directory will be /manifests (default true) --operator-name string Operator name to use while generating CSV --output-dir string Base directory to output generated CSV. If --make-manifests=false the resulting CSV bundle directory will be /olm-catalog//. If --make-manifests=true, the bundle directory will be /manifests