diff --git a/cmd/operator-cli/bundle/build.go b/cmd/operator-cli/bundle/build.go new file mode 100644 index 0000000000..b5a1004977 --- /dev/null +++ b/cmd/operator-cli/bundle/build.go @@ -0,0 +1,108 @@ +package bundle + +import ( + "fmt" + "os" + "os/exec" + "path" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var ( + dirBuildArgs string + tagBuildArgs string + imageBuilderArgs string +) + +// 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 operator-cli 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. + + $ operator-cli bundle build -dir /test/0.0.1/ -t quay.io/example/operator:v0.0.1 + + Note: Bundle image is not runnable. + `, + RunE: buildFunc, + } + + bundleBuildCmd.Flags().StringVarP(&dirBuildArgs, "directory", "d", "", "The directory where bundle manifests 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 name of the bundle image will be built.") + if err := bundleBuildCmd.MarkFlagRequired("tag"); err != nil { + log.Fatalf("Failed to mark `tag` flag for `build` subcommand as required") + } + + bundleBuildCmd.Flags().StringVarP(&imageBuilderArgs, "image-builder", "b", "docker", "Tool to build container images. One of: [docker, podman, buildah]") + + return bundleBuildCmd +} + +// 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 +} + +func buildFunc(cmd *cobra.Command, args []string) error { + // Generate annotations.yaml and Dockerfile + err := generateFunc(cmd, args) + if err != nil { + return err + } + + // Build bundle image + log.Info("Building bundle image") + buildCmd, err := buildBundleImage(path.Dir(path.Clean(dirBuildArgs)), tagBuildArgs, imageBuilderArgs) + if err != nil { + return err + } + + if err := executeCommand(buildCmd); err != nil { + return err + } + + return nil +} diff --git a/cmd/operator-cli/bundle/build_test.go b/cmd/operator-cli/bundle/build_test.go new file mode 100644 index 0000000000..dd9611e36a --- /dev/null +++ b/cmd/operator-cli/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 + }{ + { + operatorDir, + "test", + "docker", + "docker build -f test-operator/Dockerfile -t test .", + "", + }, + { + operatorDir, + "test", + "podman", + "podman bud --format=docker -f test-operator/Dockerfile -t test .", + "", + }, + { + operatorDir, + "test", + "buildah", + "buildah build -f test-operator/Dockerfile -t test .", + "", + }, + { + operatorDir, + "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.Equal(t, item.commandStr, cmd.String()) + } else { + require.Equal(t, item.errorMsg, err.Error()) + } + } +} diff --git a/cmd/operator-cli/bundle/cmd.go b/cmd/operator-cli/bundle/cmd.go new file mode 100644 index 0000000000..6abccd54ee --- /dev/null +++ b/cmd/operator-cli/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/operator-cli/bundle/generate.go b/cmd/operator-cli/bundle/generate.go new file mode 100644 index 0000000000..0d018051f3 --- /dev/null +++ b/cmd/operator-cli/bundle/generate.go @@ -0,0 +1,169 @@ +package bundle + +import ( + "fmt" + "io/ioutil" + "path" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "gopkg.in/yaml.v2" +) + +const ( + defaultPermission = 0644 + registryV1Type = "registry+v1" + plainType = "plain" + helmType = "helm" + manifestsMetadata = "manifests+metadata" + annotationsFile = "annotations.yaml" + dockerFile = "Dockerfile" + resourcesLabel = "operators.operatorframework.io.bundle.resources" + mediatypeLabel = "operators.operatorframework.io.bundle.mediatype" +) + +type AnnotationMetadata struct { + Annotations AnnotationType `yaml:"annotations"` +} + +type AnnotationType struct { + Resources string `yaml:"operators.operatorframework.io.bundle.resources"` + MediaType string `yaml:"operators.operatorframework.io.bundle.mediatype"` +} + +// 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 operator-cli buindle generate command will generate operator + bundle metadata if needed and a Dockerfile to build Operator bundle image. + + $ operator-cli bundle generate -d /test/0.0.1/ + `, + 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") + } + + return bundleGenerateCmd +} + +func generateFunc(cmd *cobra.Command, args []string) error { + var mediaType string + + // Determine mediaType + mediaType, err := getMediaType(dirBuildArgs) + if err != nil { + return err + } + + // Parent directory + parentDir := path.Dir(path.Clean(dirBuildArgs)) + + log.Info("Building annotations.yaml file") + + // Generate annotations.yaml + content, err := generateAnnotationsFunc(manifestsMetadata, mediaType) + if err != nil { + return err + } + err = writeFile(annotationsFile, parentDir, content) + if err != nil { + return err + } + + log.Info("Building Dockerfile") + + // Generate Dockerfile + content = generateDockerfileFunc(manifestsMetadata, mediaType, dirBuildArgs) + err = writeFile(dockerFile, parentDir, content) + if err != nil { + return err + } + + return nil +} + +func getMediaType(dirBuildArgs string) (string, error) { + var files []string + + // Read all file names in directory + items, _ := ioutil.ReadDir(dirBuildArgs) + 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", dirBuildArgs) + } + + // 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 +} + +func generateAnnotationsFunc(resourcesType, mediaType string) ([]byte, error) { + annotations := &AnnotationMetadata{ + Annotations: AnnotationType{ + Resources: resourcesType, + MediaType: mediaType, + }, + } + + afile, err := yaml.Marshal(annotations) + if err != nil { + return nil, err + } + + return afile, nil +} + +func generateDockerfileFunc(resourcesType, mediaType, directory string) []byte { + var fileContent string + + metadataDir := path.Dir(path.Clean(directory)) + + // FROM + fileContent += "FROM scratch\n\n" + + // LABEL + fileContent += fmt.Sprintf("LABEL %s=%s\n", resourcesLabel, resourcesType) + fileContent += fmt.Sprintf("LABEL %s=%s\n\n", mediatypeLabel, mediaType) + + // CONTENT + fileContent += fmt.Sprintf("ADD %s %s\n", directory, "/manifests") + fileContent += fmt.Sprintf("ADD %s/%s %s%s\n", metadataDir, annotationsFile, "/metadata/", annotationsFile) + + return []byte(fileContent) +} + +// 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 { + err := ioutil.WriteFile(filepath.Join(directory, fileName), content, defaultPermission) + if err != nil { + return err + } + return nil +} diff --git a/cmd/operator-cli/bundle/generate_test.go b/cmd/operator-cli/bundle/generate_test.go new file mode 100644 index 0000000000..16cf7ed140 --- /dev/null +++ b/cmd/operator-cli/bundle/generate_test.go @@ -0,0 +1,86 @@ +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 := filepath.Join(operatorDir, manifestsMetadata) + 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 TestGenerateAnnotationsFunc(t *testing.T) { + // Create test annotations struct + testAnnotations := &AnnotationMetadata{ + Annotations: AnnotationType{ + Resources: "test1", + MediaType: "test2", + }, + } + // Create result annotations struct + resultAnnotations := AnnotationMetadata{} + data, err := generateAnnotationsFunc("test1", "test2") + require.NoError(t, err) + + err = yaml.Unmarshal(data, &resultAnnotations) + require.NoError(t, err) + + require.Equal(t, testAnnotations.Annotations.Resources, resultAnnotations.Annotations.Resources) + require.Equal(t, testAnnotations.Annotations.MediaType, resultAnnotations.Annotations.Resources) +} + +func TestGenerateDockerfileFunc(t *testing.T) { + testDir := filepath.Join(operatorDir, manifestsMetadata) + output := "FROM scratch\n\n" + + "LABEL operators.operatorframework.io.bundle.resources=test1\n" + + "LABEL operators.operatorframework.io.bundle.mediatype=test2\n\n" + + "ADD test-operator/0.0.1 /manifests\n" + + "ADD test-operator/0.0.1/annotations.yaml /metadata/annotations.yaml\n" + + content := generateDockerfileFunc("test1", "test2", testDir) + require.Equal(t, output, string(content)) +} diff --git a/cmd/operator-cli/bundle/utils_test.go b/cmd/operator-cli/bundle/utils_test.go new file mode 100644 index 0000000000..114d8fdf0a --- /dev/null +++ b/cmd/operator-cli/bundle/utils_test.go @@ -0,0 +1,62 @@ +package bundle + +import ( + "io/ioutil" + "os" + "path/filepath" +) + +const ( + operatorDir = "/test-operator" + manifestsDir = "/0.0.1" + helmFile = "Chart.yaml" + csvFile = "test.clusterserviceversion.yaml" + crdFile = "test.crd.yaml" +) + +func setup(input string) { + // Create test directory + testDir := filepath.Join(operatorDir, manifestsDir) + createDir(testDir) + + // Create test files in test directory + createFiles(testDir, input) +} + +func cleanup() { + // Remove test directory + os.RemoveAll(operatorDir) +} + +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 clearDir(dir string) { + items, _ := ioutil.ReadDir(dir) + + for _, item := range items { + if item.IsDir() { + continue + } else { + os.Remove(item.Name()) + } + } +} diff --git a/cmd/operator-cli/main.go b/cmd/operator-cli/main.go new file mode 100644 index 0000000000..83c3ca5025 --- /dev/null +++ b/cmd/operator-cli/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/operator-framework/operator-lifecycle-manager/cmd/operator-cli/bundle" +) + +func main() { + var rootCmd = &cobra.Command{ + Short: "operator-cli", + Long: `A CLI tool to perform operator-related tasks.`, + + PreRunE: func(cmd *cobra.Command, args []string) error { + if debug, _ := cmd.Flags().GetBool("debug"); debug { + log.SetLevel(log.DebugLevel) + } + return nil + }, + } + + rootCmd.AddCommand(bundle.NewCmd()) + + rootCmd.Flags().Bool("debug", false, "enable debug logging") + if err := rootCmd.Flags().MarkHidden("debug"); err != nil { + log.Panic(err.Error()) + } + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/doc/design/operator-bundle.md b/doc/design/operator-bundle.md new file mode 100644 index 0000000000..0ac6474f8b --- /dev/null +++ b/doc/design/operator-bundle.md @@ -0,0 +1,149 @@ +# 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.resources` represents the bundle type: + * The value `manifests` implies that this bundle contains operator manifests only. + * The value `metadata` implies that this bundle has operator metadata only. + * The value `manifests+metadata` implies that this bundle contains both operator metadata and manifests. +* The label `operators.operatorframework.io.bundle.mediatype` reflects the media type or format of the operator bundle. It could be helm charts, plain Kubernetes manifests etc. + +The labels will also be put inside a YAML file, as shown below. + +*annotations.yaml* +```yaml +annotations: + operators.operatorframework.io.bundle.resources: "manifests+metadata" + operators.operatorframework.io.bundle.mediatype: "registry+v1" +``` + +*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 +├── 0.1.0 +│   ├── testbackup.crd.yaml +│   ├── testcluster.crd.yaml +│   ├── testoperator.v0.1.0.clusterserviceversion.yaml +│   └── testrestore.crd.yaml +└── 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.resources=manifests+metadata +LABEL operators.operatorframework.io.bundle.mediatype=registry+v1 + +ADD test/0.1.0 /manifests +ADD test/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 Command-Line Tool + +A CLI tool is available to generate Bundle annotations and Dockerfile based on provided operator manifests. + +### Bundle CLI + +In order to build `operator-cli` CLI tool, follow these steps: + +1. Clone [Operator-Lifecycle-Manager (OLM)](https://github.com/operator-framework/operator-lifecycle-manager) repository. +2. Build `operator-cli` binary: +```bash +$ go build ./cmd/operator-cli/ +``` + +Now, a binary named `operator-cli` is available in OLM's directory to use. +```bash +$ ./operator-cli +Generate operator bundle metadata and build bundle image. + +Usage: + bundle [command] + +Available Commands: + build Build operator bundle image + generate Generate operator bundle metadata and Dockerfile + +Flags: + -h, --help help for bundle + +Use " bundle [command] --help" for more information about a command. +``` + +### Generate Bundle Annotations and DockerFile + +Using `operator-cli` CLI, bundle annotations can be generated from provided operator manifests. The command for `generate` task is: +```bash +$ ./operator-cli bundle generate --directory /test/0.0.1/ +``` +The `--directory` or `-d` specifies the directory where the operator manifests are located. The `annotations.yaml` and `Dockerfile` are generated in the same directory where the manifests folder is located (not where the YAML manifests are located). For example: +```bash +$ tree test +test +├── 0.0.1 +│   ├── testbackup.crd.yaml +│   ├── testcluster.crd.yaml +│   ├── testoperator.v0.1.0.clusterserviceversion.yaml +│   └── testrestore.crd.yaml +├── annotations.yaml +└── Dockerfile +``` + +*Notes:* +* If there are `annotations.yaml` and `Dockerfile` existing in the directory, they will be overwritten. + +### Build Bundle Image + +Operator bundle image can be built from provided operator manifests using `build` command: +```bash +$ ./operator-cli bundle build --directory /test/0.0.1/ --tag quay.io/coreos/test-operator.v0.0.1:latest +``` +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-cli bundle build --directory /test/0.0.1/ --tag quay.io/coreos/test-operator.v0.0.1:latest --image-builder podman +``` + +*Notes:* +* If there are `annotations.yaml` and `Dockerfile` existing in the directory, they will be overwritten. +* The directory where the operator manifests are located must must be inside the context of the build which in this case is inside the directory where you run the command.