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 }