diff --git a/changelog/fragments/generate-packagemanifests.yaml b/changelog/fragments/generate-packagemanifests.yaml new file mode 100644 index 0000000000..d20f1720c3 --- /dev/null +++ b/changelog/fragments/generate-packagemanifests.yaml @@ -0,0 +1,3 @@ +entries: + - description: add `generate packagemanifests` subcommand for new project layouts + kind: addition diff --git a/cmd/operator-sdk/generate/cmd.go b/cmd/operator-sdk/generate/cmd.go index 694dae99e8..2dc10f6891 100644 --- a/cmd/operator-sdk/generate/cmd.go +++ b/cmd/operator-sdk/generate/cmd.go @@ -18,6 +18,7 @@ import ( "github.com/spf13/cobra" "github.com/operator-framework/operator-sdk/cmd/operator-sdk/generate/bundle" + "github.com/operator-framework/operator-sdk/cmd/operator-sdk/generate/packagemanifests" ) func newCmd() *cobra.Command { @@ -34,6 +35,7 @@ func NewCmd() *cobra.Command { cmd := newCmd() cmd.AddCommand( bundle.NewCmd(), + packagemanifests.NewCmd(), ) return cmd } diff --git a/cmd/operator-sdk/generate/packagemanifests/cmd.go b/cmd/operator-sdk/generate/packagemanifests/cmd.go new file mode 100644 index 0000000000..863f13865d --- /dev/null +++ b/cmd/operator-sdk/generate/packagemanifests/cmd.go @@ -0,0 +1,156 @@ +// 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 packagemanifests + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + kbutil "github.com/operator-framework/operator-sdk/internal/util/kubebuilder" + "github.com/operator-framework/operator-sdk/internal/util/projutil" +) + +const longHelp = ` + Note: while the package manifests format is not yet deprecated, the operator-framework is migrated + towards using bundles by default. Run 'operator-sdk generate bundle -h' for more information. + + Running 'generate packagemanifests' is the first step to publishing your operator to a catalog + and/or deploying it with OLM. This command generates a set of manifests in a versioned directory + and a package manifest file for your operator. It will interactively ask for UI metadata, + an important component of publishing your operator, by default unless a package for your + operator exists or you set '--interactive=false'. + + Set '--version' to supply a semantic version for your new package. This is a required flag when running + 'generate packagemanifests --manifests'. + + More information on the package manifests format: + https://github.com/operator-framework/operator-registry/#manifest-format +` + +//nolint:maligned +type packagemanifestsCmd struct { + // Options to turn on different parts of packaging. + kustomize bool + manifests bool + + // Common options. + operatorName string + version string + fromVersion string + inputDir string + outputDir string + deployDir string + apisDir string + crdsDir string + updateCRDs bool + stdout bool + quiet bool + + // Interactive options. + interactiveLevel projutil.InteractiveLevel + interactive bool + + // Package manifest options. + channelName string + isDefaultChannel bool +} + +// NewCmd returns the 'packagemanifests' command configured for the new project layout. +func NewCmd() *cobra.Command { + c := &packagemanifestsCmd{} + + cmd := &cobra.Command{ + Use: "packagemanifests", + Short: "Generates a package manifests format", + Long: longHelp, + Example: examples, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return fmt.Errorf("command %s doesn't accept any arguments", cmd.CommandPath()) + } + + // Check if the user has any specific preference to enable/disable + // interactive prompts. Default behaviour is to disable the prompt + // unless a base package does not exist. + if cmd.Flags().Changed("interactive") { + if c.interactive { + c.interactiveLevel = projutil.InteractiveOnAll + } else { + c.interactiveLevel = projutil.InteractiveHardOff + } + } + + // Generate kustomize bases and manifests by default if no flags are set + // so the default behavior is "do everything". + fs := cmd.Flags() + if !fs.Changed("kustomize") && !fs.Changed("manifests") { + c.kustomize = true + c.manifests = true + } + + cfg, err := kbutil.ReadConfig() + if err != nil { + log.Fatal(fmt.Errorf("error reading configuration: %v", err)) + } + c.setCommonDefaults(cfg) + + if c.kustomize { + if err = c.runKustomize(cfg); err != nil { + log.Fatalf("Error generating package bases: %v", err) + } + } + if c.manifests { + if err = c.validateManifests(); err != nil { + return fmt.Errorf("invalid command options: %v", err) + } + if err = c.runManifests(cfg); err != nil { + log.Fatalf("Error generating package manifests: %v", err) + } + } + + return nil + }, + } + + cmd.Flags().BoolVar(&c.kustomize, "kustomize", false, "Generate kustomize bases") + cmd.Flags().BoolVar(&c.manifests, "manifests", false, "Generate package manifests") + cmd.Flags().BoolVar(&c.stdout, "stdout", false, "Write package to stdout") + + c.addCommonFlagsTo(cmd.Flags()) + + return cmd +} + +func (c *packagemanifestsCmd) addCommonFlagsTo(fs *pflag.FlagSet) { + fs.StringVar(&c.operatorName, "operator-name", "", "Name of the packaged operator") + fs.StringVarP(&c.version, "version", "v", "", "Semantic version of the packaged operator") + fs.StringVar(&c.inputDir, "input-dir", "", "Directory to read existing package manifests from. "+ + "This directory is the parent of individual versioned package directories, and different from --deploy-dir") + fs.StringVar(&c.outputDir, "output-dir", "", "Directory in which to write package manifests") + fs.StringVar(&c.deployDir, "deploy-dir", "", "Root directory for operator manifests such as "+ + "Deployments and RBAC, ex. 'deploy'. This directory is different from that passed to --input-dir") + fs.StringVar(&c.apisDir, "apis-dir", "", "Root directory for API type defintions") + fs.StringVar(&c.crdsDir, "crds-dir", "", "Root directory for CustomResoureDefinition manifests") + fs.StringVar(&c.channelName, "channel", "", "Channel name for the generated package") + fs.BoolVar(&c.isDefaultChannel, "default-channel", false, "Use the channel passed to --channel "+ + "as the package manifest file's default channel") + fs.BoolVar(&c.updateCRDs, "update-crds", true, "Update CustomResoureDefinition manifests in this package") + fs.BoolVarP(&c.quiet, "quiet", "q", false, "Run in quiet mode") + fs.BoolVar(&c.interactive, "interactive", false, "When set or no package base exists, an interactive "+ + "command prompt will be presented to accept package ClusterServiceVersion metadata") +} diff --git a/cmd/operator-sdk/generate/packagemanifests/packagemanifests.go b/cmd/operator-sdk/generate/packagemanifests/packagemanifests.go new file mode 100644 index 0000000000..ef68400d0a --- /dev/null +++ b/cmd/operator-sdk/generate/packagemanifests/packagemanifests.go @@ -0,0 +1,260 @@ +// 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 packagemanifests + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/model/config" + + genutil "github.com/operator-framework/operator-sdk/cmd/operator-sdk/generate/internal" + gencsv "github.com/operator-framework/operator-sdk/internal/generate/clusterserviceversion" + "github.com/operator-framework/operator-sdk/internal/generate/collector" + genpkg "github.com/operator-framework/operator-sdk/internal/generate/packagemanifest" + "github.com/operator-framework/operator-sdk/internal/scaffold/kustomize" +) + +//nolint:lll +const examples = ` + # Generate manifests then create the package manifests base: + $ make manifests + /home/user/go/bin/controller-gen "crd:trivialVersions=true" rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases + $ operator-sdk generate packagemanifests -q --kustomize + + Display name for the operator (required): + > memcached-operator + ... + + $ kustomize build config/packagemanifests | operator-sdk generate packagemanifests --manifests --version 0.0.1 + Generating package manifests version 0.0.1 + ... + + # After running the above commands, you should see: + $ tree config/packages + config/packages + ├── bases + │   └── memcached-operator.clusterserviceversion.yaml + ├── kustomization.yaml + ├── 0.0.1 + │   ├── cache.my.domain_memcacheds.yaml + │   └── memcached-operator.clusterserviceversion.yaml + └── memcached-operator.package.yaml +` + +// kustomization.yaml file contents for manifests. This should always be written to +// config/packagemanifests/kustomization.yaml since it only references files in config. +const manifestsKustomization = `resources: +- ../default +- ../samples +` + +var defaultDir = filepath.Join("config", "packagemanifests") + +// setCommonDefaults sets defaults useful to all modes of this subcommand. +func (c *packagemanifestsCmd) setCommonDefaults(cfg *config.Config) { + if c.operatorName == "" { + c.operatorName = filepath.Base(cfg.Repo) + } +} + +// runKustomize generates kustomize package bases. +func (c packagemanifestsCmd) runKustomize(cfg *config.Config) error { + + if !c.quiet { + fmt.Println("Generating package manifests kustomize bases") + } + + if c.inputDir == "" { + c.inputDir = defaultDir + } + if c.outputDir == "" { + c.outputDir = defaultDir + } + if c.apisDir == "" { + if cfg.MultiGroup { + c.apisDir = "apis" + } else { + c.apisDir = "api" + } + } + + csvGen := gencsv.Generator{ + OperatorName: c.operatorName, + OperatorType: genutil.PluginKeyToOperatorType(cfg.Layout), + } + opts := []gencsv.Option{ + gencsv.WithBase(c.inputDir, c.apisDir, c.interactiveLevel), + gencsv.WithBaseWriter(c.outputDir), + } + if err := csvGen.Generate(cfg, opts...); err != nil { + return fmt.Errorf("error generating ClusterServiceVersion: %v", err) + } + + // Write a kustomization.yaml to the config directory. + if err := kustomize.WriteIfNotExist(defaultDir, manifestsKustomization); err != nil { + return err + } + + if !c.quiet { + fmt.Println("Bases generated successfully in", c.outputDir) + } + + return nil +} + +// validateManifests validates c for package manifests generation. +func (c packagemanifestsCmd) validateManifests() error { + + if c.version != "" { + if err := genutil.ValidateVersion(c.version); err != nil { + return err + } + } else { + return errors.New("--version must be set") + } + + if c.fromVersion != "" { + return errors.New("--from-version cannot be set for PROJECT-configured projects") + } + + if !genutil.IsPipeReader() { + if c.deployDir == "" { + return errors.New("--deploy-dir must be set if not reading from stdin") + } + if c.crdsDir == "" { + return errors.New("--crd-dir must be set if not reading from stdin") + } + } + + if c.stdout { + if c.outputDir != "" { + return errors.New("--output-dir cannot be set if writing to stdout") + } + } + + if c.isDefaultChannel && c.channelName == "" { + return fmt.Errorf("--default-channel can only be set if --channel is set") + } + + return nil +} + +// runManifests generates package manifests. +func (c packagemanifestsCmd) runManifests(cfg *config.Config) error { + + if !c.quiet && !c.stdout { + fmt.Println("Generating package manifests version", c.version) + } + + if c.inputDir == "" { + c.inputDir = defaultDir + } + if !c.stdout { + if c.outputDir == "" { + c.outputDir = defaultDir + } + } + // Only regenerate API definitions once. + if c.apisDir == "" && !c.kustomize { + if cfg.MultiGroup { + c.apisDir = "apis" + } else { + c.apisDir = "api" + } + } + packageDir := filepath.Join(c.outputDir, c.version) + + if err := c.generatePackageManifest(); err != nil { + return err + } + + col := &collector.Manifests{} + if genutil.IsPipeReader() { + if err := col.UpdateFromReader(os.Stdin); err != nil { + return err + } + } + if c.deployDir != "" { + if err := col.UpdateFromDirs(c.deployDir, c.crdsDir); err != nil { + return err + } + } + + csvGen := gencsv.Generator{ + OperatorName: c.operatorName, + OperatorType: genutil.PluginKeyToOperatorType(cfg.Layout), + Version: c.version, + Collector: col, + } + + stdout := genutil.NewMultiManifestWriter(os.Stdout) + opts := []gencsv.Option{ + gencsv.WithBase(c.inputDir, c.apisDir, c.interactiveLevel), + } + if c.stdout { + opts = append(opts, gencsv.WithWriter(stdout)) + } else { + opts = append(opts, gencsv.WithPackageWriter(c.outputDir)) + } + + if err := csvGen.Generate(cfg, opts...); err != nil { + return fmt.Errorf("error generating ClusterServiceVersion: %v", err) + } + + if c.updateCRDs { + var objs []interface{} + for _, crd := range col.V1CustomResourceDefinitions { + objs = append(objs, crd) + } + for _, crd := range col.V1beta1CustomResourceDefinitions { + objs = append(objs, crd) + } + if c.stdout { + if err := genutil.WriteObjects(stdout, objs...); err != nil { + return err + } + } else { + if err := genutil.WriteObjectsToFiles(packageDir, objs...); err != nil { + return err + } + } + } + + if !c.quiet && !c.stdout { + fmt.Println("Package manifests generated successfully in", c.outputDir) + } + + return nil +} + +func (c packagemanifestsCmd) generatePackageManifest() error { + pkgGen := genpkg.Generator{ + OperatorName: c.operatorName, + Version: c.version, + ChannelName: c.channelName, + IsDefaultChannel: c.isDefaultChannel, + } + opts := []genpkg.Option{ + genpkg.WithBase(c.inputDir), + genpkg.WithFileWriter(c.outputDir), + } + if err := pkgGen.Generate(opts...); err != nil { + return err + } + return nil +} diff --git a/internal/generate/clusterserviceversion/clusterserviceversion.go b/internal/generate/clusterserviceversion/clusterserviceversion.go index a261fb24eb..45292e6af6 100644 --- a/internal/generate/clusterserviceversion/clusterserviceversion.go +++ b/internal/generate/clusterserviceversion/clusterserviceversion.go @@ -48,12 +48,14 @@ var ( // ClusterServiceVersion configures ClusterServiceVersion manifest generation. type Generator struct { - // OperatorName is the operator's name, ex. app-operator + // OperatorName is the operator's name, ex. app-operator. OperatorName string // OperatorType determines what code API types are written in for getBase. OperatorType projutil.OperatorType // Version is the CSV current version. Version string + // FromVersion is the version of a previous CSV to upgrade from. + FromVersion string // Collector holds all manifests relevant to the Generator. Collector *collector.Manifests @@ -125,6 +127,21 @@ func WithBundleWriter(dir string) Option { } } +// WithPackageWriter sets a Generator's writer to a package CSV file under +// /. +func WithPackageWriter(dir string) Option { + return func(g *Generator) error { + fileName := makeCSVFileName(g.OperatorName) + if g.FromVersion != "" { + g.bundledPath = filepath.Join(dir, g.FromVersion, fileName) + } + g.getWriter = func() (io.Writer, error) { + return genutil.Open(filepath.Join(dir, g.Version), fileName) + } + return nil + } +} + // Generate configures the generator with cfg and opts then runs it. func (g *Generator) Generate(cfg *config.Config, opts ...Option) (err error) { g.config = cfg diff --git a/internal/generate/clusterserviceversion/clusterserviceversion_test.go b/internal/generate/clusterserviceversion/clusterserviceversion_test.go index d08da052c1..a8101e007d 100644 --- a/internal/generate/clusterserviceversion/clusterserviceversion_test.go +++ b/internal/generate/clusterserviceversion/clusterserviceversion_test.go @@ -48,10 +48,10 @@ var ( // dir and a "deploy" dir that contains `kustomize build config/default` // output to simulate actual manifest collection behavior. Using "config" // directly is not standard behavior. - goTestDataDir = filepath.Join(testDataDir, "non-standard-layout") - goAPIsDir = filepath.Join(goTestDataDir, "api") - goManifestRootDir = filepath.Join(goTestDataDir, "config") - goCRDsDir = filepath.Join(goManifestRootDir, "crds") + goTestDataDir = filepath.Join(testDataDir, "non-standard-layout") + goAPIsDir = filepath.Join(goTestDataDir, "api") + goConfigDir = filepath.Join(goTestDataDir, "config") + goCRDsDir = filepath.Join(goConfigDir, "crds") ) var ( @@ -66,7 +66,7 @@ var ( var _ = BeforeSuite(func() { col = &collector.Manifests{} - Expect(col.UpdateFromDirs(goManifestRootDir, goCRDsDir)).ToNot(HaveOccurred()) + Expect(col.UpdateFromDirs(goConfigDir, goCRDsDir)).ToNot(HaveOccurred()) cfg = readConfigHelper(goTestDataDir) @@ -100,7 +100,7 @@ var _ = Describe("Generating a ClusterServiceVersion", func() { BeforeEach(func() { tmp, err = ioutil.TempDir(".", "") - ExpectWithOffset(1, err).ToNot(HaveOccurred()) + Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { @@ -153,8 +153,24 @@ var _ = Describe("Generating a ClusterServiceVersion", func() { Expect(outputFile).To(BeAnExistingFile()) Expect(string(readFileHelper(outputFile))).To(MatchYAML(newCSVStr)) }) + It("should write a ClusterServiceVersion manifest to a package file", func() { + g = Generator{ + OperatorName: operatorName, + OperatorType: operatorType, + Version: version, + Collector: col, + } + opts := []Option{ + WithBase(csvBasesDir, goAPIsDir, projutil.InteractiveHardOff), + WithPackageWriter(tmp), + } + Expect(g.Generate(cfg, opts...)).ToNot(HaveOccurred()) + outputFile := filepath.Join(tmp, g.Version, makeCSVFileName(operatorName)) + Expect(outputFile).To(BeAnExistingFile()) + Expect(string(readFileHelper(outputFile))).To(MatchYAML(newCSVStr)) + }) - It("should write a ClusterServiceVersion manifest to a legacy base/bundle file", func() { + It("should write a ClusterServiceVersion manifest to a legacy bundle file", func() { g = Generator{ OperatorName: operatorName, OperatorType: operatorType, @@ -218,98 +234,87 @@ var _ = Describe("Generating a ClusterServiceVersion", func() { }) }) - Context("to create a new", func() { - - Context("bundle base", func() { - It("should return the default base object", func() { - g = Generator{ - OperatorName: operatorName, - OperatorType: operatorType, - config: cfg, - getBase: makeBaseGetter(baseCSV), - } - csv, err := g.generate() - Expect(err).ToNot(HaveOccurred()) - Expect(csv).To(Equal(baseCSV)) - }) - It("should return a base object with customresourcedefinitions", func() { - g = Generator{ - OperatorName: operatorName, - OperatorType: operatorType, - config: cfg, - getBase: makeBaseGetter(baseCSVUIMeta), - } - csv, err := g.generate() - Expect(err).ToNot(HaveOccurred()) - Expect(csv).To(Equal(baseCSVUIMeta)) - }) + Context("to create a new base ClusterServiceVersion", func() { + It("should return the default base object", func() { + g = Generator{ + OperatorName: operatorName, + OperatorType: operatorType, + config: cfg, + getBase: makeBaseGetter(baseCSV), + } + csv, err := g.generate() + Expect(err).ToNot(HaveOccurred()) + Expect(csv).To(Equal(baseCSV)) }) - - Context("bundle", func() { - It("should return the expected object", func() { - g = Generator{ - OperatorName: operatorName, - OperatorType: operatorType, - Version: version, - Collector: col, - config: cfg, - getBase: makeBaseGetter(baseCSVUIMeta), - } - csv, err := g.generate() - Expect(err).ToNot(HaveOccurred()) - Expect(csv).To(Equal(newCSV)) - }) + It("should return a base object with customresourcedefinitions", func() { + g = Generator{ + OperatorName: operatorName, + OperatorType: operatorType, + config: cfg, + getBase: makeBaseGetter(baseCSVUIMeta), + } + csv, err := g.generate() + Expect(err).ToNot(HaveOccurred()) + Expect(csv).To(Equal(baseCSVUIMeta)) }) - }) - Context("to update an existing", func() { - Context("bundle", func() { - It("should return the expected object", func() { - g = Generator{ - OperatorName: operatorName, - OperatorType: operatorType, - Version: version, - Collector: &collector.Manifests{}, - config: cfg, - getBase: makeBaseGetter(newCSV), - } - // Update the input's and expected CSV's Deployment image. - Expect(g.Collector.UpdateFromDirs(goManifestRootDir, goCRDsDir)).ToNot(HaveOccurred()) - Expect(len(g.Collector.Deployments)).To(BeNumerically(">=", 1)) - imageTag := "controller:v" + g.Version - modifyDepImageHelper(&g.Collector.Deployments[0].Spec, imageTag) - updatedCSV := updateCSV(newCSV, modifyCSVDepImageHelper(imageTag)) - - csv, err := g.generate() - Expect(err).ToNot(HaveOccurred()) - Expect(csv).To(Equal(updatedCSV)) - }) + Context("to create a new ClusterServiceVersion", func() { + It("should return a new object", func() { + g = Generator{ + OperatorName: operatorName, + OperatorType: operatorType, + Version: version, + Collector: col, + config: cfg, + getBase: makeBaseGetter(baseCSVUIMeta), + } + csv, err := g.generate() + Expect(err).ToNot(HaveOccurred()) + Expect(csv).To(Equal(newCSV)) }) - }) - Context("to upgrade an existing", func() { - - Context("bundle", func() { - It("should return the expected manifest", func() { - g = Generator{ - OperatorName: operatorName, - OperatorType: operatorType, - Version: "0.0.2", - Collector: col, - config: cfg, - getBase: makeBaseGetter(newCSV), - // Bundles need a path, usually set by an Option, to an existing - // CSV manifest so "replaces" can be set correctly. - bundledPath: filepath.Join(csvNewLayoutBundleDir, "memcached-operator.clusterserviceversion.yaml"), - } - csv, err := g.generate() - Expect(err).ToNot(HaveOccurred()) - Expect(csv).To(Equal(upgradeCSV(newCSV, g.OperatorName, g.Version))) - }) + Context("to update an existing ClusterServiceVersion", func() { + It("should return an updated object", func() { + g = Generator{ + OperatorName: operatorName, + OperatorType: operatorType, + Version: version, + Collector: &collector.Manifests{}, + config: cfg, + getBase: makeBaseGetter(newCSV), + } + // Update the input's and expected CSV's Deployment image. + Expect(g.Collector.UpdateFromDirs(goConfigDir, goCRDsDir)).ToNot(HaveOccurred()) + Expect(len(g.Collector.Deployments)).To(BeNumerically(">=", 1)) + imageTag := "controller:v" + g.Version + modifyDepImageHelper(&g.Collector.Deployments[0].Spec, imageTag) + updatedCSV := updateCSV(newCSV, modifyCSVDepImageHelper(imageTag)) + + csv, err := g.generate() + Expect(err).ToNot(HaveOccurred()) + Expect(csv).To(Equal(updatedCSV)) }) + }) + Context("to upgrade an existing ClusterServiceVersion", func() { + It("should return an upgraded object", func() { + g = Generator{ + OperatorName: operatorName, + OperatorType: operatorType, + Version: "0.0.2", + Collector: col, + config: cfg, + getBase: makeBaseGetter(newCSV), + // Bundles need a path, usually set by an Option, to an existing + // CSV manifest so "replaces" can be set correctly. + bundledPath: filepath.Join(csvNewLayoutBundleDir, "memcached-operator.clusterserviceversion.yaml"), + } + csv, err := g.generate() + Expect(err).ToNot(HaveOccurred()) + Expect(csv).To(Equal(upgradeCSV(newCSV, g.OperatorName, g.Version))) + }) }) }) diff --git a/internal/generate/clusterserviceversion/clusterserviceversion_updaters.go b/internal/generate/clusterserviceversion/clusterserviceversion_updaters.go index 3cd193b22e..73bd0a65be 100644 --- a/internal/generate/clusterserviceversion/clusterserviceversion_updaters.go +++ b/internal/generate/clusterserviceversion/clusterserviceversion_updaters.go @@ -328,9 +328,8 @@ func validate(csv *operatorsv1alpha1.ClusterServiceVersion) error { hasErrors = true } } - if hasErrors { - return errors.New("generated ClusterServiceVersion is invalid") + return errors.New("invalid generated ClusterServiceVersion") } return nil diff --git a/internal/generate/collector/collect.go b/internal/generate/collector/collect.go index a861a5841d..715695b69d 100644 --- a/internal/generate/collector/collect.go +++ b/internal/generate/collector/collect.go @@ -47,13 +47,13 @@ type Manifests struct { Others []unstructured.Unstructured } -// UpdateFromDirs adds Roles, ClusterRoles, Deployments, and Custom Resources -// found in manifestRoot, and CustomResourceDefinitions found in crdsDir, +// UpdateFromDirs adds Roles, ClusterRoles, Deployments, and Custom Resource examples +// found in deployDir, and CustomResourceDefinitions found in crdsDir, // to their respective fields in a Manifests, then filters and deduplicates them. // All other objects are added to Manifests.Others. -func (c *Manifests) UpdateFromDirs(manifestRoot, crdsDir string) error { +func (c *Manifests) UpdateFromDirs(deployDir, crdsDir string) error { // Collect all manifests in paths. - err := filepath.Walk(manifestRoot, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(deployDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return err } @@ -95,7 +95,7 @@ func (c *Manifests) UpdateFromDirs(manifestRoot, crdsDir string) error { return scanner.Err() }) if err != nil { - return fmt.Errorf("error collecting manifests from directory %s: %v", manifestRoot, err) + return fmt.Errorf("error collecting manifests from directory %s: %v", deployDir, err) } // Add CRDs from input. diff --git a/internal/generate/packagemanifest/bases/packagemanifest.go b/internal/generate/packagemanifest/bases/packagemanifest.go new file mode 100644 index 0000000000..0a598837b0 --- /dev/null +++ b/internal/generate/packagemanifest/bases/packagemanifest.go @@ -0,0 +1,67 @@ +// 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 bases + +import ( + "fmt" + "io/ioutil" + + apimanifests "github.com/operator-framework/api/pkg/manifests" + "sigs.k8s.io/yaml" +) + +// PackageManifest configures the PackageManifest that GetBase() returns. +type PackageManifest struct { + PackageName string + BasePath string +} + +// GetBase returns a base PackageManifest, populated either with default +// values or, if b.BasePath is set, bytes from disk. +func (b PackageManifest) GetBase() (base *apimanifests.PackageManifest, err error) { + if b.BasePath != "" { + if base, err = readPackageManifestBase(b.BasePath); err != nil { + return nil, fmt.Errorf("error reading existing PackageManifest base %s: %v", b.BasePath, err) + } + } else { + base = b.makeNewBase() + } + + return base, nil +} + +// makeNewBase returns a base makeNewBase to modify. +func (b PackageManifest) makeNewBase() *apimanifests.PackageManifest { + return &apimanifests.PackageManifest{ + PackageName: b.PackageName, + } +} + +// readPackageManifestBase returns the PackageManifest base at path. +// If no base is found, readPackageManifestBase returns an error. +func readPackageManifestBase(path string) (*apimanifests.PackageManifest, error) { + b, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + pkg := &apimanifests.PackageManifest{} + if err := yaml.Unmarshal(b, pkg); err != nil { + return nil, fmt.Errorf("error unmarshalling PackageManifest from %s: %w", path, err) + } + if pkg.PackageName == "" { + return nil, fmt.Errorf("no PackageManifest in %s", path) + } + return pkg, nil +} diff --git a/internal/generate/packagemanifest/packagemanifest.go b/internal/generate/packagemanifest/packagemanifest.go new file mode 100644 index 0000000000..87c8486fa1 --- /dev/null +++ b/internal/generate/packagemanifest/packagemanifest.go @@ -0,0 +1,230 @@ +// 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 packagemanifest + +import ( + "errors" + "fmt" + "io" + "path/filepath" + "sort" + "strings" + + apimanifests "github.com/operator-framework/api/pkg/manifests" + "github.com/operator-framework/api/pkg/validation" + log "github.com/sirupsen/logrus" + + genutil "github.com/operator-framework/operator-sdk/internal/generate/internal" + "github.com/operator-framework/operator-sdk/internal/generate/packagemanifest/bases" +) + +const ( + // File extension for all PackageManifests written by Generator. + packageManifestFileExt = ".package.yaml" +) + +var ( + // User-facing errors. + errNoVersion = errors.New("version must be set") + + // Internal errors. + errNoGetBase = genutil.InternalError("getBase must be set") + errNoGetWriter = genutil.InternalError("getWriter must be set") +) + +type Generator struct { + // OperatorName is the operator's name, ex. app-operator. + OperatorName string + // Version is the version of the operator being updated. + Version string + // ChannelName is operator's PackageManifest channel. If a new PackageManifest is generated + // or ChannelName is the only channel in the generated PackageManifest, + // this channel will be set to the PackageManifest's default. + ChannelName string + // IsDefaultChannel determines whether ChannelName should be the default channel in the + // generated PackageManifest. If true, ChannelName will be the PackageManifest's default channel. + // Setting this field is only necessary when more than one channel exists. + IsDefaultChannel bool + + // Func that returns a base PackageManifest. + getBase getBaseFunc + // Func that returns the writer the generated PackageManifest's bytes are written to. + getWriter func() (io.Writer, error) +} + +// Type of Generator.getBase. +type getBaseFunc func() (*apimanifests.PackageManifest, error) + +// Option is a function that modifies a Generator. +type Option func(*Generator) error + +// WithBase sets a Generator's base PackageManifest to one that either exists or a default. +func WithBase(inputDir string) Option { + return func(g *Generator) error { + g.getBase = g.makeBaseGetter(inputDir) + return nil + } +} + +// WithWriter sets a Generator's writer to w. +func WithWriter(w io.Writer) Option { + return func(g *Generator) error { + g.getWriter = func() (io.Writer, error) { + return w, nil + } + return nil + } +} + +// WithFileWriter sets a Generator's writer to a PackageManifest file under . +func WithFileWriter(dir string) Option { + return func(g *Generator) (err error) { + g.getWriter = func() (io.Writer, error) { + return genutil.Open(dir, makePkgManFileName(g.OperatorName)) + } + return nil + } +} + +// Generate configures the Generator with opts then runs it. +func (g *Generator) Generate(opts ...Option) error { + for _, opt := range opts { + if err := opt(g); err != nil { + return err + } + } + + if g.getWriter == nil { + return errNoGetWriter + } + + pkg, err := g.generate() + if err != nil { + return err + } + + w, err := g.getWriter() + if err != nil { + return err + } + return genutil.WriteYAML(w, pkg) +} + +// generate runs a configured Generator. +func (g *Generator) generate() (*apimanifests.PackageManifest, error) { + if g.getBase == nil { + return nil, errNoGetBase + } + if g.Version == "" { + return nil, errNoVersion + } + + base, err := g.getBase() + if err != nil { + return nil, fmt.Errorf("error getting PackageManifest base: %v", err) + } + + csvName := genutil.MakeCSVName(g.OperatorName, g.Version) + if g.ChannelName != "" { + setChannels(base, g.ChannelName, csvName) + sortChannelsByName(base) + if g.IsDefaultChannel || len(base.Channels) == 1 { + base.DefaultChannelName = g.ChannelName + } + } else if len(base.Channels) == 0 { + setChannels(base, "alpha", csvName) + base.DefaultChannelName = "alpha" + } + + if err = validatePackageManifest(base); err != nil { + return nil, err + } + + return base, nil +} + +// makeBaseGetter returns a function that gets a base from inputDir. +func (g Generator) makeBaseGetter(inputDir string) func() (*apimanifests.PackageManifest, error) { + basePath := filepath.Join(inputDir, makePkgManFileName(g.OperatorName)) + if genutil.IsNotExist(basePath) { + basePath = "" + } + + return func() (*apimanifests.PackageManifest, error) { + b := bases.PackageManifest{ + PackageName: g.OperatorName, + BasePath: basePath, + } + return b.GetBase() + } +} + +// makePkgManFileName will return the file name of a PackageManifest. +func makePkgManFileName(operatorName string) string { + return strings.ToLower(operatorName) + packageManifestFileExt +} + +// sortChannelsByName sorts pkg.Channels by each element's name. +func sortChannelsByName(pkg *apimanifests.PackageManifest) { + sort.Slice(pkg.Channels, func(i int, j int) bool { + return pkg.Channels[i].Name < pkg.Channels[j].Name + }) +} + +// validatePackageManifest will validate pkg and log warnings and errors. +// If a validation error is encountered, an error is returned. +func validatePackageManifest(pkg *apimanifests.PackageManifest) error { + if pkg == nil { + return errors.New("empty PackageManifest") + } + + hasErrors := false + results := validation.PackageManifestValidator.Validate(pkg) + for _, r := range results { + for _, e := range r.Errors { + log.Errorf("PackageManifest validation: [%s] %s", e.Type, e.Detail) + } + for _, w := range r.Warnings { + log.Warnf("PackageManifest validation: [%s] %s", w.Type, w.Detail) + } + if r.HasError() { + hasErrors = true + } + } + + if hasErrors { + return errors.New("invalid generated PackageManifest") + } + + return nil +} + +// setChannels checks for duplicate channels in pkg and sets the default channel if possible. +func setChannels(pkg *apimanifests.PackageManifest, channelName, csvName string) { + channelIdx := -1 + for i, channel := range pkg.Channels { + if channel.Name == channelName { + pkg.Channels[i].CurrentCSVName = csvName + channelIdx = i + break + } + } + if channelIdx == -1 { + pkg.Channels = append(pkg.Channels, apimanifests.PackageChannel{ + Name: channelName, + CurrentCSVName: csvName, + }) + } +} diff --git a/internal/generate/packagemanifest/packagemanifest_test.go b/internal/generate/packagemanifest/packagemanifest_test.go new file mode 100644 index 0000000000..d617292c67 --- /dev/null +++ b/internal/generate/packagemanifest/packagemanifest_test.go @@ -0,0 +1,274 @@ +// 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 packagemanifest + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/format" + + apimanifests "github.com/operator-framework/api/pkg/manifests" + "sigs.k8s.io/yaml" + + genutil "github.com/operator-framework/operator-sdk/internal/generate/internal" +) + +func TestGenerator(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Generator Suite") +} + +const ( + pkgManDefaultContent = `channels: + - currentCSV: memcached-operator.v0.0.1 + name: alpha +defaultChannel: alpha +packageName: memcached-operator +` + + pkgManSingleChannelContent = `channels: + - currentCSV: memcached-operator.v0.0.1 + name: stable +defaultChannel: stable +packageName: memcached-operator +` +) + +var ( + testDataDir = filepath.Join("..", "testdata") + + pkgManDefault, pkgManSingleChannel *apimanifests.PackageManifest +) + +var _ = BeforeSuite(func() { + initTestPackageManifestsHelper() +}) + +var _ = Describe("Generating a PackageManifest", func() { + format.UseStringerRepresentation = true + + var ( + g Generator + buf *bytes.Buffer + operatorName = "memcached-operator" + version = "0.0.1" + ) + + BeforeEach(func() { + buf = &bytes.Buffer{} + }) + + Describe("for the new Go project layout", func() { + + Context("with correct Options", func() { + + var ( + tmp string + err error + ) + + BeforeEach(func() { + tmp, err = ioutil.TempDir(".", "") + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + if tmp != "" { + os.RemoveAll(tmp) + } + }) + + It("should write a PackageManifest to an io.Writer", func() { + g = Generator{ + OperatorName: operatorName, + Version: version, + } + opts := []Option{ + WithBase(testDataDir), + WithWriter(buf), + } + Expect(g.Generate(opts...)).ToNot(HaveOccurred()) + Expect(buf.String()).To(MatchYAML(pkgManDefaultContent)) + }) + It("should write a PackageManifest to disk", func() { + g = Generator{ + OperatorName: operatorName, + Version: version, + } + opts := []Option{ + WithBase(testDataDir), + WithFileWriter(tmp), + } + Expect(g.Generate(opts...)).ToNot(HaveOccurred()) + outputFile := filepath.Join(tmp, makePkgManFileName(operatorName)) + Expect(outputFile).To(BeAnExistingFile()) + Expect(string(readFileHelper(outputFile))).To(MatchYAML(pkgManDefaultContent)) + }) + }) + + Context("with incorrect configuration", func() { + + BeforeEach(func() { + g = Generator{ + OperatorName: operatorName, + Version: version, + } + }) + + It("should return an error without any Options", func() { + opts := []Option{} + Expect(g.Generate(opts...)).To(MatchError(errNoGetWriter)) + }) + It("should return an error without a getWriter", func() { + opts := []Option{ + WithBase(testDataDir), + } + Expect(g.Generate(opts...)).To(MatchError(errNoGetWriter)) + }) + It("should return an error without a getBase", func() { + opts := []Option{ + WithWriter(&bytes.Buffer{}), + } + Expect(g.Generate(opts...)).To(MatchError(errNoGetBase)) + }) + It("should return an error without a Version", func() { + g.Version = "" + opts := []Option{ + WithBase(testDataDir), + WithWriter(&bytes.Buffer{}), + } + Expect(g.Generate(opts...)).To(MatchError(errNoVersion)) + }) + }) + + Context("to create a new PackageManifest", func() { + It("should return the default file", func() { + g = Generator{ + OperatorName: operatorName, + Version: version, + getBase: makeBaseGetter(pkgManDefaultContent), + } + pkg, err := g.generate() + Expect(err).ToNot(HaveOccurred()) + Expect(pkg).To(Equal(pkgManDefault)) + }) + It("should return a PackageManifest with a non-default channel", func() { + g = Generator{ + OperatorName: operatorName, + Version: version, + ChannelName: "stable", + getBase: makeBaseGetter(pkgManSingleChannelContent), + } + pkg, err := g.generate() + Expect(err).ToNot(HaveOccurred()) + Expect(pkg).To(Equal(pkgManSingleChannel)) + }) + }) + + Context("to update an existing PackageManifest file", func() { + It("should return a PackageManifest with one updated channel CSV name", func() { + g = Generator{ + OperatorName: operatorName, + Version: "0.0.2", + ChannelName: "alpha", + getBase: makeBaseGetter(pkgManDefaultContent), + } + pkg, err := g.generate() + Expect(err).ToNot(HaveOccurred()) + Expect(pkg).To(Equal(&apimanifests.PackageManifest{ + Channels: []apimanifests.PackageChannel{ + {Name: "alpha", CurrentCSVName: genutil.MakeCSVName(operatorName, "0.0.2")}, + }, + DefaultChannelName: "alpha", + PackageName: operatorName, + })) + }) + It("should return a PackageManifest with two channels", func() { + g = Generator{ + OperatorName: operatorName, + Version: "0.0.2", + ChannelName: "stable", + getBase: makeBaseGetter(pkgManDefaultContent), + } + pkg, err := g.generate() + Expect(err).ToNot(HaveOccurred()) + Expect(pkg).To(Equal(&apimanifests.PackageManifest{ + Channels: []apimanifests.PackageChannel{ + {Name: "alpha", CurrentCSVName: genutil.MakeCSVName(operatorName, version)}, + {Name: "stable", CurrentCSVName: genutil.MakeCSVName(operatorName, "0.0.2")}, + }, + DefaultChannelName: "alpha", + PackageName: operatorName, + })) + }) + It("should return a PackageManifest with two channels and an updated default channel", func() { + g = Generator{ + OperatorName: operatorName, + Version: "0.0.2", + ChannelName: "stable", + IsDefaultChannel: true, + getBase: makeBaseGetter(pkgManDefaultContent), + } + pkg, err := g.generate() + Expect(err).ToNot(HaveOccurred()) + Expect(pkg).To(Equal(&apimanifests.PackageManifest{ + Channels: []apimanifests.PackageChannel{ + {Name: "alpha", CurrentCSVName: genutil.MakeCSVName(operatorName, version)}, + {Name: "stable", CurrentCSVName: genutil.MakeCSVName(operatorName, "0.0.2")}, + }, + DefaultChannelName: "stable", + PackageName: operatorName, + })) + }) + }) + + }) + +}) + +func makeBaseGetter(content string) getBaseFunc { + return func() (*apimanifests.PackageManifest, error) { + return marshalContent(content) + } +} + +func initTestPackageManifestsHelper() { + var err error + pkgManDefault, err = marshalContent(pkgManDefaultContent) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + pkgManSingleChannel, err = marshalContent(pkgManSingleChannelContent) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) +} + +func readFileHelper(path string) []byte { + b, err := ioutil.ReadFile(path) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + return b +} + +func marshalContent(content string) (*apimanifests.PackageManifest, error) { + base := &apimanifests.PackageManifest{} + if content == "" { + return base, nil + } + err := yaml.Unmarshal([]byte(content), base) + return base, err +} diff --git a/internal/generate/testdata/memcached-operator.package.yaml b/internal/generate/testdata/memcached-operator.package.yaml new file mode 100644 index 0000000000..646eb1b4a9 --- /dev/null +++ b/internal/generate/testdata/memcached-operator.package.yaml @@ -0,0 +1,5 @@ +channels: +- currentCSV: memcached-operator.v0.0.1 + name: alpha +defaultChannel: alpha +packageName: memcached-operator diff --git a/test/e2e-new/e2e_suite.go b/test/e2e-new/e2e_suite.go index 23280722b7..1ea95d63d7 100644 --- a/test/e2e-new/e2e_suite.go +++ b/test/e2e-new/e2e_suite.go @@ -17,9 +17,11 @@ package e2e import ( + "bytes" "fmt" "io/ioutil" "os" + "os/exec" "path" "path/filepath" "strings" @@ -33,7 +35,11 @@ import ( var _ = Describe("operator-sdk", func() { Context("with the new project layout", func() { - var tc *utils.TestContext + var ( + tc *utils.TestContext + projectName string + ) + BeforeEach(func() { By("creating a new test context") @@ -41,6 +47,8 @@ var _ = Describe("operator-sdk", func() { tc, err = utils.NewTestContext("operator-sdk", "GO111MODULE=on") Expect(err).NotTo(HaveOccurred()) Expect(tc.Prepare()).To(Succeed()) + + projectName = filepath.Base(tc.Dir) }) AfterEach(func() { @@ -56,6 +64,7 @@ var _ = Describe("operator-sdk", func() { By("initializing a project") err := tc.Init( "--project-version", "3-alpha", + "--repo", path.Join("github.com", "example", projectName), "--domain", tc.Domain, "--fetch-deps=false") Expect(err).Should(Succeed()) @@ -154,6 +163,25 @@ var _ = Describe("operator-sdk", func() { } err = tc.Make("bundle-build", "BUNDLE_IMG="+bundleImage) Expect(err).Should(Succeed()) + + By("generating the operator package manifests") + var genPkgManCmd *exec.Cmd + Expect(tc.Make("manifests")).Should(Succeed()) + genPkgManCmd = exec.Command(tc.BinaryName, "generate", "packagemanifests", + "--kustomize", + "--interactive=false") + _, err = tc.Run(genPkgManCmd) + Expect(err).Should(Succeed()) + kustomizeOutput, err := tc.Run(exec.Command("kustomize", "build", filepath.Join("config", "packagemanifests"))) + Expect(err).Should(Succeed()) + genPkgManCmd = exec.Command(tc.BinaryName, "generate", "packagemanifests", + "--manifests", + "--update-crds", + "--version", "0.0.1") + Expect(err).Should(Succeed()) + tc.Stdin = bytes.NewBuffer(kustomizeOutput) + _, err = tc.Run(genPkgManCmd) + Expect(err).Should(Succeed()) }) }) })