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 }