github.com/joelanford/operator-sdk@v0.8.2/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  	scapiv1alpha1 "github.com/operator-framework/operator-sdk/pkg/apis/scorecard/v1alpha1"
    25  
    26  	olmapiv1alpha1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1alpha1"
    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  		res.State = scapiv1alpha1.ErrorState
   189  		return res
   190  	}
   191  	err = t.Client.Get(ctx, types.NamespacedName{Namespace: t.CR.GetNamespace(), Name: t.CR.GetName()}, t.CR)
   192  	if err != nil {
   193  		res.Errors = append(res.Errors, err)
   194  		res.State = scapiv1alpha1.ErrorState
   195  		return res
   196  	}
   197  	for _, crd := range crds {
   198  		// check if the CRD matches the testing CR
   199  		gvk := t.CR.GroupVersionKind()
   200  		// Only check the validation block if the CRD and CR have the same Kind and Version
   201  		if !(matchVersion(gvk.Version, crd) && matchKind(gvk.Kind, crd.Spec.Names.Kind)) {
   202  			continue
   203  		}
   204  		res.MaximumPoints++
   205  		if crd.Spec.Validation == nil {
   206  			res.Suggestions = append(res.Suggestions, fmt.Sprintf("Add CRD validation for %s/%s", crd.Spec.Names.Kind, crd.Spec.Version))
   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  	var missingResources []string
   239  	for _, crd := range t.CSV.Spec.CustomResourceDefinitions.Owned {
   240  		gvk := t.CR.GroupVersionKind()
   241  		if strings.EqualFold(crd.Version, gvk.Version) && matchKind(gvk.Kind, crd.Kind) {
   242  			res.MaximumPoints++
   243  			if len(crd.Resources) > 0 {
   244  				res.EarnedPoints++
   245  			}
   246  			resources, err := getUsedResources(t.ProxyPod)
   247  			if err != nil {
   248  				log.Warningf("getUsedResource failed: %v", err)
   249  			}
   250  			for _, resource := range resources {
   251  				foundResource := false
   252  				for _, listedResource := range crd.Resources {
   253  					if matchKind(resource.Kind, listedResource.Kind) && strings.EqualFold(resource.Version, listedResource.Version) {
   254  						foundResource = true
   255  						break
   256  					}
   257  				}
   258  				if foundResource == false {
   259  					missingResources = append(missingResources, fmt.Sprintf("%s/%s", resource.Kind, resource.Version))
   260  				}
   261  			}
   262  		}
   263  	}
   264  	if len(missingResources) > 0 {
   265  		res.Suggestions = append(res.Suggestions, fmt.Sprintf("If it would be helpful to an end-user to understand or troubleshoot your CR, consider adding resources %v to the resources section for owned CRD %s", missingResources, t.CR.GroupVersionKind().Kind))
   266  	}
   267  	return res
   268  }
   269  
   270  func getUsedResources(proxyPod *v1.Pod) ([]schema.GroupVersionKind, error) {
   271  	logs, err := getProxyLogs(proxyPod)
   272  	if err != nil {
   273  		return nil, err
   274  	}
   275  	resources := map[schema.GroupVersionKind]bool{}
   276  	for _, line := range strings.Split(logs, "\n") {
   277  		logMap := make(map[string]interface{})
   278  		err := json.Unmarshal([]byte(line), &logMap)
   279  		if err != nil {
   280  			// it is very common to get "unexpected end of JSON input", so we'll leave this at the debug level
   281  			log.Debugf("could not unmarshal line: %v", err)
   282  			continue
   283  		}
   284  		/*
   285  			There are 6 formats a resource uri can have:
   286  			Cluster-Scoped:
   287  				Collection:      /apis/GROUP/VERSION/KIND
   288  				Individual:      /apis/GROUP/VERSION/KIND/NAME
   289  				Core:            /api/v1/KIND
   290  				Core Individual: /api/v1/KIND/NAME
   291  
   292  			Namespaces:
   293  				All Namespaces:          /apis/GROUP/VERSION/KIND (same as cluster collection)
   294  				Collection in Namespace: /apis/GROUP/VERSION/namespaces/NAMESPACE/KIND
   295  				Individual:              /apis/GROUP/VERSION/namespaces/NAMESPACE/KIND/NAME
   296  				Core:                    /api/v1/namespaces/NAMESPACE/KIND
   297  				Core Indiviual:          /api/v1/namespaces/NAMESPACE/KIND/NAME
   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] == "api" {
   330  				resources[schema.GroupVersionKind{Version: splitURI[1], Kind: splitURI[2]}] = true
   331  				break
   332  			} else if splitURI[0] == "apis" {
   333  				resources[schema.GroupVersionKind{Group: splitURI[1], Version: splitURI[2], Kind: splitURI[3]}] = true
   334  				break
   335  			}
   336  			log.Warnf("Invalid URI: \"%s\"", uri)
   337  		case 5:
   338  			if splitURI[0] == "api" {
   339  				resources[schema.GroupVersionKind{Version: splitURI[1], Kind: splitURI[4]}] = true
   340  				break
   341  			} else if splitURI[0] == "apis" {
   342  				resources[schema.GroupVersionKind{Group: splitURI[1], Version: splitURI[2], Kind: splitURI[3]}] = true
   343  				break
   344  			}
   345  			log.Warnf("Invalid URI: \"%s\"", uri)
   346  		case 6, 7:
   347  			if splitURI[0] == "api" {
   348  				resources[schema.GroupVersionKind{Version: splitURI[1], Kind: splitURI[4]}] = true
   349  				break
   350  			} else if splitURI[0] == "apis" {
   351  				resources[schema.GroupVersionKind{Group: splitURI[1], Version: splitURI[2], Kind: splitURI[5]}] = true
   352  				break
   353  			}
   354  			log.Warnf("Invalid URI: \"%s\"", uri)
   355  		}
   356  	}
   357  	var resourcesArr []schema.GroupVersionKind
   358  	for gvk := range resources {
   359  		resourcesArr = append(resourcesArr, gvk)
   360  	}
   361  	return resourcesArr, nil
   362  }
   363  
   364  // Run - implements Test interface
   365  func (t *AnnotationsContainExamplesTest) Run(ctx context.Context) *TestResult {
   366  	res := &TestResult{Test: t, MaximumPoints: 1}
   367  	if t.CSV.Annotations != nil && t.CSV.Annotations["alm-examples"] != "" {
   368  		res.EarnedPoints = 1
   369  	}
   370  	if res.EarnedPoints == 0 {
   371  		res.Suggestions = append(res.Suggestions, fmt.Sprintf("Add an alm-examples annotation to your CSV to pass the %s test", t.GetName()))
   372  	}
   373  	return res
   374  }
   375  
   376  // Run - implements Test interface
   377  func (t *StatusDescriptorsTest) Run(ctx context.Context) *TestResult {
   378  	res := &TestResult{Test: t}
   379  	err := t.Client.Get(ctx, types.NamespacedName{Namespace: t.CR.GetNamespace(), Name: t.CR.GetName()}, t.CR)
   380  	if err != nil {
   381  		res.Errors = append(res.Errors, err)
   382  		res.State = scapiv1alpha1.ErrorState
   383  		return res
   384  	}
   385  	if t.CR.Object["status"] == nil {
   386  		return res
   387  	}
   388  	statusBlock := t.CR.Object["status"].(map[string]interface{})
   389  	res.MaximumPoints = len(statusBlock)
   390  	var crd *olmapiv1alpha1.CRDDescription
   391  	for _, owned := range t.CSV.Spec.CustomResourceDefinitions.Owned {
   392  		if owned.Kind == t.CR.GetKind() {
   393  			crd = &owned
   394  			break
   395  		}
   396  	}
   397  	if crd == nil {
   398  		return res
   399  	}
   400  	for key := range statusBlock {
   401  		for _, statDesc := range crd.StatusDescriptors {
   402  			if statDesc.Path == key {
   403  				res.EarnedPoints++
   404  				delete(statusBlock, key)
   405  				break
   406  			}
   407  		}
   408  	}
   409  	for key := range statusBlock {
   410  		res.Suggestions = append(res.Suggestions, "Add a status descriptor for "+key)
   411  	}
   412  	return res
   413  }
   414  
   415  // Run - implements Test interface
   416  func (t *SpecDescriptorsTest) Run(ctx context.Context) *TestResult {
   417  	res := &TestResult{Test: t}
   418  	err := t.Client.Get(ctx, types.NamespacedName{Namespace: t.CR.GetNamespace(), Name: t.CR.GetName()}, t.CR)
   419  	if err != nil {
   420  		res.Errors = append(res.Errors, err)
   421  		res.State = scapiv1alpha1.ErrorState
   422  		return res
   423  	}
   424  	if t.CR.Object["spec"] == nil {
   425  		return res
   426  	}
   427  	specBlock := t.CR.Object["spec"].(map[string]interface{})
   428  	res.MaximumPoints = len(specBlock)
   429  	var crd *olmapiv1alpha1.CRDDescription
   430  	for _, owned := range t.CSV.Spec.CustomResourceDefinitions.Owned {
   431  		if owned.Kind == t.CR.GetKind() {
   432  			crd = &owned
   433  			break
   434  		}
   435  	}
   436  	if crd == nil {
   437  		return res
   438  	}
   439  	for key := range specBlock {
   440  		for _, statDesc := range crd.SpecDescriptors {
   441  			if statDesc.Path == key {
   442  				res.EarnedPoints++
   443  				delete(specBlock, key)
   444  				break
   445  			}
   446  		}
   447  	}
   448  	for key := range specBlock {
   449  		res.Suggestions = append(res.Suggestions, "Add a spec descriptor for "+key)
   450  	}
   451  	return res
   452  }