diff --git a/cmd/opm/alpha/bundle/build.go b/cmd/opm/alpha/bundle/build.go new file mode 100644 index 000000000..9fe6e9888 --- /dev/null +++ b/cmd/opm/alpha/bundle/build.go @@ -0,0 +1,81 @@ +package bundle + +import ( + "github.com/operator-framework/operator-registry/pkg/lib/bundle" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var ( + dirBuildArgs string + tagBuildArgs string + imageBuilderArgs string + packageNameArgs string + channelsArgs string + channelDefaultArgs string + overwriteArgs bool +) + +// newBundleBuildCmd returns a command that will build operator bundle image. +func newBundleBuildCmd() *cobra.Command { + bundleBuildCmd := &cobra.Command{ + Use: "build", + Short: "Build operator bundle image", + Long: `The "opm alpha bundle build" command will generate operator + bundle metadata if needed and build bundle image with operator manifest + and metadata. + + For example: The command will generate annotations.yaml metadata plus + Dockerfile for bundle image and then build a container image from + provided operator bundle manifests generated metadata + e.g. "quay.io/example/operator:v0.0.1". + + After the build process is completed, a container image would be built + locally in docker and available to push to a container registry. + + $ opm alpha bundle build --directory /test/ --tag quay.io/example/operator:v0.1.0 \ + --package test-operator --channels stable,beta --default stable --overwrite + + Note: Bundle image is not runnable. + `, + RunE: buildFunc, + } + + bundleBuildCmd.Flags().StringVarP(&dirBuildArgs, "directory", "d", "", "The directory where bundle manifests and metadata are located") + if err := bundleBuildCmd.MarkFlagRequired("directory"); err != nil { + log.Fatalf("Failed to mark `directory` flag for `build` subcommand as required") + } + + bundleBuildCmd.Flags().StringVarP(&tagBuildArgs, "tag", "t", "", "The image tag applied to the bundle image") + if err := bundleBuildCmd.MarkFlagRequired("tag"); err != nil { + log.Fatalf("Failed to mark `tag` flag for `build` subcommand as required") + } + + bundleBuildCmd.Flags().StringVarP(&packageNameArgs, "package", "p", "", "The name of the package that bundle image belongs to") + if err := bundleBuildCmd.MarkFlagRequired("package"); err != nil { + log.Fatalf("Failed to mark `package` flag for `build` subcommand as required") + } + + bundleBuildCmd.Flags().StringVarP(&channelsArgs, "channels", "c", "", "The list of channels that bundle image belongs to") + if err := bundleBuildCmd.MarkFlagRequired("channels"); err != nil { + log.Fatalf("Failed to mark `channels` flag for `build` subcommand as required") + } + + bundleBuildCmd.Flags().StringVarP(&imageBuilderArgs, "image-builder", "b", "docker", "Tool to build container images. One of: [docker, podman, buildah]") + + bundleBuildCmd.Flags().StringVarP(&channelDefaultArgs, "default", "e", "", "The default channel for the bundle image") + + bundleBuildCmd.Flags().BoolVarP(&overwriteArgs, "overwrite", "o", false, "To overwrite annotations.yaml locally if existed. By default, overwrite is set to `false`.") + + return bundleBuildCmd +} + +func buildFunc(cmd *cobra.Command, args []string) error { + err := bundle.BuildFunc(dirBuildArgs, tagBuildArgs, imageBuilderArgs, + packageNameArgs, channelsArgs, channelDefaultArgs, overwriteArgs) + if err != nil { + return err + } + + return nil +} diff --git a/cmd/opm/alpha/bundle/cmd.go b/cmd/opm/alpha/bundle/cmd.go new file mode 100644 index 000000000..6abccd54e --- /dev/null +++ b/cmd/opm/alpha/bundle/cmd.go @@ -0,0 +1,17 @@ +package bundle + +import ( + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + runCmd := &cobra.Command{ + Use: "bundle", + Short: "Operator bundle commands", + Long: `Generate operator bundle metadata and build bundle image.`, + } + + runCmd.AddCommand(newBundleGenerateCmd()) + runCmd.AddCommand(newBundleBuildCmd()) + return runCmd +} diff --git a/cmd/opm/alpha/bundle/generate.go b/cmd/opm/alpha/bundle/generate.go new file mode 100644 index 000000000..1f065550c --- /dev/null +++ b/cmd/opm/alpha/bundle/generate.go @@ -0,0 +1,51 @@ +package bundle + +import ( + "github.com/operator-framework/operator-registry/pkg/lib/bundle" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +// newBundleGenerateCmd returns a command that will generate operator bundle +// annotations.yaml metadata +func newBundleGenerateCmd() *cobra.Command { + bundleGenerateCmd := &cobra.Command{ + Use: "generate", + Short: "Generate operator bundle metadata and Dockerfile", + Long: `The "opm alpha bundle generate" command will generate operator + bundle metadata if needed and a Dockerfile to build Operator bundle image. + + $ opm alpha bundle generate --directory /test/ --package test-operator \ + --channels stable,beta --default stable + `, + RunE: generateFunc, + } + + bundleGenerateCmd.Flags().StringVarP(&dirBuildArgs, "directory", "d", "", "The directory where bundle manifests are located.") + if err := bundleGenerateCmd.MarkFlagRequired("directory"); err != nil { + log.Fatalf("Failed to mark `directory` flag for `generate` subcommand as required") + } + + bundleGenerateCmd.Flags().StringVarP(&packageNameArgs, "package", "p", "", "The name of the package that bundle image belongs to") + if err := bundleGenerateCmd.MarkFlagRequired("package"); err != nil { + log.Fatalf("Failed to mark `package` flag for `generate` subcommand as required") + } + + bundleGenerateCmd.Flags().StringVarP(&channelsArgs, "channels", "c", "", "The list of channels that bundle image belongs to") + if err := bundleGenerateCmd.MarkFlagRequired("channels"); err != nil { + log.Fatalf("Failed to mark `channels` flag for `generate` subcommand as required") + } + + bundleGenerateCmd.Flags().StringVarP(&channelDefaultArgs, "default", "e", "", "The default channel for the bundle image") + + return bundleGenerateCmd +} + +func generateFunc(cmd *cobra.Command, args []string) error { + err := bundle.GenerateFunc(dirBuildArgs, packageNameArgs, channelsArgs, channelDefaultArgs, true) + if err != nil { + return err + } + + return nil +} diff --git a/cmd/opm/alpha/cmd.go b/cmd/opm/alpha/cmd.go new file mode 100644 index 000000000..b068c5e29 --- /dev/null +++ b/cmd/opm/alpha/cmd.go @@ -0,0 +1,17 @@ +package alpha + +import ( + "github.com/operator-framework/operator-registry/cmd/opm/alpha/bundle" + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + runCmd := &cobra.Command{ + Hidden: true, + Use: "alpha", + Short: "Run an alpha subcommand", + } + + runCmd.AddCommand(bundle.NewCmd()) + return runCmd +} diff --git a/cmd/opm/main.go b/cmd/opm/main.go index bcfcbd7fa..57db2780f 100644 --- a/cmd/opm/main.go +++ b/cmd/opm/main.go @@ -1,9 +1,12 @@ package main import ( + "os" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "github.com/operator-framework/operator-registry/cmd/opm/alpha" "github.com/operator-framework/operator-registry/cmd/opm/registry" ) @@ -12,11 +15,23 @@ func main() { Use: "opm", Short: "operator package manager", Long: "CLI to interact with operator-registry and build indexes of operator content", + PreRunE: func(cmd *cobra.Command, args []string) error { + if debug, _ := cmd.Flags().GetBool("debug"); debug { + logrus.SetLevel(logrus.DebugLevel) + } + return nil + }, } rootCmd.AddCommand(registry.NewOpmRegistryCmd()) + rootCmd.AddCommand(alpha.NewCmd()) - if err := rootCmd.Execute(); err != nil { + rootCmd.Flags().Bool("debug", false, "enable debug logging") + if err := rootCmd.Flags().MarkHidden("debug"); err != nil { logrus.Panic(err.Error()) } + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } } diff --git a/docs/design/operator-bundle.md b/docs/design/operator-bundle.md new file mode 100644 index 000000000..b81a16aa0 --- /dev/null +++ b/docs/design/operator-bundle.md @@ -0,0 +1,199 @@ +# Operator Bundle + +An `Operator Bundle` is a container image that stores Kubernetes manifests and metadata associated with an operator. A bundle is meant to present a *specific* version of an operator. + +## Operator Bundle Overview + +The operator manifests refers to a set of Kubernetes manifest(s) the defines the deployment and RBAC model of the operator. The operator metadata on the other hand are, but not limited to: +* Information that identifies the operator, its name, version etc. +* Additional information that drives the UI: + * Icon + * Example CR(s) +* Channel(s) +* API(s) provided and required. +* Related images. + +An `Operator Bundle` is built as a scratch (non-runnable) container image that contains operator manifests and specific metadata in designated directories inside the image. Then, it can be pushed and pulled from an OCI-compliant container registry. Ultimately, an operator bundle will be used by [Operator Registry](https://github.com/operator-framework/operator-registry) and [Operator-Lifecycle-Manager (OLM)](https://github.com/operator-framework/operator-lifecycle-manager) to install an operator in OLM-enabled clusters. + +### Bundle Annotations + +We use the following labels to annotate the operator bundle image. +* The label `operators.operatorframework.io.bundle.mediatype.v1` reflects the media type or format of the operator bundle. It could be helm charts, plain Kubernetes manifests etc. +* The label `operators.operatorframework.io.bundle.manifests.v1 `reflects the path in the image to the directory that contains the operator manifests. +* The label `operators.operatorframework.io.bundle.metadata.v1` reflects the path in the image to the directory that contains metadata files about the bundle. +* The `manifests.v1` and `metadata.v1` labels imply the bundle type: + * The value `manifests.v1` implies that this bundle contains operator manifests. + * The value `metadata.v1` implies that this bundle has operator metadata. +* The label `operators.operatorframework.io.bundle.package.v1` reflects the package name of the bundle. +* The label `operators.operatorframework.io.bundle.channels.v1` reflects the list of channels the bundle is subscribing to when added into an operator registry +* The label `operators.operatorframework.io.bundle.channel.default.v1` reflects the default channel an operator should be subscribed to when installed from a registry + +The labels will also be put inside a YAML file, as shown below. + +*annotations.yaml* +```yaml +annotations: + operators.operatorframework.io.bundle.mediatype.v1: "registry+v1" + operators.operatorframework.io.bundle.manifests.v1: "manifests/" + operators.operatorframework.io.bundle.metadata.v1: "metadata/" + operators.operatorframework.io.bundle.package.v1: "test-operator" + operators.operatorframework.io.bundle.channels.v1: "beta,stable" + operators.operatorframework.io.bundle.channel.default.v1: "stable" +``` + +*Notes:* +* In case of a mismatch, the `annotations.yaml` file is authoritative because the on-cluster operator-registry that relies on these annotations has access to the yaml file only. +* The potential use case for the `LABELS` is - an external off-cluster tool can inspect the image to check the type of a given bundle image without downloading the content. + +This example uses [Operator Registry Manifests](https://github.com/operator-framework/operator-registry#manifest-format) format to build an operator bundle image. The source directory of an operator registry bundle has the following layout. +``` +$ tree test +test +├── testbackup.crd.yaml +├── testcluster.crd.yaml +├── testoperator.v0.1.0.clusterserviceversion.yaml +├── testrestore.crd.yaml +└── metadata + └── annotations.yaml +``` + +### Bundle Dockerfile + +This is an example of a `Dockerfile` for operator bundle: +``` +FROM scratch + +# We are pushing an operator-registry bundle +# that has both metadata and manifests. +LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1 +LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/ +LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/ +LABEL operators.operatorframework.io.bundle.package.v1=test-operator +LABEL operators.operatorframework.io.bundle.channels.v1=beta,stable +LABEL operators.operatorframework.io.bundle.channel.default.v1=stable + +ADD test/*.yaml /manifests +ADD test/metadata/annotations.yaml /metadata/annotations.yaml +``` + +Below is the directory layout of the operator bundle inside the image: +```bash +$ tree +/ +├── manifests +│ ├── testbackup.crd.yaml +│ ├── testcluster.crd.yaml +│ ├── testoperator.v0.1.0.clusterserviceversion.yaml +│ └── testrestore.crd.yaml +└── metadata + └── annotations.yaml +``` + +## Operator Bundle Commands + +Operator SDK CLI is available to generate Bundle annotations and Dockerfile based on provided operator manifests. + +### Operator SDK CLI + +In order to use Operator SDK CLI, follow the operator-SDK installation instruction: + +1. Install the [Operator SDK CLI](https://github.com/operator-framework/operator-sdk/blob/master/doc/user/install-operator-sdk.md) + +Now, a binary named `operator-sdk` is available in OLM's directory to use. +```bash +$ ./operator-sdk +An SDK for building operators with ease + +Usage: + operator-sdk [command] + +Available Commands: + bundle Operator bundle commands + +Flags: + -h, --help help for operator-sdk + --verbose Enable verbose logging + +Use "operator-sdk [command] --help" for more information about a command. +``` + +### Generate Bundle Annotations and DockerFile +*Notes:* +* If there are `annotations.yaml` and `Dockerfile` existing in the directory, they will be overwritten. + +Using `operator-sdk` CLI, bundle annotations can be generated from provided operator manifests. The overall `bundle generate` command usage is: +```bash +Usage: + operator-sdk bundle generate [flags] + +Flags: + -c, --channels string The list of channels that bundle image belongs to + -e, --default string The default channel for the bundle image + -d, --directory string The directory where bundle manifests are located. + -h, --help help for generate + -p, --package string The name of the package that bundle image belongs to +``` + +The `--directory/-d`, `--channels/-c`, `--package/-p` are required flags while `--default/-e` is optional. + +The command for `generate` task is: +```bash +$ ./operator-sdk bundle generate --directory /test --package test-operator \ +--channels stable,beta --default stable +``` + +The `--directory` or `-d` specifies the directory where the operator manifests are located. The `Dockerfile` is generated in the same directory where the YAML manifests are located while the `annotations.yaml` file is located in a folder named `metadata`. For example: +```bash +$ tree test +test +├── testbackup.crd.yaml +├── testcluster.crd.yaml +├── testoperator.v0.1.0.clusterserviceversion.yaml +├── testrestore.crd.yaml +├── metadata +│   └── annotations.yaml +└── Dockerfile +``` + +The `--package` or `-p` is the name of package fo the operator such as `etcd` which which map `channels` to a particular application definition. `channels` allow package authors to write different upgrade paths for different users (e.g. `beta` vs. `stable`). The `channels` list is provided via `--channels` or `-c` flag. Multiple `channels` are separated by a comma (`,`). The default channel is provided optionally via `--default` or `-e` flag. If the default channel is not provided, the first channel in channel list is selected as default. + +All information in `annotations.yaml` is also existed in `LABEL` section of `Dockerfile`. + +### Build Bundle Image + +Operator bundle image can be built from provided operator manifests using `build` command (see *Notes* below). The overall `bundle build` command usage is: +```bash +Usage: + operator-SDK bundle build [flags] + +Flags: + -c, --channels string The list of channels that bundle image belongs to + -e, --default string The default channel for the bundle image + -d, --directory string The directory where bundle manifests are located + -h, --help help for build + -b, --image-builder string Tool to build container images. One of: [docker, podman, buildah] (default "docker") + -0, --overwrite To overwrite annotations.yaml if existing + -p, --package string The name of the package that bundle image belongs to + -t, --tag string The name of the bundle image will be built +``` + +The command for `build` task is: +```bash +$ ./operator-sdk bundle build --directory /test --tag quay.io/coreos/test-operator.v0.1.0:latest \ +--package test-operator --channels stable,beta --default stable +``` + +The `--directory` or `-d` specifies the directory where the operator manifests are located. The `--tag` or `-t` specifies the image tag that you want the operator bundle image to have. By using `build` command, the `annotations.yaml` and `Dockerfile` are automatically generated in the background. + +The default image builder is `Docker`. However, ` Buildah` and `Podman` are also supported. An image builder can specified via `--image-builder` or `-b` optional tag in `build` command. For example: +```bash +$ ./operator-sdk bundle build --directory /test/0.1.0/ --tag quay.io/coreos/test-operator.v0.1.0:latest \ +--image-builder podman --package test-operator --channels stable,beta --default stable +``` + +The `--package` or `-p` is the name of package fo the operator such as `etcd` which which map `channels` to a particular application definition. `channels` allow package authors to write different upgrade paths for different users (e.g. `beta` vs. `stable`). The `channels` list is provided via `--channels` or `-c` flag. Multiple `channels` are separated by a comma (`,`). The default channel is provided optionally via `--default` or `-e` flag. If the default channel is not provided, the first channel in channel list is selected as default. + +*Notes:* +* If there is `Dockerfile` existing in the directory, it will be overwritten. +* If there is an existing `annotations.yaml` in `/metadata` directory, the cli will attempt to validate it and returns any found errors. If the ``annotations.yaml`` is valid, it will be used as a part of build process. The optional boolean `--overwrite/-o` flag can be enabled (false by default) to allow cli to overwrite the `annotations.yaml` if existed. +* The directory where the operator manifests are located must be inside the context of the build which in this case is inside the directory where you run the command. diff --git a/pkg/lib/bundle/build.go b/pkg/lib/bundle/build.go new file mode 100644 index 000000000..0094f33d0 --- /dev/null +++ b/pkg/lib/bundle/build.go @@ -0,0 +1,75 @@ +package bundle + +import ( + "fmt" + "os" + "os/exec" + "path" + + log "github.com/sirupsen/logrus" +) + +// Create build command to build bundle manifests image +func BuildBundleImage(directory, imageTag, imageBuilder string) (*exec.Cmd, error) { + var args []string + + dockerfilePath := path.Join(directory, DockerFile) + + switch imageBuilder { + case "docker", "podman": + args = append(args, "build", "-f", dockerfilePath, "-t", imageTag, ".") + case "buildah": + args = append(args, "bud", "--format=docker", "-f", dockerfilePath, "-t", imageTag, ".") + default: + return nil, fmt.Errorf("%s is not supported image builder", imageBuilder) + } + + return exec.Command(imageBuilder, args...), nil +} + +func ExecuteCommand(cmd *exec.Cmd) error { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + log.Debugf("Running %#v", cmd.Args) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("Failed to exec %#v: %v", cmd.Args, err) + } + + return nil +} + +// BuildFunc is used to build bundle container image from a list of manifests +// that exist in local directory and it also generates Dockerfile annotations.yaml +// which contains media type, package name and channels information if the file +// doesn't exist locally. +// Inputs: +// @directory: The local directory where bundle manifests and metadata are located +// @imageTag: The image tag that is applied to the bundle image +// @imageBuilder: The image builder tool that is used to build container image +// (docker, buildah or podman) +// @packageName: The name of the package that bundle image belongs to +// @channels: The list of channels that bundle image belongs to +// @channelDefault: The default channel for the bundle image +// @overwrite: Boolean flag to enable overwriting annotations.yaml locally if existed +func BuildFunc(directory, imageTag, imageBuilder, packageName, channels, channelDefault string, overwrite bool) error { + // Generate annotations.yaml and Dockerfile + err := GenerateFunc(directory, packageName, channels, channelDefault, overwrite) + if err != nil { + return err + } + + // Build bundle image + log.Info("Building bundle image") + buildCmd, err := BuildBundleImage(path.Clean(directory), imageBuilder, imageTag) + if err != nil { + return err + } + + if err := ExecuteCommand(buildCmd); err != nil { + return err + } + + return nil +} diff --git a/pkg/lib/bundle/build_test.go b/pkg/lib/bundle/build_test.go new file mode 100644 index 000000000..e0f8ad004 --- /dev/null +++ b/pkg/lib/bundle/build_test.go @@ -0,0 +1,60 @@ +package bundle + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildBundleImage(t *testing.T) { + setup("") + defer cleanup() + + tests := []struct { + directory string + imageTag string + imageBuilder string + commandStr string + errorMsg string + }{ + { + testOperatorDir, + "test", + "docker", + "docker build -f /test-operator/Dockerfile -t test .", + "", + }, + { + testOperatorDir, + "test", + "buildah", + "buildah bud --format=docker -f /test-operator/Dockerfile -t test .", + "", + }, + { + testOperatorDir, + "test", + "podman", + "podman build -f /test-operator/Dockerfile -t test .", + "", + }, + { + testOperatorDir, + "test", + "hello", + "", + "hello is not supported image builder", + }, + } + + for _, item := range tests { + var cmd *exec.Cmd + cmd, err := BuildBundleImage(item.directory, item.imageTag, item.imageBuilder) + if item.errorMsg == "" { + require.Contains(t, cmd.String(), item.commandStr) + } else { + require.Equal(t, item.errorMsg, err.Error()) + } + } +} diff --git a/pkg/lib/bundle/generate.go b/pkg/lib/bundle/generate.go new file mode 100644 index 000000000..971196941 --- /dev/null +++ b/pkg/lib/bundle/generate.go @@ -0,0 +1,267 @@ +package bundle + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" + + "gopkg.in/yaml.v2" +) + +const ( + DefaultPermission = 0644 + RegistryV1Type = "registry+v1" + PlainType = "plain" + HelmType = "helm" + AnnotationsFile = "annotations.yaml" + DockerFile = "Dockerfile" + ManifestsDir = "manifests/" + MetadataDir = "metadata/" + ManifestsLabel = "operators.operatorframework.io.bundle.manifests.v1" + MetadataLabel = "operators.operatorframework.io.bundle.metadata.v1" + MediatypeLabel = "operators.operatorframework.io.bundle.mediatype.v1" + PackageLabel = "operators.operatorframework.io.bundle.package.v1" + ChannelsLabel = "operators.operatorframework.io.bundle.channels.v1" + ChannelDefaultLabel = "operators.operatorframework.io.bundle.channel.default.v1" +) + +type AnnotationMetadata struct { + Annotations map[string]string `yaml:"annotations"` +} + +// GenerateFunc builds annotations.yaml with mediatype, manifests & +// metadata directories in bundle image, package name, channels and default +// channels information and then writes the file to `/metadata` directory. +// Inputs: +// @directory: The local directory where bundle manifests and metadata are located +// @packageName: The name of the package that bundle image belongs to +// @channels: The list of channels that bundle image belongs to +// @channelDefault: The default channel for the bundle image +// @overwrite: Boolean flag to enable overwriting annotations.yaml locally if existed +func GenerateFunc(directory, packageName, channels, channelDefault string, overwrite bool) error { + var mediaType string + + // Determine mediaType + mediaType, err := GetMediaType(directory) + if err != nil { + return err + } + + log.Info("Building annotations.yaml") + + // Generate annotations.yaml + content, err := GenerateAnnotations(mediaType, ManifestsDir, MetadataDir, packageName, channels, channelDefault) + if err != nil { + return err + } + + file, err := ioutil.ReadFile(filepath.Join(directory, MetadataDir, AnnotationsFile)) + if os.IsNotExist(err) || overwrite { + err = WriteFile(AnnotationsFile, filepath.Join(directory, MetadataDir), content) + if err != nil { + return err + } + } else if err != nil { + return err + } else { + log.Info("An annotations.yaml already exists in directory") + if err = ValidateAnnotations(file, content); err != nil { + return err + } + } + + log.Info("Building Dockerfile") + + // Generate Dockerfile + content, err = GenerateDockerfile(directory, mediaType, ManifestsDir, MetadataDir, packageName, channels, channelDefault) + if err != nil { + return err + } + + err = WriteFile(DockerFile, directory, content) + if err != nil { + return err + } + + return nil +} + +// GenerateFunc determines mediatype from files (yaml) in given directory +// Currently able to detect helm chart, registry+v1 (CSV) and plain k8s resources +// such as CRD. +func GetMediaType(directory string) (string, error) { + var files []string + + // Read all file names in directory + items, _ := ioutil.ReadDir(directory) + for _, item := range items { + if item.IsDir() { + continue + } else { + files = append(files, item.Name()) + } + } + + if len(files) == 0 { + return "", fmt.Errorf("The directory %s contains no files", directory) + } + + // Validate the file names to determine media type + for _, file := range files { + if file == "Chart.yaml" { + return HelmType, nil + } else if strings.HasSuffix(file, "clusterserviceversion.yaml") { + return RegistryV1Type, nil + } else { + continue + } + } + + return PlainType, nil +} + +// ValidateAnnotations validates existing annotations.yaml against generated +// annotations.yaml to ensure existing annotations.yaml contains expected values. +func ValidateAnnotations(existing, expected []byte) error { + var fileAnnotations AnnotationMetadata + var expectedAnnotations AnnotationMetadata + + log.Info("Validating existing annotations.yaml") + + err := yaml.Unmarshal(existing, &fileAnnotations) + if err != nil { + log.Errorf("Unable to parse existing annotations.yaml") + return err + } + + err = yaml.Unmarshal(expected, &expectedAnnotations) + if err != nil { + log.Errorf("Unable to parse expected annotations.yaml") + return err + } + + if len(fileAnnotations.Annotations) != len(expectedAnnotations.Annotations) { + return fmt.Errorf("Unmatched number of fields. Expected (%d) vs existing (%d)", + len(expectedAnnotations.Annotations), len(fileAnnotations.Annotations)) + } + + for label, item := range expectedAnnotations.Annotations { + value, ok := fileAnnotations.Annotations[label] + if ok == false { + return fmt.Errorf("Missing field: %s", label) + } + + if item != value { + return fmt.Errorf(`Expect field "%s" to have value "%s" instead of "%s"`, + label, item, value) + } + } + + return nil +} + +// ValidateAnnotations validates provided default channel to ensure it exists in +// provided channel list. +func ValidateChannelDefault(channels, channelDefault string) (string, error) { + var chanDefault string + var chanErr error + channelList := strings.Split(channels, ",") + + if channelDefault != "" { + for _, channel := range channelList { + if channel == channelDefault { + chanDefault = channelDefault + break + } + } + if chanDefault == "" { + chanDefault = channelList[0] + chanErr = fmt.Errorf(`The channel list "%s" doesn't contain channelDefault "%s"`, channels, channelDefault) + } + } else { + chanDefault = channelList[0] + } + + if chanDefault != "" { + return chanDefault, chanErr + } else { + return chanDefault, fmt.Errorf("Invalid channels is provied: %s", channels) + } +} + +// GenerateAnnotations builds annotations.yaml with mediatype, manifests & +// metadata directories in bundle image, package name, channels and default +// channels information. +func GenerateAnnotations(mediaType, manifests, metadata, packageName, channels, channelDefault string) ([]byte, error) { + annotations := &AnnotationMetadata{ + Annotations: map[string]string{ + MediatypeLabel: mediaType, + ManifestsLabel: manifests, + MetadataLabel: metadata, + PackageLabel: packageName, + ChannelsLabel: channels, + ChannelDefaultLabel: channelDefault, + }, + } + + chanDefault, err := ValidateChannelDefault(channels, channelDefault) + if err != nil { + return nil, err + } + + annotations.Annotations[ChannelDefaultLabel] = chanDefault + + afile, err := yaml.Marshal(annotations) + if err != nil { + return nil, err + } + + return afile, nil +} + +// GenerateDockerfile builds Dockerfile with mediatype, manifests & +// metadata directories in bundle image, package name, channels and default +// channels information in LABEL section. +func GenerateDockerfile(directory, mediaType, manifests, metadata, packageName, channels, channelDefault string) ([]byte, error) { + var fileContent string + + chanDefault, err := ValidateChannelDefault(channels, channelDefault) + if err != nil { + return nil, err + } + + // FROM + fileContent += "FROM scratch\n\n" + + // LABEL + fileContent += fmt.Sprintf("LABEL %s=%s\n", MediatypeLabel, mediaType) + fileContent += fmt.Sprintf("LABEL %s=%s\n", ManifestsLabel, manifests) + fileContent += fmt.Sprintf("LABEL %s=%s\n", MetadataLabel, metadata) + fileContent += fmt.Sprintf("LABEL %s=%s\n", PackageLabel, packageName) + fileContent += fmt.Sprintf("LABEL %s=%s\n", ChannelsLabel, channels) + fileContent += fmt.Sprintf("LABEL %s=%s\n\n", ChannelDefaultLabel, chanDefault) + + // CONTENT + fileContent += fmt.Sprintf("ADD %s %s\n", filepath.Join(directory, "*.yaml"), "/manifests") + fileContent += fmt.Sprintf("ADD %s %s%s\n", filepath.Join(directory, metadata, AnnotationsFile), "/metadata/", AnnotationsFile) + + return []byte(fileContent), nil +} + +// Write `fileName` file with `content` into a `directory` +// Note: Will overwrite the existing `fileName` file if it exists +func WriteFile(fileName, directory string, content []byte) error { + if _, err := os.Stat(directory); os.IsNotExist(err) { + os.Mkdir(directory, os.ModePerm) + } + + err := ioutil.WriteFile(filepath.Join(directory, fileName), content, DefaultPermission) + if err != nil { + return err + } + return nil +} diff --git a/pkg/lib/bundle/generate_test.go b/pkg/lib/bundle/generate_test.go new file mode 100644 index 000000000..7aeab8a05 --- /dev/null +++ b/pkg/lib/bundle/generate_test.go @@ -0,0 +1,222 @@ +package bundle + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestGetMediaType(t *testing.T) { + setup("") + defer cleanup() + + testDir := getTestDir() + tests := []struct { + directory string + mediaType string + errorMsg string + }{ + { + testDir, + RegistryV1Type, + "", + }, + { + testDir, + HelmType, + "", + }, + { + testDir, + PlainType, + "", + }, + { + testDir, + "", + fmt.Sprintf("The directory %s contains no files", testDir), + }, + } + + for _, item := range tests { + createFiles(testDir, item.mediaType) + manifestType, err := GetMediaType(item.directory) + if item.errorMsg == "" { + require.Equal(t, item.mediaType, manifestType) + } else { + require.Equal(t, item.errorMsg, err.Error()) + } + clearDir(testDir) + } +} + +func TestValidateChannelDefault(t *testing.T) { + tests := []struct { + channels string + channelDefault string + result string + errorMsg string + }{ + { + "test5,test6", + "", + "test5", + "", + }, + { + "test5,test6", + "test7", + "test5", + `The channel list "test5,test6" doesn't contain channelDefault "test7"`, + }, + { + ",", + "", + "", + `Invalid channels is provied: ,`, + }, + } + + for _, item := range tests { + output, err := ValidateChannelDefault(item.channels, item.channelDefault) + if item.errorMsg == "" { + require.Equal(t, item.result, output) + } else { + require.Equal(t, item.errorMsg, err.Error()) + } + } +} + +func TestValidateAnnotations(t *testing.T) { + tests := []struct { + existing []byte + expected []byte + err error + }{ + { + buildTestAnnotations("annotations", + map[string]string{ + "test1": "stable", + "test2": "stable,beta", + }), + buildTestAnnotations("annotations", + map[string]string{ + "test1": "stable", + "test2": "stable,beta", + }), + nil, + }, + { + buildTestAnnotations("annotations", + map[string]string{ + "test1": "stable", + "test2": "stable,beta", + "test3": "beta", + }), + buildTestAnnotations("annotations", + map[string]string{ + "test1": "stable", + "test2": "stable,beta", + }), + fmt.Errorf("Unmatched number of fields. Expected (2) vs existing (3)"), + }, + { + buildTestAnnotations("annotations", + map[string]string{ + "test1": "stable", + "test2": "stable", + }), + buildTestAnnotations("annotations", + map[string]string{ + "test1": "stable", + "test2": "stable,beta", + }), + fmt.Errorf(`Expect field "test2" to have value "stable,beta" instead of "stable"`), + }, + { + buildTestAnnotations("annotations", + map[string]string{ + "test1": "stable", + "test3": "stable", + }), + buildTestAnnotations("annotations", + map[string]string{ + "test1": "stable", + "test2": "stable,beta", + }), + fmt.Errorf("Missing field: test2"), + }, + { + []byte("\t"), + buildTestAnnotations("annotations", + map[string]string{ + "test1": "stable", + "test2": "stable,beta", + }), + fmt.Errorf("yaml: found character that cannot start any token"), + }, + { + buildTestAnnotations("annotations", + map[string]string{ + "test1": "stable", + "test2": "stable,beta", + }), + []byte("\t"), + fmt.Errorf("yaml: found character that cannot start any token"), + }, + } + + for _, item := range tests { + err := ValidateAnnotations(item.existing, item.expected) + if item.err != nil { + require.Equal(t, item.err.Error(), err.Error()) + } else { + require.Nil(t, err) + } + } +} + +func TestGenerateAnnotationsFunc(t *testing.T) { + // Create test annotations struct + testAnnotations := &AnnotationMetadata{ + Annotations: map[string]string{ + MediatypeLabel: "test1", + ManifestsLabel: "test2", + MetadataLabel: "test3", + PackageLabel: "test4", + ChannelsLabel: "test5", + ChannelDefaultLabel: "test5", + }, + } + // Create result annotations struct + resultAnnotations := AnnotationMetadata{} + data, err := GenerateAnnotations("test1", "test2", "test3", "test4", "test5", "test5") + require.NoError(t, err) + + err = yaml.Unmarshal(data, &resultAnnotations) + require.NoError(t, err) + + for key, value := range testAnnotations.Annotations { + require.Equal(t, value, resultAnnotations.Annotations[key]) + } +} + +func TestGenerateDockerfileFunc(t *testing.T) { + output := fmt.Sprintf("FROM scratch\n\n"+ + "LABEL operators.operatorframework.io.bundle.mediatype.v1=test1\n"+ + "LABEL operators.operatorframework.io.bundle.manifests.v1=test2\n"+ + "LABEL operators.operatorframework.io.bundle.metadata.v1=%s\n"+ + "LABEL operators.operatorframework.io.bundle.package.v1=test4\n"+ + "LABEL operators.operatorframework.io.bundle.channels.v1=test5\n"+ + "LABEL operators.operatorframework.io.bundle.channel.default.v1=test5\n\n"+ + "ADD %s/*.yaml /manifests\n"+ + "ADD %s/annotations.yaml /metadata/annotations.yaml\n", MetadataDir, getTestDir(), + filepath.Join(getTestDir(), MetadataDir)) + + content, err := GenerateDockerfile(getTestDir(), "test1", "test2", MetadataDir, "test4", "test5", "") + require.NoError(t, err) + require.Equal(t, output, string(content)) +} diff --git a/pkg/lib/bundle/utils_test.go b/pkg/lib/bundle/utils_test.go new file mode 100644 index 000000000..64281bc00 --- /dev/null +++ b/pkg/lib/bundle/utils_test.go @@ -0,0 +1,77 @@ +package bundle + +import ( + "io/ioutil" + "os" + "path/filepath" + + "gopkg.in/yaml.v2" +) + +const ( + testOperatorDir = "/test-operator" + helmFile = "Chart.yaml" + csvFile = "test.clusterserviceversion.yaml" + crdFile = "test.crd.yaml" +) + +func setup(input string) { + // Create test directory + testDir := getTestDir() + createDir(testDir) + + // Create test files in test directory + createFiles(testDir, input) +} + +func getTestDir() string { + // Create test directory + dir, _ := os.Getwd() + testDir := filepath.Join(dir, testOperatorDir) + return testDir +} + +func cleanup() { + // Remove test directory + os.RemoveAll(getTestDir()) +} + +func createDir(dir string) { + os.MkdirAll(dir, os.ModePerm) +} + +func createFiles(dir, input string) { + // Create test files in test directory + switch input { + case RegistryV1Type: + file, _ := os.Create(filepath.Join(dir, csvFile)) + file.Close() + case HelmType: + file, _ := os.Create(filepath.Join(dir, helmFile)) + file.Close() + case PlainType: + file, _ := os.Create(filepath.Join(dir, crdFile)) + file.Close() + default: + break + } +} + +func buildTestAnnotations(key string, items map[string]string) []byte { + temp := make(map[string]interface{}) + temp[key] = items + output, _ := yaml.Marshal(temp) + return output +} + +func clearDir(dir string) { + items, _ := ioutil.ReadDir(dir) + + for _, item := range items { + if item.IsDir() { + continue + } else { + os.Remove(filepath.Join(dir, item.Name())) + } + } +}