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 }