Skip to content

Commit fb423ef

Browse files
eggfoobarclaude
andcommitted
feat: add events viewer for cluster events
added ability to consume the events.json collected from gather-extra of a job run Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: ehila <ehila@redhat.com>
1 parent 94f73c6 commit fb423ef

5 files changed

Lines changed: 770 additions & 1 deletion

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package jobrunevents
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"regexp"
9+
"strings"
10+
11+
"cloud.google.com/go/storage"
12+
log "github.com/sirupsen/logrus"
13+
14+
"github.com/openshift/sippy/pkg/api"
15+
"github.com/openshift/sippy/pkg/dataloader/prowloader/gcs"
16+
"github.com/openshift/sippy/pkg/db"
17+
)
18+
19+
// eventsJSONRegex matches paths like artifacts/*e2e*/gather-extra/artifacts/events.json
20+
var eventsJSONRegex = regexp.MustCompile(`gather-extra/artifacts/events\.json$`)
21+
22+
// KubeEvent represents a flattened Kubernetes Event for the API response
23+
type KubeEvent struct {
24+
FirstTimestamp string `json:"firstTimestamp"`
25+
LastTimestamp string `json:"lastTimestamp"`
26+
Namespace string `json:"namespace"`
27+
Kind string `json:"kind"`
28+
Name string `json:"name"`
29+
Type string `json:"type"`
30+
Reason string `json:"reason"`
31+
Message string `json:"message"`
32+
Count int `json:"count"`
33+
Source string `json:"source"`
34+
}
35+
36+
// rawKubeEvent is the Kubernetes Event structure from events.json
37+
type rawKubeEvent struct {
38+
FirstTimestamp string `json:"firstTimestamp"`
39+
LastTimestamp string `json:"lastTimestamp"`
40+
EventTime string `json:"eventTime"`
41+
InvolvedObject map[string]interface{} `json:"involvedObject"`
42+
Type string `json:"type"`
43+
Reason string `json:"reason"`
44+
Message string `json:"message"`
45+
Count int `json:"count"`
46+
Source map[string]string `json:"source"`
47+
Metadata map[string]interface{} `json:"metadata"`
48+
ReportingComp string `json:"reportingComponent"`
49+
}
50+
51+
// EventListResponse is the API response for job run events
52+
type EventListResponse struct {
53+
Items []KubeEvent `json:"items"`
54+
JobRunURL string `json:"jobRunURL"`
55+
}
56+
57+
// JobRunEvents fetches events.json for a given job run from the GCS path
58+
// artifacts/*e2e*/gather-extra/artifacts/events.json
59+
func JobRunEvents(gcsClient *storage.Client, dbc *db.DB, jobRunID int64, gcsBucket, gcsPath string, logger *log.Entry) (*EventListResponse, error) {
60+
jobRunURL := fmt.Sprintf("https://prow.ci.openshift.org/view/gs/%s/%s", gcsBucket, gcsPath)
61+
62+
jobRun, err := api.FetchJobRun(dbc, jobRunID, false, nil, logger)
63+
if err != nil {
64+
logger.WithError(err).Debugf("failed to fetch job run %d", jobRunID)
65+
if gcsPath == "" {
66+
return nil, errors.New("no GCS path given and no job run found in DB")
67+
}
68+
} else {
69+
jobRunURL = jobRun.URL
70+
gcsBucket = jobRun.GCSBucket
71+
_, path, found := strings.Cut(jobRunURL, "/"+gcsBucket+"/")
72+
if !found {
73+
return nil, fmt.Errorf("job run URL %q does not contain bucket %q", jobRun.URL, gcsBucket)
74+
}
75+
gcsPath = path
76+
}
77+
78+
gcsJobRun := gcs.NewGCSJobRun(gcsClient.Bucket(gcsBucket), gcsPath)
79+
matches, err := gcsJobRun.FindAllMatches([]*regexp.Regexp{eventsJSONRegex})
80+
if err != nil {
81+
return &EventListResponse{JobRunURL: jobRunURL}, err
82+
}
83+
84+
if len(matches) == 0 || len(matches[0]) == 0 {
85+
logger.Info("no events.json file found")
86+
return &EventListResponse{Items: []KubeEvent{}, JobRunURL: jobRunURL}, nil
87+
}
88+
89+
eventsPath := matches[0][0]
90+
logger.WithField("events_path", eventsPath).Info("found events.json")
91+
92+
content, err := gcsJobRun.GetContent(context.TODO(), eventsPath)
93+
if err != nil {
94+
logger.WithError(err).Errorf("error getting content for file: %s", eventsPath)
95+
return nil, err
96+
}
97+
98+
var rawEvents struct {
99+
Items []rawKubeEvent `json:"items"`
100+
}
101+
if err := json.Unmarshal(content, &rawEvents); err != nil {
102+
logger.WithError(err).Error("error unmarshaling events.json")
103+
return nil, err
104+
}
105+
106+
events := make([]KubeEvent, 0, len(rawEvents.Items))
107+
for _, raw := range rawEvents.Items {
108+
evt := flattenEvent(raw)
109+
events = append(events, evt)
110+
}
111+
112+
return &EventListResponse{Items: events, JobRunURL: jobRunURL}, nil
113+
}
114+
115+
func flattenEvent(raw rawKubeEvent) KubeEvent {
116+
evt := KubeEvent{
117+
FirstTimestamp: raw.FirstTimestamp,
118+
LastTimestamp: raw.LastTimestamp,
119+
Type: raw.Type,
120+
Reason: raw.Reason,
121+
Message: raw.Message,
122+
Count: raw.Count,
123+
}
124+
if raw.Count == 0 {
125+
evt.Count = 1
126+
}
127+
if raw.InvolvedObject != nil {
128+
if k, ok := raw.InvolvedObject["kind"].(string); ok {
129+
evt.Kind = k
130+
}
131+
if n, ok := raw.InvolvedObject["name"].(string); ok {
132+
evt.Name = n
133+
}
134+
}
135+
if raw.Metadata != nil {
136+
if ns, ok := raw.Metadata["namespace"].(string); ok {
137+
evt.Namespace = ns
138+
}
139+
}
140+
if raw.Source != nil && raw.Source["component"] != "" {
141+
evt.Source = raw.Source["component"]
142+
} else if raw.ReportingComp != "" {
143+
evt.Source = raw.ReportingComp
144+
}
145+
return evt
146+
}

pkg/sippyserver/server.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import (
4444

4545
"github.com/openshift/sippy/pkg/api"
4646
"github.com/openshift/sippy/pkg/api/componentreadiness"
47+
"github.com/openshift/sippy/pkg/api/jobrunevents"
4748
"github.com/openshift/sippy/pkg/api/jobrunintervals"
4849
apitype "github.com/openshift/sippy/pkg/apis/api"
4950
"github.com/openshift/sippy/pkg/apis/cache"
@@ -1424,6 +1425,55 @@ func (s *Server) jsonJobRunIntervals(w http.ResponseWriter, req *http.Request) {
14241425
api.RespondWithJSON(http.StatusOK, w, result)
14251426
}
14261427

1428+
// jsonJobRunEvents fetches Kubernetes events from events.json in the job run's GCS artifacts.
1429+
// The file is located at artifacts/*e2e*/gather-extra/artifacts/events.json
1430+
func (s *Server) jsonJobRunEvents(w http.ResponseWriter, req *http.Request) {
1431+
logger := log.WithField("func", "jsonJobRunEvents")
1432+
1433+
if s.gcsClient == nil {
1434+
failureResponse(w, http.StatusBadRequest, "server not configured for GCS, unable to use this API")
1435+
return
1436+
}
1437+
1438+
jobRunIDStr := s.getParamOrFail(w, req, "prow_job_run_id")
1439+
if jobRunIDStr == "" {
1440+
return
1441+
}
1442+
1443+
jobRunID, err := strconv.ParseInt(jobRunIDStr, 10, 64)
1444+
if err != nil {
1445+
failureResponse(w, http.StatusBadRequest, "unable to parse prow_job_run_id: "+err.Error())
1446+
return
1447+
}
1448+
logger = logger.WithField("jobRunID", jobRunID)
1449+
1450+
jobName := param.SafeRead(req, "job_name")
1451+
repoInfo := param.SafeRead(req, "repo_info")
1452+
pullNumber := param.SafeRead(req, "pull_number")
1453+
1454+
var gcsPath string
1455+
if len(jobName) > 0 {
1456+
if len(repoInfo) > 0 {
1457+
if repoInfo == "openshift_origin" {
1458+
gcsPath = fmt.Sprintf("pr-logs/pull/%s/%s/%s", pullNumber, jobName, jobRunIDStr)
1459+
} else {
1460+
gcsPath = fmt.Sprintf("pr-logs/pull/%s/%s/%s/%s", repoInfo, pullNumber, jobName, jobRunIDStr)
1461+
}
1462+
} else {
1463+
gcsPath = fmt.Sprintf("logs/%s/%s", jobName, jobRunIDStr)
1464+
}
1465+
}
1466+
1467+
result, err := jobrunevents.JobRunEvents(s.gcsClient, s.db, jobRunID, s.gcsBucket, gcsPath,
1468+
logger.WithField("func", "JobRunEvents"))
1469+
if err != nil {
1470+
failureResponse(w, http.StatusBadRequest, err.Error())
1471+
return
1472+
}
1473+
1474+
api.RespondWithJSON(http.StatusOK, w, result)
1475+
}
1476+
14271477
func isValidProwJobRun(jobRun *models.ProwJobRun) (bool, string) {
14281478
if (jobRun == nil || jobRun == &models.ProwJobRun{} || &jobRun.ProwJob == &models.ProwJob{} || jobRun.ProwJob.Name == "") {
14291479

@@ -2179,6 +2229,13 @@ func (s *Server) Serve() {
21792229
CacheTime: 4 * time.Hour,
21802230
HandlerFunc: s.jsonJobRunIntervals,
21812231
},
2232+
{
2233+
EndpointPath: "/api/jobs/runs/events",
2234+
Description: "Returns Kubernetes events from job run artifacts (events.json)",
2235+
Capabilities: []string{LocalDBCapability},
2236+
CacheTime: 4 * time.Hour,
2237+
HandlerFunc: s.jsonJobRunEvents,
2238+
},
21822239
{
21832240
EndpointPath: "/api/jobs/analysis",
21842241
Description: "Analyzes jobs from the database",

sippy-ng/src/App.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight'
4545
import CollapsibleChatDrawer from './chat/CollapsibleChatDrawer'
4646
import ComponentReadiness from './component_readiness/ComponentReadiness'
4747
import Drawer from '@mui/material/Drawer'
48+
import EventsChart from './prow_job_runs/EventsChart'
4849
import FeatureGates from './tests/FeatureGates'
4950
import IconButton from '@mui/material/IconButton'
5051
import Install from './releases/Install'
@@ -360,6 +361,19 @@ const IntervalsChartWrapper = () => {
360361
)
361362
}
362363

364+
const EventsChartWrapper = () => {
365+
const { jobrunid, jobname, repoinfo, pullnumber } = useParams()
366+
367+
return (
368+
<EventsChart
369+
jobRunID={jobrunid}
370+
jobName={jobname}
371+
repoInfo={repoinfo}
372+
pullNumber={pullnumber}
373+
/>
374+
)
375+
}
376+
363377
const ChatInterfaceWrapper = () => {
364378
const { id } = useParams()
365379
return <ChatInterface mode="fullPage" conversationId={id} />
@@ -756,6 +770,10 @@ function App(props) {
756770
path="/job_runs/:jobrunid/:jobname?/:repoinfo?/:pullnumber?/intervals"
757771
element={<IntervalsChartWrapper />}
758772
/>
773+
<Route
774+
path="/job_runs/:jobrunid/:jobname?/:repoinfo?/:pullnumber?/events"
775+
element={<EventsChartWrapper />}
776+
/>
759777

760778
{sippyCapabilities.includes('chat') && (
761779
<>

0 commit comments

Comments
 (0)