From 3b6cdf020ea79ea5a30fbfc0fbb6db0a88ad4bb7 Mon Sep 17 00:00:00 2001 From: Eric Stroczynski Date: Mon, 18 May 2020 09:33:14 -0700 Subject: [PATCH] scorecard (alpha): use bundle labels in pod runner, from image or bundle directory if no image is available internal/util/registry: operator-registry utilities, mainly label helpers --- cmd/operator-sdk/alpha/scorecard/cmd.go | 54 ++++++-- cmd/operator-sdk/alpha/scorecard/cmd_test.go | 3 +- images/scorecard-test/cmd/test/main.go | 8 +- .../alpha/registry.go => registry/image.go} | 2 +- internal/registry/labels.go | 129 ++++++++++++++++++ internal/registry/validate.go | 1 - internal/scorecard/alpha/bundle.go | 87 ++++++++++-- internal/scorecard/alpha/bundle_test.go | 11 ++ internal/scorecard/alpha/scorecard.go | 2 + internal/scorecard/alpha/tar.go | 103 +++++++------- .../scorecard/alpha/testdata/bundle.tar.gz | Bin 2809 -> 2865 bytes internal/scorecard/alpha/testpod.go | 7 + internal/scorecard/alpha/tests/bundle_test.go | 7 +- internal/scorecard/alpha/tests/olm.go | 13 +- 14 files changed, 346 insertions(+), 81 deletions(-) rename internal/{scorecard/alpha/registry.go => registry/image.go} (99%) create mode 100644 internal/registry/labels.go diff --git a/cmd/operator-sdk/alpha/scorecard/cmd.go b/cmd/operator-sdk/alpha/scorecard/cmd.go index 70b7787da98..6d587194179 100644 --- a/cmd/operator-sdk/alpha/scorecard/cmd.go +++ b/cmd/operator-sdk/alpha/scorecard/cmd.go @@ -30,6 +30,7 @@ import ( "k8s.io/apimachinery/pkg/labels" "github.com/operator-framework/operator-sdk/internal/flags" + registryutil "github.com/operator-framework/operator-sdk/internal/registry" scorecard "github.com/operator-framework/operator-sdk/internal/scorecard/alpha" "github.com/operator-framework/operator-sdk/pkg/apis/scorecard/v1alpha3" ) @@ -62,6 +63,7 @@ If the argument holds an image tag, it must be present remotely.`, return c.validate(args) }, RunE: func(cmd *cobra.Command, args []string) (err error) { + c.bundle = args[0] return c.run() }, } @@ -108,27 +110,30 @@ func (c *scorecardCmd) printOutput(output v1alpha3.Test) error { return fmt.Errorf("invalid output format selected") } return nil - } -func (c *scorecardCmd) run() error { - var err error +func (c *scorecardCmd) run() (err error) { // Extract bundle image contents if bundle is inferred to be an image. + var bundleLabels registryutil.Labels if _, err = os.Stat(c.bundle); err != nil && errors.Is(err, os.ErrNotExist) { - // Discard bundle extraction logs unless user sets verbose mode. - logger := log.NewEntry(discardLogger()) - if viper.GetBool(flags.VerboseOpt) { - logger = log.WithFields(log.Fields{"bundle": c.bundle}) - } - // FEAT: enable explicit local image extraction. - if c.bundle, err = scorecard.ExtractBundleImage(context.TODO(), logger, c.bundle, false); err != nil { + if c.bundle, bundleLabels, err = getBundlePathAndLabelsFromImage(c.bundle); err != nil { log.Fatal(err) } defer func() { if err := os.RemoveAll(c.bundle); err != nil { - logger.Error(err) + log.Error(err) } }() + } else { + // Search for the metadata dir since we cannot assume its path, and + // use that metadata as the source of truth when testing. + metadataDir, err := registryutil.FindMetadataDir(c.bundle) + if err != nil { + log.Fatal(err) + } + if bundleLabels, err = registryutil.GetMetadataLabels(metadataDir); err != nil { + log.Fatal(err) + } } o := scorecard.Scorecard{ @@ -160,6 +165,7 @@ func (c *scorecardCmd) run() error { ServiceAccount: c.serviceAccount, Namespace: c.namespace, BundlePath: c.bundle, + BundleLabels: bundleLabels, } // Only get the client if running tests. @@ -185,8 +191,6 @@ func (c *scorecardCmd) validate(args []string) error { if len(args) != 1 { return fmt.Errorf("a bundle image or directory argument is required") } - c.bundle = args[0] - return nil } @@ -196,3 +200,27 @@ func discardLogger() *log.Logger { logger.SetOutput(ioutil.Discard) return logger } + +// getBundlePathAndLabelsFromImage returns bundleImage's path on disk post- +// extraction and image labels. +func getBundlePathAndLabelsFromImage(bundleImage string) (string, registryutil.Labels, error) { + // Discard bundle extraction logs unless user sets verbose mode. + logger := log.NewEntry(discardLogger()) + if viper.GetBool(flags.VerboseOpt) { + logger = log.WithFields(log.Fields{"bundle": bundleImage}) + } + // FEAT: enable explicit local image extraction. + bundlePath, err := registryutil.ExtractBundleImage(context.TODO(), logger, bundleImage, false) + if err != nil { + return "", nil, err + } + + // Get image labels from bundleImage locally, since the bundle extraction + // already pulled the image. + bundleLabels, err := registryutil.GetImageLabels(context.TODO(), logger, bundleImage, true) + if err != nil { + return "", nil, err + } + + return bundlePath, bundleLabels, nil +} diff --git a/cmd/operator-sdk/alpha/scorecard/cmd_test.go b/cmd/operator-sdk/alpha/scorecard/cmd_test.go index 1b1e85fe87c..9a0edc64e1b 100644 --- a/cmd/operator-sdk/alpha/scorecard/cmd_test.go +++ b/cmd/operator-sdk/alpha/scorecard/cmd_test.go @@ -81,11 +81,10 @@ var _ = Describe("Running an alpha scorecard command", func() { Expect(err).To(HaveOccurred()) }) - It("succeeds if exactly one arg is provided and parses the arg", func() { + It("succeeds if exactly one arg is provided", func() { input := "cherry" err := cmd.validate([]string{input}) Expect(err).NotTo(HaveOccurred()) - Expect(cmd.bundle).To(Equal(input)) }) }) }) diff --git a/images/scorecard-test/cmd/test/main.go b/images/scorecard-test/cmd/test/main.go index a075231b7b5..a25cfca7f98 100644 --- a/images/scorecard-test/cmd/test/main.go +++ b/images/scorecard-test/cmd/test/main.go @@ -22,6 +22,7 @@ import ( apimanifests "github.com/operator-framework/api/pkg/manifests" + registryutil "github.com/operator-framework/operator-sdk/internal/registry" scorecard "github.com/operator-framework/operator-sdk/internal/scorecard/alpha" "github.com/operator-framework/operator-sdk/internal/scorecard/alpha/tests" scapiv1alpha3 "github.com/operator-framework/operator-sdk/pkg/apis/scorecard/v1alpha3" @@ -48,11 +49,16 @@ func main() { log.Fatal(err.Error()) } + labels, err := registryutil.GetMetadataLabels(scorecard.PodLabelsDir) + if err != nil { + log.Fatal(err.Error()) + } + var result scapiv1alpha3.TestStatus switch entrypoint[0] { case tests.OLMBundleValidationTest: - result = tests.BundleValidationTest(scorecard.PodBundleRoot) + result = tests.BundleValidationTest(scorecard.PodBundleRoot, labels) case tests.OLMCRDsHaveValidationTest: result = tests.CRDsHaveValidationTest(cfg) case tests.OLMCRDsHaveResourcesTest: diff --git a/internal/scorecard/alpha/registry.go b/internal/registry/image.go similarity index 99% rename from internal/scorecard/alpha/registry.go rename to internal/registry/image.go index 1f94a055f55..7031fdfefb3 100644 --- a/internal/scorecard/alpha/registry.go +++ b/internal/registry/image.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package alpha +package registry import ( "context" diff --git a/internal/registry/labels.go b/internal/registry/labels.go new file mode 100644 index 00000000000..0619c3f6b88 --- /dev/null +++ b/internal/registry/labels.go @@ -0,0 +1,129 @@ +// 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 registry + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + + registryimage "github.com/operator-framework/operator-registry/pkg/image" + "github.com/operator-framework/operator-registry/pkg/image/containerdregistry" + registrybundle "github.com/operator-framework/operator-registry/pkg/lib/bundle" + log "github.com/sirupsen/logrus" + + // TODO: replace `gopkg.in/yaml.v2` with `sigs.k8s.io/yaml` once operator-registry has `json` tags in the + // annotations struct. + yaml "gopkg.in/yaml.v3" +) + +// Labels is a set of key:value labels from an operator-registry object. +type Labels map[string]string + +// GetManifestsDir returns the manifests directory name in ls using +// a predefined key, or false if it does not exist. +func (ls Labels) GetManifestsDir() (string, bool) { + value, hasLabel := ls.getLabel(registrybundle.ManifestsLabel) + return filepath.Clean(value), hasLabel +} + +// getLabel returns the string by key in ls, or an empty string and false +// if key is not found in ls. +func (ls Labels) getLabel(key string) (string, bool) { + value, hasLabel := ls[key] + return value, hasLabel +} + +// GetImageLabels returns the set of labels on image. +func GetImageLabels(ctx context.Context, logger *log.Entry, image string, local bool) (Labels, error) { + // Create a containerd registry for socket-less image layer reading. + reg, err := containerdregistry.NewRegistry(containerdregistry.WithLog(logger)) + if err != nil { + return nil, fmt.Errorf("error creating new image registry: %v", err) + } + defer func() { + if err := reg.Destroy(); err != nil { + logger.WithError(err).Warn("Error destroying local cache") + } + }() + + // Pull the image if it isn't present locally. + if !local { + if err := reg.Pull(ctx, registryimage.SimpleReference(image)); err != nil { + return nil, fmt.Errorf("error pulling image %s: %v", image, err) + } + } + + // Query the image reference for its labels. + labels, err := reg.Labels(ctx, registryimage.SimpleReference(image)) + if err != nil { + return nil, fmt.Errorf("error reading image %s labels: %v", image, err) + } + + return labels, err +} + +// FindMetadataDir walks bundleRoot searching for metadata, and returns that directory if found. +// If one is not found, an error is returned. +func FindMetadataDir(bundleRoot string) (metadataDir string, err error) { + err = filepath.Walk(bundleRoot, func(path string, info os.FileInfo, err error) error { + if err != nil || !info.IsDir() { + return err + } + // Already found the first metadata dir, do not overwrite it. + if metadataDir != "" { + return nil + } + // The annotations file is well-defined. + _, err = os.Stat(filepath.Join(path, registrybundle.AnnotationsFile)) + if err == nil || errors.Is(err, os.ErrExist) { + metadataDir = path + return nil + } + return err + }) + if err != nil { + return "", err + } + if metadataDir == "" { + return "", fmt.Errorf("metadata dir not found in %s", bundleRoot) + } + + return metadataDir, nil +} + +// GetMetadataLabels reads annotations from file(s) in metadataDir and returns them as Labels. +func GetMetadataLabels(metadataDir string) (Labels, error) { + // The annotations file is well-defined. + annotationsPath := filepath.Join(metadataDir, registrybundle.AnnotationsFile) + b, err := ioutil.ReadFile(annotationsPath) + if err != nil { + return nil, err + } + + // Use the arbitrarily-labelled bundle representation of the annotations file + // for forwards and backwards compatibility. + meta := registrybundle.AnnotationMetadata{ + Annotations: make(map[string]string), + } + if err = yaml.Unmarshal(b, &meta); err != nil { + return nil, err + } + + return meta.Annotations, nil +} diff --git a/internal/registry/validate.go b/internal/registry/validate.go index cf0a67d1476..510f7ee2a29 100644 --- a/internal/registry/validate.go +++ b/internal/registry/validate.go @@ -27,7 +27,6 @@ import ( "gopkg.in/yaml.v2" k8svalidation "k8s.io/apimachinery/pkg/api/validation" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/validation/field" ) diff --git a/internal/scorecard/alpha/bundle.go b/internal/scorecard/alpha/bundle.go index 0b8bfe3f10b..6480201883f 100644 --- a/internal/scorecard/alpha/bundle.go +++ b/internal/scorecard/alpha/bundle.go @@ -15,12 +15,23 @@ package alpha import ( + "archive/tar" + "bytes" + "compress/gzip" "fmt" - "io/ioutil" "os" + "path" "path/filepath" + "strings" - "k8s.io/apimachinery/pkg/util/rand" + "github.com/operator-framework/operator-registry/pkg/lib/bundle" + log "github.com/sirupsen/logrus" + + // TODO: replace `gopkg.in/yaml.v2` with `sigs.k8s.io/yaml` once operator-registry has `json` tags in the + // annotations struct. + yaml "gopkg.in/yaml.v3" + + registryutil "github.com/operator-framework/operator-sdk/internal/registry" ) // getBundleData tars up the contents of a bundle from a path, and returns that tar file in []byte @@ -28,25 +39,75 @@ func (r PodTestRunner) getBundleData() (bundleData []byte, err error) { // make sure the bundle exists on disk _, err = os.Stat(r.BundlePath) - if os.IsNotExist(err) { - return bundleData, fmt.Errorf("bundle path is not valid %w", err) + if err != nil && os.IsNotExist(err) { + return nil, fmt.Errorf("bundle path does not exist: %w", err) } - tempTarFileName := filepath.Join(os.TempDir(), fmt.Sprintf("tempBundle-%s.tar.gz", rand.String(4))) + // Write the tarball in-memory. + buf := &bytes.Buffer{} + gz := gzip.NewWriter(buf) + w := tar.NewWriter(gz) + // Both tar and gzip writer Close() methods write some data that is + // required when reading the result, so we must close these without a defer. + closers := closeFuncs{w.Close, gz.Close} + // Write the bundle itself. paths := []string{r.BundlePath} - err = CreateTarFile(tempTarFileName, paths) - if err != nil { - return bundleData, fmt.Errorf("error creating tar of bundle %w", err) + if err = WritePathsToTar(w, paths); err != nil { + if err := closers.close(); err != nil { + log.Error(err) + } + return nil, fmt.Errorf("error writing bundle tar: %w", err) + } + + // Write the source of truth labels to the expected path within a pod. + labelPath := filepath.Join(PodLabelsDirName, bundle.AnnotationsFile) + if err = writeLabels(w, labelPath, r.BundleLabels); err != nil { + if err := closers.close(); err != nil { + log.Error(err) + } + return nil, fmt.Errorf("error writing image labels to bundle tar: %w", err) + } + + if err := closers.close(); err != nil { + log.Error(err) } - defer os.Remove(tempTarFileName) + return buf.Bytes(), nil +} - var buf []byte - buf, err = ioutil.ReadFile(tempTarFileName) +type closeFuncs []func() error + +func (fs closeFuncs) close() error { + for _, f := range fs { + if err := f(); err != nil { + return err + } + } + return nil +} + +// writeLabels writes labels to w, creating each directory in +func writeLabels(w *tar.Writer, labelPath string, labels registryutil.Labels) error { + annotations := bundle.AnnotationMetadata{ + Annotations: labels, + } + b, err := yaml.Marshal(annotations) if err != nil { - return bundleData, fmt.Errorf("error reading tar of bundle %w", err) + return err + } + + // Create one header per directory in path. + labelPath = path.Clean(labelPath) + pathSplit := strings.Split(labelPath, "/") + for i := 1; i < len(pathSplit); i++ { + hdr := newTarDirHeader(filepath.Join(pathSplit[:i]...)) + if err = WriteToTar(w, &bytes.Buffer{}, hdr); err != nil { + return err + } } - return buf, err + // Write labels to path. + hdr := newTarFileHeader(labelPath, int64(len(b))) + return WriteToTar(w, bytes.NewBuffer(b), hdr) } diff --git a/internal/scorecard/alpha/bundle_test.go b/internal/scorecard/alpha/bundle_test.go index 70479ea0892..30739e74622 100644 --- a/internal/scorecard/alpha/bundle_test.go +++ b/internal/scorecard/alpha/bundle_test.go @@ -24,6 +24,8 @@ import ( "path/filepath" "reflect" "testing" + + "github.com/operator-framework/operator-sdk/internal/registry" ) func TestBundlePath(t *testing.T) { @@ -44,6 +46,15 @@ func TestBundlePath(t *testing.T) { t.Run(c.bundlePathValue, func(t *testing.T) { r := PodTestRunner{} r.BundlePath = c.bundlePathValue + + if c.bundlePathValue != "" { + var err error + r.BundleLabels, err = registry.GetMetadataLabels(filepath.Join(c.bundlePathValue, "metadata")) + if err != nil { + t.Fatalf("Failed to get test bundle labels: %v", err) + } + } + bundleData, err := r.getBundleData() if err != nil && !c.wantError { t.Fatalf("Wanted result but got error: %v", err) diff --git a/internal/scorecard/alpha/scorecard.go b/internal/scorecard/alpha/scorecard.go index 6fe7a5cd1e2..2813fa22824 100644 --- a/internal/scorecard/alpha/scorecard.go +++ b/internal/scorecard/alpha/scorecard.go @@ -25,6 +25,7 @@ import ( "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes" + registryutil "github.com/operator-framework/operator-sdk/internal/registry" "github.com/operator-framework/operator-sdk/pkg/apis/scorecard/v1alpha3" ) @@ -45,6 +46,7 @@ type PodTestRunner struct { Namespace string ServiceAccount string BundlePath string + BundleLabels registryutil.Labels Client kubernetes.Interface configMapName string diff --git a/internal/scorecard/alpha/tar.go b/internal/scorecard/alpha/tar.go index c4bee3240f3..fbd836dcd19 100644 --- a/internal/scorecard/alpha/tar.go +++ b/internal/scorecard/alpha/tar.go @@ -22,50 +22,27 @@ import ( "os" "path/filepath" "strings" -) + "time" -// CreateTarFile walks paths to create tar file tarName -func CreateTarFile(tarName string, paths []string) (err error) { - tarFile, err := os.Create(tarName) - if err != nil { - return err - } - defer func() { - err = tarFile.Close() - }() + log "github.com/sirupsen/logrus" +) - absTar, err := filepath.Abs(tarName) - if err != nil { +func WriteToTar(tw *tar.Writer, r io.Reader, hdr *tar.Header) error { + if err := tw.WriteHeader(hdr); err != nil { return err } + _, err := io.Copy(tw, r) + return err +} - // enable compression if file ends in .gz - tw := tar.NewWriter(tarFile) - if strings.HasSuffix(tarName, ".gz") || strings.HasSuffix(tarName, ".gzip") { - gz := gzip.NewWriter(tarFile) - defer gz.Close() - tw = tar.NewWriter(gz) - } - defer tw.Close() - +// WritePathsToTar walks paths to create tar file tarName +func WritePathsToTar(tw *tar.Writer, paths []string) (err error) { // walk each specified path and add encountered file to tar for _, path := range paths { - // validate path path = filepath.Clean(path) - absPath, err := filepath.Abs(path) - if err != nil { - fmt.Println(err) - continue - } - if absPath == absTar { - continue - } - if absPath == filepath.Dir(absTar) { - continue - } walker := func(file string, finfo os.FileInfo, err error) error { - if err != nil { + if err != nil || file == path { return err } @@ -83,14 +60,13 @@ func CreateTarFile(tarName string, paths []string) (err error) { } } // ensure header has relative file path - hdr.Name = relFilePath - - hdr.Name = strings.TrimPrefix(relFilePath, path) + hdr.Name = strings.TrimPrefix(relFilePath, path+string(filepath.Separator)) if err := tw.WriteHeader(hdr); err != nil { return err } + // if path is a dir, dont continue - if finfo.Mode().IsDir() { + if finfo.IsDir() { return nil } @@ -99,12 +75,11 @@ func CreateTarFile(tarName string, paths []string) (err error) { if err != nil { return err } - defer srcFile.Close() _, err = io.Copy(tw, srcFile) - if err != nil { - return err + if err := srcFile.Close(); err != nil { + log.Error(err) } - return nil + return err } // build tar @@ -123,7 +98,9 @@ func UntarFile(tarName, target string) (err error) { return err } defer func() { - err = tarFile.Close() + if err := tarFile.Close(); err != nil { + log.Error(err) + } }() absPath, err := filepath.Abs(target) @@ -131,14 +108,20 @@ func UntarFile(tarName, target string) (err error) { return err } - tr := tar.NewReader(tarFile) - if strings.HasSuffix(tarName, ".gz") || strings.HasSuffix(tarName, ".gzip") { + var tr *tar.Reader + if isFileGzipped(tarName) { gz, err := gzip.NewReader(tarFile) if err != nil { return err } - defer gz.Close() + defer func() { + if err := gz.Close(); err != nil { + log.Error(err) + } + }() tr = tar.NewReader(gz) + } else { + tr = tar.NewReader(tarFile) } // untar each segment @@ -188,3 +171,31 @@ func UntarFile(tarName, target string) (err error) { return nil } + +// isFileGzipped returns true if file is compressed with gzip. +func isFileGzipped(file string) bool { + return strings.HasSuffix(file, ".gz") || strings.HasSuffix(file, ".gzip") +} + +func newTarFileHeader(path string, size int64) *tar.Header { + return &tar.Header{ + Typeflag: tar.TypeReg, + Name: filepath.Clean(path), + ModTime: time.Now(), + Mode: 0666, + Size: size, + Uid: os.Getuid(), + Gid: os.Getgid(), + } +} + +func newTarDirHeader(path string) *tar.Header { + return &tar.Header{ + Typeflag: tar.TypeDir, + Name: filepath.Clean(path) + "/", + ModTime: time.Now(), + Mode: 0700, + Uid: os.Getuid(), + Gid: os.Getgid(), + } +} diff --git a/internal/scorecard/alpha/testdata/bundle.tar.gz b/internal/scorecard/alpha/testdata/bundle.tar.gz index 9e5d7ae4b4e8ea6c76abf0c1247b2992d5d45f24..4f569ab3c15911047bcb8a161fa7a26c01a8a3e8 100644 GIT binary patch literal 2865 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 P00960eqT0=08jt`>X5_F literal 2809 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