k8s.io/test-infra@v0.0.0-20240520184403-27c6b4c223d8/experiment/prowjob-report/main.go (about)

     1  /*
     2  Copyright 2018 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  // This generates a csv listing of all of our prowjobs to import into a
    20  // spreadsheet so humans could see prowjob info relevant to enforcing
    21  // policies at a glance
    22  
    23  // The intent is for actual tests to enforce these policies, but their
    24  // output is not amenable to generating a report that could be broadcast
    25  // to humans
    26  
    27  import (
    28  	"encoding/json"
    29  	"flag"
    30  	"fmt"
    31  	"html/template"
    32  	"os"
    33  	"sort"
    34  	"strconv"
    35  	"strings"
    36  	"time"
    37  
    38  	corev1 "k8s.io/api/core/v1"
    39  	"k8s.io/apimachinery/pkg/api/resource"
    40  
    41  	cfg "sigs.k8s.io/prow/pkg/config"
    42  	configflagutil "sigs.k8s.io/prow/pkg/flagutil/config"
    43  )
    44  
    45  // TODO: parse testgrid config to catch
    46  //   - jobs that aren't prowjobs but are on release-informing dashboards
    47  //   - jobs that don't declare testgrid info via annotations
    48  var reportFormat = flag.String("format", "csv", "Output format [csv|json|html] defaults to csv")
    49  var reportDate = flag.String("date", "now", "Date to include in report ('now' is converted to today)")
    50  
    51  // Loaded at TestMain.
    52  var prowConfig *cfg.Config
    53  
    54  func main() {
    55  	configOpts := configflagutil.ConfigOptions{
    56  		ConfigPathFlagName:    "config",
    57  		JobConfigPathFlagName: "job-config",
    58  		ConfigPath:            "../../config/prow/config.yaml",
    59  		JobConfigPath:         "../../config/jobs",
    60  	}
    61  	configOpts.AddFlags(flag.CommandLine)
    62  	flag.Parse()
    63  	if err := configOpts.Validate(false); err != nil {
    64  		fmt.Println(err.Error())
    65  		os.Exit(1)
    66  	}
    67  
    68  	agent, err := configOpts.ConfigAgent()
    69  	if err != nil {
    70  		fmt.Printf("Could not load config: %v\n", err)
    71  		os.Exit(1)
    72  	}
    73  	prowConfig = agent.Config()
    74  
    75  	date := *reportDate
    76  	if date == "now" {
    77  		date = time.Now().Format("2006-01-02")
    78  	}
    79  
    80  	rows := GatherProwJobReportRows(date)
    81  	switch *reportFormat {
    82  	case "csv":
    83  		PrintCSVReport(rows)
    84  	case "json":
    85  		PrintJSONReport(rows)
    86  	case "html":
    87  		PrintHTMLReport(rows)
    88  	default:
    89  		fmt.Printf("ERROR: unknown format: %v\n", *reportFormat)
    90  	}
    91  }
    92  
    93  // Consistently sorted ProwJob configs
    94  
    95  func sortedPeriodics() []cfg.Periodic {
    96  	jobs := prowConfig.AllPeriodics()
    97  	sort.Slice(jobs, func(i, j int) bool {
    98  		return jobs[i].Name < jobs[j].Name
    99  	})
   100  	return jobs
   101  }
   102  
   103  func sortedPresubmitsByRepo() (repos []string, jobsByRepo map[string][]cfg.Presubmit) {
   104  	jobsByRepo = make(map[string][]cfg.Presubmit)
   105  	for repo, jobs := range prowConfig.PresubmitsStatic {
   106  		repos = append(repos, repo)
   107  		sort.Slice(jobs, func(i, j int) bool {
   108  			return jobs[i].Name < jobs[j].Name
   109  		})
   110  		jobsByRepo[repo] = jobs
   111  	}
   112  	sort.Strings(repos)
   113  	return repos, jobsByRepo
   114  }
   115  
   116  func sortedPostsubmitsByRepo() (repos []string, jobsByRepo map[string][]cfg.Postsubmit) {
   117  	jobsByRepo = make(map[string][]cfg.Postsubmit)
   118  	for repo, jobs := range prowConfig.PostsubmitsStatic {
   119  		repos = append(repos, repo)
   120  		sort.Slice(jobs, func(i, j int) bool {
   121  			return jobs[i].Name < jobs[j].Name
   122  		})
   123  		jobsByRepo[repo] = jobs
   124  	}
   125  	sort.Strings(repos)
   126  	return repos, jobsByRepo
   127  }
   128  
   129  // ResourceRequirement utils
   130  
   131  func TotalResourceRequirements(spec *corev1.PodSpec) corev1.ResourceRequirements {
   132  	resourceNames := []corev1.ResourceName{
   133  		corev1.ResourceCPU,
   134  		corev1.ResourceMemory,
   135  	}
   136  	total := corev1.ResourceRequirements{
   137  		Requests: corev1.ResourceList{},
   138  		Limits:   corev1.ResourceList{},
   139  	}
   140  	zero := resource.MustParse("0")
   141  	for _, r := range resourceNames {
   142  		total.Requests[r] = zero.DeepCopy()
   143  		total.Limits[r] = zero.DeepCopy()
   144  	}
   145  	if spec == nil {
   146  		return total
   147  	}
   148  	for _, c := range spec.Containers {
   149  		for _, r := range resourceNames {
   150  			if limit, ok := c.Resources.Limits[r]; ok {
   151  				tmp := total.Limits[r]
   152  				tmp.Add(limit)
   153  				total.Limits[r] = tmp
   154  			}
   155  			if request, ok := c.Resources.Requests[r]; ok {
   156  				tmp := total.Requests[r]
   157  				tmp.Add(request)
   158  				total.Requests[r] = tmp
   159  			}
   160  		}
   161  	}
   162  	return total
   163  }
   164  
   165  func ScaledValue(q resource.Quantity, s resource.Scale) int64 {
   166  	return q.ScaledValue(s)
   167  }
   168  
   169  // Testgrid dashboard utils
   170  
   171  // Primary dashboard aka which is most likely to have more viewers
   172  // Choose from: sig-release-.*, or sig-.*, or first in list
   173  func PrimaryDashboard(job cfg.JobBase) string {
   174  	dashboardsAnnotation, ok := job.Annotations["testgrid-dashboards"]
   175  	if !ok {
   176  		// technically it could be specified in a testgrid config, would need testgrid/cmd/configurator code to know for sure
   177  		return "TODO"
   178  	}
   179  	dashboards := []string{}
   180  	for _, db := range strings.Split(dashboardsAnnotation, ",") {
   181  		dashboards = append(dashboards, strings.TrimSpace(db))
   182  	}
   183  	for _, db := range dashboards {
   184  		if strings.HasPrefix(db, "sig-release-") {
   185  			return db
   186  		}
   187  	}
   188  	for _, db := range dashboards {
   189  		if strings.HasPrefix(db, "sig-") {
   190  			return db
   191  		}
   192  	}
   193  	return dashboards[0]
   194  }
   195  
   196  // Owner dashboard aka who is responsible for maintaining the job
   197  // Choose from: sig-(not-release)-*, or sig-release, or first in list
   198  func OwnerDashboard(job cfg.JobBase) string {
   199  	dashboardsAnnotation, ok := job.Annotations["testgrid-dashboards"]
   200  	if !ok {
   201  		// technically it could be specified in a testgrid config, would need testgrid/cmd/configurator code to know for sure
   202  		return "TODO"
   203  	}
   204  	dashboards := []string{}
   205  	for _, db := range strings.Split(dashboardsAnnotation, ",") {
   206  		dashboards = append(dashboards, strings.TrimSpace(db))
   207  	}
   208  	for _, db := range dashboards {
   209  		if strings.HasPrefix(db, "sig-") && !strings.HasPrefix(db, "sig-release-") {
   210  			return db
   211  		}
   212  	}
   213  	for _, db := range dashboards {
   214  		if strings.HasPrefix(db, "sig-release-") {
   215  			return db
   216  		}
   217  	}
   218  	return dashboards[0]
   219  }
   220  
   221  func verifyPodQOSGuaranteed(spec *corev1.PodSpec) (errs []error) {
   222  	resourceNames := []corev1.ResourceName{
   223  		corev1.ResourceCPU,
   224  		corev1.ResourceMemory,
   225  	}
   226  	zero := resource.MustParse("0")
   227  	for _, c := range spec.Containers {
   228  		for _, r := range resourceNames {
   229  			limit, ok := c.Resources.Limits[r]
   230  			if !ok {
   231  				errs = append(errs, fmt.Errorf("container '%v' should have resources.limits[%v] specified", c.Name, r))
   232  			}
   233  			request, ok := c.Resources.Requests[r]
   234  			if !ok {
   235  				errs = append(errs, fmt.Errorf("container '%v' should have resources.requests[%v] specified", c.Name, r))
   236  			}
   237  			if limit.Cmp(zero) == 0 {
   238  				errs = append(errs, fmt.Errorf("container '%v' resources.limits[%v] should be non-zero", c.Name, r))
   239  			} else if limit.Cmp(request) != 0 {
   240  				errs = append(errs, fmt.Errorf("container '%v' resources.limits[%v] (%v) should match request (%v)", c.Name, r, limit.String(), request.String()))
   241  			}
   242  		}
   243  	}
   244  	return errs
   245  }
   246  
   247  // A PodSpec is PodQOS Guaranteed if all of its containers have non-zero
   248  // resource limits equal to their resource requests for cpu and memory
   249  func isPodQOSGuaranteed(spec *corev1.PodSpec) bool {
   250  	return len(verifyPodQOSGuaranteed(spec)) == 0
   251  }
   252  
   253  // A presubmit is merge-blocking if it:
   254  // - is not optional
   255  // - reports (aka does not skip reporting)
   256  // - always runs OR runs if some path changed
   257  func isMergeBlocking(job cfg.Presubmit) bool {
   258  	return !job.Optional && !job.SkipReport && (job.AlwaysRun || job.RunIfChanged != "" || job.SkipIfOnlyChanged != "")
   259  }
   260  
   261  func guessPeriodicRepoAndBranch(job cfg.Periodic) (repo, branch string) {
   262  	repo = "TODO"
   263  	branch = "TODO"
   264  	defaultBranch := "master"
   265  	// First, assume the first extra ref is our repo
   266  	if len(job.ExtraRefs) > 0 {
   267  		ref := job.ExtraRefs[0]
   268  		repo = fmt.Sprintf("%s/%s", ref.Org, ref.Repo)
   269  		branch = ref.BaseRef
   270  		return
   271  	}
   272  
   273  	// If we have no extra refs, maybe we're using the defunct bootstrap args,
   274  	// in which case we assume the job is a single-container pod, and then...
   275  
   276  	// Assume the first repo arg we find is "the" repo; save scenario for later
   277  	scenario := ""
   278  	for _, arg := range job.Spec.Containers[0].Args {
   279  		if strings.HasPrefix(arg, "--scenario=") {
   280  			scenario = strings.Split(arg, "=")[1]
   281  		}
   282  		if !strings.HasPrefix(arg, "--repo=") {
   283  			continue
   284  		}
   285  		arg = strings.SplitN(arg, "=", 2)[1]
   286  		arg = strings.ReplaceAll(arg, "sigs.k8s.io", "kubernetes-sigs")
   287  		arg = strings.ReplaceAll(arg, "k8s.io", "kubernetes")
   288  		arg = strings.ReplaceAll(arg, "github.com/", "")
   289  		split := strings.Split(arg, "=")
   290  		repo = split[0]
   291  		branch = defaultBranch
   292  		if len(split) > 1 {
   293  			branch = split[1]
   294  		}
   295  		return
   296  	}
   297  
   298  	// We didn't find an explicit repo, so now assume if --scenario=kubernetes_e2e
   299  	// was used, the repo is kubernetes/kubernetes
   300  	if scenario == "kubernetes_e2e" {
   301  		repo = "kubernetes/kubernetes"
   302  		branch = defaultBranch
   303  	}
   304  	return
   305  }
   306  
   307  type ProwJobReportRow struct {
   308  	Date             string // TODO: make this an actual date instead a string pass-through
   309  	Name             string
   310  	ProwJobType      string
   311  	Repo             string
   312  	Branch           string
   313  	PrimaryDashboard string
   314  	OwnerDashboard   string
   315  	Cluster          string
   316  	MaxConcurrency   int
   317  	AlwaysRun        bool // presubmits may be false
   318  	MergeBlocking    bool // presubmits may be true
   319  	QOSGuaranteed    bool
   320  	RequestMilliCPU  int64
   321  	LimitMilliCPU    int64
   322  	RequestGigaMem   int64
   323  	LimitGigaMem     int64
   324  }
   325  
   326  func NewProwJobReportRow(date, jobType, repo, branch string, alwaysRun, mergeBlocking bool, job cfg.JobBase) ProwJobReportRow {
   327  	r := TotalResourceRequirements(job.Spec)
   328  	// TODO: actually read testgrid config please
   329  	primaryDashboard := PrimaryDashboard(job)
   330  	if mergeBlocking && repo == "kubernetes/kubernetes" {
   331  		primaryDashboard = "kubernetes-presubmits-blocking"
   332  	}
   333  	return ProwJobReportRow{
   334  		Date:             date,
   335  		Name:             job.Name,
   336  		ProwJobType:      jobType,
   337  		Repo:             repo,
   338  		Branch:           branch,
   339  		PrimaryDashboard: primaryDashboard,
   340  		OwnerDashboard:   OwnerDashboard(job),
   341  		Cluster:          job.Cluster,
   342  		MaxConcurrency:   job.MaxConcurrency,
   343  		AlwaysRun:        alwaysRun,
   344  		MergeBlocking:    mergeBlocking,
   345  		QOSGuaranteed:    isPodQOSGuaranteed(job.Spec),
   346  		RequestMilliCPU:  ScaledValue(r.Requests[corev1.ResourceCPU], resource.Milli),
   347  		LimitMilliCPU:    ScaledValue(r.Limits[corev1.ResourceCPU], resource.Milli),
   348  		RequestGigaMem:   ScaledValue(r.Requests[corev1.ResourceMemory], resource.Giga),
   349  		LimitGigaMem:     ScaledValue(r.Limits[corev1.ResourceMemory], resource.Giga),
   350  	}
   351  }
   352  
   353  func GatherProwJobReportRows(date string) []ProwJobReportRow {
   354  	rows := []ProwJobReportRow{}
   355  	for _, job := range sortedPeriodics() {
   356  		// TODO: depending on whether decoration or bootstrap is used repo could be any number of repos
   357  		repo, branch := guessPeriodicRepoAndBranch(job)
   358  		rows = append(rows, NewProwJobReportRow(date, "periodic", repo, branch, true, false, job.JobBase))
   359  	}
   360  	repos, postsubmitsByRepo := sortedPostsubmitsByRepo()
   361  	for _, repo := range repos {
   362  		for _, job := range postsubmitsByRepo[repo] {
   363  			branch := "default"
   364  			if len(job.Branches) > 0 {
   365  				branch = strings.Join(job.Branches, "|")
   366  			}
   367  			rows = append(rows, NewProwJobReportRow(date, "postsubmit", repo, branch, false, false, job.JobBase))
   368  		}
   369  	}
   370  	repos, presubmitsByRepo := sortedPresubmitsByRepo()
   371  	for _, repo := range repos {
   372  		for _, job := range presubmitsByRepo[repo] {
   373  			branch := "default"
   374  			if len(job.Branches) > 0 {
   375  				branch = strings.Join(job.Branches, "|")
   376  			}
   377  			rows = append(rows, NewProwJobReportRow(date, "presubmit", repo, branch, job.AlwaysRun, isMergeBlocking(job), job.JobBase))
   378  		}
   379  	}
   380  	return rows
   381  }
   382  
   383  func PrintCSVReport(rows []ProwJobReportRow) {
   384  	fmt.Printf("report_date, name, type, repo, branch, primary_dash, owner_dash, cluster, concurrency, always_run, merge_blocking, qosGuaranteed, req.cpu (m), lim.cpu (m), req.mem (Gi), lim.mem (Gi)\n")
   385  	for _, row := range rows {
   386  		cols := []string{
   387  			row.Date,
   388  			row.Name,
   389  			row.ProwJobType,
   390  			row.Repo,
   391  			row.Branch,
   392  			row.PrimaryDashboard,
   393  			row.OwnerDashboard,
   394  			row.Cluster,
   395  			strconv.Itoa(row.MaxConcurrency),
   396  			strconv.FormatBool(row.AlwaysRun),
   397  			strconv.FormatBool(row.MergeBlocking),
   398  			strconv.FormatBool(row.QOSGuaranteed),
   399  			strconv.FormatInt(row.RequestMilliCPU, 10),
   400  			strconv.FormatInt(row.LimitMilliCPU, 10),
   401  			strconv.FormatInt(row.RequestGigaMem, 10),
   402  			strconv.FormatInt(row.LimitGigaMem, 10),
   403  		}
   404  		fmt.Println(strings.Join(cols, ", "))
   405  	}
   406  }
   407  
   408  func PrintJSONReport(rows []ProwJobReportRow) {
   409  	b, err := json.Marshal(rows)
   410  	if err != nil {
   411  		fmt.Println("error:", err)
   412  	}
   413  	os.Stdout.Write(b)
   414  }
   415  
   416  func PrintHTMLReport(rows []ProwJobReportRow) {
   417  	t := template.Must(template.ParseGlob("./tpl/*"))
   418  	err := t.ExecuteTemplate(os.Stdout, "report", rows)
   419  	if err != nil {
   420  		fmt.Print("execute: ", err)
   421  		return
   422  	}
   423  }