k8s.io/test-infra@v0.0.0-20240520184403-27c6b4c223d8/hack/cluster-migration/main.go (about)

     1  /*
     2  Copyright 2023 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package main
    18  
    19  import (
    20  	"bytes"
    21  	"encoding/json"
    22  	"flag"
    23  	"fmt"
    24  	"log"
    25  	"os"
    26  	"sort"
    27  	"strings"
    28  
    29  	v1 "k8s.io/api/core/v1"
    30  	"k8s.io/utils/strings/slices"
    31  	cfg "sigs.k8s.io/prow/pkg/config"
    32  )
    33  
    34  type Config struct {
    35  	configPath       string
    36  	jobConfigPath    string
    37  	repoReport       bool
    38  	repo             string
    39  	output           string
    40  	ineligibleReport bool
    41  	eligibleReport   bool
    42  	todoReport       bool
    43  }
    44  
    45  type status struct {
    46  	TotalJobs     int             `json:"totalJobs"`
    47  	CompletedJobs int             `json:"completedJobs"`
    48  	EligibleJobs  int             `json:"eligibleJobs"`
    49  	Clusters      []clusterStatus `json:"clusters"`
    50  }
    51  
    52  type clusterStatus struct {
    53  	ClusterName  string       `json:"clusterName"`
    54  	EligibleJobs int          `json:"eligibleJobs"`
    55  	TotalJobs    int          `json:"totalJobs"`
    56  	RepoStatus   []repoStatus `json:"repoStatus"`
    57  }
    58  
    59  type repoStatus struct {
    60  	RepoName     string      `json:"repoName"`
    61  	EligibleJobs int         `json:"eligibleJobs"`
    62  	TotalJobs    int         `json:"totalJobs"`
    63  	Jobs         []jobStatus `json:"jobs"`
    64  }
    65  
    66  type jobStatus struct {
    67  	JobName    string      `json:"jobName"`
    68  	JobDetails cfg.JobBase `json:"jobDetails"`
    69  	Eligible   bool        `json:"eligible"`
    70  	Reason     string      `json:"reason"`
    71  	SourcePath string      `json:"sourcePath"`
    72  }
    73  
    74  var config Config
    75  
    76  var allowedSecretNames = []string{
    77  	"service-account",
    78  	"aws-credentials-607362164682",
    79  	"aws-credentials-768319786644",
    80  	"aws-credentials-boskos-scale-001-kops",
    81  	"aws-ssh-key-secret",
    82  	"ssh-key-secret",
    83  }
    84  
    85  var allowedLabelNames = []string{
    86  	"preset-aws-credential",
    87  	"preset-aws-ssh",
    88  }
    89  
    90  var allowedVolumeNames = []string{
    91  	"aws-cred",
    92  	"ssh",
    93  }
    94  
    95  var allowedEnvironmentVariables = []string{
    96  	"GOOGLE_APPLICATION_CREDENTIALS_DEPRECATED",
    97  	"E2E_GOOGLE_APPLICATION_CREDENTIALS",
    98  	"GOOGLE_APPLICATION_CREDENTIALS",
    99  	"AWS_SHARED_CREDENTIALS_FILE",
   100  }
   101  
   102  func (c *Config) validate() error {
   103  	if c.configPath == "" {
   104  		return fmt.Errorf("--config must set")
   105  	}
   106  	return nil
   107  }
   108  
   109  func loadConfig(configPath, jobConfigPath string) (*cfg.Config, error) {
   110  	return cfg.Load(configPath, jobConfigPath, nil, "")
   111  }
   112  
   113  // The function "reportTotalJobs" prints the total number of jobs, completed jobs, and eligible jobs.
   114  func reportTotalJobs(s status) {
   115  	fmt.Printf("Total jobs: %v\n", s.TotalJobs)
   116  	fmt.Printf("Completed jobs: %v\n", s.CompletedJobs)
   117  	fmt.Printf("Eligible jobs: %v\n", s.EligibleJobs-s.CompletedJobs)
   118  }
   119  
   120  // The function "reportClusterStats" prints the statistics of each cluster in a sorted order.
   121  func reportClusterStats(s status) {
   122  	printHeader()
   123  	sortedClusters := []string{}
   124  	for _, cluster := range s.Clusters {
   125  		sortedClusters = append(sortedClusters, cluster.ClusterName)
   126  	}
   127  	sort.Strings(sortedClusters)
   128  
   129  	for _, cluster := range sortedClusters {
   130  		for _, c := range s.Clusters {
   131  			if c.ClusterName == cluster {
   132  				if cluster == "default" {
   133  					printDefaultClusterStats(cluster, c, s.Clusters)
   134  					continue
   135  				} else {
   136  					printClusterStat(cluster, c, s.Clusters)
   137  				}
   138  			}
   139  		}
   140  	}
   141  }
   142  
   143  // The function "printHeader" prints a formatted header for displaying cluster information.
   144  func printHeader() {
   145  	format := "%-30v %-20v %v\n"
   146  	header := fmt.Sprintf("\n"+format, "Cluster", "Total(Eligible)", "% of Total(% of Eligible)")
   147  	separator := strings.Repeat("-", len(header))
   148  	fmt.Print(header, separator+"\n")
   149  }
   150  
   151  func printDefaultClusterStats(clusterName string, stat clusterStatus, allStats []clusterStatus) {
   152  	format := "%-30v %-20v %-10v(%v)\n"
   153  	eligibleP := getPercentage(stat.EligibleJobs, getTotalEligible(allStats))
   154  	totalP := getPercentage(stat.TotalJobs, getTotalJobs(allStats))
   155  	fmt.Printf(format, clusterName, fmt.Sprintf("%v(%v)", stat.TotalJobs, stat.EligibleJobs), printPercentage(totalP), printPercentage(eligibleP))
   156  }
   157  
   158  // The function "printClusterStat" prints the status of a cluster, including the number of eligible and
   159  // total jobs, as well as the percentage of eligible and total jobs compared to all clusters.
   160  func printClusterStat(clusterName string, stat clusterStatus, allStats []clusterStatus) {
   161  	format := "%-30v %-20v %-10v(%v)\n"
   162  	eligibleP := getPercentage(stat.EligibleJobs, getTotalEligible(allStats))
   163  	totalP := getPercentage(stat.TotalJobs, getTotalJobs(allStats))
   164  	fmt.Printf(format, clusterName, stat.TotalJobs, printPercentage(totalP), printPercentage(eligibleP))
   165  }
   166  
   167  // The function `getTotalEligible` calculates the total number of eligible jobs from a given list of
   168  // cluster statuses.
   169  func getTotalEligible(allStats []clusterStatus) int {
   170  	total := 0
   171  	for _, stat := range allStats {
   172  		total += stat.EligibleJobs
   173  	}
   174  	return total
   175  }
   176  
   177  // The function "getTotalJobs" calculates the total number of jobs from a given slice of clusterStatus
   178  // structs.
   179  func getTotalJobs(allStats []clusterStatus) int {
   180  	total := 0
   181  	for _, stat := range allStats {
   182  		total += stat.TotalJobs
   183  	}
   184  	return total
   185  }
   186  
   187  func getAllRepos(s status) []string {
   188  	repos := []string{}
   189  	for _, cluster := range s.Clusters {
   190  		for _, repo := range cluster.RepoStatus {
   191  			if !slices.Contains(repos, repo.RepoName) {
   192  				repos = append(repos, repo.RepoName)
   193  			}
   194  		}
   195  	}
   196  	return repos
   197  }
   198  
   199  // The function `printRepoStatistics` prints statistics for repositories, including completion status,
   200  // eligibility, remaining jobs, and percentage complete.
   201  func printRepoStatistics(s status) {
   202  	format := "%-55v  %-10v %-20v %-10v (%v)\n"
   203  	header := fmt.Sprintf("\n"+format, "Repository", "Complete", "Total(Eligible)", "Remaining", "Percent")
   204  	separator := strings.Repeat("-", len(header))
   205  
   206  	fmt.Print(header)
   207  	fmt.Println(separator)
   208  
   209  	sortedRepos := []string{}
   210  	for _, cluster := range s.Clusters {
   211  		for _, repo := range cluster.RepoStatus {
   212  			if !slices.Contains(sortedRepos, repo.RepoName) {
   213  				sortedRepos = append(sortedRepos, repo.RepoName)
   214  			}
   215  		}
   216  	}
   217  	sort.Strings(sortedRepos)
   218  
   219  	for _, repo := range sortedRepos {
   220  		total := 0
   221  		complete := 0
   222  		eligible := 0
   223  		for _, cluster := range s.Clusters {
   224  			for _, r := range cluster.RepoStatus {
   225  				if r.RepoName == repo {
   226  					total += r.TotalJobs
   227  					eligible += r.EligibleJobs
   228  					if cluster.ClusterName != "default" {
   229  						complete += r.TotalJobs
   230  					}
   231  				}
   232  			}
   233  		}
   234  		remaining := eligible - complete
   235  		percent := getPercentage(complete, eligible)
   236  		fmt.Printf(format, repo, complete, fmt.Sprintf("%v(%v)", total, eligible), remaining, printPercentage(percent))
   237  	}
   238  }
   239  
   240  func getRepo(path string) string {
   241  	return strings.Split(path, "/")[1]
   242  }
   243  
   244  // The function `getStatus` calculates the status of jobs based on their clusters and repositories.
   245  func getStatus(jobs map[string][]cfg.JobBase) status {
   246  	s := status{}
   247  	for repo, jobConfigs := range jobs {
   248  		for _, job := range jobConfigs {
   249  			cluster, eligible, ineligibleReason := getJobStatus(job)
   250  			s.TotalJobs++
   251  			if cluster != "" && cluster != "default" {
   252  				s.CompletedJobs++
   253  			} else {
   254  				cluster = "default"
   255  			}
   256  			if eligible {
   257  				s.EligibleJobs++
   258  			}
   259  			if !containsCluster(s.Clusters, cluster) {
   260  				s.Clusters = append(s.Clusters, clusterStatus{ClusterName: cluster})
   261  			}
   262  			for i, c := range s.Clusters {
   263  				if c.ClusterName == cluster {
   264  					s.Clusters[i].TotalJobs++
   265  					if eligible {
   266  						s.Clusters[i].EligibleJobs++
   267  					}
   268  					if !containsRepo(s.Clusters[i].RepoStatus, repo) {
   269  						s.Clusters[i].RepoStatus = append(s.Clusters[i].RepoStatus, repoStatus{RepoName: repo})
   270  					}
   271  					for j, r := range s.Clusters[i].RepoStatus {
   272  						if r.RepoName == repo {
   273  							s.Clusters[i].RepoStatus[j].TotalJobs++
   274  							if eligible {
   275  								s.Clusters[i].RepoStatus[j].EligibleJobs++
   276  							}
   277  							s.Clusters[i].RepoStatus[j].Jobs = append(s.Clusters[i].RepoStatus[j].Jobs, jobStatus{JobName: job.Name, JobDetails: job, Eligible: eligible, Reason: ineligibleReason, SourcePath: job.SourcePath})
   278  						}
   279  					}
   280  				}
   281  			}
   282  		}
   283  	}
   284  	return s
   285  }
   286  
   287  func getJobStatus(job cfg.JobBase) (string, bool, string) {
   288  	if job.Cluster != "default" {
   289  		return job.Cluster, true, ""
   290  	}
   291  
   292  	eligible, ineligibleReason := checkIfEligible(job)
   293  
   294  	return "", eligible, ineligibleReason
   295  }
   296  
   297  func containsCluster(clusters []clusterStatus, cluster string) bool {
   298  	for _, c := range clusters {
   299  		if c.ClusterName == cluster {
   300  			return true
   301  		}
   302  	}
   303  	return false
   304  }
   305  
   306  func containsRepo(repos []repoStatus, repo string) bool {
   307  	for _, r := range repos {
   308  		if r.RepoName == repo {
   309  			return true
   310  		}
   311  	}
   312  	return false
   313  }
   314  
   315  func getIncompleteJobs(repo string, status status) []jobStatus {
   316  	ret := []jobStatus{}
   317  	for _, cluster := range status.Clusters {
   318  		for _, repoStatus := range cluster.RepoStatus {
   319  			if repoStatus.RepoName == repo {
   320  				for _, job := range repoStatus.Jobs {
   321  					if !job.Eligible {
   322  						ret = append(ret, job)
   323  					}
   324  				}
   325  			}
   326  		}
   327  	}
   328  	return ret
   329  }
   330  
   331  // The function `printJobStats` prints the status of jobs in a given repository,
   332  func printJobStats(repo string, status status, onlyIneligible bool, onlyEligible bool) {
   333  	format := "%-70v is %s%v\033[0m\n" // \033[0m resets color back to default after printing
   334  
   335  	for _, cluster := range status.Clusters {
   336  		for _, repoStatus := range cluster.RepoStatus {
   337  			if repoStatus.RepoName == repo {
   338  				for _, job := range repoStatus.Jobs {
   339  					if onlyIneligible && job.Eligible {
   340  						continue
   341  					}
   342  					if onlyEligible && !job.Eligible || cluster.ClusterName != "default" {
   343  						continue
   344  					}
   345  
   346  					if cluster.ClusterName != "default" {
   347  						fmt.Printf(format, job.JobName, "\033[33m", "done") // \033[33m sets text color to yellow
   348  					} else if job.Eligible {
   349  						fmt.Printf(format, job.JobName, "\033[32m", "eligible") // \033[32m sets text color to green
   350  					} else {
   351  						fmt.Printf(format, job.JobName, "\033[31m", "not eligible ("+job.Reason+")") // \033[31m sets text color to red
   352  					}
   353  				}
   354  			}
   355  		}
   356  	}
   357  }
   358  
   359  // The function "allStaticJobs" returns a sorted list of all static jobs from a given configuration.
   360  func allStaticJobs(c *cfg.Config) map[string][]cfg.JobBase {
   361  	jobs := map[string][]cfg.JobBase{}
   362  	for key, postJobs := range c.JobConfig.PresubmitsStatic {
   363  		for _, job := range postJobs {
   364  			jobs[getRepo(key)] = append(jobs[getRepo(key)], job.JobBase)
   365  		}
   366  	}
   367  	for key, postJobs := range c.JobConfig.PostsubmitsStatic {
   368  		for _, job := range postJobs {
   369  			jobs[getRepo(key)] = append(jobs[getRepo(key)], job.JobBase)
   370  		}
   371  	}
   372  	for _, periodicJobs := range c.JobConfig.Periodics {
   373  		key := strings.TrimPrefix(periodicJobs.JobBase.SourcePath, "../../config/jobs/")
   374  		jobs[getRepo(key)] = append(jobs[getRepo(key)], periodicJobs.JobBase)
   375  	}
   376  
   377  	return jobs
   378  }
   379  
   380  func getPercentage(int1, int2 int) float64 {
   381  	if int2 == 0 {
   382  		return 100
   383  	}
   384  	return float64(int1) / float64(int2) * 100
   385  }
   386  
   387  func printPercentage(f float64) string {
   388  	return fmt.Sprintf("%.2f%%", f)
   389  }
   390  
   391  // checkIfEligible determines if a given job is eligible based on its cluster, labels, containers, and volumes.
   392  // To be eligible:
   393  // - The job must belong to one of the specified valid community clusters.
   394  // - The job's labels must not contain any disallowed substrings. The only current disallowed substring is "cred".
   395  // - The job's containers must not have any disallowed attributes. The disallowed attributes include:
   396  //   - Environment variables containing the substring "cred".
   397  //   - Environment variables derived from secrets.
   398  //   - Arguments containing any of the disallowed arguments.
   399  //   - Commands containing any of the disallowed commands.
   400  //   - Volume mounts containing any of the disallowed words like "cred" or "secret".
   401  //
   402  // - The job's volumes must not contain any disallowed volumes. Volumes are considered disallowed if:
   403  //   - Their name contains the substring "cred".
   404  //   - They are of type Secret but their name is not in the list of allowed secret names.
   405  func checkIfEligible(job cfg.JobBase) (bool, string) {
   406  	validClusters := []string{"test-infra-trusted", "k8s-infra-prow-build", "k8s-infra-prow-build-trusted", "eks-prow-build-cluster"}
   407  	if slices.Contains(validClusters, job.Cluster) {
   408  		return true, ""
   409  	}
   410  	if ok, reason := containsDisallowedLabel(job.Labels); ok {
   411  		return false, reason
   412  	}
   413  
   414  	for _, container := range job.Spec.Containers {
   415  		if ok, reason := containsDisallowedAttributes(container); ok {
   416  			return false, reason
   417  		}
   418  	}
   419  
   420  	if ok, reason := containsDisallowedVolume(job.Spec.Volumes); ok {
   421  		return false, reason
   422  	}
   423  
   424  	if job.Spec.ServiceAccountName != "" && job.Spec.ServiceAccountName != "prowjob-default-sa" {
   425  		return false, "disallowed service account - " + job.Spec.ServiceAccountName
   426  	}
   427  
   428  	return true, ""
   429  }
   430  
   431  // The function checks if any label in a given map contains the substring "cred".
   432  func containsDisallowedLabel(labels map[string]string) (bool, string) {
   433  	for key := range labels {
   434  		if checkContains(key, "cred") && !labelIsAllowed(key) {
   435  			return true, "disallowed label - " + key
   436  		}
   437  	}
   438  	return false, ""
   439  }
   440  
   441  func checkContains(s string, substring string) bool {
   442  	return strings.Contains(strings.ToLower(s), strings.ToLower(substring))
   443  }
   444  
   445  func labelIsAllowed(label string) bool {
   446  	for _, allowedLabel := range allowedLabelNames {
   447  		if checkContains(label, allowedLabel) {
   448  			return true
   449  		}
   450  	}
   451  	return false
   452  }
   453  
   454  func volumeIsAllowed(volumeName string) bool {
   455  	for _, allowedVolume := range allowedVolumeNames {
   456  		if volumeName == allowedVolume {
   457  			return true
   458  		}
   459  	}
   460  	return false
   461  }
   462  
   463  func secretIsAllowed(secretName string) bool {
   464  	for _, allowedSecret := range allowedSecretNames {
   465  		if secretName == allowedSecret {
   466  			return true
   467  		}
   468  	}
   469  	return false
   470  }
   471  
   472  func envVarIsAllowed(envVar string) bool {
   473  	for _, allowedEnvVar := range allowedEnvironmentVariables {
   474  		if allowedEnvVar == envVar {
   475  			return true
   476  		}
   477  	}
   478  	return false
   479  }
   480  
   481  // The function checks if a container contains any disallowed attributes such as environment variables,
   482  // arguments, or commands.
   483  func containsDisallowedAttributes(container v1.Container) (bool, string) {
   484  	for _, env := range container.Env {
   485  		if checkContains(env.Name, "cred") && !envVarIsAllowed(env.Name) {
   486  			return true, "disallowed environment variable - " + env.Name
   487  		}
   488  		if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && !secretIsAllowed(env.ValueFrom.SecretKeyRef.Key) {
   489  			return true, "disallowed environment variable - " + env.Name
   490  		}
   491  	}
   492  	if ok, reason := containsDisallowedVolumeMount(container.VolumeMounts); ok {
   493  		return true, reason
   494  	}
   495  
   496  	return false, ""
   497  }
   498  
   499  // The function "containsAny" checks if a given string contains any of the words in a given slice of
   500  // strings.
   501  func containsAny(s string, disallowed []string) bool {
   502  	for _, word := range disallowed {
   503  		if checkContains(s, word) {
   504  			return true
   505  		}
   506  	}
   507  	return false
   508  }
   509  
   510  // The function checks if any volume mount in a given list contains disallowed words in its name or
   511  // mount path.
   512  func containsDisallowedVolumeMount(volumeMounts []v1.VolumeMount) (bool, string) {
   513  	disallowedWords := []string{"cred", "secret"}
   514  	for _, vol := range volumeMounts {
   515  		if (containsAny(vol.Name, disallowedWords) || containsAny(vol.MountPath, disallowedWords)) && !volumeIsAllowed(vol.Name) {
   516  			return true, "disallowed volume mount - " + vol.Name
   517  		}
   518  	}
   519  	return false, ""
   520  }
   521  
   522  // The function checks if a list of volumes contains any disallowed volumes based on their name or if
   523  // they are of type Secret.
   524  func containsDisallowedVolume(volumes []v1.Volume) (bool, string) {
   525  	for _, vol := range volumes {
   526  		if (checkContains(vol.Name, "cred") && !volumeIsAllowed(vol.Name)) || (vol.Secret != nil && !secretIsAllowed(vol.Secret.SecretName)) {
   527  			return true, "disallowed volume - " + vol.Name
   528  		}
   529  	}
   530  	return false, ""
   531  }
   532  
   533  func main() {
   534  	flag.StringVar(&config.configPath, "config", "../../config/prow/config.yaml", "Path to prow config")
   535  	flag.StringVar(&config.jobConfigPath, "job-config", "../../config/jobs", "Path to prow job config")
   536  	flag.StringVar(&config.repo, "repo", "", "Find eligible jobs for a specific repo")
   537  	flag.StringVar(&config.output, "output", "", "Output format (default, json)")
   538  	flag.BoolVar(&config.repoReport, "repo-report", false, "Detailed report of all repo status")
   539  	flag.BoolVar(&config.ineligibleReport, "ineligible-report", false, "Get a detailed report of ineligible jobs")
   540  	flag.BoolVar(&config.eligibleReport, "eligible-report", false, "Get a detailed report of eligible jobs")
   541  	flag.BoolVar(&config.todoReport, "todo-report", false, "Get a detailed report of jobs that are not yet completed")
   542  	flag.Parse()
   543  
   544  	if err := config.validate(); err != nil {
   545  		log.Fatal(err)
   546  	}
   547  
   548  	c, err := loadConfig(config.configPath, config.jobConfigPath)
   549  	if err != nil {
   550  		log.Fatalf("Could not load config: %v", err)
   551  	}
   552  
   553  	jobs := allStaticJobs(c)
   554  	status := getStatus(jobs)
   555  
   556  	if config.output == "json" {
   557  		bt, err := json.Marshal(status)
   558  		if err != nil {
   559  			log.Fatal(err)
   560  		}
   561  
   562  		var out bytes.Buffer
   563  		json.Indent(&out, bt, "", " ")
   564  		out.WriteTo(os.Stdout)
   565  		println("\n")
   566  		return
   567  	}
   568  
   569  	if config.todoReport {
   570  		// Create an output html file
   571  		f, err := os.Create("job-migration-todo.md")
   572  		if err != nil {
   573  			log.Fatal(err)
   574  		}
   575  		defer f.Close()
   576  
   577  		// Write the html header
   578  		f.WriteString(`
   579  ## JOBS IN DANGER!! ☠
   580  
   581  If you own any jobs listed below, PLEASE ensure they are migrated to a community cluster prior to August 1st, 2024. If you need help, please reach out to #sig-testing of #sig-k8s-infra on Slack.
   582  | File Path | Job | Link |
   583  | --- | --- | --- |
   584  `)
   585  		repos := getAllRepos(status)
   586  		sort.Strings(repos)
   587  		for _, repo := range repos {
   588  			jobs := getIncompleteJobs(repo, status)
   589  			sort.Slice(jobs, func(i, j int) bool {
   590  				return jobs[i].SourcePath < jobs[j].SourcePath
   591  			})
   592  			for _, job := range jobs {
   593  				link := "https://cs.k8s.io/?q=name%3A%20" + job.JobName + "%24&i=nope&files=&excludeFiles=&repos="
   594  				// Write the lines in the table
   595  				_, err = f.WriteString(fmt.Sprintf("|%v|%v|[Search Results](%s)|\n", job.SourcePath, job.JobName, link))
   596  				if err != nil {
   597  					log.Fatal(err)
   598  				}
   599  			}
   600  		}
   601  		return
   602  	}
   603  
   604  	if config.ineligibleReport {
   605  		for _, repo := range getAllRepos(status) {
   606  			fmt.Println("\nRepo: " + repo)
   607  			printJobStats(repo, status, true, false)
   608  		}
   609  		return
   610  	}
   611  
   612  	if config.eligibleReport {
   613  		for _, repo := range getAllRepos(status) {
   614  			fmt.Println("\nRepo: " + repo)
   615  			printJobStats(repo, status, false, true)
   616  		}
   617  		return
   618  	}
   619  
   620  	if config.repo != "" {
   621  		printJobStats(config.repo, status, false, false)
   622  		return
   623  	}
   624  
   625  	reportTotalJobs(status)
   626  	reportClusterStats(status)
   627  	if config.repoReport {
   628  		printRepoStatistics(status)
   629  	}
   630  }