github.com/joelanford/operator-sdk@v0.8.2/internal/pkg/scorecard/scorecard.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 "bytes" 19 "context" 20 "encoding/json" 21 "fmt" 22 "io" 23 "io/ioutil" 24 "os" 25 "os/exec" 26 27 "github.com/operator-framework/operator-sdk/internal/pkg/scaffold" 28 k8sInternal "github.com/operator-framework/operator-sdk/internal/util/k8sutil" 29 "github.com/operator-framework/operator-sdk/internal/util/projutil" 30 "github.com/operator-framework/operator-sdk/internal/util/yamlutil" 31 scapiv1alpha1 "github.com/operator-framework/operator-sdk/pkg/apis/scorecard/v1alpha1" 32 33 "github.com/ghodss/yaml" 34 olmapiv1alpha1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1alpha1" 35 olminstall "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/install" 36 "github.com/pkg/errors" 37 "github.com/sirupsen/logrus" 38 "github.com/spf13/cobra" 39 "github.com/spf13/viper" 40 v1 "k8s.io/api/core/v1" 41 extscheme "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/scheme" 42 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 43 "k8s.io/apimachinery/pkg/runtime" 44 "k8s.io/apimachinery/pkg/runtime/schema" 45 "k8s.io/apimachinery/pkg/runtime/serializer" 46 "k8s.io/client-go/discovery/cached" 47 "k8s.io/client-go/kubernetes" 48 cgoscheme "k8s.io/client-go/kubernetes/scheme" 49 "k8s.io/client-go/rest" 50 "k8s.io/client-go/restmapper" 51 "sigs.k8s.io/controller-runtime/pkg/client" 52 ) 53 54 const ( 55 ConfigOpt = "config" 56 NamespaceOpt = "namespace" 57 KubeconfigOpt = "kubeconfig" 58 InitTimeoutOpt = "init-timeout" 59 OlmDeployedOpt = "olm-deployed" 60 CSVPathOpt = "csv-path" 61 BasicTestsOpt = "basic-tests" 62 OLMTestsOpt = "olm-tests" 63 NamespacedManifestOpt = "namespaced-manifest" 64 GlobalManifestOpt = "global-manifest" 65 CRManifestOpt = "cr-manifest" 66 ProxyImageOpt = "proxy-image" 67 ProxyPullPolicyOpt = "proxy-pull-policy" 68 CRDsDirOpt = "crds-dir" 69 OutputFormatOpt = "output" 70 PluginDirOpt = "plugin-dir" 71 JSONOutputFormat = "json" 72 HumanReadableOutputFormat = "human-readable" 73 ) 74 75 const ( 76 basicOperator = "Basic Operator" 77 olmIntegration = "OLM Integration" 78 ) 79 80 var ( 81 kubeconfig *rest.Config 82 dynamicDecoder runtime.Decoder 83 runtimeClient client.Client 84 restMapper *restmapper.DeferredDiscoveryRESTMapper 85 deploymentName string 86 proxyPodGlobal *v1.Pod 87 cleanupFns []cleanupFn 88 ) 89 90 const ( 91 scorecardPodName = "operator-scorecard-test" 92 scorecardContainerName = "scorecard-proxy" 93 ) 94 95 // make a global logger for scorecard 96 var ( 97 logReadWriter io.ReadWriter 98 log = logrus.New() 99 ) 100 101 func runTests() ([]scapiv1alpha1.ScorecardOutput, error) { 102 defer func() { 103 if err := cleanupScorecard(); err != nil { 104 log.Errorf("Failed to cleanup resources: (%v)", err) 105 } 106 }() 107 108 var ( 109 tmpNamespaceVar string 110 err error 111 ) 112 kubeconfig, tmpNamespaceVar, err = k8sInternal.GetKubeconfigAndNamespace(viper.GetString(KubeconfigOpt)) 113 if err != nil { 114 return nil, fmt.Errorf("failed to build the kubeconfig: %v", err) 115 } 116 if viper.GetString(NamespaceOpt) == "" { 117 viper.Set(NamespaceOpt, tmpNamespaceVar) 118 } 119 scheme := runtime.NewScheme() 120 // scheme for client go 121 if err := cgoscheme.AddToScheme(scheme); err != nil { 122 return nil, fmt.Errorf("failed to add client-go scheme to client: (%v)", err) 123 } 124 // api extensions scheme (CRDs) 125 if err := extscheme.AddToScheme(scheme); err != nil { 126 return nil, fmt.Errorf("failed to add failed to add extensions api scheme to client: (%v)", err) 127 } 128 // olm api (CS 129 if err := olmapiv1alpha1.AddToScheme(scheme); err != nil { 130 return nil, fmt.Errorf("failed to add failed to add oml api scheme (CSVs) to client: (%v)", err) 131 } 132 dynamicDecoder = serializer.NewCodecFactory(scheme).UniversalDeserializer() 133 // if a user creates a new CRD, we need to be able to reset the rest mapper 134 // temporary kubeclient to get a cached discovery 135 kubeclient, err := kubernetes.NewForConfig(kubeconfig) 136 if err != nil { 137 return nil, fmt.Errorf("failed to get a kubeclient: %v", err) 138 } 139 cachedDiscoveryClient := cached.NewMemCacheClient(kubeclient.Discovery()) 140 restMapper = restmapper.NewDeferredDiscoveryRESTMapper(cachedDiscoveryClient) 141 restMapper.Reset() 142 runtimeClient, _ = client.New(kubeconfig, client.Options{Scheme: scheme, Mapper: restMapper}) 143 144 csv := &olmapiv1alpha1.ClusterServiceVersion{} 145 if viper.GetBool(OLMTestsOpt) { 146 yamlSpec, err := ioutil.ReadFile(viper.GetString(CSVPathOpt)) 147 if err != nil { 148 return nil, fmt.Errorf("failed to read csv: %v", err) 149 } 150 if err = yaml.Unmarshal(yamlSpec, csv); err != nil { 151 return nil, fmt.Errorf("error getting ClusterServiceVersion: %v", err) 152 } 153 } 154 155 // Extract operator manifests from the CSV if olm-deployed is set. 156 if viper.GetBool(OlmDeployedOpt) { 157 // Get deploymentName from the deployment manifest within the CSV. 158 strat, err := (&olminstall.StrategyResolver{}).UnmarshalStrategy(csv.Spec.InstallStrategy) 159 if err != nil { 160 return nil, err 161 } 162 stratDep, ok := strat.(*olminstall.StrategyDetailsDeployment) 163 if !ok { 164 return nil, fmt.Errorf("expected StrategyDetailsDeployment, got strategy of type %T", strat) 165 } 166 deploymentName = stratDep.DeploymentSpecs[0].Name 167 // Get the proxy pod, which should have been created with the CSV. 168 proxyPodGlobal, err = getPodFromDeployment(deploymentName, viper.GetString(NamespaceOpt)) 169 if err != nil { 170 return nil, err 171 } 172 173 logCRMsg := false 174 if crMans := viper.GetStringSlice(CRManifestOpt); len(crMans) == 0 { 175 // Create a temporary CR manifest from metadata if one is not provided. 176 if crJSONStr, ok := csv.ObjectMeta.Annotations["alm-examples"]; ok { 177 var crs []interface{} 178 if err = json.Unmarshal([]byte(crJSONStr), &crs); err != nil { 179 return nil, err 180 } 181 // TODO: run scorecard against all CR's in CSV. 182 cr := crs[0] 183 logCRMsg = len(crs) > 1 184 crJSONBytes, err := json.Marshal(cr) 185 if err != nil { 186 return nil, err 187 } 188 crYAMLBytes, err := yaml.JSONToYAML(crJSONBytes) 189 if err != nil { 190 return nil, err 191 } 192 crFile, err := ioutil.TempFile("", "*.cr.yaml") 193 if err != nil { 194 return nil, err 195 } 196 if _, err := crFile.Write(crYAMLBytes); err != nil { 197 return nil, err 198 } 199 viper.Set(CRManifestOpt, []string{crFile.Name()}) 200 defer func() { 201 for _, f := range viper.GetStringSlice(CRManifestOpt) { 202 if err := os.Remove(f); err != nil { 203 log.Errorf("Could not delete temporary CR manifest file: (%v)", err) 204 } 205 } 206 }() 207 } else { 208 return nil, errors.New("cr-manifest config option must be set if CSV has no metadata.annotations['alm-examples']") 209 } 210 } else { 211 // TODO: run scorecard against all CR's in CSV. 212 viper.Set(CRManifestOpt, []string{crMans[0]}) 213 logCRMsg = len(crMans) > 1 214 } 215 // Let users know that only the first CR is being tested. 216 if logCRMsg { 217 log.Infof("The scorecard does not support testing multiple CR's at once when run with --olm-deployed. Testing the first CR %s", viper.GetStringSlice(CRManifestOpt)[0]) 218 } 219 220 } else { 221 // If no namespaced manifest path is given, combine 222 // deploy/{service_account,role.yaml,role_binding,operator}.yaml. 223 if viper.GetString(NamespacedManifestOpt) == "" { 224 file, err := yamlutil.GenerateCombinedNamespacedManifest(scaffold.DeployDir) 225 if err != nil { 226 return nil, err 227 } 228 viper.Set(NamespacedManifestOpt, file.Name()) 229 defer func() { 230 err := os.Remove(viper.GetString(NamespacedManifestOpt)) 231 if err != nil { 232 log.Errorf("Could not delete temporary namespace manifest file: (%v)", err) 233 } 234 }() 235 } 236 // If no global manifest is given, combine all CRD's in the given CRD's dir. 237 if viper.GetString(GlobalManifestOpt) == "" { 238 gMan, err := yamlutil.GenerateCombinedGlobalManifest(viper.GetString(CRDsDirOpt)) 239 if err != nil { 240 return nil, err 241 } 242 viper.Set(GlobalManifestOpt, gMan.Name()) 243 defer func() { 244 err := os.Remove(viper.GetString(GlobalManifestOpt)) 245 if err != nil { 246 log.Errorf("Could not delete global manifest file: (%v)", err) 247 } 248 }() 249 } 250 } 251 252 crs := viper.GetStringSlice(CRManifestOpt) 253 // check if there are duplicate CRs 254 gvks := []schema.GroupVersionKind{} 255 for _, cr := range crs { 256 file, err := ioutil.ReadFile(cr) 257 if err != nil { 258 return nil, fmt.Errorf("failed to read file: %s", cr) 259 } 260 newGVKs, err := getGVKs(file) 261 if err != nil { 262 return nil, fmt.Errorf("could not get GVKs for resource(s) in file: %s, due to error: (%v)", cr, err) 263 } 264 gvks = append(gvks, newGVKs...) 265 } 266 dupMap := make(map[schema.GroupVersionKind]bool) 267 for _, gvk := range gvks { 268 if _, ok := dupMap[gvk]; ok { 269 log.Warnf("Duplicate gvks in CR list detected (%s); results may be inaccurate", gvk) 270 } 271 dupMap[gvk] = true 272 } 273 274 var pluginResults []scapiv1alpha1.ScorecardOutput 275 var suites []TestSuite 276 for _, cr := range crs { 277 // TODO: Change built-in tests into plugins 278 // Run built-in tests. 279 fmt.Printf("Running for cr: %s\n", cr) 280 if !viper.GetBool(OlmDeployedOpt) { 281 if err := createFromYAMLFile(viper.GetString(GlobalManifestOpt)); err != nil { 282 return nil, fmt.Errorf("failed to create global resources: %v", err) 283 } 284 if err := createFromYAMLFile(viper.GetString(NamespacedManifestOpt)); err != nil { 285 return nil, fmt.Errorf("failed to create namespaced resources: %v", err) 286 } 287 } 288 if err := createFromYAMLFile(cr); err != nil { 289 return nil, fmt.Errorf("failed to create cr resource: %v", err) 290 } 291 obj, err := yamlToUnstructured(cr) 292 if err != nil { 293 return nil, fmt.Errorf("failed to decode custom resource manifest into object: %s", err) 294 } 295 if err := waitUntilCRStatusExists(obj); err != nil { 296 return nil, fmt.Errorf("failed waiting to check if CR status exists: %v", err) 297 } 298 if viper.GetBool(BasicTestsOpt) { 299 conf := BasicTestConfig{ 300 Client: runtimeClient, 301 CR: obj, 302 ProxyPod: proxyPodGlobal, 303 } 304 basicTests := NewBasicTestSuite(conf) 305 basicTests.Run(context.TODO()) 306 suites = append(suites, *basicTests) 307 } 308 if viper.GetBool(OLMTestsOpt) { 309 conf := OLMTestConfig{ 310 Client: runtimeClient, 311 CR: obj, 312 CSV: csv, 313 CRDsDir: viper.GetString(CRDsDirOpt), 314 ProxyPod: proxyPodGlobal, 315 } 316 olmTests := NewOLMTestSuite(conf) 317 olmTests.Run(context.TODO()) 318 suites = append(suites, *olmTests) 319 } 320 // set up clean environment for every CR 321 if err := cleanupScorecard(); err != nil { 322 log.Errorf("Failed to cleanup resources: (%v)", err) 323 } 324 // reset cleanup functions 325 cleanupFns = []cleanupFn{} 326 // clear name of operator deployment 327 deploymentName = "" 328 } 329 suites, err = MergeSuites(suites) 330 if err != nil { 331 return nil, fmt.Errorf("failed to merge test suite results: %v", err) 332 } 333 for _, suite := range suites { 334 // convert to ScorecardOutput format 335 // will add log when basic and olm tests are separated into plugins 336 pluginResults = append(pluginResults, TestSuitesToScorecardOutput([]TestSuite{suite}, "")) 337 } 338 // Run plugins 339 pluginDir := viper.GetString(PluginDirOpt) 340 if dir, err := os.Stat(pluginDir); err != nil || !dir.IsDir() { 341 log.Warnf("Plugin directory not found; skipping plugin tests: %v", err) 342 return pluginResults, nil 343 } 344 if err := os.Chdir(pluginDir); err != nil { 345 return nil, fmt.Errorf("failed to chdir into scorecard plugin directory: %v", err) 346 } 347 // executable files must be in "bin" subdirectory 348 files, err := ioutil.ReadDir("bin") 349 if err != nil { 350 return nil, fmt.Errorf("failed to list files in %s/bin: %v", pluginDir, err) 351 } 352 for _, file := range files { 353 cmd := exec.Command("./bin/" + file.Name()) 354 stdout := &bytes.Buffer{} 355 cmd.Stdout = stdout 356 stderr := &bytes.Buffer{} 357 cmd.Stderr = stderr 358 err := cmd.Run() 359 if err != nil { 360 name := fmt.Sprintf("Failed Plugin: %s", file.Name()) 361 description := fmt.Sprintf("Plugin with file name `%s` failed", file.Name()) 362 logs := fmt.Sprintf("%s:\nStdout: %s\nStderr: %s", err, string(stdout.Bytes()), string(stderr.Bytes())) 363 pluginResults = append(pluginResults, failedPlugin(name, description, logs)) 364 // output error to main logger as well for human-readable output 365 log.Errorf("Plugin `%s` failed with error (%v)", file.Name(), err) 366 continue 367 } 368 // parse output and add to suites 369 result := scapiv1alpha1.ScorecardOutput{} 370 err = json.Unmarshal(stdout.Bytes(), &result) 371 if err != nil { 372 name := fmt.Sprintf("Plugin output invalid: %s", file.Name()) 373 description := fmt.Sprintf("Plugin with file name %s did not produce valid ScorecardOutput JSON", file.Name()) 374 logs := fmt.Sprintf("Stdout: %s\nStderr: %s", string(stdout.Bytes()), string(stderr.Bytes())) 375 pluginResults = append(pluginResults, failedPlugin(name, description, logs)) 376 log.Errorf("Output from plugin `%s` failed to unmarshal with error (%v)", file.Name(), err) 377 continue 378 } 379 stderrString := string(stderr.Bytes()) 380 if len(stderrString) != 0 { 381 log.Warn(stderrString) 382 } 383 pluginResults = append(pluginResults, result) 384 } 385 return pluginResults, nil 386 } 387 388 func ScorecardTests(cmd *cobra.Command, args []string) error { 389 if err := initConfig(); err != nil { 390 return err 391 } 392 if err := validateScorecardFlags(); err != nil { 393 return err 394 } 395 cmd.SilenceUsage = true 396 pluginOutputs, err := runTests() 397 if err != nil { 398 return err 399 } 400 totalScore := 0.0 401 // Update the state for the tests 402 for _, suite := range pluginOutputs { 403 for idx, res := range suite.Results { 404 suite.Results[idx] = UpdateSuiteStates(res) 405 } 406 } 407 if viper.GetString(OutputFormatOpt) == HumanReadableOutputFormat { 408 numSuites := 0 409 for _, plugin := range pluginOutputs { 410 for _, suite := range plugin.Results { 411 fmt.Printf("%s:\n", suite.Name) 412 for _, result := range suite.Tests { 413 fmt.Printf("\t%s: %d/%d\n", result.Name, result.EarnedPoints, result.MaximumPoints) 414 } 415 totalScore += float64(suite.TotalScore) 416 numSuites++ 417 } 418 } 419 totalScore = totalScore / float64(numSuites) 420 fmt.Printf("\nTotal Score: %.0f%%\n", totalScore) 421 // TODO: We can probably use some helper functions to clean up these quadruple nested loops 422 // Print suggestions 423 for _, plugin := range pluginOutputs { 424 for _, suite := range plugin.Results { 425 for _, result := range suite.Tests { 426 for _, suggestion := range result.Suggestions { 427 // 33 is yellow (specifically, the same shade of yellow that logrus uses for warnings) 428 fmt.Printf("\x1b[%dmSUGGESTION:\x1b[0m %s\n", 33, suggestion) 429 } 430 } 431 } 432 } 433 // Print errors 434 for _, plugin := range pluginOutputs { 435 for _, suite := range plugin.Results { 436 for _, result := range suite.Tests { 437 for _, err := range result.Errors { 438 // 31 is red (specifically, the same shade of red that logrus uses for errors) 439 fmt.Printf("\x1b[%dmERROR:\x1b[0m %s\n", 31, err) 440 } 441 } 442 } 443 } 444 } 445 if viper.GetString(OutputFormatOpt) == JSONOutputFormat { 446 log, err := ioutil.ReadAll(logReadWriter) 447 if err != nil { 448 return fmt.Errorf("failed to read log buffer: %v", err) 449 } 450 scTest := CombineScorecardOutput(pluginOutputs, string(log)) 451 // Pretty print so users can also read the json output 452 bytes, err := json.MarshalIndent(scTest, "", " ") 453 if err != nil { 454 return err 455 } 456 fmt.Printf("%s\n", string(bytes)) 457 } 458 return nil 459 } 460 461 func initConfig() error { 462 // viper/cobra already has flags parsed at this point; we can check if a config file flag is set 463 if viper.GetString(ConfigOpt) != "" { 464 // Use config file from the flag. 465 viper.SetConfigFile(viper.GetString(ConfigOpt)) 466 } else { 467 viper.AddConfigPath(projutil.MustGetwd()) 468 // using SetConfigName allows users to use a .yaml, .json, or .toml file 469 viper.SetConfigName(".osdk-scorecard") 470 } 471 472 if err := viper.ReadInConfig(); err == nil { 473 // configure logger output before logging anything 474 err := configureLogger() 475 if err != nil { 476 return err 477 } 478 log.Info("Using config file: ", viper.ConfigFileUsed()) 479 } else { 480 err := configureLogger() 481 if err != nil { 482 return err 483 } 484 log.Warn("Could not load config file; using flags") 485 } 486 return nil 487 } 488 489 func configureLogger() error { 490 if viper.GetString(OutputFormatOpt) == HumanReadableOutputFormat { 491 logReadWriter = os.Stdout 492 } else if viper.GetString(OutputFormatOpt) == JSONOutputFormat { 493 logReadWriter = &bytes.Buffer{} 494 } else { 495 return fmt.Errorf("invalid output format: %s", viper.GetString(OutputFormatOpt)) 496 } 497 log.SetOutput(logReadWriter) 498 return nil 499 } 500 501 func validateScorecardFlags() error { 502 if !viper.GetBool(OlmDeployedOpt) && len(viper.GetStringSlice(CRManifestOpt)) == 0 { 503 return errors.New("cr-manifest config option must be set") 504 } 505 if !viper.GetBool(BasicTestsOpt) && !viper.GetBool(OLMTestsOpt) { 506 return errors.New("at least one test type must be set") 507 } 508 if viper.GetBool(OLMTestsOpt) && viper.GetString(CSVPathOpt) == "" { 509 return fmt.Errorf("csv-path must be set if olm-tests is enabled") 510 } 511 if viper.GetBool(OlmDeployedOpt) && viper.GetString(CSVPathOpt) == "" { 512 return fmt.Errorf("csv-path must be set if olm-deployed is enabled") 513 } 514 pullPolicy := viper.GetString(ProxyPullPolicyOpt) 515 if pullPolicy != "Always" && pullPolicy != "Never" && pullPolicy != "PullIfNotPresent" { 516 return fmt.Errorf("invalid proxy pull policy: (%s); valid values: Always, Never, PullIfNotPresent", pullPolicy) 517 } 518 // this is already being checked in configure logger; may be unnecessary 519 outputFormat := viper.GetString(OutputFormatOpt) 520 if outputFormat != HumanReadableOutputFormat && outputFormat != JSONOutputFormat { 521 return fmt.Errorf("invalid output format (%s); valid values: %s, %s", outputFormat, HumanReadableOutputFormat, JSONOutputFormat) 522 } 523 return nil 524 } 525 526 func getGVKs(yamlFile []byte) ([]schema.GroupVersionKind, error) { 527 var gvks []schema.GroupVersionKind 528 529 scanner := yamlutil.NewYAMLScanner(yamlFile) 530 for scanner.Scan() { 531 yamlSpec := scanner.Bytes() 532 533 obj := &unstructured.Unstructured{} 534 jsonSpec, err := yaml.YAMLToJSON(yamlSpec) 535 if err != nil { 536 return nil, fmt.Errorf("could not convert yaml file to json: %v", err) 537 } 538 if err := obj.UnmarshalJSON(jsonSpec); err != nil { 539 return nil, fmt.Errorf("failed to unmarshal object spec: (%v)", err) 540 } 541 gvks = append(gvks, obj.GroupVersionKind()) 542 } 543 return gvks, nil 544 } 545 546 func failedPlugin(name, desc, log string) scapiv1alpha1.ScorecardOutput { 547 return scapiv1alpha1.ScorecardOutput{ 548 Results: []scapiv1alpha1.ScorecardSuiteResult{{ 549 Name: name, 550 Description: desc, 551 Error: 1, 552 Log: log, 553 }, 554 }, 555 } 556 }