package main
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"k8s.io/apimachinery/pkg/util/sets"
"github.com/openshift/api/tools/codegen/pkg/sippy"
"github.com/openshift/api/tools/codegen/pkg/utils"
)
const (
// all features should have at least this many tests
requiredNumberOfTests = 5
// all variant should run at least this many times
requiredNumberOfTestRunsPerVariant = 14
// required pass rate.
// nearly all current tests pass 99% of the time, but in a two week window we lack enough data to say.
requiredPassRateOfTestsPerVariant = 0.95
)
type FeatureGateTestAnalyzerOptions struct {
In io.Reader
Out io.Writer
ErrOut io.Writer
CurrentFeatureSetDir string
PreviousFeatureSetDir string
OutputDir string
}
func NewFeatureGateTestAnalyzerOptions(in io.Reader, out, errOut io.Writer) *FeatureGateTestAnalyzerOptions {
return &FeatureGateTestAnalyzerOptions{
In: in,
Out: out,
ErrOut: errOut,
CurrentFeatureSetDir: filepath.Join("payload-manifests", "featuregates"),
PreviousFeatureSetDir: filepath.Join("_tmp", "previous-openshift-api", "payload-manifests", "featuregates"),
}
}
func NewFeatureGateTestAnalyzerFlagsCommand(in io.Reader, out, errOut io.Writer) *cobra.Command {
o := NewFeatureGateTestAnalyzerOptions(in, out, errOut)
cmd := &cobra.Command{
Use: "featuregate-test-analyzer",
Short: "featuregate-test-analyzer looks to see how well tested a particular FeatureGate is.",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancelFn := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelFn()
if err := o.Complete(); err != nil {
return err
}
if err := o.Validate(); err != nil {
return err
}
return o.Run(ctx)
},
}
o.AddFlags(cmd.Flags())
return cmd
}
func (o *FeatureGateTestAnalyzerOptions) Validate() error {
if len(o.CurrentFeatureSetDir) == 0 {
return fmt.Errorf("--featureset-manifest-path is required")
}
if len(o.PreviousFeatureSetDir) == 0 {
return fmt.Errorf("--previous-featureset-manifest-path is required")
}
if _, err := os.ReadDir(o.CurrentFeatureSetDir); err != nil {
return fmt.Errorf("--featureset-manifest-path cannot be read: %w", err)
}
if _, err := os.ReadDir(o.PreviousFeatureSetDir); err != nil {
return fmt.Errorf("--previous-featureset-manifest-path cannot be read: %w", err)
}
return nil
}
func (o *FeatureGateTestAnalyzerOptions) Complete() error {
artifactDir := os.Getenv("ARTIFACT_DIR")
if len(artifactDir) > 0 {
o.OutputDir = artifactDir
}
return nil
}
func (o *FeatureGateTestAnalyzerOptions) AddFlags(flags *pflag.FlagSet) {
flags.StringVar(&o.CurrentFeatureSetDir, "featureset-manifest-path", o.CurrentFeatureSetDir, "path to directory containing the FeatureGate YAMLs for each FeatureGateTestAnalyzer,ClusterProfile tuple.")
flags.StringVar(&o.PreviousFeatureSetDir, "previous-featureset-manifest-path", o.PreviousFeatureSetDir, "path to directory containing the OLD FeatureGate YAMLs for each FeatureGateTestAnalyzer,ClusterProfile tuple.")
}
func init() {
rootCmd.AddCommand(NewFeatureGateTestAnalyzerFlagsCommand(os.Stdin, os.Stdout, os.Stderr))
}
func (o *FeatureGateTestAnalyzerOptions) Run(ctx context.Context) error {
allCurrentClusterProfiles, _, _, currentByClusterProfileByFeatureSetTestAnalyzer, err := readFeatureGate(ctx, o.CurrentFeatureSetDir)
if err != nil {
return err
}
_, _, _, previousByClusterProfileByFeatureSetTestAnalyzer, err := readFeatureGate(ctx, o.PreviousFeatureSetDir)
if err != nil {
return err
}
md := utils.NewMarkdown("FeatureGate Promotion Summary")
recentlyEnabledFeatureGatesToClusterProfiles := map[string]sets.Set[string]{}
errs := []error{}
for _, clusterProfile := range allCurrentClusterProfiles.List() {
// we only need to check test coverage for current cluster profiles
currentByFeatureSet := currentByClusterProfileByFeatureSetTestAnalyzer[clusterProfile]
currentDefaultFeatureGateInfo := currentByFeatureSet["Default"]
var previousDefaultFeatureGateInfo *featureGateInfo
if previousByFeatureSet, ok := previousByClusterProfileByFeatureSetTestAnalyzer[clusterProfile]; ok {
previousDefaultFeatureGateInfo = previousByFeatureSet["Default"]
}
currentFeatureGateNames := sets.StringKeySet(currentDefaultFeatureGateInfo.allFeatureGates)
for _, featureGateName := range currentFeatureGateNames.List() {
currentFeatureGateEnabled := currentDefaultFeatureGateInfo.allFeatureGates[featureGateName]
if !currentFeatureGateEnabled {
continue
}
previousFeatureGateEnabled := false
if previousDefaultFeatureGateInfo != nil {
previousFeatureGateEnabled = previousDefaultFeatureGateInfo.allFeatureGates[featureGateName]
}
if currentFeatureGateEnabled == previousFeatureGateEnabled {
continue
}
// we've gone from false to true.
if _, ok := recentlyEnabledFeatureGatesToClusterProfiles[featureGateName]; !ok {
recentlyEnabledFeatureGatesToClusterProfiles[featureGateName] = sets.Set[string]{}
}
recentlyEnabledFeatureGatesToClusterProfiles[featureGateName].Insert(clusterProfile)
}
}
if len(recentlyEnabledFeatureGatesToClusterProfiles) == 0 {
md.Textf("No new Default FeatureGates found.\n")
fmt.Fprintf(o.Out, "No new Default FeatureGates found.\n")
}
release, err := getRelease()
if err != nil {
return fmt.Errorf("couldn't determine release version: %w", err)
}
featureGateHTMLData := []utils.HTMLFeatureGate{}
recentlyEnabledFeatureGates := sets.KeySet(recentlyEnabledFeatureGatesToClusterProfiles)
for _, enabledFeatureGate := range sets.List(recentlyEnabledFeatureGates) {
clusterProfiles := recentlyEnabledFeatureGatesToClusterProfiles[enabledFeatureGate]
md.Title(1, enabledFeatureGate)
testingResults, err := listTestResultFor(enabledFeatureGate, clusterProfiles)
if err != nil {
return err
}
requirementSummaries := buildVariantRequirementSummaries(testingResults)
writeRequirementsOverview(enabledFeatureGate, requirementSummaries, md, o.Out)
writeTestingMarkDown(testingResults, md)
validationResults := checkIfTestingIsSufficient(enabledFeatureGate, testingResults)
// Only add blocking errors to the error list (warnings don't fail the job;
// they are surfaced in the requirements overview instead).
blockingErrors := []error{}
for _, vr := range validationResults {
if !vr.IsWarning {
blockingErrors = append(blockingErrors, vr.Error)
}
}
errs = append(errs, blockingErrors...)
featureGateHTMLData = append(featureGateHTMLData, buildHTMLFeatureGateData(enabledFeatureGate, testingResults, blockingErrors, requirementSummaries, release))
}
summaryMarkdown := md.ExactBytes()
if len(o.OutputDir) > 0 {
filename := filepath.Join(o.OutputDir, "feature-promotion-summary.md")
if err := os.WriteFile(filename, summaryMarkdown, 0o644); err != nil {
errs = append(errs, err)
}
htmlFilename := filepath.Join(o.OutputDir, "feature-promotion-summary.html")
if err := writeHTMLFromTemplate(htmlFilename, featureGateHTMLData); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func topologyDisplayName(topology string) string {
if topology == "external" {
return "hypershift"
}
return topology
}
// VariantRequirementSummary is a digestible pass/fail overview for one SKU/variant.
type VariantRequirementSummary struct {
Variant JobVariant
// OverallPassed is true when every blocking requirement for this variant is met.
OverallPassed bool
// IsWarning is true when failures on this variant are non-blocking (optional SKUs).
IsWarning bool
TestCountActual int
TestCountExpected int
TestCountPassed bool
// MinRunsActual is the lowest TotalRuns across tests for this variant (0 if no tests).
MinRunsActual int
MinRunsExpected int
MinRunsPassed bool
// MinPassRateActual is the lowest pass rate across tests for this variant, as a percent.
MinPassRateActual int
MinPassRateExpected int
MinPassRatePassed bool
HasCandidateTier bool
// TestFailures lists only tests that miss run-count or pass-rate requirements.
TestFailures []TestRequirementFailure
}
// TestRequirementFailure describes a single test that missed a numeric requirement.
type TestRequirementFailure struct {
TestName string
RunsActual int
RunsExpected int
RunsPassed bool
PassRateActual int
PassRateExpected int
PassRatePassed bool
}
func jobVariantLabel(jv JobVariant) string {
parts := []string{topologyDisplayName(jv.Topology), jv.Cloud, jv.Architecture}
if jv.NetworkStack != "" {
parts = append(parts, jv.NetworkStack)
}
if jv.OS != "" {
parts = append(parts, "OS:"+jv.OS)
}
if jv.JobTiers != "" {
parts = append(parts, "Tiers:"+jv.JobTiers)
}
if jv.Optional {
parts = append(parts, "(optional)")
}
return strings.Join(parts, " / ")
}
func buildVariantRequirementSummaries(testingResults map[JobVariant]*TestingResults) []VariantRequirementSummary {
jobVariants := OrderedJobVariants(sets.KeySet(testingResults).UnsortedList())
sort.Sort(jobVariants)
summaries := make([]VariantRequirementSummary, 0, len(jobVariants))
for _, jobVariant := range jobVariants {
summaries = append(summaries, evaluateVariantRequirements(jobVariant, testingResults[jobVariant]))
}
return summaries
}
func evaluateVariantRequirements(jobVariant JobVariant, testedVariant *TestingResults) VariantRequirementSummary {
summary := VariantRequirementSummary{
Variant: jobVariant,
IsWarning: jobVariant.Optional,
TestCountExpected: requiredNumberOfTests,
MinRunsExpected: requiredNumberOfTestRunsPerVariant,
MinPassRateExpected: int(requiredPassRateOfTestsPerVariant * 100),
MinRunsPassed: true,
MinPassRatePassed: true,
}
if testedVariant == nil {
summary.TestCountPassed = false
summary.MinRunsPassed = false
summary.MinPassRatePassed = false
summary.OverallPassed = false
return summary
}
summary.HasCandidateTier = testedVariant.HasCandidateTierResults
summary.TestCountActual = len(testedVariant.TestResults)
summary.TestCountPassed = summary.TestCountActual >= requiredNumberOfTests
seenRuns := false
seenPassRate := false
for _, testResults := range testedVariant.TestResults {
runsPassed := testResults.TotalRuns >= requiredNumberOfTestRunsPerVariant
passRateActual := 0
passRatePassed := true
if testResults.TotalRuns > 0 {
passPercent := float32(testResults.SuccessfulRuns) / float32(testResults.TotalRuns)
passRateActual = int(passPercent * 100)
passRatePassed = passPercent >= requiredPassRateOfTestsPerVariant
}
if !seenRuns || testResults.TotalRuns < summary.MinRunsActual {
summary.MinRunsActual = testResults.TotalRuns
seenRuns = true
}
// Pass rate is only meaningful when the test has runs (matches checkIfTestingIsSufficient).
if testResults.TotalRuns > 0 && (!seenPassRate || passRateActual < summary.MinPassRateActual) {
summary.MinPassRateActual = passRateActual
seenPassRate = true
}
if !runsPassed {
summary.MinRunsPassed = false
}
if !passRatePassed {
summary.MinPassRatePassed = false
}
if !runsPassed || !passRatePassed {
summary.TestFailures = append(summary.TestFailures, TestRequirementFailure{
TestName: testResults.TestName,
RunsActual: testResults.TotalRuns,
RunsExpected: requiredNumberOfTestRunsPerVariant,
RunsPassed: runsPassed,
PassRateActual: passRateActual,
PassRateExpected: int(requiredPassRateOfTestsPerVariant * 100),
PassRatePassed: passRatePassed,
})
}
}
if len(testedVariant.TestResults) == 0 {
summary.MinRunsPassed = false
summary.MinPassRatePassed = false
}
summary.OverallPassed = summary.TestCountPassed && summary.MinRunsPassed && summary.MinPassRatePassed
return summary
}
func statusMark(passed bool, warning bool) string {
switch {
case passed:
return "✓"
case warning:
return "⚠"
default:
return "✗"
}
}
func writeRequirementsOverview(featureGate string, summaries []VariantRequirementSummary, md *utils.Markdown, out io.Writer) {
blockingFailures := 0
warnings := 0
for _, s := range summaries {
if s.OverallPassed && !s.HasCandidateTier {
continue
}
if !s.OverallPassed {
if s.IsWarning {
warnings++
} else {
blockingFailures++
}
}
if s.HasCandidateTier {
warnings++
}
}
var statusLine string
switch {
case blockingFailures > 0:
statusLine = fmt.Sprintf("INSUFFICIENT CI testing for %q (%d blocking, %d warnings).", featureGate, blockingFailures, warnings)
case warnings > 0:
statusLine = fmt.Sprintf("CI testing issues found for %q (non-blocking warnings).", featureGate)
default:
statusLine = fmt.Sprintf("Sufficient CI testing for %q.", featureGate)
}
fmt.Fprintf(out, "\n=== Requirements overview: %s ===\n", featureGate)
fmt.Fprintf(out, "%s\n\n", statusLine)
md.Textf("%s\n", statusLine)
md.Textf("### Requirements checklist\n")
md.Textf("Expected: ≥%d tests, ≥%d runs/test, ≥%d%% pass rate per SKU.\n",
requiredNumberOfTests, requiredNumberOfTestRunsPerVariant, int(requiredPassRateOfTestsPerVariant*100))
fmt.Fprintf(out, "Expected: ≥%d tests, ≥%d runs/test, ≥%d%% pass rate per SKU.\n\n",
requiredNumberOfTests, requiredNumberOfTestRunsPerVariant, int(requiredPassRateOfTestsPerVariant*100))
md.NextTableColumn()
md.Exact("Status ")
md.NextTableColumn()
md.Exact("SKU / Variant ")
md.NextTableColumn()
md.Exact("Tests (actual / expected) ")
md.NextTableColumn()
md.Exact("Min runs (actual / expected) ")
md.NextTableColumn()
md.Exact("Min pass rate (actual / expected) ")
md.EndTableRow()
md.NextTableColumn()
md.Exact(":---: ")
md.NextTableColumn()
md.Exact(":------ ")
md.NextTableColumn()
md.Exact(":---: ")
md.NextTableColumn()
md.Exact(":---: ")
md.NextTableColumn()
md.Exact(":---: ")
md.EndTableRow()
for _, s := range summaries {
mark := statusMark(s.OverallPassed, s.IsWarning && !s.OverallPassed)
label := jobVariantLabel(s.Variant)
testsCell := fmt.Sprintf("%s %d / ≥%d", statusMark(s.TestCountPassed, s.IsWarning && !s.TestCountPassed), s.TestCountActual, s.TestCountExpected)
runsCell := fmt.Sprintf("%s %d / ≥%d", statusMark(s.MinRunsPassed, s.IsWarning && !s.MinRunsPassed), s.MinRunsActual, s.MinRunsExpected)
passCell := fmt.Sprintf("%s %d%% / ≥%d%%", statusMark(s.MinPassRatePassed, s.IsWarning && !s.MinPassRatePassed), s.MinPassRateActual, s.MinPassRateExpected)
md.NextTableColumn()
md.Exact(mark + " ")
md.NextTableColumn()
md.Exact(label + " ")
md.NextTableColumn()
md.Exact(testsCell + " ")
md.NextTableColumn()
md.Exact(runsCell + " ")
md.NextTableColumn()
md.Exact(passCell + " ")
md.EndTableRow()
fmt.Fprintf(out, "%s %s\n", mark, label)
fmt.Fprintf(out, " tests: %s\n", testsCell)
fmt.Fprintf(out, " min runs: %s\n", runsCell)
fmt.Fprintf(out, " min pass: %s\n", passCell)
if s.HasCandidateTier {
fmt.Fprintf(out, " ⚠ includes candidate-tier job data (not covered by Component Readiness)\n")
}
for _, failure := range s.TestFailures {
detailParts := []string{}
if !failure.RunsPassed {
detailParts = append(detailParts, fmt.Sprintf("runs %d / ≥%d", failure.RunsActual, failure.RunsExpected))
}
if !failure.PassRatePassed {
detailParts = append(detailParts, fmt.Sprintf("pass %d%% / ≥%d%%", failure.PassRateActual, failure.PassRateExpected))
}
fmt.Fprintf(out, " └─ %s: %s\n", failure.TestName, strings.Join(detailParts, ", "))
}
fmt.Fprintln(out)
}
if hasAnyTestFailures(summaries) {
md.Text("")
md.Textf("**Failing tests:**\n")
for _, s := range summaries {
if len(s.TestFailures) == 0 {
continue
}
md.Textf("- %s\n", jobVariantLabel(s.Variant))
for _, failure := range s.TestFailures {
detailParts := []string{}
if !failure.RunsPassed {
detailParts = append(detailParts, fmt.Sprintf("runs %d / ≥%d", failure.RunsActual, failure.RunsExpected))
}
if !failure.PassRatePassed {
detailParts = append(detailParts, fmt.Sprintf("pass %d%% / ≥%d%%", failure.PassRateActual, failure.PassRateExpected))
}
md.Textf(" - `%s`: %s\n", failure.TestName, strings.Join(detailParts, ", "))
}
}
}
for _, s := range summaries {
if s.HasCandidateTier {
md.Textf("- ⚠ %s includes candidate-tier job data (not covered by Component Readiness)\n", jobVariantLabel(s.Variant))
}
}
md.Text("")
md.Text("")
fmt.Fprintln(out)
}
func hasAnyTestFailures(summaries []VariantRequirementSummary) bool {
for _, s := range summaries {
if len(s.TestFailures) > 0 {
return true
}
}
return false
}
func buildHTMLFeatureGateData(name string, testingResults map[JobVariant]*TestingResults, blockingErrors []error, requirementSummaries []VariantRequirementSummary, release string) utils.HTMLFeatureGate {
jobVariantsSet := sets.KeySet(testingResults)
jobVariants := OrderedJobVariants(jobVariantsSet.UnsortedList())
sort.Sort(jobVariants)
variants := make([]utils.HTMLVariantColumn, 0, len(jobVariants))
for i, jv := range jobVariants {
variants = append(variants, utils.HTMLVariantColumn{
Topology: topologyDisplayName(jv.Topology),
Cloud: jv.Cloud,
Architecture: jv.Architecture,
NetworkStack: jv.NetworkStack,
OS: jv.OS,
JobTiers: jv.JobTiers,
Optional: jv.Optional,
ColIndex: i + 1,
})
}
allTests := sets.Set[string]{}
for _, variantTestingResults := range testingResults {
for _, currTestingResult := range variantTestingResults.TestResults {
allTests.Insert(currTestingResult.TestName)
}
}
tests := make([]utils.HTMLTestRow, 0, len(allTests))
for _, testName := range sets.List(allTests) {
row := utils.HTMLTestRow{
TestName: testName,
Cells: make([]utils.HTMLTestCell, len(jobVariants)),
}
for i, jobVariant := range jobVariants {
allTesting := testingResults[jobVariant]
testResults := testResultByName(allTesting.TestResults, testName)
cell := utils.HTMLTestCell{
SippyURL: sippy.BuildSippyTestAnalysisURL(
release,
testName,
jobVariant.Topology,
jobVariant.Cloud,
jobVariant.Architecture,
jobVariant.NetworkStack,
jobVariant.OS,
),
}
if testResults == nil {
cell.Failed = true
} else {
var passPercent float32
if testResults.TotalRuns > 0 {
passPercent = float32(testResults.SuccessfulRuns) / float32(testResults.TotalRuns)
}
cell.PassPercent = int(passPercent * 100)
cell.SuccessfulRuns = testResults.SuccessfulRuns
cell.TotalRuns = testResults.TotalRuns
cell.FailedRuns = testResults.FailedRuns
if testResults.TotalRuns < requiredNumberOfTestRunsPerVariant || passPercent < requiredPassRateOfTestsPerVariant {
cell.Failed = true
}
}
row.Cells[i] = cell
}
tests = append(tests, row)
}
htmlRequirements := make([]utils.HTMLRequirementRow, 0, len(requirementSummaries))
for _, s := range requirementSummaries {
htmlRequirements = append(htmlRequirements, utils.HTMLRequirementRow{
Status: statusMark(s.OverallPassed, s.IsWarning && !s.OverallPassed),
Passed: s.OverallPassed,
Warning: s.IsWarning && !s.OverallPassed,
Variant: jobVariantLabel(s.Variant),
TestCountActual: s.TestCountActual,
TestCountExpected: s.TestCountExpected,
TestCountPassed: s.TestCountPassed,
MinRunsActual: s.MinRunsActual,
MinRunsExpected: s.MinRunsExpected,
MinRunsPassed: s.MinRunsPassed,
MinPassActual: s.MinPassRateActual,
MinPassExpected: s.MinPassRateExpected,
MinPassPassed: s.MinPassRatePassed,
HasCandidateTier: s.HasCandidateTier,
})
}
return utils.HTMLFeatureGate{
Name: name,
Sufficient: len(blockingErrors) == 0,
Requirements: htmlRequirements,
Variants: variants,
Tests: tests,
}
}
func writeHTMLFromTemplate(filename string, featureGateHTMLData []utils.HTMLFeatureGate) error {
data := utils.HTMLTemplateData{
FeatureGates: featureGateHTMLData,
}
tmpl, err := template.New("report").Parse(utils.HTMLTemplateSrc)
if err != nil {
return fmt.Errorf("error parsing HTML template: %w", err)
}
f, err := os.Create(filename)
if err != nil {
return fmt.Errorf("error creating HTML file: %w", err)
}
defer f.Close()
if err := tmpl.Execute(f, data); err != nil {
return fmt.Errorf("error executing HTML template: %w", err)
}
return nil
}
func checkIfTestingIsSufficient(featureGate string, testingResults map[JobVariant]*TestingResults) []ValidationResult {
results := []ValidationResult{}
for jobVariant, testedVariant := range testingResults {
// Use the Optional field to determine if validation failures are warnings or errors
// Optional variants (like RHEL 10 in 4.22) have non-blocking warnings
isOptional := jobVariant.Optional
// If candidate-tier queries returned results for this variant, emit a warning.
// Candidate tier jobs are not covered by the Component Readiness main view and
// do not have our standard regression protection mechanisms. The results are still
// included in the pass/fail calculation alongside other tiers.
if testedVariant.HasCandidateTierResults {
results = append(results, ValidationResult{
Error: fmt.Errorf("warning: variant %v includes test data from candidate-tier jobs which are not covered by Component Readiness and lack standard regression protection",
jobVariant),
IsWarning: true,
})
}
if len(testedVariant.TestResults) < requiredNumberOfTests {
results = append(results, ValidationResult{
Error: fmt.Errorf("error: only %d tests found, need at least %d for %q on %v",
len(testedVariant.TestResults), requiredNumberOfTests, featureGate, jobVariant),
IsWarning: isOptional,
})
}
for _, testResults := range testedVariant.TestResults {
if testResults.TotalRuns < requiredNumberOfTestRunsPerVariant {
results = append(results, ValidationResult{
Error: fmt.Errorf("error: %q only has %d runs, need at least %d runs for %q on %v",
testResults.TestName, testResults.TotalRuns, requiredNumberOfTestRunsPerVariant, featureGate, jobVariant),
IsWarning: isOptional,
})
}
if testResults.TotalRuns == 0 {
continue
}
passPercent := float32(testResults.SuccessfulRuns) / float32(testResults.TotalRuns)
if passPercent < requiredPassRateOfTestsPerVariant {
displayExpected := int(requiredPassRateOfTestsPerVariant * 100)
displayActual := int(passPercent * 100)
results = append(results, ValidationResult{
Error: fmt.Errorf("error: %q only passed %d%%, need at least %d%% for %q on %v",
testResults.TestName, displayActual, displayExpected, featureGate, jobVariant),
IsWarning: isOptional,
})
}
}
}
return results
}
func writeTestingMarkDown(testingResults map[JobVariant]*TestingResults, md *utils.Markdown) {
jobVariantsSet := sets.KeySet(testingResults)
jobVariants := jobVariantsSet.UnsortedList()
sort.Sort(OrderedJobVariants(jobVariants))
md.NextTableColumn()
md.Exact("Test ")
for _, jobVariant := range jobVariants {
md.NextTableColumn()
columnHeader := fmt.Sprintf("%v
%v
%v ", topologyDisplayName(jobVariant.Topology), jobVariant.Cloud, jobVariant.Architecture)
if jobVariant.NetworkStack != "" {
columnHeader = columnHeader + fmt.Sprintf("
%v ", jobVariant.NetworkStack)
}
if jobVariant.OS != "" {
columnHeader = columnHeader + fmt.Sprintf("
OS:%v ", jobVariant.OS)
}
if jobVariant.JobTiers != "" {
columnHeader = columnHeader + fmt.Sprintf("
Tiers:%v ", jobVariant.JobTiers)
}
md.Exact(columnHeader)
}
md.EndTableRow()
md.NextTableColumn()
md.Exact(":------ ")
for i := 0; i < len(jobVariants); i++ {
md.NextTableColumn()
md.Exact(":---: ")
}
md.EndTableRow()
allTests := sets.Set[string]{}
for _, variantTestingResults := range testingResults {
for _, currTestingResult := range variantTestingResults.TestResults {
allTests.Insert(currTestingResult.TestName)
}
}
for _, testName := range sets.List(allTests) {
md.NextTableColumn()
md.Exact(fmt.Sprintf("%s ", testName))
for _, jobVariant := range jobVariants {
md.NextTableColumn()
allTesting := testingResults[jobVariant]
testResults := testResultByName(allTesting.TestResults, testName)
if testResults == nil {
md.Exact(fmt.Sprintf("FAIL
%d%% ( %d / %d ) ", 0, 0, 0))
continue
}
failString := ""
passPercent := float32(testResults.SuccessfulRuns) / float32(testResults.TotalRuns)
switch {
case testResults.TotalRuns < requiredNumberOfTestRunsPerVariant:
failString = "FAIL
"
case passPercent < requiredPassRateOfTestsPerVariant:
failString = "FAIL
"
}
cellString := fmt.Sprintf("%s%d%% ( %d / %d ) ", failString, int(passPercent*100), testResults.SuccessfulRuns, testResults.TotalRuns)
md.Exact(cellString)
}
md.EndTableRow()
}
md.Text("")
md.Text("")
}
var (
requiredSelfManagedJobVariants = []JobVariant{
{
Cloud: "aws",
Architecture: "amd64",
Topology: "ha",
},
{
Cloud: "azure",
Architecture: "amd64",
Topology: "ha",
},
{
Cloud: "gcp",
Architecture: "amd64",
Topology: "ha",
},
{
Cloud: "vsphere",
Architecture: "amd64",
Topology: "ha",
},
{
Cloud: "metal",
Architecture: "amd64",
Topology: "ha",
NetworkStack: "ipv4",
},
{
Cloud: "metal",
Architecture: "amd64",
Topology: "ha",
NetworkStack: "ipv6",
},
{
Cloud: "metal",
Architecture: "amd64",
Topology: "ha",
NetworkStack: "dual",
},
{
Cloud: "aws",
Architecture: "amd64",
Topology: "single",
},
{
Cloud: "aws",
Architecture: "amd64",
Topology: "ha",
OS: "rhel10",
Optional: true, // RHEL 10 is optional in 4.22, will be required in OCP 5
},
// TODO restore these once we run TechPreview jobs that contain them
//{
// Cloud: "metal-ipi",
// Architecture: "amd64",
// Topology: "single",
//},
}
// These are only checked if the feature gate is platform specific
optionalSelfManagedPlatformVariants = []JobVariant{
{
Cloud: "nutanix",
Architecture: "amd64",
Topology: "ha",
},
{
Cloud: "openstack",
Architecture: "amd64",
Topology: "ha",
},
{
Cloud: "metal",
Architecture: "amd64",
Topology: "two-node-arbiter",
NetworkStack: "ipv4",
},
{
Cloud: "metal",
Architecture: "amd64",
Topology: "two-node-arbiter",
NetworkStack: "ipv6",
},
{
Cloud: "metal",
Architecture: "amd64",
Topology: "two-node-arbiter",
NetworkStack: "dual",
},
{
Cloud: "metal",
Architecture: "amd64",
Topology: "two-node-fencing",
NetworkStack: "ipv4",
JobTiers: "candidate,standard,informing,blocking",
},
{
Cloud: "metal",
Architecture: "amd64",
Topology: "two-node-fencing",
NetworkStack: "ipv6",
JobTiers: "candidate,standard,informing,blocking",
},
{
Cloud: "metal",
Architecture: "amd64",
Topology: "two-node-fencing",
NetworkStack: "dual",
JobTiers: "candidate,standard,informing,blocking",
},
}
nonHypershiftPlatforms = regexp.MustCompile("(?i)nutanix|metal|vsphere|openstack|azure|gcp")
requiredHypershiftJobVariants = []JobVariant{
{
Cloud: "aws",
Architecture: "amd64",
Topology: "external",
},
// ibm and powervs?
}
)
type JobVariant struct {
Cloud string
Architecture string
Topology string
NetworkStack string
OS string
JobTiers string // Comma-separated tiers (e.g., "standard,informing,blocking"). If empty, defaults to "standard,informing,blocking,candidate"
Optional bool // If true, validation failures for this variant are non-blocking warnings
}
type OrderedJobVariants []JobVariant
func (a OrderedJobVariants) Len() int { return len(a) }
func (a OrderedJobVariants) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a OrderedJobVariants) Less(i, j int) bool {
if strings.Compare(a[i].Topology, a[j].Topology) < 0 {
return true
} else if strings.Compare(a[i].Topology, a[j].Topology) > 0 {
return false
}
if strings.Compare(a[i].Cloud, a[j].Cloud) < 0 {
return true
} else if strings.Compare(a[i].Cloud, a[j].Cloud) > 0 {
return false
}
if strings.Compare(a[i].Architecture, a[j].Architecture) < 0 {
return true
} else if strings.Compare(a[i].Architecture, a[j].Architecture) > 0 {
return false
}
// Map these to an ordered list of strings so that we can define the order
// rather than them being alphabetical.
networkStackOrder := map[string]string{
"": "0",
"ipv4": "1",
"ipv6": "2",
"dual": "3",
}
if strings.Compare(networkStackOrder[a[i].NetworkStack], networkStackOrder[a[j].NetworkStack]) < 0 {
return true
} else if strings.Compare(networkStackOrder[a[i].NetworkStack], networkStackOrder[a[j].NetworkStack]) > 0 {
return false
}
if strings.Compare(a[i].OS, a[j].OS) < 0 {
return true
} else if strings.Compare(a[i].OS, a[j].OS) > 0 {
return false
}
if strings.Compare(a[i].JobTiers, a[j].JobTiers) < 0 {
return true
} else if strings.Compare(a[i].JobTiers, a[j].JobTiers) > 0 {
return false
}
return false
}
type TestingResults struct {
JobVariant JobVariant
TestResults []TestResults
HasCandidateTierResults bool // true if candidate-tier queries returned any test data
}
type TestResults struct {
TestName string
TotalRuns int
SuccessfulRuns int
FailedRuns int
FlakedRuns int
}
// ValidationResult represents a validation error or warning
type ValidationResult struct {
Error error
IsWarning bool // if true, this is a non-blocking warning (for optional variants)
}
func testResultByName(results []TestResults, testName string) *TestResults {
for _, curr := range results {
if curr.TestName == testName {
return &curr
}
}
return nil
}
func validateJobTiers(jobVariant JobVariant) error {
if jobVariant.JobTiers == "" {
return nil // Empty is valid - will default to standard,informing,blocking,candidate
}
validTiers := map[string]bool{
"standard": true,
"informing": true,
"blocking": true,
"candidate": true,
}
hasValidTier := false
for _, tier := range strings.Split(jobVariant.JobTiers, ",") {
tier = strings.TrimSpace(tier)
if tier != "" {
hasValidTier = true
if !validTiers[tier] {
return fmt.Errorf("invalid JobTier %q in variant %+v - must be one of: standard, informing, blocking, candidate", tier, jobVariant)
}
}
}
// Reject malformed strings like "," or " , " that contain no valid tiers
if !hasValidTier {
return fmt.Errorf("JobTiers string %q contains no valid tier names in variant %+v", jobVariant.JobTiers, jobVariant)
}
return nil
}
func listTestResultFor(featureGate string, clusterProfiles sets.Set[string]) (map[JobVariant]*TestingResults, error) {
fmt.Printf("Query sippy for all test run results for feature gate %q on clusterProfile %q\n", featureGate, sets.List(clusterProfiles))
results := map[JobVariant]*TestingResults{}
var jobVariantsToCheck []JobVariant
if clusterProfiles.Has("Hypershift") && !nonHypershiftPlatforms.MatchString(featureGate) {
// See if the feature gate is specific to any platform
hypershiftPlatformVariants := filterVariants(featureGate, requiredHypershiftJobVariants)
if len(hypershiftPlatformVariants) == 0 {
hypershiftPlatformVariants = requiredHypershiftJobVariants
}
jobVariantsToCheck = append(jobVariantsToCheck, hypershiftPlatformVariants...)
}
if clusterProfiles.Has("SelfManagedHA") {
// See if the feature gate is specific to any platform
selfManagedPlatformVariants := filterVariants(featureGate, optionalSelfManagedPlatformVariants, requiredSelfManagedJobVariants)
// If this isn't a platform specific variant, then check all required ones
if len(selfManagedPlatformVariants) == 0 {
selfManagedPlatformVariants = requiredSelfManagedJobVariants
}
jobVariantsToCheck = append(jobVariantsToCheck, selfManagedPlatformVariants...)
}
// Validate all variants before making expensive API calls
for _, jobVariant := range jobVariantsToCheck {
if err := validateJobTiers(jobVariant); err != nil {
return nil, err
}
}
for _, jobVariant := range jobVariantsToCheck {
jobVariantResults, err := listTestResultForVariant(featureGate, jobVariant)
if err != nil {
return nil, err
}
results[jobVariant] = jobVariantResults
}
return results, nil
}
func filterVariants(featureGate string, variantsList ...[]JobVariant) []JobVariant {
var filteredVariants []JobVariant
normalizedFeatureGate := strings.ToLower(featureGate)
for _, variants := range variantsList {
for _, variant := range variants {
normalizedCloud := strings.ReplaceAll(strings.ToLower(variant.Cloud), "-ipi", "") // The feature gate probably won't include the install type, but some cloud variants do
normalizedArchitecture := strings.ToLower(variant.Architecture)
normalizedTopology := strings.ToLower(variant.Topology)
if strings.Contains(normalizedFeatureGate, normalizedCloud) || strings.Contains(normalizedFeatureGate, normalizedArchitecture) || matchTwoNodeFeatureGates(normalizedFeatureGate, normalizedTopology) {
filteredVariants = append(filteredVariants, variant)
}
}
}
return filteredVariants
}
// flexibleDateTime wraps time.Time so that JSON values can be decoded from
// either an RFC 3339 timestamp (e.g. "2018-07-11T00:00:00Z") or a bare
// date-only string (e.g. "2018-07-11"). The Sippy /api/releases endpoint
// switched the development_start and ga fields from RFC 3339 timestamps to
// bare dates; supporting both keeps this tool compatible with old and new
// responses.
type flexibleDateTime struct {
time.Time
}
// UnmarshalJSON decodes the JSON value into the wrapped time.Time, trying the
// RFC 3339 layout first and falling back to the date-only layout ("2006-01-02").
func (f *flexibleDateTime) UnmarshalJSON(data []byte) error {
s := strings.Trim(string(data), `"`)
if s == "" || s == "null" {
return nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
f.Time = t
return nil
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return fmt.Errorf("cannot parse %q as RFC 3339 or date-only (2006-01-02): %w", s, err)
}
f.Time = t
return nil
}
// getLatestRelease returns the latest release from Sippy.
func getLatestRelease() (string, error) {
releaseAPI := "https://sippy.dptools.openshift.org/api/releases"
resp, err := http.Get(releaseAPI)
if err != nil {
return "", fmt.Errorf("error fetching data from API: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response body: %v", err)
}
var result struct {
ReleaseAttrs map[string]struct {
DevelopmentStart *flexibleDateTime `json:"development_start,omitempty"`
Product string `json:"product,omitempty"`
} `json:"release_attrs,omitempty"`
}
err = json.Unmarshal(body, &result)
if err != nil {
return "", fmt.Errorf("error unmarshalling JSON: %v", err)
}
latestRelease := ""
latestReleaseStart := time.Time{}
for release, releaseAttrs := range result.ReleaseAttrs {
if releaseAttrs.Product != "OCP" {
// We only want to consider OCP releases.
continue
}
if releaseAttrs.DevelopmentStart != nil && !releaseAttrs.DevelopmentStart.Time.IsZero() && time.Now().Before(releaseAttrs.DevelopmentStart.Time) {
// We only want to consider releases that have started development.
continue
}
if releaseAttrs.DevelopmentStart != nil && !releaseAttrs.DevelopmentStart.Time.IsZero() && releaseAttrs.DevelopmentStart.Time.After(latestReleaseStart) {
latestRelease = release
latestReleaseStart = releaseAttrs.DevelopmentStart.Time
}
}
if latestRelease == "" {
return "", fmt.Errorf("no valid development releases found")
}
return latestRelease, nil
}
func getRelease() (string, error) {
// if its not main branch, then use the ENV var to determine the release version
currentRelease := os.Getenv("PULL_BASE_REF")
if strings.Contains(currentRelease, "release-") {
// example: release-4.18, release-4.17
return strings.TrimPrefix(currentRelease, "release-"), nil
}
// means its main branch
return getLatestRelease()
}
func listTestResultForVariant(featureGate string, jobVariant JobVariant) (*TestingResults, error) {
// Substring here matches for both [OCPFeatureGate:...] and [FeatureGate:...]
testPattern := fmt.Sprintf("FeatureGate:%s]", featureGate)
// Feature gates used by the installer don't need separate tests, use the overall install tests
if strings.Contains(featureGate, "Install") {
return verifyJobBasedFeatureGatePromotion(featureGate, jobVariant)
}
fmt.Printf("Query sippy for all test run results for pattern %q on variant %#v\n", testPattern, jobVariant)
defaultTransport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
sippyClient := &http.Client{
Timeout: 2 * time.Minute,
Transport: defaultTransport,
}
testNameToResults := map[string]*TestResults{}
hasCandidateTierResults := false
queries := sippy.QueriesFor(jobVariant.Cloud, jobVariant.Architecture, jobVariant.Topology, jobVariant.NetworkStack, jobVariant.OS, jobVariant.JobTiers, testPattern)
release, err := getRelease()
if err != nil {
return nil, fmt.Errorf("couldn't fetch latest release version: %w", err)
}
fmt.Printf("Querying sippy release %s for test run results\n", release)
for _, currQuery := range queries {
currURL := &url.URL{
Scheme: "https",
Host: "sippy.dptools.openshift.org",
Path: "api/tests",
}
queryParams := currURL.Query()
queryParams.Add("release", release)
queryParams.Add("period", "default")
filterJSON, err := json.Marshal(currQuery)
if err != nil {
return nil, err
}
queryParams.Add("filter", string(filterJSON))
currURL.RawQuery = queryParams.Encode()
req, err := http.NewRequest(http.MethodGet, currURL.String(), nil)
if err != nil {
return nil, err
}
response, err := sippyClient.Do(req)
if err != nil {
return nil, err
}
if response.StatusCode < 200 || response.StatusCode > 299 {
return nil, fmt.Errorf("error getting sippy results (status=%d) for: %v", response.StatusCode, currURL.String())
}
queryResultBytes, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
response.Body.Close()
testInfos := []sippy.SippyTestInfo{}
if err := json.Unmarshal(queryResultBytes, &testInfos); err != nil {
return nil, err
}
if currQuery.TierName == "candidate" && len(testInfos) > 0 {
hasCandidateTierResults = true
}
for _, currTest := range testInfos {
testResults, ok := testNameToResults[currTest.Name]
if !ok {
testResults = &TestResults{
TestName: currTest.Name,
}
}
// Try to find enough test results in the last week, but if we have to we can extend
// the window to two weeks.
// NOTE: Use += to accumulate results across multiple JobTier queries
if currTest.CurrentRuns >= requiredNumberOfTestRunsPerVariant {
testResults.TotalRuns += currTest.CurrentRuns
testResults.SuccessfulRuns += currTest.CurrentSuccesses
testResults.FailedRuns += currTest.CurrentFailures
testResults.FlakedRuns += currTest.CurrentFlakes
} else {
fmt.Printf("Insufficient results in last 7 days, increasing lookback to 2 weeks...")
testResults.TotalRuns += currTest.CurrentRuns + currTest.PreviousRuns
testResults.SuccessfulRuns += currTest.CurrentSuccesses + currTest.PreviousSuccesses
testResults.FailedRuns += currTest.CurrentFailures + currTest.PreviousFailures
testResults.FlakedRuns += currTest.CurrentFlakes + currTest.PreviousFlakes
}
testNameToResults[currTest.Name] = testResults
}
}
jobVariantResults := &TestingResults{
JobVariant: jobVariant,
TestResults: nil,
HasCandidateTierResults: hasCandidateTierResults,
}
testNames := sets.StringKeySet(testNameToResults)
for _, testName := range testNames.List() {
jobVariantResults.TestResults = append(jobVariantResults.TestResults, *testNameToResults[testName])
}
return jobVariantResults, nil
}
// Check for Arbiter and DualReplica or Fencing featureGates as these have special topologies
func matchTwoNodeFeatureGates(featureGate string, topology string) bool {
if (strings.Contains(featureGate, "dualreplica") || strings.Contains(featureGate, "fencing")) && strings.Contains(topology, "fencing") {
return true
}
return false
}
func verifyJobBasedFeatureGatePromotion(featureGate string, jobVariant JobVariant) (*TestingResults, error) {
ocpRelease, err := getRelease()
if err != nil {
return nil, fmt.Errorf("getting release version: %w", err)
}
defaultTransport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
sippyClient := &http.Client{
Timeout: 2 * time.Minute,
Transport: defaultTransport,
}
jobs, err := getJobsForFeatureGateFromSippy(sippyClient, ocpRelease, featureGate, jobVariant)
if err != nil {
return nil, fmt.Errorf("getting jobs for feature-gate %q for variant %v : %w", featureGate, jobVariant, err)
}
testResults := []TestResults{}
for _, job := range jobs {
results, err := verifyJobPassRate(sippyClient, ocpRelease, job, jobVariant)
if err != nil {
return nil, fmt.Errorf("verifying job pass rate for job %q: %w", job.Name, err)
}
testResults = append(testResults, *results)
}
return &TestingResults{
JobVariant: jobVariant,
TestResults: testResults,
}, nil
}
func verifyJobPassRate(client *http.Client, release string, job sippy.SippyJob, variant JobVariant) (*TestResults, error) {
// Do an early check for 95% pass rate with at least 14 runs
runs := job.CurrentRuns
passes := job.CurrentPasses
if runs < requiredNumberOfTestRunsPerVariant {
fmt.Printf("Insufficient results in last 7 days, increasing lookback to 2 weeks...")
runs += job.PreviousRuns
passes += job.PreviousPasses
}
// If we have less than 14 runs, return the current set of results as-is
// because it doesn't meet promotion criteria.
//
// This saves us from unnecessarily making calls out to Sippy to perform a more nuanced
// failures analysis of the job runs to see if failed runs are true failures or known regressions.
if runs < requiredNumberOfTestRunsPerVariant {
return &TestResults{
TestName: job.Name,
TotalRuns: runs,
SuccessfulRuns: passes,
FailedRuns: runs - passes,
}, nil
}
// If we have greater than or equal to 14 runs AND they are passing at a rate of at least 95%,
// we can return early because this job has passed the promotion requirements.
//
// This saves us from unnecessarily making calls out to Sippy to perform a more nuanced
// failures analysis of the job runs to see if failed runs are true failures or known regressions.
if float32(passes)/float32(runs) >= requiredPassRateOfTestsPerVariant {
return &TestResults{
TestName: job.Name,
TotalRuns: runs,
SuccessfulRuns: passes,
FailedRuns: runs - passes,
}, nil
}
// We haven't passed promotion requirements with this job, but jobs might be impacted
// by known regressed tests. While important to get fixed, many regressions are either
// release blockers or require an exception to not be a release blocker.
//
// We can be reasonably confident in promoting a feature if the tests that are failing
// on failed runs are only ones with known regressions for the platform being tested.
//
// From here on, we fetch up to the 100 most recent job runs for the job in question from Sippy,
// fetch the known regressions for the release + platform variant, and compare failing
// job runs failed tests with the known regressions - only counting failures that have
// unknown test failures as a true failure.
jobRuns, err := getJobRunsFromSippy(client, release, job.Name)
if err != nil {
return nil, fmt.Errorf("getting job %q results from sippy: %w", job.Name, err)
}
testResults := &TestResults{
TestName: job.Name,
TotalRuns: len(jobRuns),
}
triagedTestFailures, err := getTriagedTestFailuresFromSippy(client, release, variant)
if err != nil {
return nil, fmt.Errorf("getting triaged test failures from sippy: %w", err)
}
for _, jobRun := range jobRuns {
if jobRun.OverallResult == "F" && !jobRun.KnownFailure {
untriagedTestFailures := []string{}
for _, failure := range jobRun.FailedTestNames {
if !triagedTestFailures.Has(failure) {
untriagedTestFailures = append(untriagedTestFailures, failure)
}
}
if len(untriagedTestFailures) > 0 {
var writer strings.Builder
writer.WriteString(fmt.Sprintf("job run %s has untriaged test failures:\n", jobRun.TestGridURL))
for _, testFailure := range untriagedTestFailures {
writer.WriteString(fmt.Sprintf("\t- %s\n", testFailure))
}
fmt.Println(writer.String())
testResults.FailedRuns++
continue
}
}
testResults.SuccessfulRuns++
}
return testResults, nil
}
func getJobsForFeatureGateFromSippy(client *http.Client, release, featureGate string, variant JobVariant) ([]sippy.SippyJob, error) {
resp, err := client.Get(sippy.BuildSippyJobsForFeatureGateURL(featureGate, release, variant.Topology, variant.Cloud, variant.Architecture, variant.NetworkStack, variant.OS))
if err != nil {
return nil, fmt.Errorf("getting job info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("expected a 200 OK status code but got %s", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
jobs := []sippy.SippyJob{}
err = json.Unmarshal(body, &jobs)
if err != nil {
return nil, fmt.Errorf("unmarshalling response body: %w", err)
}
return jobs, nil
}
func getJobRunsFromSippy(client *http.Client, release, jobName string) ([]sippy.SippyJobRun, error) {
resp, err := client.Get(sippy.BuildSippyJobRunsForJobURL(release, jobName, time.Now().Add(-1*14*24*time.Hour)))
if err != nil {
return nil, fmt.Errorf("getting job info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("expected a 200 OK status code but got %s", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
runResults := &sippy.SippyJobRunsResult{}
err = json.Unmarshal(body, runResults)
if err != nil {
return nil, fmt.Errorf("unmarshalling response body: %w", err)
}
return runResults.Rows, nil
}
func getTriagedTestFailuresFromSippy(client *http.Client, release string, variant JobVariant) (sets.Set[string], error) {
reqURL, err := url.Parse("https://sippy.dptools.openshift.org/api/component_readiness/triages")
if err != nil {
panic(fmt.Sprintf("couldn't parse sippy triages url: %v", err))
}
resp, err := client.Get(reqURL.String())
if err != nil {
return nil, fmt.Errorf("getting sippy triages: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("expected a 200 OK status code but got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
defer resp.Body.Close()
triageItems := []sippy.SippyTriageItem{}
err = json.Unmarshal(body, &triageItems)
if err != nil {
return nil, fmt.Errorf("unmarshalling response body: %w", err)
}
regressedTests := sets.New[string]()
for _, triageItem := range triageItems {
for _, regression := range triageItem.Regressions {
if regression.Release != release {
continue
}
regressionVariants := sets.New(regression.Variants...)
if !regressionVariants.Has(fmt.Sprintf("Platform:%s", variant.Cloud)) {
continue
}
if !regressionVariants.Has(fmt.Sprintf("Topology:%s", variant.Topology)) {
continue
}
if !regressionVariants.Has(fmt.Sprintf("Architecture:%s", variant.Architecture)) {
continue
}
if variant.NetworkStack != "" && !regressionVariants.Has(fmt.Sprintf("NetworkStack:%s", variant.NetworkStack)) {
continue
}
if variant.OS != "" && !regressionVariants.Has(fmt.Sprintf("OS:%s", variant.OS)) {
continue
}
regressedTests.Insert(regression.TestName)
}
}
return regressedTests, nil
}