diff --git a/cmd/release-controller-api/controller.go b/cmd/release-controller-api/controller.go index d3525afd3..0dbf8ee12 100644 --- a/cmd/release-controller-api/controller.go +++ b/cmd/release-controller-api/controller.go @@ -4,6 +4,7 @@ import ( imagev1 "github.com/openshift/api/image/v1" releasepayloadlister "github.com/openshift/release-controller/pkg/client/listers/release/v1alpha1" releasecontroller "github.com/openshift/release-controller/pkg/release-controller" + "github.com/openshift/release-controller/pkg/releasequalifiers" lru "github.com/hashicorp/golang-lru" @@ -66,6 +67,9 @@ type Controller struct { releasePayloadNamespace string releasePayloadLister releasepayloadlister.ReleasePayloadLister + // configAccessor provides access to release qualifiers configuration (optional, may be nil) + configAccessor releasequalifiers.ConfigAccessor + // All image streams from app.ci cluster imageStreams []*imagev1.ImageStream } @@ -81,6 +85,7 @@ func NewController( artSuffix string, releasePayloadNamespace string, releasePayloadLister releasepayloadlister.ReleasePayloadLister, + configAccessor releasequalifiers.ConfigAccessor, ) *Controller { // log events at v2 and send them to the server broadcaster := record.NewBroadcaster() @@ -115,6 +120,8 @@ func NewController( releasePayloadNamespace: releasePayloadNamespace, releasePayloadLister: releasePayloadLister, + + configAccessor: configAccessor, } c.dashboards = []Dashboard{ diff --git a/cmd/release-controller-api/http.go b/cmd/release-controller-api/http.go index 066427086..c846ee30a 100644 --- a/cmd/release-controller-api/http.go +++ b/cmd/release-controller-api/http.go @@ -21,6 +21,7 @@ import ( "time" "github.com/openshift/release-controller/pkg/apis/release/v1alpha1" + "github.com/openshift/release-controller/pkg/releasequalifiers" releasecontroller "github.com/openshift/release-controller/pkg/release-controller" "github.com/openshift/release-controller/pkg/rhcos" @@ -177,6 +178,7 @@ func (c *Controller) userInterfaceHandler() http.Handler { mux.HandleFunc("/releasestream/{release}/latest", c.httpReleaseLatest) mux.HandleFunc("/releasestream/{release}/latest/download", c.httpReleaseLatestDownload) mux.HandleFunc("/releasestream/{release}/candidates", c.httpReleaseCandidateList) + mux.HandleFunc("/releasestream/{release}/qualifier/{qualifier}", c.httpQualifierStatus) mux.HandleFunc("/dashboards/overview", c.httpDashboardOverview) mux.HandleFunc("/dashboards/compare", c.httpDashboardCompare) @@ -187,11 +189,14 @@ func (c *Controller) userInterfaceHandler() http.Handler { mux.HandleFunc("/api/v1/releasestream/{release}/candidate", c.apiReleaseCandidate) mux.HandleFunc("/api/v1/releasestream/{release}/release/{tag}", c.apiReleaseInfo) mux.HandleFunc("/api/v1/releasestream/{release}/config", c.apiReleaseConfig) + mux.HandleFunc("/api/v1/releasestream/{release}/qualifier/{qualifier}/config", c.apiQualifierConfig) mux.HandleFunc("/api/v1/releasestreams/accepted", c.apiAcceptedStreams) mux.HandleFunc("/api/v1/releasestreams/rejected", c.apiRejectedStreams) mux.HandleFunc("/api/v1/releasestreams/all", c.apiAllStreams) mux.HandleFunc("/api/v1/releasestreams/approvals", c.apiReleaseApprovals) + mux.HandleFunc("/api/v1/releasetag/{tag}/qualifiers", c.apiReleaseQualifiersSummaries) + //mux.HandleFunc("/api/v1/features/{tag}", c.apiFeatureInfo) //mux.HandleFunc("/features/{tag}", c.httpFeatureInfo) @@ -1432,12 +1437,22 @@ func (c *Controller) httpReleaseInfo(w http.ResponseWriter, req *http.Request) { renderInstallInstructions(w, tagInfo.Info.Tag, tagInfo.TagPullSpec, c.artifactsHost) } - fmt.Fprintf(w, "Team Approvals: ") - teamApprovedList := c.renderTeamApprovals(tagInfo.Tag, true) - if teamApprovedList == "" { - fmt.Fprintf(w, "None
") + qualifierStatusAPI := fmt.Sprintf(`(status api)`, template.HTMLEscapeString(url.PathEscape(tagInfo.Tag))) + qualifierBadges := c.renderQualifierBadges(tagInfo.Tag, true) + if qualifierBadges == "" { + fmt.Fprintf(w, `

Qualifiers %s: None

`, qualifierStatusAPI) } else { - fmt.Fprint(w, "
"+teamApprovedList+"
") + fmt.Fprintf(w, `

Qualifiers %s:

%s
`, qualifierStatusAPI, qualifierBadges) + } + + if payload := c.GetReleasePayload(tagInfo.Tag); payload != nil && + payload.Status.QualifiersSummary != nil && + len(payload.Status.QualifiersSummary.FailureLabels) > 0 { + fmt.Fprintf(w, `

Failure Labels:

`) } c.renderVerifyLinks(w, *tagInfo.Info.Tag, tagInfo.Info.Release) @@ -1764,7 +1779,7 @@ func (c *Controller) httpReleases(w http.ResponseWriter, req *http.Request) { "inc": func(i int) int { return i + 1 }, "upgradeCells": upgradeCells, "removeSpecialCharacters": removeSpecialCharacters, - "teamApprovals": c.renderTeamApprovals, + "qualifierBadges": c.renderQualifierBadges, "since": func(utcDate string) string { t, err := time.Parse(time.RFC3339, utcDate) if err != nil { @@ -1836,60 +1851,286 @@ func (c *Controller) httpReleases(w http.ResponseWriter, req *http.Request) { } } -func (c *Controller) renderTeamApprovals(tag string, asList bool) string { +func qualifierStateText(summary v1alpha1.ReleaseQualifierSummary) string { + if summary.Approval { + switch summary.AggregateState { + case v1alpha1.JobStateSuccess: + return "Accepted" + case v1alpha1.JobStateFailure: + return "Rejected" + default: + return "" + } + } + switch summary.AggregateState { + case v1alpha1.JobStateSuccess: + return "Passed" + case v1alpha1.JobStateFailure: + return "Failed" + default: + return "Pending" + } +} + +func (c *Controller) renderQualifierBadges(tag string, showAll bool) string { payload := c.GetReleasePayload(tag) if payload == nil { return "" } - acceptedLabels := []string{} - rejectedLabels := []string{} - for label, value := range payload.Labels { - if strings.HasSuffix(label, "_state") { - if value == "Accepted" { - acceptedLabels = append(acceptedLabels, label) - } - if value == "Rejected" { - rejectedLabels = append(rejectedLabels, label) + if payload.Status.QualifiersSummary == nil || len(payload.Status.QualifiersSummary.Qualifiers) == 0 { + return "" + } + + // Sort qualifier IDs for deterministic display order + qualifierIDs := make([]releasequalifiers.QualifierId, 0, len(payload.Status.QualifiersSummary.Qualifiers)) + for qID := range payload.Status.QualifiersSummary.Qualifiers { + qualifierIDs = append(qualifierIDs, qID) + } + slices.Sort(qualifierIDs) + + streamName := payload.Spec.PayloadCoordinates.StreamName + + var badges strings.Builder + for _, qID := range qualifierIDs { + summary := payload.Status.QualifiersSummary.Qualifiers[qID] + if !showAll && !summary.BadgePropagated { + continue + } + + badgeName := summary.BadgeName + if badgeName == "" { + badgeName = string(qID) + } + + var badgeClass string + switch summary.AggregateState { + case v1alpha1.JobStateSuccess: + badgeClass = "badge-success" + case v1alpha1.JobStateFailure: + badgeClass = "badge-danger" + default: + badgeClass = "badge-warning" + } + + escapedBadgeName := template.HTMLEscapeString(badgeName) + stateText := qualifierStateText(summary) + linkURL := fmt.Sprintf("/releasestream/%s/qualifier/%s", + url.PathEscape(streamName), url.PathEscape(string(qID))) + fmt.Fprintf(&badges, `%s `, + linkURL, escapedBadgeName, stateText, badgeClass, escapedBadgeName) + } + return badges.String() +} + +func (c *Controller) httpQualifierStatus(w http.ResponseWriter, req *http.Request) { + start := time.Now() + defer func() { klog.V(4).Infof("rendered in %s", time.Since(start)) }() + + vars := mux.Vars(req) + streamName := vars["release"] + qualifierID := releasequalifiers.QualifierId(vars["qualifier"]) + + // Find the most recent payload in this stream that has this qualifier + payloads := c.GetReleasePayloads() + var payload *v1alpha1.ReleasePayload + for _, p := range payloads { + if p.Spec.PayloadCoordinates.StreamName != streamName { + continue + } + if p.Status.QualifiersSummary != nil { + if _, ok := p.Status.QualifiersSummary.Qualifiers[qualifierID]; ok { + if payload == nil || p.CreationTimestamp.After(payload.CreationTimestamp.Time) { + payload = p + } } } } - teamName := func(fullLabel string) string { - return strings.ToUpper(strings.Split(strings.TrimSuffix(fullLabel, "_state"), "/")[1]) + + if payload == nil { + http.Error(w, fmt.Sprintf("No payload found for qualifier %q in stream %q", qualifierID, streamName), http.StatusNotFound) + return + } + + summary := payload.Status.QualifiersSummary.Qualifiers[qualifierID] + + // Build a lookup of job name -> JobStatus from the payload results + jobStatusMap := make(map[string]*v1alpha1.JobStatus) + for i := range payload.Status.BlockingJobResults { + js := &payload.Status.BlockingJobResults[i] + jobStatusMap[js.CIConfigurationName] = js } - var approvals strings.Builder - if asList { - approvals.WriteString("") - } - return approvals.String() + } func (c *Controller) httpReleaseStreamTable(w http.ResponseWriter, req *http.Request) { @@ -2004,6 +2245,7 @@ func (c *Controller) httpReleaseStreamTable(w http.ResponseWriter, req *http.Req "inc": func(i int) int { return i + 1 }, "upgradeCells": upgradeCells, "removeSpecialCharacters": removeSpecialCharacters, + "qualifierBadges": c.renderQualifierBadges, "since": func(utcDate string) string { t, err := time.Parse(time.RFC3339, utcDate) if err != nil { @@ -2361,6 +2603,117 @@ func (c *Controller) apiReleaseConfig(w http.ResponseWriter, req *http.Request) fmt.Fprintln(w) } +func (c *Controller) apiQualifierConfig(w http.ResponseWriter, req *http.Request) { + start := time.Now() + defer func() { klog.V(4).Infof("rendered in %s", time.Since(start)) }() + + vars := mux.Vars(req) + streamName := vars["release"] + qualifierID := releasequalifiers.QualifierId(vars["qualifier"]) + + jobName := req.URL.Query().Get("job") + if jobName == "" { + http.Error(w, "error: job query parameter is required", http.StatusBadRequest) + return + } + + // Find the most recent payload in this stream that has this qualifier + payloads := c.GetReleasePayloads() + var payload *v1alpha1.ReleasePayload + for _, p := range payloads { + if p.Spec.PayloadCoordinates.StreamName != streamName { + continue + } + if p.Status.QualifiersSummary != nil { + if _, ok := p.Status.QualifiersSummary.Qualifiers[qualifierID]; ok { + if payload == nil || p.CreationTimestamp.After(payload.CreationTimestamp.Time) { + payload = p + } + } + } + } + + if payload == nil { + http.Error(w, fmt.Sprintf("No payload found for qualifier %q in stream %q", qualifierID, streamName), http.StatusNotFound) + return + } + + // Find the CIConfiguration for the specified job + var jobConfig *v1alpha1.CIConfiguration + for i := range payload.Spec.PayloadVerificationConfig.BlockingJobs { + if payload.Spec.PayloadVerificationConfig.BlockingJobs[i].CIConfigurationName == jobName { + jobConfig = &payload.Spec.PayloadVerificationConfig.BlockingJobs[i] + break + } + } + if jobConfig == nil { + for i := range payload.Spec.PayloadVerificationConfig.InformingJobs { + if payload.Spec.PayloadVerificationConfig.InformingJobs[i].CIConfigurationName == jobName { + jobConfig = &payload.Spec.PayloadVerificationConfig.InformingJobs[i] + break + } + } + } + if jobConfig == nil { + http.Error(w, fmt.Sprintf("Job %q not found in payload %q", jobName, payload.Name), http.StatusNotFound) + return + } + + // Start with the global config as the base + var mergedQualifier releasequalifiers.ReleaseQualifier + if c.configAccessor != nil { + globalConfig := c.configAccessor.Get() + if globalConfig != nil { + if gq, ok := globalConfig[qualifierID]; ok { + mergedQualifier = gq + } + } + } + + // Merge per-job override on top + if override, ok := jobConfig.Qualifiers[qualifierID]; ok { + mergedQualifier = mergedQualifier.Merge(override) + } + + data, err := json.MarshalIndent(&mergedQualifier, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + fmt.Fprintln(w) +} + +func (c *Controller) apiReleaseQualifiersSummaries(w http.ResponseWriter, req *http.Request) { + start := time.Now() + defer func() { klog.V(4).Infof("rendered in %s", time.Since(start)) }() + + vars := mux.Vars(req) + tag := vars["tag"] + + payload := c.GetReleasePayload(tag) + if payload == nil { + http.Error(w, fmt.Sprintf("No release payload found for tag %q", tag), http.StatusNotFound) + return + } + + data, err := json.MarshalIndent(payload.Status.QualifiersSummary, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + fmt.Fprintln(w) +} + func (c *Controller) apiAcceptedStreams(w http.ResponseWriter, req *http.Request) { data, err := c.filteredStreams(releasecontroller.ReleasePhaseAccepted) if err != nil { diff --git a/cmd/release-controller-api/http_helper.go b/cmd/release-controller-api/http_helper.go index 4ae2644a0..f33888aa1 100644 --- a/cmd/release-controller-api/http_helper.go +++ b/cmd/release-controller-api/http_helper.go @@ -6,6 +6,7 @@ import ( "io" "net/url" "regexp" + "slices" "sort" "strings" "text/template" @@ -15,6 +16,7 @@ import ( "github.com/openshift/release-controller/pkg/apis/release/v1alpha1" "github.com/openshift/release-controller/pkg/releasepayload" + "github.com/openshift/release-controller/pkg/releasequalifiers" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/klog/v2" @@ -486,36 +488,76 @@ func (c *Controller) renderVerifyLinks(w io.Writer, tag imagev1.TagReference, re fmt.Fprint(w, msg) return } + + // Build reverse map: jobName -> []qualifierId for badge display + jobQualifiers := c.getJobQualifierMap(tag.Name) + buf := &bytes.Buffer{} final := tag.Annotations[releasecontroller.ReleaseAnnotationPhase] == releasecontroller.ReleasePhaseRejected || tag.Annotations[releasecontroller.ReleaseAnnotationPhase] == releasecontroller.ReleasePhaseAccepted if len(verificationJobs.BlockingJobs) > 0 { buf.WriteString("
  • Blocking jobs
  • ") } if len(verificationJobs.InformingJobs) > 0 { buf.WriteString("
  • Informing jobs
  • ") } if len(verificationJobs.AsyncJobs) > 0 { buf.WriteString("
  • Async jobs
  • ") } if len(verificationJobs.PendingJobs) > 0 { buf.WriteString("
  • Pending jobs
  • ") } if out := buf.String(); len(out) > 0 { - fmt.Fprintf(w, `

    Tests:

    `, out) + fmt.Fprintf(w, `

    Tests:

    `, out) } else { fmt.Fprintf(w, `

    No tests for this release`) } } -func (c *Controller) renderVerificationJobsList(jobs releasecontroller.VerificationStatusMap, release *releasecontroller.Release, tag imagev1.TagReference, final bool) string { +// getJobQualifierMap builds a reverse mapping from job CIConfigurationName to qualifier IDs +func (c *Controller) getJobQualifierMap(tagName string) map[string][]releasequalifiers.QualifierId { + payload := c.GetReleasePayload(tagName) + if payload == nil || payload.Status.QualifiersSummary == nil || len(payload.Status.QualifiersSummary.Qualifiers) == 0 { + return nil + } + result := make(map[string][]releasequalifiers.QualifierId) + for qID, summary := range payload.Status.QualifiersSummary.Qualifiers { + for _, job := range summary.Jobs { + result[job.CIConfigurationName] = append(result[job.CIConfigurationName], qID) + } + } + // Sort qualifier IDs for deterministic display + for _, qIDs := range result { + slices.Sort(qIDs) + } + return result +} + +// renderJobQualifierBadges renders small inline badge indicators for qualifiers associated with a job +func renderJobQualifierBadges(jobName string, jobQualifiers map[string][]releasequalifiers.QualifierId) string { + if jobQualifiers == nil { + return "" + } + qIDs, ok := jobQualifiers[jobName] + if !ok || len(qIDs) == 0 { + return "" + } + var buf bytes.Buffer + for _, qID := range qIDs { + fmt.Fprintf(&buf, ` %s`, + template.HTMLEscapeString(string(qID)), template.HTMLEscapeString(string(qID))) + } + return buf.String() +} + +func (c *Controller) renderVerificationJobsList(jobs releasecontroller.VerificationStatusMap, release *releasecontroller.Release, tag imagev1.TagReference, final bool, jobQualifiers map[string][]releasequalifiers.QualifierId) string { buf := &bytes.Buffer{} keys := make([]string, 0, len(jobs)) for k := range jobs { @@ -532,7 +574,9 @@ func (c *Controller) renderVerificationJobsList(jobs releasecontroller.Verificat if !verificationJobs[key].Disabled && !final { buf.WriteString("

  • ") buf.WriteString(template.HTMLEscapeString(key)) - buf.WriteString("
  • ") + buf.WriteString("") + buf.WriteString(renderJobQualifierBadges(key, jobQualifiers)) + buf.WriteString("") } continue } @@ -560,6 +604,7 @@ func (c *Controller) renderVerificationJobsList(jobs releasecontroller.Verificat buf.WriteString(" ") buf.WriteString(pj.Name) } + buf.WriteString(renderJobQualifierBadges(key, jobQualifiers)) buf.WriteString("