github.com/mkimuram/operator-sdk@v0.7.1-0.20190410172100-52ad33a4bda0/internal/pkg/scorecard/olm_tests.go (about)

     1  // Copyright 2019 The Operator-SDK Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package scorecard
    16  
    17  import (
    18  	"context"
    19  	"encoding/json"
    20  	"fmt"
    21  	"strings"
    22  
    23  	"github.com/operator-framework/operator-sdk/internal/util/k8sutil"
    24  
    25  	olmapiv1alpha1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1alpha1"
    26  	log "github.com/sirupsen/logrus"
    27  	v1 "k8s.io/api/core/v1"
    28  	apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
    29  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    30  	"k8s.io/apimachinery/pkg/runtime/schema"
    31  	"k8s.io/apimachinery/pkg/types"
    32  	"sigs.k8s.io/controller-runtime/pkg/client"
    33  )
    34  
    35  // OLMTestConfig contains all variables required by the OLMTest TestSuite
    36  type OLMTestConfig struct {
    37  	Client   client.Client
    38  	CR       *unstructured.Unstructured
    39  	CSV      *olmapiv1alpha1.ClusterServiceVersion
    40  	CRDsDir  string
    41  	ProxyPod *v1.Pod
    42  }
    43  
    44  // Test Defintions
    45  
    46  // CRDsHaveValidationTest is a scorecard test that verifies that all CRDs have a validation section
    47  type CRDsHaveValidationTest struct {
    48  	TestInfo
    49  	OLMTestConfig
    50  }
    51  
    52  // NewCRDsHaveValidationTest returns a new CRDsHaveValidationTest object
    53  func NewCRDsHaveValidationTest(conf OLMTestConfig) *CRDsHaveValidationTest {
    54  	return &CRDsHaveValidationTest{
    55  		OLMTestConfig: conf,
    56  		TestInfo: TestInfo{
    57  			Name:        "Provided APIs have validation",
    58  			Description: "All CRDs have an OpenAPI validation subsection",
    59  			Cumulative:  true,
    60  		},
    61  	}
    62  }
    63  
    64  // CRDsHaveResourcesTest is a scorecard test that verifies that the CSV lists used resources in its owned CRDs secyion
    65  type CRDsHaveResourcesTest struct {
    66  	TestInfo
    67  	OLMTestConfig
    68  }
    69  
    70  // NewCRDsHaveResourcesTest returns a new CRDsHaveResourcesTest object
    71  func NewCRDsHaveResourcesTest(conf OLMTestConfig) *CRDsHaveResourcesTest {
    72  	return &CRDsHaveResourcesTest{
    73  		OLMTestConfig: conf,
    74  		TestInfo: TestInfo{
    75  			Name:        "Owned CRDs have resources listed",
    76  			Description: "All Owned CRDs contain a resources subsection",
    77  			Cumulative:  true,
    78  		},
    79  	}
    80  }
    81  
    82  // AnnotationsContainExamplesTest is a scorecard test that verifies that the CSV contains examples via the alm-examples annotation
    83  type AnnotationsContainExamplesTest struct {
    84  	TestInfo
    85  	OLMTestConfig
    86  }
    87  
    88  // NewAnnotationsContainExamplesTest returns a new AnnotationsContainExamplesTest object
    89  func NewAnnotationsContainExamplesTest(conf OLMTestConfig) *AnnotationsContainExamplesTest {
    90  	return &AnnotationsContainExamplesTest{
    91  		OLMTestConfig: conf,
    92  		TestInfo: TestInfo{
    93  			Name:        "CRs have at least 1 example",
    94  			Description: "The CSV's metadata contains an alm-examples section",
    95  			Cumulative:  true,
    96  		},
    97  	}
    98  }
    99  
   100  // SpecDescriptorsTest is a scorecard test that verifies that all spec fields have descriptors
   101  type SpecDescriptorsTest struct {
   102  	TestInfo
   103  	OLMTestConfig
   104  }
   105  
   106  // NewSpecDescriptorsTest returns a new SpecDescriptorsTest object
   107  func NewSpecDescriptorsTest(conf OLMTestConfig) *SpecDescriptorsTest {
   108  	return &SpecDescriptorsTest{
   109  		OLMTestConfig: conf,
   110  		TestInfo: TestInfo{
   111  			Name:        "Spec fields with descriptors",
   112  			Description: "All spec fields have matching descriptors in the CSV",
   113  			Cumulative:  true,
   114  		},
   115  	}
   116  }
   117  
   118  // StatusDescriptorsTest is a scorecard test that verifies that all status fields have descriptors
   119  type StatusDescriptorsTest struct {
   120  	TestInfo
   121  	OLMTestConfig
   122  }
   123  
   124  // NewStatusDescriptorsTest returns a new StatusDescriptorsTest object
   125  func NewStatusDescriptorsTest(conf OLMTestConfig) *StatusDescriptorsTest {
   126  	return &StatusDescriptorsTest{
   127  		OLMTestConfig: conf,
   128  		TestInfo: TestInfo{
   129  			Name:        "Status fields with descriptors",
   130  			Description: "All status fields have matching descriptors in the CSV",
   131  			Cumulative:  true,
   132  		},
   133  	}
   134  }
   135  
   136  func matchKind(kind1, kind2 string) bool {
   137  	singularKind1, err := restMapper.ResourceSingularizer(kind1)
   138  	if err != nil {
   139  		singularKind1 = kind1
   140  		log.Warningf("could not find singular version of %s", kind1)
   141  	}
   142  	singularKind2, err := restMapper.ResourceSingularizer(kind2)
   143  	if err != nil {
   144  		singularKind2 = kind2
   145  		log.Warningf("could not find singular version of %s", kind2)
   146  	}
   147  	return strings.EqualFold(singularKind1, singularKind2)
   148  }
   149  
   150  // NewOLMTestSuite returns a new TestSuite object containing CSV best practice checks
   151  func NewOLMTestSuite(conf OLMTestConfig) *TestSuite {
   152  	ts := NewTestSuite(
   153  		"OLM Tests",
   154  		"Test suite checks if an operator's CSV follows best practices",
   155  	)
   156  
   157  	ts.AddTest(NewCRDsHaveValidationTest(conf), 1.25)
   158  	ts.AddTest(NewCRDsHaveResourcesTest(conf), 1)
   159  	ts.AddTest(NewAnnotationsContainExamplesTest(conf), 1)
   160  	ts.AddTest(NewSpecDescriptorsTest(conf), 1)
   161  	ts.AddTest(NewStatusDescriptorsTest(conf), 1)
   162  
   163  	return ts
   164  }
   165  
   166  // Test Implentations
   167  
   168  // matchVersion checks if a CRD contains a specified version in a case insensitive manner
   169  func matchVersion(version string, crd *apiextv1beta1.CustomResourceDefinition) bool {
   170  	if strings.EqualFold(version, crd.Spec.Version) {
   171  		return true
   172  	}
   173  	// crd.Spec.Version is deprecated, so check in crd.Spec.Versions as well
   174  	for _, currVer := range crd.Spec.Versions {
   175  		if strings.EqualFold(version, currVer.Name) {
   176  			return true
   177  		}
   178  	}
   179  	return false
   180  }
   181  
   182  // Run - implements Test interface
   183  func (t *CRDsHaveValidationTest) Run(ctx context.Context) *TestResult {
   184  	res := &TestResult{Test: t}
   185  	crds, err := k8sutil.GetCRDs(t.CRDsDir)
   186  	if err != nil {
   187  		res.Errors = append(res.Errors, fmt.Errorf("failed to get CRDs in %s directory: %v", t.CRDsDir, err))
   188  		return res
   189  	}
   190  	err = t.Client.Get(ctx, types.NamespacedName{Namespace: t.CR.GetNamespace(), Name: t.CR.GetName()}, t.CR)
   191  	if err != nil {
   192  		res.Errors = append(res.Errors, err)
   193  		return res
   194  	}
   195  	// TODO: we need to make this handle multiple CRs better/correctly
   196  	for _, crd := range crds {
   197  		res.MaximumPoints++
   198  		if crd.Spec.Validation == nil {
   199  			res.Suggestions = append(res.Suggestions, fmt.Sprintf("Add CRD validation for %s/%s", crd.Spec.Names.Kind, crd.Spec.Version))
   200  			continue
   201  		}
   202  		// check if the CRD matches the testing CR
   203  		gvk := t.CR.GroupVersionKind()
   204  		// Only check the validation block if the CRD and CR have the same Kind and Version
   205  		if !(matchVersion(gvk.Version, crd) && matchKind(gvk.Kind, crd.Spec.Names.Kind)) {
   206  			res.EarnedPoints++
   207  			continue
   208  		}
   209  		failed := false
   210  		if t.CR.Object["spec"] != nil {
   211  			spec := t.CR.Object["spec"].(map[string]interface{})
   212  			for key := range spec {
   213  				if _, ok := crd.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[key]; !ok {
   214  					failed = true
   215  					res.Suggestions = append(res.Suggestions, fmt.Sprintf("Add CRD validation for spec field `%s` in %s/%s", key, gvk.Kind, gvk.Version))
   216  				}
   217  			}
   218  		}
   219  		if t.CR.Object["status"] != nil {
   220  			status := t.CR.Object["status"].(map[string]interface{})
   221  			for key := range status {
   222  				if _, ok := crd.Spec.Validation.OpenAPIV3Schema.Properties["status"].Properties[key]; !ok {
   223  					failed = true
   224  					res.Suggestions = append(res.Suggestions, fmt.Sprintf("Add CRD validation for status field `%s` in %s/%s", key, gvk.Kind, gvk.Version))
   225  				}
   226  			}
   227  		}
   228  		if !failed {
   229  			res.EarnedPoints++
   230  		}
   231  	}
   232  	return res
   233  }
   234  
   235  // Run - implements Test interface
   236  func (t *CRDsHaveResourcesTest) Run(ctx context.Context) *TestResult {
   237  	res := &TestResult{Test: t}
   238  	for _, crd := range t.CSV.Spec.CustomResourceDefinitions.Owned {
   239  		res.MaximumPoints++
   240  		gvk := t.CR.GroupVersionKind()
   241  		if strings.EqualFold(crd.Version, gvk.Version) && matchKind(gvk.Kind, crd.Kind) {
   242  			resources, err := getUsedResources(t.ProxyPod)
   243  			if err != nil {
   244  				log.Warningf("getUsedResource failed: %v", err)
   245  			}
   246  			allResourcesListed := true
   247  			for _, resource := range resources {
   248  				foundResource := false
   249  				for _, listedResource := range crd.Resources {
   250  					if matchKind(resource.Kind, listedResource.Kind) && strings.EqualFold(resource.Version, listedResource.Version) {
   251  						foundResource = true
   252  					}
   253  				}
   254  				if foundResource == false {
   255  					allResourcesListed = false
   256  				}
   257  			}
   258  			if allResourcesListed {
   259  				res.EarnedPoints++
   260  			}
   261  		} else {
   262  			if len(crd.Resources) > 0 {
   263  				res.EarnedPoints++
   264  			}
   265  		}
   266  	}
   267  	if res.EarnedPoints < res.MaximumPoints {
   268  		res.Suggestions = append(res.Suggestions, "Add resources to owned CRDs")
   269  	}
   270  	return res
   271  }
   272  
   273  func getUsedResources(proxyPod *v1.Pod) ([]schema.GroupVersionKind, error) {
   274  	logs, err := getProxyLogs(proxyPod)
   275  	if err != nil {
   276  		return nil, err
   277  	}
   278  	resources := map[schema.GroupVersionKind]bool{}
   279  	for _, line := range strings.Split(logs, "\n") {
   280  		logMap := make(map[string]interface{})
   281  		err := json.Unmarshal([]byte(line), &logMap)
   282  		if err != nil {
   283  			// it is very common to get "unexpected end of JSON input", so we'll leave this at the debug level
   284  			log.Debugf("could not unmarshal line: %v", err)
   285  			continue
   286  		}
   287  		/*
   288  			There are 6 formats a resource uri can have:
   289  			Cluster-Scoped:
   290  				Collection: /apis/GROUP/VERSION/KIND
   291  				Individual: /apis/GROUP/VERSION/KIND/NAME
   292  				Core:       /api/v1/KIND
   293  			Namespaces:
   294  				All Namespaces:          /apis/GROUP/VERSION/KIND (same as cluster collection)
   295  				Collection in Namespace: /apis/GROUP/VERSION/namespaces/NAMESPACE/KIND
   296  				Individual:              /apis/GROUP/VERSION/namespaces/NAMESPACE/KIND/NAME
   297  				Core:                    /api/v1/namespaces/NAMESPACE/KIND
   298  
   299  			These urls are also often appended with options, which are denoted by the '?' symbol
   300  		*/
   301  		if msg, ok := logMap["msg"].(string); !ok || msg != "Request Info" {
   302  			continue
   303  		}
   304  		uri, ok := logMap["uri"].(string)
   305  		if !ok {
   306  			log.Warn("URI type is not string")
   307  			continue
   308  		}
   309  		removedOptions := strings.Split(uri, "?")[0]
   310  		splitURI := strings.Split(removedOptions, "/")
   311  		// first string is empty string ""
   312  		if len(splitURI) < 2 {
   313  			log.Warnf("Invalid URI: \"%s\"", uri)
   314  			continue
   315  		}
   316  		splitURI = splitURI[1:]
   317  		switch len(splitURI) {
   318  		case 3:
   319  			if splitURI[0] == "api" {
   320  				resources[schema.GroupVersionKind{Version: splitURI[1], Kind: splitURI[2]}] = true
   321  				break
   322  			} else if splitURI[0] == "apis" {
   323  				// this situation happens when the client enumerates the available resources of the server
   324  				// Example: "/apis/apps/v1?timeout=32s"
   325  				break
   326  			}
   327  			log.Warnf("Invalid URI: \"%s\"", uri)
   328  		case 4:
   329  			if splitURI[0] == "apis" {
   330  				resources[schema.GroupVersionKind{Group: splitURI[1], Version: splitURI[2], Kind: splitURI[3]}] = true
   331  				break
   332  			}
   333  			log.Warnf("Invalid URI: \"%s\"", uri)
   334  		case 5:
   335  			if splitURI[0] == "api" {
   336  				resources[schema.GroupVersionKind{Version: splitURI[1], Kind: splitURI[4]}] = true
   337  				break
   338  			} else if splitURI[0] == "apis" {
   339  				resources[schema.GroupVersionKind{Group: splitURI[1], Version: splitURI[2], Kind: splitURI[3]}] = true
   340  				break
   341  			}
   342  			log.Warnf("Invalid URI: \"%s\"", uri)
   343  		case 6, 7:
   344  			if splitURI[0] == "apis" {
   345  				resources[schema.GroupVersionKind{Group: splitURI[1], Version: splitURI[2], Kind: splitURI[5]}] = true
   346  				break
   347  			}
   348  			log.Warnf("Invalid URI: \"%s\"", uri)
   349  		}
   350  	}
   351  	var resourcesArr []schema.GroupVersionKind
   352  	for gvk := range resources {
   353  		resourcesArr = append(resourcesArr, gvk)
   354  	}
   355  	return resourcesArr, nil
   356  }
   357  
   358  // Run - implements Test interface
   359  func (t *AnnotationsContainExamplesTest) Run(ctx context.Context) *TestResult {
   360  	res := &TestResult{Test: t, MaximumPoints: 1}
   361  	if t.CSV.Annotations != nil && t.CSV.Annotations["alm-examples"] != "" {
   362  		res.EarnedPoints = 1
   363  	}
   364  	if res.EarnedPoints == 0 {
   365  		res.Suggestions = append(res.Suggestions, fmt.Sprintf("Add an alm-examples annotation to your CSV to pass the %s test", t.GetName()))
   366  	}
   367  	return res
   368  }
   369  
   370  // Run - implements Test interface
   371  func (t *StatusDescriptorsTest) Run(ctx context.Context) *TestResult {
   372  	res := &TestResult{Test: t}
   373  	err := t.Client.Get(ctx, types.NamespacedName{Namespace: t.CR.GetNamespace(), Name: t.CR.GetName()}, t.CR)
   374  	if err != nil {
   375  		res.Errors = append(res.Errors, err)
   376  		return res
   377  	}
   378  	if t.CR.Object["status"] == nil {
   379  		return res
   380  	}
   381  	statusBlock := t.CR.Object["status"].(map[string]interface{})
   382  	res.MaximumPoints = len(statusBlock)
   383  	var crd *olmapiv1alpha1.CRDDescription
   384  	for _, owned := range t.CSV.Spec.CustomResourceDefinitions.Owned {
   385  		if owned.Kind == t.CR.GetKind() {
   386  			crd = &owned
   387  			break
   388  		}
   389  	}
   390  	if crd == nil {
   391  		return res
   392  	}
   393  	for key := range statusBlock {
   394  		for _, statDesc := range crd.StatusDescriptors {
   395  			if statDesc.Path == key {
   396  				res.EarnedPoints++
   397  				delete(statusBlock, key)
   398  				break
   399  			}
   400  		}
   401  	}
   402  	for key := range statusBlock {
   403  		res.Suggestions = append(res.Suggestions, "Add a status descriptor for "+key)
   404  	}
   405  	return res
   406  }
   407  
   408  // Run - implements Test interface
   409  func (t *SpecDescriptorsTest) Run(ctx context.Context) *TestResult {
   410  	res := &TestResult{Test: t}
   411  	err := t.Client.Get(ctx, types.NamespacedName{Namespace: t.CR.GetNamespace(), Name: t.CR.GetName()}, t.CR)
   412  	if err != nil {
   413  		res.Errors = append(res.Errors, err)
   414  		return res
   415  	}
   416  	if t.CR.Object["spec"] == nil {
   417  		return res
   418  	}
   419  	specBlock := t.CR.Object["spec"].(map[string]interface{})
   420  	res.MaximumPoints = len(specBlock)
   421  	var crd *olmapiv1alpha1.CRDDescription
   422  	for _, owned := range t.CSV.Spec.CustomResourceDefinitions.Owned {
   423  		if owned.Kind == t.CR.GetKind() {
   424  			crd = &owned
   425  			break
   426  		}
   427  	}
   428  	if crd == nil {
   429  		return res
   430  	}
   431  	for key := range specBlock {
   432  		for _, statDesc := range crd.SpecDescriptors {
   433  			if statDesc.Path == key {
   434  				res.EarnedPoints++
   435  				delete(specBlock, key)
   436  				break
   437  			}
   438  		}
   439  	}
   440  	for key := range specBlock {
   441  		res.Suggestions = append(res.Suggestions, "Add a spec descriptor for "+key)
   442  	}
   443  	return res
   444  }