Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions pkg/api/jobrunevents/job_run_events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package jobrunevents

import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"

"cloud.google.com/go/storage"
log "github.com/sirupsen/logrus"

"github.com/openshift/sippy/pkg/api"
"github.com/openshift/sippy/pkg/dataloader/prowloader/gcs"
"github.com/openshift/sippy/pkg/db"
)

// eventsJSONRegex matches paths like artifacts/*e2e*/gather-extra/artifacts/events.json
var eventsJSONRegex = regexp.MustCompile(`gather-extra/artifacts/events\.json$`)

// KubeEvent represents a flattened Kubernetes Event for the API response
type KubeEvent struct {
FirstTimestamp string `json:"firstTimestamp"`
LastTimestamp string `json:"lastTimestamp"`
Namespace string `json:"namespace"`
Kind string `json:"kind"`
Name string `json:"name"`
Type string `json:"type"`
Reason string `json:"reason"`
Message string `json:"message"`
Count int `json:"count"`
Source string `json:"source"`
}

// rawKubeEvent is the Kubernetes Event structure from events.json
type rawKubeEvent struct {
FirstTimestamp string `json:"firstTimestamp"`
LastTimestamp string `json:"lastTimestamp"`
EventTime string `json:"eventTime"`
InvolvedObject map[string]interface{} `json:"involvedObject"`
Type string `json:"type"`
Reason string `json:"reason"`
Message string `json:"message"`
Count int `json:"count"`
Source map[string]string `json:"source"`
Metadata map[string]interface{} `json:"metadata"`
ReportingComp string `json:"reportingComponent"`
}

// EventListResponse is the API response for job run events
type EventListResponse struct {
Items []KubeEvent `json:"items"`
JobRunURL string `json:"jobRunURL"`
}

// JobRunEvents fetches events.json for a given job run from the GCS path
// artifacts/*e2e*/gather-extra/artifacts/events.json
func JobRunEvents(gcsClient *storage.Client, dbc *db.DB, jobRunID int64, gcsBucket, gcsPath string, logger *log.Entry) (*EventListResponse, error) {
jobRunURL := fmt.Sprintf("https://prow.ci.openshift.org/view/gs/%s/%s", gcsBucket, gcsPath)

jobRun, err := api.FetchJobRun(dbc, jobRunID, false, nil, logger)
if err != nil {
logger.WithError(err).Debugf("failed to fetch job run %d", jobRunID)
if gcsPath == "" {
return nil, errors.New("no GCS path given and no job run found in DB")
}
} else {
jobRunURL = jobRun.URL
gcsBucket = jobRun.GCSBucket
_, path, found := strings.Cut(jobRunURL, "/"+gcsBucket+"/")
if !found {
return nil, fmt.Errorf("job run URL %q does not contain bucket %q", jobRun.URL, gcsBucket)
}
gcsPath = path
}

gcsJobRun := gcs.NewGCSJobRun(gcsClient.Bucket(gcsBucket), gcsPath)
matches, err := gcsJobRun.FindAllMatches([]*regexp.Regexp{eventsJSONRegex})
if err != nil {
return &EventListResponse{JobRunURL: jobRunURL}, err
}

if len(matches) == 0 || len(matches[0]) == 0 {
logger.Info("no events.json file found")
return &EventListResponse{Items: []KubeEvent{}, JobRunURL: jobRunURL}, nil
}

eventsPath := matches[0][0]
logger.WithField("events_path", eventsPath).Info("found events.json")

content, err := gcsJobRun.GetContent(context.TODO(), eventsPath)
if err != nil {
logger.WithError(err).Errorf("error getting content for file: %s", eventsPath)
return nil, err
}

var rawEvents struct {
Items []rawKubeEvent `json:"items"`
}
if err := json.Unmarshal(content, &rawEvents); err != nil {
logger.WithError(err).Error("error unmarshaling events.json")
return nil, err
}

events := make([]KubeEvent, 0, len(rawEvents.Items))
for _, raw := range rawEvents.Items {
evt := flattenEvent(raw)
events = append(events, evt)
}

return &EventListResponse{Items: events, JobRunURL: jobRunURL}, nil
}

func flattenEvent(raw rawKubeEvent) KubeEvent {
evt := KubeEvent{
FirstTimestamp: raw.FirstTimestamp,
LastTimestamp: raw.LastTimestamp,
Type: raw.Type,
Reason: raw.Reason,
Message: raw.Message,
Count: raw.Count,
}
if raw.Count == 0 {
evt.Count = 1
}
if raw.InvolvedObject != nil {
if k, ok := raw.InvolvedObject["kind"].(string); ok {
evt.Kind = k
}
if n, ok := raw.InvolvedObject["name"].(string); ok {
evt.Name = n
}
}
if raw.Metadata != nil {
if ns, ok := raw.Metadata["namespace"].(string); ok {
evt.Namespace = ns
}
}
if raw.Source != nil && raw.Source["component"] != "" {
evt.Source = raw.Source["component"]
} else if raw.ReportingComp != "" {
evt.Source = raw.ReportingComp
}
return evt
}
57 changes: 57 additions & 0 deletions pkg/sippyserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import (

"github.com/openshift/sippy/pkg/api"
"github.com/openshift/sippy/pkg/api/componentreadiness"
"github.com/openshift/sippy/pkg/api/jobrunevents"
"github.com/openshift/sippy/pkg/api/jobrunintervals"
apitype "github.com/openshift/sippy/pkg/apis/api"
"github.com/openshift/sippy/pkg/apis/cache"
Expand Down Expand Up @@ -1424,6 +1425,55 @@ func (s *Server) jsonJobRunIntervals(w http.ResponseWriter, req *http.Request) {
api.RespondWithJSON(http.StatusOK, w, result)
}

// jsonJobRunEvents fetches Kubernetes events from events.json in the job run's GCS artifacts.
// The file is located at artifacts/*e2e*/gather-extra/artifacts/events.json
func (s *Server) jsonJobRunEvents(w http.ResponseWriter, req *http.Request) {
logger := log.WithField("func", "jsonJobRunEvents")

if s.gcsClient == nil {
failureResponse(w, http.StatusBadRequest, "server not configured for GCS, unable to use this API")
return
}

jobRunIDStr := s.getParamOrFail(w, req, "prow_job_run_id")
if jobRunIDStr == "" {
return
}

jobRunID, err := strconv.ParseInt(jobRunIDStr, 10, 64)
if err != nil {
failureResponse(w, http.StatusBadRequest, "unable to parse prow_job_run_id: "+err.Error())
return
}
logger = logger.WithField("jobRunID", jobRunID)

jobName := param.SafeRead(req, "job_name")
repoInfo := param.SafeRead(req, "repo_info")
pullNumber := param.SafeRead(req, "pull_number")

var gcsPath string
if len(jobName) > 0 {
if len(repoInfo) > 0 {
if repoInfo == "openshift_origin" {
gcsPath = fmt.Sprintf("pr-logs/pull/%s/%s/%s", pullNumber, jobName, jobRunIDStr)
} else {
gcsPath = fmt.Sprintf("pr-logs/pull/%s/%s/%s/%s", repoInfo, pullNumber, jobName, jobRunIDStr)
}
} else {
gcsPath = fmt.Sprintf("logs/%s/%s", jobName, jobRunIDStr)
}
}

result, err := jobrunevents.JobRunEvents(s.gcsClient, s.db, jobRunID, s.gcsBucket, gcsPath,
logger.WithField("func", "JobRunEvents"))
if err != nil {
failureResponse(w, http.StatusBadRequest, err.Error())
return
}

api.RespondWithJSON(http.StatusOK, w, result)
}

func isValidProwJobRun(jobRun *models.ProwJobRun) (bool, string) {
if (jobRun == nil || jobRun == &models.ProwJobRun{} || &jobRun.ProwJob == &models.ProwJob{} || jobRun.ProwJob.Name == "") {

Expand Down Expand Up @@ -2179,6 +2229,13 @@ func (s *Server) Serve() {
CacheTime: 4 * time.Hour,
HandlerFunc: s.jsonJobRunIntervals,
},
{
EndpointPath: "/api/jobs/runs/events",
Description: "Returns Kubernetes events from job run artifacts (events.json)",
Capabilities: []string{LocalDBCapability},
CacheTime: 4 * time.Hour,
HandlerFunc: s.jsonJobRunEvents,
},
{
EndpointPath: "/api/jobs/analysis",
Description: "Analyzes jobs from the database",
Expand Down
18 changes: 18 additions & 0 deletions sippy-ng/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import CollapsibleChatDrawer from './chat/CollapsibleChatDrawer'
import ComponentReadiness from './component_readiness/ComponentReadiness'
import Drawer from '@mui/material/Drawer'
import EventsChart from './prow_job_runs/EventsChart'
import FeatureGates from './tests/FeatureGates'
import IconButton from '@mui/material/IconButton'
import Install from './releases/Install'
Expand Down Expand Up @@ -360,6 +361,19 @@ const IntervalsChartWrapper = () => {
)
}

const EventsChartWrapper = () => {
const { jobrunid, jobname, repoinfo, pullnumber } = useParams()

return (
<EventsChart
jobRunID={jobrunid}
jobName={jobname}
repoInfo={repoinfo}
pullNumber={pullnumber}
/>
)
}

const ChatInterfaceWrapper = () => {
const { id } = useParams()
return <ChatInterface mode="fullPage" conversationId={id} />
Expand Down Expand Up @@ -756,6 +770,10 @@ function App(props) {
path="/job_runs/:jobrunid/:jobname?/:repoinfo?/:pullnumber?/intervals"
element={<IntervalsChartWrapper />}
/>
<Route
path="/job_runs/:jobrunid/:jobname?/:repoinfo?/:pullnumber?/events"
element={<EventsChartWrapper />}
/>

{sippyCapabilities.includes('chat') && (
<>
Expand Down
Loading