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 }