diff --git a/backend/fakegithub/server.go b/backend/fakegithub/server.go index 5d556828..3a6a8d5c 100644 --- a/backend/fakegithub/server.go +++ b/backend/fakegithub/server.go @@ -10,10 +10,13 @@ import ( "net/http" "slices" "sort" + "strconv" "strings" "sync" "golang.org/x/crypto/nacl/box" + + "github.com/openshift/faas-console-plugin/backend/functions" ) // User configures the authenticated identity returned by the fake server. @@ -33,6 +36,36 @@ type repo struct { Commits map[string]*commit Refs map[string]string // "refs/heads/main" -> commit sha Secrets map[string]string // name -> encrypted value + Runs []workflowRun // scripted GitHub Actions runs, most recent last +} + +type workflowRun struct { + ID int64 `json:"id"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + Status string `json:"status"` // queued | in_progress | completed + Conclusion string `json:"conclusion"` // success | failure | cancelled | timed_out | "" + HTMLURL string `json:"html_url"` + Jobs []workflowJob `json:"-"` // returned by the jobs endpoint, not the runs listing + // WorkflowFile is the workflow file this run belongs to. It is used only to + // scope the by-file-name runs endpoint (mirroring real GitHub); it is not + // part of the runs listing the client parses. + WorkflowFile string `json:"-"` +} + +type workflowJob struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Steps []workflowStep `json:"steps"` +} + +type workflowStep struct { + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Number int `json:"number"` } type treeEntry struct { @@ -66,6 +99,8 @@ type Server struct { pubKeyB64 string keyID string + runIDSeq int64 // monotonic id source for scripted workflow runs + mux *http.ServeMux } @@ -136,9 +171,17 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/secrets/public-key", s.handleGetPublicKey) s.mux.HandleFunc("PUT /repos/{owner}/{repo}/actions/secrets/{name}", s.handlePutSecret) + // Actions runs (build status). The client scopes build status to a single + // workflow file via the by-file-name endpoint, which filters runs to that + // workflow (mirroring real GitHub). The repo-wide endpoint returns every run. + s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/runs", s.handleListWorkflowRuns) + s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/workflows/{workflow}/runs", s.handleListWorkflowRuns) + s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs", s.handleListWorkflowJobs) + // Admin API (for test setup) s.mux.HandleFunc("POST /_admin/seed", s.handleAdminSeed) s.mux.HandleFunc("POST /_admin/reset", s.handleAdminReset) + s.mux.HandleFunc("POST /_admin/actions/runs", s.handleAdminSetRun) } // --- GitHub API handlers --- @@ -628,6 +671,74 @@ func (s *Server) handlePutSecret(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +func (s *Server) handleListWorkflowRuns(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + rp := s.getRepo(r) + if rp == nil { + writeError(w, http.StatusNotFound, "Not Found") + return + } + + branch := r.URL.Query().Get("branch") + // workflow is set only on the by-file-name route; when present, scope runs to + // that workflow file the way real GitHub does. The repo-wide route leaves it + // empty and returns every run. + workflow := r.PathValue("workflow") + // GitHub returns most-recent first; our slice keeps most-recent last, so reverse. + var runs []workflowRun + for i := len(rp.Runs) - 1; i >= 0; i-- { + run := rp.Runs[i] + if branch != "" && run.HeadBranch != branch { + continue + } + if workflow != "" && run.WorkflowFile != workflow { + continue + } + runs = append(runs, run) + } + if runs == nil { + runs = []workflowRun{} + } + writeJSON(w, http.StatusOK, map[string]any{ + "total_count": len(runs), + "workflow_runs": runs, + }) +} + +func (s *Server) handleListWorkflowJobs(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + rp := s.getRepo(r) + if rp == nil { + writeError(w, http.StatusNotFound, "Not Found") + return + } + + runID, err := strconv.ParseInt(r.PathValue("run_id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid run id") + return + } + + for _, run := range rp.Runs { + if run.ID == runID { + jobs := run.Jobs + if jobs == nil { + jobs = []workflowJob{} + } + writeJSON(w, http.StatusOK, map[string]any{ + "total_count": len(jobs), + "jobs": jobs, + }) + return + } + } + writeError(w, http.StatusNotFound, "Not Found") +} + // --- Admin API handlers --- type seedRequest struct { @@ -689,6 +800,67 @@ func (s *Server) handleAdminReset(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "reset"}) } +type adminRunRequest struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Branch string `json:"branch"` + HeadSHA string `json:"headSha"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Jobs []workflowJob `json:"jobs"` + // Workflow is the workflow file the run belongs to. Defaults to the func + // build workflow; set it to script a run under a different workflow (e.g. to + // verify build-status queries stay scoped to the func workflow). + Workflow string `json:"workflow"` +} + +func (s *Server) handleAdminSetRun(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + var req adminRunRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid run request: "+err.Error()) + return + } + if req.Branch == "" { + req.Branch = "main" + } + workflowFile := req.Workflow + if workflowFile == "" { + workflowFile = functions.WorkflowFilename + } + + key := req.Owner + "/" + req.Repo + rp, ok := s.repos[key] + if !ok { + writeError(w, http.StatusNotFound, "repo not seeded: "+key) + return + } + + s.runIDSeq++ + run := workflowRun{ + ID: s.runIDSeq, + HeadBranch: req.Branch, + HeadSHA: req.HeadSHA, + Status: req.Status, + Conclusion: req.Conclusion, + HTMLURL: fmt.Sprintf("https://github.com/%s/actions/runs/%d", key, s.runIDSeq), + Jobs: req.Jobs, + WorkflowFile: workflowFile, + } + // Replace the latest run for this branch, keep others. + kept := rp.Runs[:0:0] + for _, existing := range rp.Runs { + if existing.HeadBranch != req.Branch { + kept = append(kept, existing) + } + } + rp.Runs = append(kept, run) + + writeJSON(w, http.StatusOK, map[string]any{"status": "run set", "id": run.ID}) +} + // --- Helpers --- func (s *Server) getRepo(r *http.Request) *repo { diff --git a/backend/fakegithub/server_test.go b/backend/fakegithub/server_test.go index 5b4c3d6b..e8e25dc3 100644 --- a/backend/fakegithub/server_test.go +++ b/backend/fakegithub/server_test.go @@ -233,6 +233,157 @@ var _ = Describe("FakeGitHub Server", func() { Expect(err).NotTo(HaveOccurred()) Expect(repos).To(BeEmpty()) }) + + It("stores a scripted workflow run via /_admin/actions/runs", func() { + ts, cl := startServer() + seedRepo(ts) + + setWorkflowRun(ts, `{ + "owner": "testuser", "repo": "test-func", "branch": "main", + "headSha": "abc123", "status": "in_progress", "conclusion": "" + }`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + Expect(run.Status).To(Equal("in_progress")) + Expect(run.HeadSHA).To(Equal("abc123")) + }) + + It("clears workflow runs on reset", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{"owner":"testuser","repo":"test-func","branch":"main","status":"completed","conclusion":"success"}`) + + resetFakeGitHub(ts) + seedRepo(ts) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) + }) + + Describe("LatestWorkflowRun", func() { + It("returns nil when the repo has no runs", func() { + ts, cl := startServer() + seedRepo(ts) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) + + It("returns the latest in-progress run", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{"owner":"testuser","repo":"test-func","branch":"main","headSha":"sha1","status":"in_progress","conclusion":""}`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + Expect(run.Status).To(Equal("in_progress")) + Expect(run.Conclusion).To(BeEmpty()) + Expect(run.HeadSHA).To(Equal("sha1")) + Expect(run.HTMLURL).To(ContainSubstring("/actions/runs/")) + Expect(run.FailureReason).To(BeEmpty()) + }) + + It("filters by branch", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{"owner":"testuser","repo":"test-func","branch":"other","status":"completed","conclusion":"success"}`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) + + It("scopes to the func workflow file, ignoring runs of other workflows", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{ + "owner":"testuser","repo":"test-func","branch":"main", + "status":"completed","conclusion":"success","workflow":"other.yaml" + }`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) + + It("composes a failure reason from the first failed step", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{ + "owner":"testuser","repo":"test-func","branch":"main","headSha":"badsha", + "status":"completed","conclusion":"failure", + "jobs":[{ + "id":1,"name":"build","status":"completed","conclusion":"failure", + "steps":[ + {"name":"checkout","status":"completed","conclusion":"success","number":1}, + {"name":"go test","status":"completed","conclusion":"failure","number":2} + ] + }] + }`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + Expect(run.Conclusion).To(Equal("failure")) + Expect(run.FailureReason).To(Equal("build / go test")) + }) + + It("falls back to the job name when no step failed", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{ + "owner":"testuser","repo":"test-func","branch":"main","headSha":"badsha", + "status":"completed","conclusion":"failure", + "jobs":[{ + "id":1,"name":"build","status":"completed","conclusion":"failure", + "steps":[ + {"name":"checkout","status":"completed","conclusion":"success","number":1} + ] + }] + }`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + Expect(run.Conclusion).To(Equal("failure")) + Expect(run.FailureReason).To(Equal("build")) + }) + + It("skips successful jobs and uses a later failing job", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{ + "owner":"testuser","repo":"test-func","branch":"main","headSha":"badsha", + "status":"completed","conclusion":"failure", + "jobs":[ + { + "id":1,"name":"lint","status":"completed","conclusion":"success", + "steps":[ + {"name":"eslint","status":"completed","conclusion":"success","number":1} + ] + }, + { + "id":2,"name":"test","status":"completed","conclusion":"failure", + "steps":[ + {"name":"setup","status":"completed","conclusion":"success","number":1}, + {"name":"unit tests","status":"completed","conclusion":"failure","number":2} + ] + } + ] + }`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + Expect(run.Conclusion).To(Equal("failure")) + Expect(run.FailureReason).To(Equal("test / unit tests")) + }) }) }) @@ -264,6 +415,16 @@ func seedRepo(ts *httptest.Server) { resp.Body.Close() } +func setWorkflowRun(ts *httptest.Server, body string) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, ts.URL+"/_admin/actions/runs", strings.NewReader(body)) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", "application/json") + resp, err := ts.Client().Do(req) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + ExpectWithOffset(1, resp.StatusCode).To(Equal(200)) + resp.Body.Close() +} + func resetFakeGitHub(ts *httptest.Server) { req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, ts.URL+"/_admin/reset", nil) ExpectWithOffset(1, err).NotTo(HaveOccurred()) diff --git a/backend/functions/ci.go b/backend/functions/ci.go index 59655c68..2ca6ee32 100644 --- a/backend/functions/ci.go +++ b/backend/functions/ci.go @@ -11,6 +11,13 @@ import ( "github.com/openshift/faas-console-plugin/backend/scm" ) +// WorkflowFilename is the file name (under .github/workflows/) of the CI +// workflow generated for a function. It is the identifier used to scope build +// status queries to the func build workflow rather than to arbitrary other +// workflows in the repository. It aliases func's default so it stays in sync +// with what generateGithubCIFiles actually writes. +const WorkflowFilename = cigithub.DefaultGitHubWorkflowFilename + var ciGenerators = map[scm.Platform]func(string, ScaffoldConfig) error{ scm.GitHub: generateGithubCIFiles, } diff --git a/backend/go.mod b/backend/go.mod index 36958be5..b1659301 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/google/go-github/v90 v90.0.0 + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 github.com/onsi/ginkgo/v2 v2.32.1 github.com/onsi/gomega v1.42.1 golang.org/x/crypto v0.55.0 @@ -95,7 +96,6 @@ require ( github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect diff --git a/backend/handler/build.go b/backend/handler/build.go new file mode 100644 index 00000000..b36b5c63 --- /dev/null +++ b/backend/handler/build.go @@ -0,0 +1,255 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/openshift/faas-console-plugin/backend/config" + "github.com/openshift/faas-console-plugin/backend/functions" + "github.com/openshift/faas-console-plugin/backend/scm" +) + +// Tunable so tests can drive the SSE loop quickly. +var ( + buildPollInterval = 3 * time.Second + buildRediscoverInterval = 30 * time.Second + buildHeartbeatInterval = 15 * time.Second +) + +type buildStatusItem struct { + Key string `json:"key"` + BuildStatus string `json:"buildStatus"` // Building | Succeeded | Failed | None + Conclusion string `json:"conclusion,omitempty"` + RunURL string `json:"runURL,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + HeadSHA string `json:"headSHA,omitempty"` +} + +type buildSnapshot struct { + Functions []buildStatusItem `json:"functions"` +} + +func deriveBuildStatus(run *scm.WorkflowRun) string { + if run == nil { + return "None" + } + switch run.Status { + // Every pre-completion status (including the gated "waiting"/"requested"/ + // "pending" states) means a run exists but has not finished, so the build is + // still in flight. + case "queued", "in_progress", "waiting", "requested", "pending": + return "Building" + case "completed": + switch run.Conclusion { + case "success": + return "Succeeded" + case "failure", "cancelled", "timed_out": + return "Failed" + default: + // Non-failure outcomes like "skipped", "neutral", "stale", or + // "action_required" are not build failures; report no build signal + // so the frontend falls back to the cluster-derived status rather + // than showing a red "Build failed" badge. + return "None" + } + default: + return "None" + } +} + +func toBuildStatusItem(key string, run *scm.WorkflowRun) buildStatusItem { + item := buildStatusItem{Key: key, BuildStatus: deriveBuildStatus(run)} + if run != nil { + item.Conclusion = run.Conclusion + item.RunURL = run.HTMLURL + item.FailureReason = run.FailureReason + item.HeadSHA = run.HeadSHA + } + return item +} + +// buildStatusSnapshot fetches the latest run for each repo and builds a sorted +// snapshot. When a per-repo fetch fails and prev holds a last-known item for +// that repo, the previous item is carried forward instead of resetting to +// "None": a transient GitHub error would otherwise flicker the badge back to +// the cluster status and, via a varying error string, defeat the watch loop's +// change-detection and force a re-send every poll. prev may be nil (the +// one-shot snapshot endpoint has no prior state), in which case a "None" item is +// emitted for the failed repo (the cause is logged, not sent to the client). +func buildStatusSnapshot(ctx context.Context, client scm.Client, repos []scm.Repo, prev map[string]buildStatusItem) buildSnapshot { + items := make([]buildStatusItem, len(repos)) + g, ctx := errgroup.WithContext(ctx) + g.SetLimit(10) + for i, repo := range repos { + g.Go(func() error { + key := repo.Owner + "/" + repo.Name + run, err := client.LatestWorkflowRun(ctx, repo.Owner, repo.Name, repo.DefaultBranch, functions.WorkflowFilename) + if err != nil { + slog.Warn("failed to get workflow run", "repo", key, "err", err) + if last, ok := prev[key]; ok { + items[i] = last + } else { + // No prior status to carry forward: report "None" and rely on + // the server log above for the cause. The error is deliberately + // not put on the wire (the frontend does not consume it). + items[i] = buildStatusItem{Key: key, BuildStatus: "None"} + } + return nil + } + items[i] = toBuildStatusItem(key, run) + return nil + }) + } + _ = g.Wait() + sort.Slice(items, func(i, j int) bool { return items[i].Key < items[j].Key }) + return buildSnapshot{Functions: items} +} + +// snapshotIndex keys a snapshot's items by their repo key for carry-forward +// lookups on the next poll. +func snapshotIndex(snap buildSnapshot) map[string]buildStatusItem { + index := make(map[string]buildStatusItem, len(snap.Functions)) + for _, item := range snap.Functions { + index[item.Key] = item + } + return index +} + +func (h *Handlers) HandleBuildStatus(w http.ResponseWriter, r *http.Request) { + pat, ok := extractSCMToken(r) + if !ok { + writeError(w, http.StatusUnauthorized, "X-SCM-Token header is required") + return + } + client := config.SCMRegistry.Client(scm.DefaultPlatform, pat) + + repos, err := client.ListRepos(r.Context()) + if err != nil { + if errors.Is(err, scm.ErrUnauthorized) { + writeError(w, http.StatusUnauthorized, "invalid SCM token") + return + } + slog.Error("build status: list repos failed", "err", err) + writeError(w, http.StatusBadGateway, "failed to list repositories") + return + } + + snap := buildStatusSnapshot(r.Context(), client, repos, nil) + writeJSON(w, http.StatusOK, snap) +} + +func (h *Handlers) HandleBuildWatch(w http.ResponseWriter, r *http.Request) { + pat, ok := extractSCMToken(r) + if !ok { + writeError(w, http.StatusUnauthorized, "X-SCM-Token header is required") + return + } + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "streaming unsupported") + return + } + client := config.SCMRegistry.Client(scm.DefaultPlatform, pat) + ctx := r.Context() + + // Discover repos before switching to SSE so auth failures return a normal status. + repos, err := client.ListRepos(ctx) + if err != nil { + if errors.Is(err, scm.ErrUnauthorized) { + writeError(w, http.StatusUnauthorized, "invalid SCM token") + return + } + slog.Error("build watch: list repos failed", "err", err) + writeError(w, http.StatusBadGateway, "failed to list repositories") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + snap := buildStatusSnapshot(ctx, client, repos, nil) + data, err := json.Marshal(snap) + if err != nil { + slog.Error("build watch: marshal snapshot failed", "err", err) + return + } + if err := writeSnapshotEvent(w, data); err != nil { + return + } + flusher.Flush() + // The marshaled bytes double as the change-detection key. + prev := string(data) + // Last-known items, carried forward when a per-repo poll fails transiently. + prevItems := snapshotIndex(snap) + + poll := time.NewTicker(buildPollInterval) + defer poll.Stop() + rediscover := time.NewTicker(buildRediscoverInterval) + defer rediscover.Stop() + heartbeat := time.NewTicker(buildHeartbeatInterval) + defer heartbeat.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-heartbeat.C: + if _, err := io.WriteString(w, ":\n\n"); err != nil { + return + } + flusher.Flush() + case <-rediscover.C: + if latest, err := client.ListRepos(ctx); err != nil { + // A revoked/expired token makes this global call fail + // unambiguously. End the stream so the client's reconnect hits the + // initial ListRepos, gets a 401, and triggers its re-auth path; + // otherwise per-repo poll errors are carried forward and the client + // would show stale status indefinitely. + if errors.Is(err, scm.ErrUnauthorized) { + slog.Info("build watch: token no longer authorized, ending stream") + return + } + slog.Warn("build watch: rediscover failed", "err", err) + } else { + repos = latest + } + case <-poll.C: + next := buildStatusSnapshot(ctx, client, repos, prevItems) + data, err := json.Marshal(next) + if err != nil { + slog.Warn("build watch: marshal snapshot failed", "err", err) + continue + } + prevItems = snapshotIndex(next) + key := string(data) + if key == prev { + continue + } + prev = key + if err := writeSnapshotEvent(w, data); err != nil { + return + } + flusher.Flush() + } + } +} + +// writeSnapshotEvent writes the already-marshaled snapshot bytes as an SSE frame. +func writeSnapshotEvent(w io.Writer, data []byte) error { + if _, err := fmt.Fprintf(w, "event: build-status\ndata: %s\n\n", data); err != nil { + return fmt.Errorf("write build-status event: %w", err) + } + return nil +} diff --git a/backend/handler/build_test.go b/backend/handler/build_test.go new file mode 100644 index 00000000..b735f60f --- /dev/null +++ b/backend/handler/build_test.go @@ -0,0 +1,402 @@ +package handler + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openshift/faas-console-plugin/backend/scm" +) + +var _ = Describe("HandleBuildStatus", func() { + It("returns 401 without an SCM token", func() { + withSCMStub(&scm.ClientStub{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("maps a run to a build-status item keyed by owner/repo", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { + return &scm.WorkflowRun{Status: "in_progress", HeadSHA: "sha1", HTMLURL: "u"}, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var snap buildSnapshot + Expect(json.Unmarshal(w.Body.Bytes(), &snap)).To(Succeed()) + Expect(snap.Functions).To(HaveLen(1)) + Expect(snap.Functions[0].Key).To(Equal("alice/fn")) + Expect(snap.Functions[0].BuildStatus).To(Equal("Building")) + }) + + It("reports None when a repo has no runs", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { + return nil, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + var snap buildSnapshot + Expect(json.Unmarshal(w.Body.Bytes(), &snap)).To(Succeed()) + Expect(snap.Functions[0].BuildStatus).To(Equal("None")) + }) + + It("returns 401 when the SCM token is rejected", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return nil, scm.ErrUnauthorized + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("isolates a per-repo fetch error to that repo without leaking it", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{ + {Owner: "alice", Name: "bad", DefaultBranch: "main"}, + {Owner: "alice", Name: "good", DefaultBranch: "main"}, + }, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { + if repo == "bad" { + return nil, errors.New("boom") + } + return &scm.WorkflowRun{Status: "in_progress"}, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var snap buildSnapshot + Expect(json.Unmarshal(w.Body.Bytes(), &snap)).To(Succeed()) + Expect(snap.Functions).To(HaveLen(2)) + + byKey := map[string]buildStatusItem{} + for _, f := range snap.Functions { + byKey[f.Key] = f + } + Expect(byKey["alice/bad"].BuildStatus).To(Equal("None")) + Expect(byKey["alice/good"].BuildStatus).To(Equal("Building")) + }) +}) + +var _ = Describe("HandleBuildWatch", func() { + // pinIntervals makes the SSE timing fully explicit: a fast poll, and + // rediscover/heartbeat pushed far out so they never fire during a test. + pinIntervals := func() { + origPoll := buildPollInterval + origRediscover := buildRediscoverInterval + origHeartbeat := buildHeartbeatInterval + buildPollInterval = 10 * time.Millisecond + buildRediscoverInterval = time.Hour + buildHeartbeatInterval = time.Hour + DeferCleanup(func() { + buildPollInterval = origPoll + buildRediscoverInterval = origRediscover + buildHeartbeatInterval = origHeartbeat + }) + } + + It("emits an initial snapshot then a new snapshot on change", func() { + pinIntervals() + + var mu sync.Mutex + calls := 0 + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { + mu.Lock() + defer mu.Unlock() + calls++ + if calls == 1 { + return &scm.WorkflowRun{Status: "in_progress"}, nil + } + return &scm.WorkflowRun{Status: "completed", Conclusion: "failure", FailureReason: "build / test"}, nil + }, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /watch", (&Handlers{}).HandleBuildWatch) + ts := httptest.NewServer(mux) + DeferCleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/watch", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("X-SCM-Token", "pat") + resp, err := ts.Client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.Header.Get("Content-Type")).To(Equal("text/event-stream")) + + reader := bufio.NewReader(resp.Body) + first, ok := readSSEDataWithin(reader, 2*time.Second) + Expect(ok).To(BeTrue(), "expected an initial snapshot frame") + Expect(first).To(ContainSubstring(`"buildStatus":"Building"`)) + + second, ok := readSSEDataWithin(reader, 2*time.Second) + Expect(ok).To(BeTrue(), "expected a second snapshot frame on change") + Expect(second).To(ContainSubstring(`"buildStatus":"Failed"`)) + Expect(second).To(ContainSubstring(`"failureReason":"build / test"`)) + }) + + It("does not emit a second frame when the snapshot is unchanged", func() { + pinIntervals() + + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { + // Same state on every poll, so the change-detection key never moves. + return &scm.WorkflowRun{Status: "in_progress"}, nil + }, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /watch", (&Handlers{}).HandleBuildWatch) + ts := httptest.NewServer(mux) + DeferCleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/watch", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("X-SCM-Token", "pat") + resp, err := ts.Client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + + reader := bufio.NewReader(resp.Body) + first, ok := readSSEDataWithin(reader, 2*time.Second) + Expect(ok).To(BeTrue(), "expected an initial snapshot frame") + Expect(first).To(ContainSubstring(`"buildStatus":"Building"`)) + + // Wait well beyond several poll cycles (poll is 10ms). No new frame should arrive. + _, ok = readSSEDataWithin(reader, 300*time.Millisecond) + Expect(ok).To(BeFalse(), "expected no second frame while the snapshot is unchanged") + }) + + It("returns 401 without an SCM token", func() { + withSCMStub(&scm.ClientStub{}) + req := httptest.NewRequest(http.MethodGet, "/watch", nil) + w := httptest.NewRecorder() + (&Handlers{}).HandleBuildWatch(w, req) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("carries forward the last-known status when a poll errors transiently", func() { + pinIntervals() + + var mu sync.Mutex + calls := 0 + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { + mu.Lock() + defer mu.Unlock() + calls++ + if calls == 1 { + return &scm.WorkflowRun{Status: "in_progress"}, nil + } + return nil, errors.New("transient boom") + }, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /watch", (&Handlers{}).HandleBuildWatch) + ts := httptest.NewServer(mux) + DeferCleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/watch", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("X-SCM-Token", "pat") + resp, err := ts.Client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + + reader := bufio.NewReader(resp.Body) + first, ok := readSSEDataWithin(reader, 2*time.Second) + Expect(ok).To(BeTrue(), "expected an initial snapshot frame") + Expect(first).To(ContainSubstring(`"buildStatus":"Building"`)) + + // Subsequent polls error; the last-known "Building" is carried forward, so + // the change-detection key does not move and no new frame is emitted (no + // flicker back to "None", no error-string churn re-sends). + _, ok = readSSEDataWithin(reader, 300*time.Millisecond) + Expect(ok).To(BeFalse(), "expected no new frame while transient errors are carried forward") + }) + + It("ends the stream when the token is revoked mid-stream", func() { + // Fire rediscover quickly so the test does not wait a real interval. + origPoll := buildPollInterval + origRediscover := buildRediscoverInterval + origHeartbeat := buildHeartbeatInterval + buildPollInterval = 10 * time.Millisecond + buildRediscoverInterval = 10 * time.Millisecond + buildHeartbeatInterval = time.Hour + DeferCleanup(func() { + buildPollInterval = origPoll + buildRediscoverInterval = origRediscover + buildHeartbeatInterval = origHeartbeat + }) + + var mu sync.Mutex + listCalls := 0 + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + mu.Lock() + defer mu.Unlock() + listCalls++ + // The initial discovery succeeds; the token is then revoked, so + // every rediscover call is unauthorized. + if listCalls == 1 { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + } + return nil, scm.ErrUnauthorized + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { + return &scm.WorkflowRun{Status: "in_progress"}, nil + }, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /watch", (&Handlers{}).HandleBuildWatch) + ts := httptest.NewServer(mux) + DeferCleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/watch", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("X-SCM-Token", "pat") + resp, err := ts.Client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + + reader := bufio.NewReader(resp.Body) + first, ok := readSSEDataWithin(reader, 2*time.Second) + Expect(ok).To(BeTrue(), "expected an initial snapshot frame") + Expect(first).To(ContainSubstring(`"buildStatus":"Building"`)) + + // Once the token is revoked, the rediscover tick ends the stream, so the + // response body reaches EOF. + errCh := make(chan error, 1) + go func() { + for { + if _, err := reader.ReadString('\n'); err != nil { + errCh <- err + return + } + } + }() + select { + case err := <-errCh: + Expect(err).To(MatchError(io.EOF)) + case <-time.After(2 * time.Second): + Fail("expected the stream to close after the token was revoked") + } + }) +}) + +var _ = Describe("deriveBuildStatus", func() { + DescribeTable("maps run status and conclusion to a build status", + func(status, conclusion, expected string) { + Expect(deriveBuildStatus(&scm.WorkflowRun{Status: status, Conclusion: conclusion})).To(Equal(expected)) + }, + Entry("queued -> Building", "queued", "", "Building"), + Entry("in_progress -> Building", "in_progress", "", "Building"), + Entry("waiting -> Building", "waiting", "", "Building"), + Entry("requested -> Building", "requested", "", "Building"), + Entry("pending -> Building", "pending", "", "Building"), + Entry("completed+success -> Succeeded", "completed", "success", "Succeeded"), + Entry("completed+failure -> Failed", "completed", "failure", "Failed"), + Entry("completed+cancelled -> Failed", "completed", "cancelled", "Failed"), + Entry("completed+timed_out -> Failed", "completed", "timed_out", "Failed"), + Entry("completed+skipped -> None", "completed", "skipped", "None"), + Entry("completed+neutral -> None", "completed", "neutral", "None"), + Entry("completed+stale -> None", "completed", "stale", "None"), + Entry("completed+action_required -> None", "completed", "action_required", "None"), + Entry("unknown status -> None", "bogus", "", "None"), + ) + + It("maps a nil run to None", func() { + Expect(deriveBuildStatus(nil)).To(Equal("None")) + }) +}) + +// readSSEDataWithin runs readSSEData with a timeout so a handler that never +// emits fails fast instead of blocking until the spec timeout. It returns the +// payload and true on success, or "" and false if the timeout elapses first. +func readSSEDataWithin(reader *bufio.Reader, timeout time.Duration) (string, bool) { + ch := make(chan string, 1) + go func() { ch <- readSSEData(reader) }() + select { + case data := <-ch: + return data, true + case <-time.After(timeout): + return "", false + } +} + +// readSSEData reads frames until it finds one with a data: line and returns that payload. +func readSSEData(reader *bufio.Reader) string { + var data []string + for { + line, err := reader.ReadString('\n') + if err != nil { + return strings.Join(data, "\n") + } + line = strings.TrimRight(line, "\n") + if line == "" { + if len(data) > 0 { + return strings.Join(data, "\n") + } + continue // heartbeat or blank separator, keep reading + } + if strings.HasPrefix(line, "data:") { + data = append(data, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } +} diff --git a/backend/main.go b/backend/main.go index 4b954970..013c59b5 100644 --- a/backend/main.go +++ b/backend/main.go @@ -65,6 +65,8 @@ func main() { mux.HandleFunc("GET /api/v1/func/{owner}/{name}/files", h.HandleGetFiles) mux.HandleFunc("PUT /api/v1/func/{owner}/{name}/files", h.HandlePutFiles) mux.HandleFunc("POST /api/v1/func/create", h.HandleFuncCreate) + mux.HandleFunc("GET /api/v1/func/build/status", h.HandleBuildStatus) + mux.HandleFunc("GET /api/v1/func/build/watch", h.HandleBuildWatch) mux.Handle("/", http.FileServer(http.FS(static))) muxHandler := loggingMiddleware(mux) diff --git a/backend/scm/client.go b/backend/scm/client.go index 6f4e7cf4..83391c52 100644 --- a/backend/scm/client.go +++ b/backend/scm/client.go @@ -49,6 +49,7 @@ type Client interface { InitRepo(ctx context.Context, owner, name, branch string, topics []string) error StoreSecret(ctx context.Context, owner, repo, name, value string) error DeleteRepo(ctx context.Context, owner, repo string) error + LatestWorkflowRun(ctx context.Context, owner, repo, branch, workflowFile string) (*WorkflowRun, error) } type Repo struct { @@ -71,15 +72,28 @@ type FileEntry struct { Deleted bool `json:"deleted,omitempty"` } +// WorkflowRun is the latest GitHub Actions run of a specific workflow file on a +// repo branch. A nil *WorkflowRun means the workflow has no runs on that branch +// (including when the workflow file does not exist in the repo). +type WorkflowRun struct { + ID int64 + Status string // queued | in_progress | completed + Conclusion string // success | failure | cancelled | timed_out | "" + HeadSHA string + HTMLURL string + FailureReason string // set for failures: " / " summary +} + type ClientStub struct { - OnGetUser func(ctx context.Context) (*User, error) - OnListRepos func(ctx context.Context) ([]Repo, error) - OnGetFileContent func(ctx context.Context, owner, repo, ref, path string) (string, error) - OnGetFiles func(ctx context.Context, owner, repo, ref string) ([]FileEntry, error) - OnPushFiles func(ctx context.Context, owner, repo, branch, message string, files []FileEntry) error - OnInitRepo func(ctx context.Context, owner, name, branch string, topics []string) error - OnStoreSecret func(ctx context.Context, owner, repo, name, value string) error - OnDeleteRepo func(ctx context.Context, owner, repo string) error + OnGetUser func(ctx context.Context) (*User, error) + OnListRepos func(ctx context.Context) ([]Repo, error) + OnGetFileContent func(ctx context.Context, owner, repo, ref, path string) (string, error) + OnGetFiles func(ctx context.Context, owner, repo, ref string) ([]FileEntry, error) + OnPushFiles func(ctx context.Context, owner, repo, branch, message string, files []FileEntry) error + OnInitRepo func(ctx context.Context, owner, name, branch string, topics []string) error + OnStoreSecret func(ctx context.Context, owner, repo, name, value string) error + OnDeleteRepo func(ctx context.Context, owner, repo string) error + OnLatestWorkflowRun func(ctx context.Context, owner, repo, branch, workflowFile string) (*WorkflowRun, error) } func (s *ClientStub) GetUser(ctx context.Context) (*User, error) { @@ -137,3 +151,10 @@ func (s *ClientStub) DeleteRepo(ctx context.Context, owner, repo string) error { } return nil } + +func (s *ClientStub) LatestWorkflowRun(ctx context.Context, owner, repo, branch, workflowFile string) (*WorkflowRun, error) { + if s.OnLatestWorkflowRun != nil { + return s.OnLatestWorkflowRun(ctx, owner, repo, branch, workflowFile) + } + return nil, nil +} diff --git a/backend/scm/github/client.go b/backend/scm/github/client.go index 5b2f575e..71d41b43 100644 --- a/backend/scm/github/client.go +++ b/backend/scm/github/client.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "time" ghlib "github.com/google/go-github/v90/github" + "github.com/gregjones/httpcache" "golang.org/x/sync/errgroup" "github.com/openshift/faas-console-plugin/backend/scm" @@ -18,7 +20,19 @@ func New(pat string) scm.Client { } func NewWithBaseURL(pat, baseURL string) scm.Client { - httpClient := &http.Client{Timeout: 30 * time.Second} + // A per-client in-memory HTTP cache issues conditional requests + // (If-None-Match) using the ETags GitHub returns. When build status is + // unchanged the server replies 304 Not Modified, which does NOT count + // against the primary rate limit, so the 3s poll loop stays nearly free. + // The cache is scoped per client (one per PAT), so one user's cached + // responses are never served to another. + // + // forceRevalidate wraps the cache so every request revalidates instead of + // being served from GitHub's max-age freshness window. Without it a newly + // triggered build would stay hidden for up to ~60s; with it an unchanged + // status is still just a (free) 304, but a real change is seen immediately. + cacheTransport := httpcache.NewMemoryCacheTransport() + httpClient := &http.Client{Transport: &forceRevalidate{next: cacheTransport}, Timeout: 30 * time.Second} opts := []ghlib.ClientOptionsFunc{ ghlib.WithHTTPClient(httpClient), ghlib.WithAuthToken(pat), @@ -33,6 +47,23 @@ func NewWithBaseURL(pat, baseURL string) scm.Client { return &ghClient{client: client} } +// forceRevalidate sets Cache-Control: max-age=0 on every request so the +// underlying cache always revalidates with a conditional request rather than +// serving a still-"fresh" response from GitHub's max-age window. +type forceRevalidate struct { + next http.RoundTripper +} + +func (t *forceRevalidate) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set("Cache-Control", "max-age=0") + resp, err := t.next.RoundTrip(req) + if err != nil { + return nil, fmt.Errorf("forced-revalidation round trip: %w", err) + } + return resp, nil +} + type ghClient struct { client *ghlib.Client } @@ -48,6 +79,11 @@ func mapErr(err error) error { return err } +func isNotFound(err error) bool { + var ghErr *ghlib.ErrorResponse + return errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound +} + func isRepoExists(err error) bool { var ghErr *ghlib.ErrorResponse if !errors.As(err, &ghErr) || ghErr.Response == nil || ghErr.Response.StatusCode != http.StatusUnprocessableEntity { @@ -298,3 +334,61 @@ func (c *ghClient) DeleteRepo(ctx context.Context, owner, repo string) error { } return nil } + +func (c *ghClient) LatestWorkflowRun(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { + opts := &ghlib.ListWorkflowRunsOptions{ + Branch: branch, + ListOptions: ghlib.ListOptions{PerPage: 1}, + } + runs, _, err := c.client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowFile, opts) + if err != nil { + if isNotFound(err) { + // The workflow file does not exist in this repo (e.g. a non-func repo, + // or the func workflow has not been pushed yet). Treat it like a repo + // with no runs rather than surfacing an error. + return nil, nil + } + return nil, fmt.Errorf("list workflow runs for %s/%s (%s): %w", owner, repo, workflowFile, mapErr(err)) + } + if len(runs.WorkflowRuns) == 0 { + return nil, nil + } + + // GitHub returns runs in created_at descending order by default, so with + // PerPage 1 the single element WorkflowRuns[0] is the newest run. + run := runs.WorkflowRuns[0] + result := &scm.WorkflowRun{ + ID: run.GetID(), + Status: run.GetStatus(), + Conclusion: run.GetConclusion(), + HeadSHA: run.GetHeadSHA(), + HTMLURL: run.GetHTMLURL(), + } + if result.Conclusion == "failure" { + result.FailureReason = c.failureReason(ctx, owner, repo, result.ID) + } + return result, nil +} + +// failureReason returns a " / " summary of the first failed step, +// or the failing job name, or "" if it cannot be determined. Best-effort: never +// fails the caller. +func (c *ghClient) failureReason(ctx context.Context, owner, repo string, runID int64) string { + jobs, _, err := c.client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, nil) + if err != nil { + slog.Warn("failed to list workflow jobs", "repo", owner+"/"+repo, "run", runID, "err", err) + return "" + } + for _, job := range jobs.Jobs { + if job.GetConclusion() != "failure" { + continue + } + for _, step := range job.Steps { + if step.GetConclusion() == "failure" { + return job.GetName() + " / " + step.GetName() + } + } + return job.GetName() + } + return "" +} diff --git a/backend/scm/github/client_test.go b/backend/scm/github/client_test.go index 5a679e8a..ada0f221 100644 --- a/backend/scm/github/client_test.go +++ b/backend/scm/github/client_test.go @@ -639,4 +639,92 @@ var _ = Describe("GitHub SCM client", func() { Expect(secretBody["encrypted_value"]).NotTo(BeEmpty()) }) }) + + Describe("LatestWorkflowRun conditional requests", func() { + It("revalidates with If-None-Match and serves cached data on a 304", func() { + const runsPath = "/repos/alice/my-func/actions/workflows/func-deploy.yaml/runs" + var requestCount int + var conditional []string + cl := newClient(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, runsPath) { + return + } + requestCount++ + inm := r.Header.Get("If-None-Match") + conditional = append(conditional, inm) + + w.Header().Set("ETag", `"run-etag-v1"`) + // A "fresh" response (like GitHub's max-age=60). The client must + // still revalidate on every poll, otherwise a new build would be + // hidden behind this window. This guards the forceRevalidate wrap. + w.Header().Set("Cache-Control", "max-age=60") + + if inm == `"run-etag-v1"` { + w.WriteHeader(http.StatusNotModified) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "total_count": 1, + "workflow_runs": []map[string]any{ + { + "id": 42, + "status": "completed", + "conclusion": "success", + "head_sha": "abc123", + "html_url": "https://example.com/runs/42", + }, + }, + }) + }) + + first, err := cl.LatestWorkflowRun(context.Background(), "alice", "my-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(first).NotTo(BeNil()) + Expect(first.ID).To(Equal(int64(42))) + + second, err := cl.LatestWorkflowRun(context.Background(), "alice", "my-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(second).NotTo(BeNil()) + + // The first call was unconditional; the second sent If-None-Match and + // got a 304, yet still returned the same run data (served from cache). + Expect(requestCount).To(Equal(2)) + Expect(conditional[0]).To(BeEmpty()) + Expect(conditional[1]).To(Equal(`"run-etag-v1"`)) + Expect(*second).To(Equal(*first)) + }) + }) + + Describe("LatestWorkflowRun workflow scoping", func() { + It("queries only the given workflow file, not the whole repo", func() { + var gotPath string + cl := newClient(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + json.NewEncoder(w).Encode(map[string]any{ + "total_count": 1, + "workflow_runs": []map[string]any{ + {"id": 7, "status": "completed", "conclusion": "success"}, + }, + }) + }) + + run, err := cl.LatestWorkflowRun(context.Background(), "alice", "my-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + // The by-file-name endpoint scopes results to the func workflow; the + // repo-wide /actions/runs endpoint would leak unrelated workflows. + Expect(gotPath).To(Equal("/repos/alice/my-func/actions/workflows/func-deploy.yaml/runs")) + }) + + It("returns a nil run when the workflow file does not exist (404)", func() { + cl := newClient(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]any{"message": "Not Found"}) + }) + + run, err := cl.LatestWorkflowRun(context.Background(), "alice", "no-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) + }) }) diff --git a/docs/design/2026-08-26-SRVOCF-1038-build-status-design.md b/docs/design/2026-08-26-SRVOCF-1038-build-status-design.md new file mode 100644 index 00000000..d4521905 --- /dev/null +++ b/docs/design/2026-08-26-SRVOCF-1038-build-status-design.md @@ -0,0 +1,298 @@ +# SRVOCF-1038: Show deployment status and pipeline failures in the UI + +Status: Design (prototype) +Date: 2026-08-26 +Jira: SRVOCF-1038 (Story, parent SRVOCF-953) + +## Problem + +After "Save & Deploy", a function is pushed to GitHub, a GitHub Actions workflow +builds the image and deploys a Knative Service, and only then does the cluster +show status. Today function status comes entirely from `useCluster.ts` (a K8s +watch of the Knative Service + Deployment). That means: + +- The build window (queued, building, deploying) is invisible in the UI. +- A pipeline failure (compile error, image push error) is completely invisible: + the ksvc simply never appears and the row shows `NotDeployed` forever. + +This work surfaces GitHub Actions build status, including failure reasons, in the +functions list, and keeps the list current as a deployment progresses. + +## Scope (prototype) + +- Surface only in the **functions list** (no separate post-save detail view). +- Simple statuses only: build is **Building**, **Succeeded**, or **Failed**. + More granular states can come later. +- Live updates via **SSE**; plus a non-streaming snapshot endpoint. +- Deterministic testing via a scripted fake GitHub Actions API. + +## Lifecycle and status merge + +``` +push -> GH Actions run: queued -> in_progress -> completed(success|failure) + | + success -----------+--> ksvc -> Deployment ready -> Running + failure -----------/ (tracked by useCluster today) + (never reaches cluster; currently invisible) +``` + +GitHub Actions is authoritative during the build; the cluster is authoritative +after a successful deploy. The frontend merges the two per function: + +A function is treated as **available** when its cluster status is `Running` +(actively serving) or `ScaledToZero` (deployed and idle, cold-starts on demand). +Both have deployed successfully at least once, so a subsequent build is a rebuild. + +| GH Actions latest run | Cluster (useCluster) | Shown status | +|------------------------------|---------------------------|-----------------------| +| queued / in_progress | available (Running/ScaledToZero) | cluster status kept + secondary "build in progress" indicator | +| queued / in_progress | not available | `Building` | +| completed = failure | available (Running/ScaledToZero) | cluster status kept + secondary "build failed" indicator | +| completed = failure | not available | `BuildFailed` | +| completed = success, or none | (fall through) | existing cluster status (`Deploying`/`Running`/`ScaledToZero`/`Error`/`NotDeployed`) | + +Notes: +- The build status is **non-destructive** over an available function: a function + that is deployed and available (serving `Running`, or idle `ScaledToZero` that + cold-starts on demand) keeps its cluster status even while a new revision builds + or a rebuild fails, so availability is never misrepresented. The build activity + is surfaced as a small secondary indicator next to the status: a spinner + (tooltip "Build in progress") while building, or a red (danger-colored) warning + icon (tooltip "Latest build failed: ", link to the run) when the latest + build failed. The failed tooltip is phrased to make clear the function is still + available and only the latest rebuild failed, not the function itself. This + avoids flip-flopping an available function between its cluster status and + `Building`/`BuildFailed` on every redeploy. +- For a function that is **not** currently available, the build status is the most + useful thing to show, so `Building` (first deploy / redeploy of a stopped + function) and `BuildFailed` become the primary status. +- `BuildFailed` (and the secondary "build failed" indicator) carries a failure + reason and a link to the failing run. +- The backend returns a build-centric status (`Building`/`Succeeded`/`Failed`/`None`); + the merge to `FunctionStatus` happens in the frontend, which is the only place + that also has the cluster status. +- **Deferred: non-destructive treatment for a cluster `Error`.** A cluster `Error` + (ksvc `Ready=False`) means a deployed revision is broken, so a failed rebuild + currently overwrites it with `BuildFailed`, losing the runtime signal. Ideally it + would be handled like the available states (keep `Error` as primary, show the + build as a secondary indicator). The catch is that `Error` is overloaded: it also + covers a repo/list-level error (`FunctionListItem.err`, no cluster resource), + which should keep falling through to `BuildFailed` like `NotDeployed`. Doing this + correctly means gating the non-destructive branch on **cluster presence** (whether + a `ClusterFunction` exists), not on the status string. Deferred to a later change. + +## Transport decision: SSE over consoleFetch stream + +- Server-to-client push only, so SSE fits better than WebSocket (full-duplex we + would never use). +- The GitHub PAT lives only in the browser (sessionStorage) and reaches the + backend as the `X-SCM-Token` header. Both native `EventSource` and native + `WebSocket` cannot set custom headers, so they would force the PAT into a URL + query param or WS subprotocol. Reading an SSE stream with `consoleFetch` + + `ReadableStream` lets us send the PAT as a header cleanly. This is the same + pattern the OpenShift Lightspeed console plugin uses for streaming chat. +- SSE is plain `net/http` + `Flusher`: zero new backend dependencies, matching + the stdlib-only backend. WebSocket would need a third-party library. + +## Backend + +Stateless, matching the current design: no shared in-memory store (unlike the +removed SSE spike). Each request creates its own SCM client from the caller's +PAT; each SSE connection runs its own poll loop. + +### SCM interface (`backend/scm/client.go`) + +Add one method to `scm.Client`: + +```go +LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*WorkflowRun, error) +``` + +```go +type WorkflowRun struct { + ID int64 + Status string // queued | in_progress | completed + Conclusion string // success | failure | cancelled | timed_out | "" + HeadSHA string + HTMLURL string + FailureReason string // set for failures: " / " summary +} +``` + +Returns `nil, nil` when the repo has no runs on that branch (maps to `None`). + +### GitHub implementation (`backend/scm/github/client.go`) + +- `Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, &ListWorkflowRunsOptions{Branch: branch, ...})`, + take the most recent run. +- On `conclusion == "failure"`, call `Actions.ListWorkflowJobs` and compose + `FailureReason` from the first failed job and its first failed step. +- Errors mapped through the existing `mapErr` (401/403 -> `scm.ErrUnauthorized`). + +### Endpoints (`backend/handler/build.go`) + +Both are **parameterless and user-scoped**, mirroring `GET /api/v1/func/list`. +They require only the `X-SCM-Token` header and discover the caller's function +repos server-side the same way the list endpoint does (`ListRepos`, +`topic:serverless-function user:`), then fetch `LatestWorkflowRun` per repo +using each repo's default branch (already returned by `ListRepos`). No owner/ +name/branch params, no per-function URLs, one connection for the whole list. The +snapshot keys (`owner/repo`) match what `listFunctions` returns, so the frontend +merge is a direct key lookup. + +`GET /api/v1/func/build/status` (snapshot): + +```json +{ + "functions": [ + { + "key": "matejvasek/fn-testing-a", + "buildStatus": "Failed", + "conclusion": "failure", + "runURL": "https://github.com/.../actions/runs/123", + "failureReason": "build / go test", + "headSHA": "abc123" + } + ] +} +``` + +`buildStatus` is one of `Building | Succeeded | Failed | None`, derived from the run: +- `queued` / `in_progress` -> `Building` +- `completed` + `success` -> `Succeeded` +- `completed` + `failure|cancelled|timed_out` -> `Failed` +- no run -> `None` + +`GET /api/v1/func/build/watch` (SSE): +- Headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, + `Connection: keep-alive`, `X-Accel-Buffering: no`. +- First event is a full snapshot (same shape as above), then a full snapshot is + re-sent whenever any function's build status changes. +- Heartbeat comment (`:\n\n`) every 15s to survive proxy idle timeouts. +- Poll interval ~3s: each cycle fetches `LatestWorkflowRun` for every discovered + repo and re-emits the snapshot if anything changed. The user's function set is + discovered on connect and refreshed on a slower cadence (~30s) so newly created + or deleted functions appear/disappear without reconnecting. +- Exit on `r.Context().Done()` or on write error (detects client disconnect + without TCP close). Both learnings from the spike (c5a1455, f7059667). + +## Fake GitHub (`backend/fakegithub`) + +Add the Actions API surface plus an admin control to script runs deterministically. + +GitHub API: +- `GET /repos/{owner}/{repo}/actions/runs` -> `{ total_count, workflow_runs: [...] }`, + filtered by `?branch=`. Each run: `id, head_branch, head_sha, status, conclusion, + html_url, created_at`. +- `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs` -> `{ total_count, jobs: [...] }`, + each job: `id, name, status, conclusion, steps: [{ name, status, conclusion, number }]`. + +Admin control: +- `POST /_admin/actions/runs` with `{ owner, repo, branch, headSha, status, conclusion, jobs: [...] }` + creates/replaces the latest run for a repo. Lets tests drive queued -> + in_progress -> failed transitions with exact control. +- `POST /_admin/reset` also clears runs. + +State: add a `runs` slice (or latest-run field) to the in-memory `repo` struct. + +## Frontend + +### Client hook (`src/common/clients/useBuildStatus.ts`) + +`useBuildStatus()` (no arguments; the backend scopes to the user) opens the SSE +stream with `consoleFetch` (PAT in `X-SCM-Token`), reads the `ReadableStream` +body, parses `event: build-status` frames, and returns +`ReadonlyMap` where + +```ts +interface BuildStatus { + buildStatus: 'Building' | 'Succeeded' | 'Failed' | 'None'; + conclusion?: string; + runURL?: string; + failureReason?: string; +} +``` + +Handles reconnect on stream end/error with a small backoff (EventSource's +built-in reconnect is not available with fetch streaming). + +### List integration (`src/pages/function-list/FunctionsListPage.tsx`) + +`useFunctionListPage` calls `useBuildStatus()` alongside +`useCluster(functionNames)` and merges per the table above in `enrichItem`, +looking up each item's build status by its `owner/repo` key. + +### Types and rendering + +- `FunctionStatus` gains `Building` and `BuildFailed`. +- `FunctionTableItem` gains optional `buildRunURL`, `failureReason`, and + `buildActivity` (`'Building' | 'Failed'`, set only when the primary status is an + available cluster status (`Running`/`ScaledToZero`) that the build status must + not overwrite). +- `StatusCell` in `FunctionTable.tsx`: + - `Building` -> `ProgressStatus`. + - `BuildFailed` -> error style with a tooltip showing `failureReason` and a link + to `buildRunURL`. + - An available status (`Running` -> `SuccessStatus`, `ScaledToZero` -> + `InfoStatus`) with `buildActivity` renders the cluster badge plus a secondary + indicator: a spinner (tooltip "Build in progress") for `'Building'`, or a red + (danger-colored) warning icon (tooltip "Latest build failed: `failureReason`", + link to `buildRunURL`) for `'Failed'`. + +## Testing + +Follow `docs/TESTING.md` (red/green/refactor, one test at a time). + +Backend (Ginkgo/Gomega): +- `scm/github` client: `LatestWorkflowRun` happy path (in_progress, success) and + failure path (composes `FailureReason`). Use the existing github client test + harness; also manually cross-check against real GitHub repo + `matejvasek/fn-testing-a` (token in `gh-token.txt`) during development. +- `handler` build endpoints: snapshot maps runs to `buildStatus`; SSE emits an + initial snapshot then a new snapshot on change; `X-SCM-Token` required; error + mapping. Extend `scmStub` with a `LatestWorkflowRun` function field. +- `fakegithub`: the new Actions endpoints and `/_admin/actions/runs` (directly or + via the github client test that points at fakegithub). + +Frontend (Vitest + RTL): stub the network boundary, do not `vi.mock` our own +hook. Following the SRVOCF-822 precedent (the list test stubs +`useK8sWatchResource` and runs the real `useCluster`), add a reusable +`consoleFetchStreamStub` in `src/common/testing/` that returns a `Response` whose +body is a `ReadableStream` fed SSE frames. +- `useBuildStatus.test`: real hook against the stream stub; asserts frame parsing, + the returned map, and reconnect. +- `FunctionsListPage.test` / `FunctionTable.test`: real `useBuildStatus` via the + same stub (alongside the existing K8s stub); asserts the `Building`/`BuildFailed` + merge and rendering. This catches SSE-payload vs consumer shape drift. + +Pragmatic fallback if streaming in jsdom proves fiddly: stub `consoleFetch` +directly (still the boundary), never mock `useBuildStatus` itself. + +E2e (Playwright): run against the **real backend connected to fakegithub**, no +`page.route` mocking (e2e no longer mocks GitHub; it seeds/resets fakegithub via +`/_admin` in `e2e/helpers/fakegithub.ts`). Add a `setWorkflowRun(owner, name, +branch, run)` helper that POSTs to `/_admin/actions/runs`. A test seeds a repo, +scripts an `in_progress` run, loads the list and asserts the status column shows +`Building`, then scripts a `completed`/`failure` run and asserts the column +updates to `BuildFailed` with the failure reason and a link to the run. Because +the list streams over SSE, the update should appear without a manual refresh +(use `expect.poll` / `toBeVisible` with a timeout). + +## Implementation order + +1. fakegithub Actions endpoints + `/_admin/actions/runs` (foundation for tests + and manual cross-check). +2. `scm.Client.LatestWorkflowRun` + github implementation + unit tests; cross-check + against real GitHub. +3. Backend snapshot + SSE endpoints + handler tests; wire routes in `main.go`. +4. Frontend `useBuildStatus` hook, list merge, new statuses, `StatusCell` + + component tests. +5. E2e test. +6. Revisit backend for anything the frontend surfaces. + +## Out of scope (prototype) + +- Faithful workflow execution via `act` (deferred; `/_admin` scripting instead). +- Live streaming on any surface other than the list. +- Granular per-step progress beyond Building/Succeeded/Failed. +- Persisting build history. diff --git a/docs/plans/completed/2026-08-26-SRVOCF-1038-build-status.md b/docs/plans/completed/2026-08-26-SRVOCF-1038-build-status.md new file mode 100644 index 00000000..e212a3ab --- /dev/null +++ b/docs/plans/completed/2026-08-26-SRVOCF-1038-build-status.md @@ -0,0 +1,1748 @@ +# SRVOCF-1038: Build Status and Pipeline Failures Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface GitHub Actions build status (Building / Succeeded / Failed, with failure reasons) in the functions list, live-updated over SSE. + +**Architecture:** A new `LatestWorkflowRun` method on `scm.Client` (implemented for GitHub, faked in `fakegithub`) feeds two stateless, user-scoped backend endpoints: a JSON snapshot (`GET /api/v1/func/build/status`) and an SSE stream (`GET /api/v1/func/build/watch`) that re-emits the snapshot on change. The frontend `useBuildStatus()` hook reads the SSE stream via `consoleFetch` and the list page merges the build status onto the existing cluster status. + +**Tech Stack:** Go stdlib `net/http` + go-github v72 + Ginkgo/Gomega (backend); React + TypeScript + PatternFly 6 + Vitest/RTL (frontend); Playwright (e2e). + +**Design spec:** `docs/design/2026-08-26-SRVOCF-1038-build-status-design.md` + +--- + +## File Structure + +**Backend (create/modify):** +- `backend/fakegithub/server.go` (modify): add `runs` state, Actions endpoints, `POST /_admin/actions/runs`. +- `backend/fakegithub/server_test.go` (modify): tests for the Actions endpoints via the github client. +- `backend/scm/client.go` (modify): add `WorkflowRun` type and `LatestWorkflowRun` to the `Client` interface. +- `backend/scm/github/client.go` (modify): implement `LatestWorkflowRun` + failure-reason composition. +- `backend/handler/build.go` (create): snapshot + SSE handlers and shared helpers. +- `backend/handler/build_test.go` (create): handler tests. +- `backend/handler/handler_test.go` (modify): add `latestWorkflowRun` field to `scmStub`. +- `backend/main.go` (modify): wire the two new routes. + +**Frontend (create/modify):** +- `src/common/types.ts` (modify): extend `FunctionStatus`, add `BuildStatus`. +- `src/common/clients/useBuildStatus.ts` (create): the SSE client hook. +- `src/common/clients/useBuildStatus.test.tsx` (create): hook test. +- `src/common/testing/consoleFetchStreamStub.ts` (create): SSE stream stub. +- `src/pages/function-list/FunctionsListPage.tsx` (modify): call `useBuildStatus()` and merge. +- `src/pages/function-list/FunctionsListPage.test.tsx` (modify): wire the stream stub. +- `src/pages/function-list/components/FunctionTable.tsx` (modify): `Building`/`BuildFailed` rendering. + +**E2e (create/modify):** +- `e2e/helpers/fakegithub.ts` (modify): add `setWorkflowRun` helper. +- `e2e/use-cases/build-status/build-status.test.ts` (create): end-to-end test. + +--- + +## Phase 1: Fake GitHub Actions API + +### Task 1: Fake GitHub run state + admin control + +**Files:** +- Modify: `backend/fakegithub/server.go` +- Test: `backend/fakegithub/server_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `backend/fakegithub/server_test.go`, inside `Describe("Admin API", ...)` (after the existing `It`): + +```go + It("stores a scripted workflow run via /_admin/actions/runs", func() { + ts, cl := startServer() + seedRepo(ts) + + setWorkflowRun(ts, `{ + "owner": "testuser", "repo": "test-func", "branch": "main", + "headSha": "abc123", "status": "in_progress", "conclusion": "" + }`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + Expect(run.Status).To(Equal("in_progress")) + Expect(run.HeadSHA).To(Equal("abc123")) + }) + + It("clears workflow runs on reset", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{"owner":"testuser","repo":"test-func","branch":"main","status":"completed","conclusion":"success"}`) + + resetFakeGitHub(ts) + seedRepo(ts) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) +``` + +Add this helper next to `seedRepo` in the same file: + +```go +func setWorkflowRun(ts *httptest.Server, body string) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, ts.URL+"/_admin/actions/runs", strings.NewReader(body)) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", "application/json") + resp, err := ts.Client().Do(req) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + ExpectWithOffset(1, resp.StatusCode).To(Equal(200)) + resp.Body.Close() +} +``` + +> Note: this test also depends on `LatestWorkflowRun` (Task 4/5). It will not compile until those exist. If executing strictly one task at a time, temporarily assert via a raw HTTP GET to `/repos/testuser/test-func/actions/runs` instead, then switch to `cl.LatestWorkflowRun` after Task 5. The recommended path is to implement Tasks 1-5 as a group, running the fakegithub suite once `LatestWorkflowRun` lands. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./fakegithub/ -run TestFakeGitHub` +Expected: FAIL (compile error: `LatestWorkflowRun` undefined and/or 404 on `/_admin/actions/runs`). + +- [ ] **Step 3: Add run state to the `repo` struct and Server** + +In `backend/fakegithub/server.go`, add fields to `repo`: + +```go +type repo struct { + Owner string + Name string + DefaultBranch string + Topics []string + Files map[string]string // path -> content (source of truth) + Blobs map[string]string // sha -> content + Trees map[string][]treeEntry + Commits map[string]*commit + Refs map[string]string // "refs/heads/main" -> commit sha + Secrets map[string]string // name -> encrypted value + Runs []workflowRun // scripted GitHub Actions runs, most recent last +} +``` + +Add the run/job/step types (near the `commit` type): + +```go +type workflowRun struct { + ID int64 `json:"id"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + Status string `json:"status"` // queued | in_progress | completed + Conclusion string `json:"conclusion"` // success | failure | cancelled | timed_out | "" + HTMLURL string `json:"html_url"` + Jobs []workflowJob `json:"-"` // returned by the jobs endpoint, not the runs listing +} + +type workflowJob struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Steps []workflowStep `json:"steps"` +} + +type workflowStep struct { + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Number int `json:"number"` +} +``` + +Add a monotonic run-ID counter to `Server`: + +```go +type Server struct { + mu sync.Mutex + user User + pat string // required PAT for API routes (empty = no auth check) + repos map[string]*repo // "owner/name" -> repo + + pubKey [32]byte + privKey [32]byte + pubKeyB64 string + keyID string + + runIDSeq int64 // monotonic id source for scripted workflow runs + + mux *http.ServeMux +} +``` + +- [ ] **Step 4: Register routes and add the admin handler** + +In `routes()`, add under the Actions secrets section: + +```go + // Actions runs (build status) + s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/runs", s.handleListWorkflowRuns) + s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs", s.handleListWorkflowJobs) +``` + +And under the Admin API section: + +```go + s.mux.HandleFunc("POST /_admin/actions/runs", s.handleAdminSetRun) +``` + +Add the admin handler (near `handleAdminSeed`): + +```go +type adminRunRequest struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Branch string `json:"branch"` + HeadSHA string `json:"headSha"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Jobs []workflowJob `json:"jobs"` +} + +func (s *Server) handleAdminSetRun(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + var req adminRunRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid run request: "+err.Error()) + return + } + if req.Branch == "" { + req.Branch = "main" + } + + key := req.Owner + "/" + req.Repo + rp, ok := s.repos[key] + if !ok { + writeError(w, http.StatusNotFound, "repo not seeded: "+key) + return + } + + s.runIDSeq++ + run := workflowRun{ + ID: s.runIDSeq, + HeadBranch: req.Branch, + HeadSHA: req.HeadSHA, + Status: req.Status, + Conclusion: req.Conclusion, + HTMLURL: fmt.Sprintf("https://github.com/%s/actions/runs/%d", key, s.runIDSeq), + Jobs: req.Jobs, + } + // Replace the latest run for this branch, keep others. + kept := rp.Runs[:0:0] + for _, existing := range rp.Runs { + if existing.HeadBranch != req.Branch { + kept = append(kept, existing) + } + } + rp.Runs = append(kept, run) + + writeJSON(w, http.StatusOK, map[string]any{"status": "run set", "id": run.ID}) +} +``` + +Also clear runs in `handleAdminReset` (it already resets the whole `s.repos` map, so runs are cleared automatically; no change needed there since runs live on `repo`). + +- [ ] **Step 5: Run test to verify admin storage passes** + +Run: `cd backend && go test ./fakegithub/ -run TestFakeGitHub` +Expected: the two new admin specs still fail to compile until Tasks 4-5 land `LatestWorkflowRun`; the Actions GET endpoints (Task 2/3) are exercised by Task 5's client. Proceed to Task 2. + +- [ ] **Step 6: Commit** (after Tasks 1-3 in this phase compile together) + +```bash +git add backend/fakegithub/server.go backend/fakegithub/server_test.go +git commit -m "feat(fakegithub): add scripted workflow run state and admin control" +``` + +### Task 2: Fake GitHub `GET .../actions/runs` + +**Files:** +- Modify: `backend/fakegithub/server.go` + +- [ ] **Step 1: Add the runs listing handler** + +```go +func (s *Server) handleListWorkflowRuns(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + rp := s.getRepo(r) + if rp == nil { + writeError(w, http.StatusNotFound, "Not Found") + return + } + + branch := r.URL.Query().Get("branch") + // GitHub returns most-recent first; our slice keeps most-recent last, so reverse. + var runs []workflowRun + for i := len(rp.Runs) - 1; i >= 0; i-- { + run := rp.Runs[i] + if branch == "" || run.HeadBranch == branch { + runs = append(runs, run) + } + } + if runs == nil { + runs = []workflowRun{} + } + writeJSON(w, http.StatusOK, map[string]any{ + "total_count": len(runs), + "workflow_runs": runs, + }) +} +``` + +- [ ] **Step 2: Verify build** — `cd backend && go build ./...` → PASS. + +### Task 3: Fake GitHub `GET .../actions/runs/{run_id}/jobs` + +**Files:** +- Modify: `backend/fakegithub/server.go` (add `strconv` import) + +- [ ] **Step 1: Add the jobs listing handler** + +```go +func (s *Server) handleListWorkflowJobs(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + rp := s.getRepo(r) + if rp == nil { + writeError(w, http.StatusNotFound, "Not Found") + return + } + + runID, err := strconv.ParseInt(r.PathValue("run_id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid run id") + return + } + + for _, run := range rp.Runs { + if run.ID == runID { + jobs := run.Jobs + if jobs == nil { + jobs = []workflowJob{} + } + writeJSON(w, http.StatusOK, map[string]any{ + "total_count": len(jobs), + "jobs": jobs, + }) + return + } + } + writeError(w, http.StatusNotFound, "Not Found") +} +``` + +Add `"strconv"` to the import block. + +- [ ] **Step 2: Verify build** — `cd backend && go build ./...` → PASS. + +--- + +## Phase 2: SCM `LatestWorkflowRun` + +### Task 4: Extend the `scm.Client` interface + +**Files:** +- Modify: `backend/scm/client.go` +- Modify: `backend/handler/handler_test.go` (keep `scmStub` implementing the interface) + +- [ ] **Step 1: Add the type and interface method** + +In `backend/scm/client.go`, add to the `Client` interface (after `DeleteRepo`): + +```go + LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*WorkflowRun, error) +``` + +And add the type (after the `FileEntry` type): + +```go +// WorkflowRun is the latest GitHub Actions run for a repo branch. +// A nil *WorkflowRun means the branch has no runs. +type WorkflowRun struct { + ID int64 + Status string // queued | in_progress | completed + Conclusion string // success | failure | cancelled | timed_out | "" + HeadSHA string + HTMLURL string + FailureReason string // set for failures: " / " summary +} +``` + +- [ ] **Step 2: Add the stub field so tests still compile** + +In `backend/handler/handler_test.go`, add to `scmStub`: + +```go + latestWorkflowRun func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) +``` + +And add the method (after `DeleteRepo`): + +```go +func (s *scmStub) LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + if s.latestWorkflowRun != nil { + return s.latestWorkflowRun(ctx, owner, repo, branch) + } + return nil, nil +} +``` + +- [ ] **Step 3: Verify it fails to build** — `cd backend && go build ./...` +Expected: FAIL — `*ghClient` does not implement `scm.Client` (missing `LatestWorkflowRun`). Fixed in Task 5. + +### Task 5: GitHub `LatestWorkflowRun` (happy path) + +**Files:** +- Modify: `backend/scm/github/client.go` (add `log/slog` import) +- Test: `backend/fakegithub/server_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `backend/fakegithub/server_test.go` a new top-level `Describe` (before the closing of the outer `Describe`): + +```go + Describe("LatestWorkflowRun", func() { + It("returns nil when the repo has no runs", func() { + ts, cl := startServer() + seedRepo(ts) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) + + It("returns the latest in-progress run", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{"owner":"testuser","repo":"test-func","branch":"main","headSha":"sha1","status":"in_progress","conclusion":""}`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + Expect(run.Status).To(Equal("in_progress")) + Expect(run.Conclusion).To(BeEmpty()) + Expect(run.HeadSHA).To(Equal("sha1")) + Expect(run.HTMLURL).To(ContainSubstring("/actions/runs/")) + Expect(run.FailureReason).To(BeEmpty()) + }) + + It("filters by branch", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{"owner":"testuser","repo":"test-func","branch":"other","status":"completed","conclusion":"success"}`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) + }) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./fakegithub/ -run TestFakeGitHub` +Expected: FAIL (compile error until the method exists). + +- [ ] **Step 3: Implement `LatestWorkflowRun`** + +In `backend/scm/github/client.go`, add `"log/slog"` to the imports, then add: + +```go +func (c *ghClient) LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + opts := &ghlib.ListWorkflowRunsOptions{ + Branch: branch, + ListOptions: ghlib.ListOptions{PerPage: 1}, + } + runs, _, err := c.client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("list workflow runs for %s/%s: %w", owner, repo, mapErr(err)) + } + if len(runs.WorkflowRuns) == 0 { + return nil, nil + } + + run := runs.WorkflowRuns[0] + result := &scm.WorkflowRun{ + ID: run.GetID(), + Status: run.GetStatus(), + Conclusion: run.GetConclusion(), + HeadSHA: run.GetHeadSHA(), + HTMLURL: run.GetHTMLURL(), + } + if result.Conclusion == "failure" { + result.FailureReason = c.failureReason(ctx, owner, repo, result.ID) + } + return result, nil +} + +// failureReason returns a " / " summary of the first failed step, +// or the failing job name, or "" if it cannot be determined. Best-effort: never +// fails the caller. +func (c *ghClient) failureReason(ctx context.Context, owner, repo string, runID int64) string { + jobs, _, err := c.client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, nil) + if err != nil { + slog.Warn("failed to list workflow jobs", "repo", owner+"/"+repo, "run", runID, "err", err) + return "" + } + for _, job := range jobs.Jobs { + if job.GetConclusion() != "failure" { + continue + } + for _, step := range job.Steps { + if step.GetConclusion() == "failure" { + return job.GetName() + " / " + step.GetName() + } + } + return job.GetName() + } + return "" +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && go test ./fakegithub/ -run TestFakeGitHub` +Expected: PASS (all `LatestWorkflowRun` and admin specs green). + +- [ ] **Step 5: Commit** + +```bash +git add backend/scm/client.go backend/scm/github/client.go backend/handler/handler_test.go backend/fakegithub/server_test.go +git commit -m "feat(scm): add LatestWorkflowRun with GitHub Actions implementation" +``` + +### Task 6: GitHub `LatestWorkflowRun` failure reason + +**Files:** +- Test: `backend/fakegithub/server_test.go` + +- [ ] **Step 1: Write the failing test** + +Add inside the `Describe("LatestWorkflowRun", ...)` block: + +```go + It("composes a failure reason from the first failed step", func() { + ts, cl := startServer() + seedRepo(ts) + setWorkflowRun(ts, `{ + "owner":"testuser","repo":"test-func","branch":"main","headSha":"badsha", + "status":"completed","conclusion":"failure", + "jobs":[{ + "id":1,"name":"build","status":"completed","conclusion":"failure", + "steps":[ + {"name":"checkout","status":"completed","conclusion":"success","number":1}, + {"name":"go test","status":"completed","conclusion":"failure","number":2} + ] + }] + }`) + + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + Expect(run.Conclusion).To(Equal("failure")) + Expect(run.FailureReason).To(Equal("build / go test")) + }) +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `cd backend && go test ./fakegithub/ -run TestFakeGitHub` +Expected: PASS (implementation from Task 5 already composes the reason). + +- [ ] **Step 3: Commit** + +```bash +git add backend/fakegithub/server_test.go +git commit -m "test(scm): cover workflow-run failure reason composition" +``` + +> Manual cross-check (not automated): with the token in `gh-token.txt`, point a scratch program or a temporary test at real GitHub repo `matejvasek/fn-testing-a` and confirm `LatestWorkflowRun` returns sane values against the live Actions API. Do not commit the token or a live test. + +--- + +## Phase 3: Backend build endpoints + +### Task 7: Snapshot endpoint `GET /api/v1/func/build/status` + +**Files:** +- Create: `backend/handler/build.go` +- Test: `backend/handler/build_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `backend/handler/build_test.go`: + +```go +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openshift/faas-console-plugin/backend/scm" +) + +var _ = Describe("HandleBuildStatus", func() { + It("returns 401 without an SCM token", func() { + withSCMStub(&scmStub{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("maps a run to a build-status item keyed by owner/repo", func() { + withSCMStub(&scmStub{ + listRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + latestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + return &scm.WorkflowRun{Status: "in_progress", HeadSHA: "sha1", HTMLURL: "u"}, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var snap buildSnapshot + Expect(json.Unmarshal(w.Body.Bytes(), &snap)).To(Succeed()) + Expect(snap.Functions).To(HaveLen(1)) + Expect(snap.Functions[0].Key).To(Equal("alice/fn")) + Expect(snap.Functions[0].BuildStatus).To(Equal("Building")) + }) + + It("reports None when a repo has no runs", func() { + withSCMStub(&scmStub{ + listRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + latestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + return nil, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + var snap buildSnapshot + Expect(json.Unmarshal(w.Body.Bytes(), &snap)).To(Succeed()) + Expect(snap.Functions[0].BuildStatus).To(Equal("None")) + }) + + It("returns 401 when the SCM token is rejected", func() { + withSCMStub(&scmStub{ + listRepos: func(ctx context.Context) ([]scm.Repo, error) { + return nil, scm.ErrUnauthorized + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./handler/ -run TestHandler` +Expected: FAIL (compile error: `HandleBuildStatus`, `buildSnapshot` undefined). + +> The handler suite entrypoint already exists (Ginkgo `RunSpecs`). If the run name differs, run `cd backend && go test ./handler/`. + +- [ ] **Step 3: Create `backend/handler/build.go`** + +```go +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/openshift/faas-console-plugin/backend/config" + "github.com/openshift/faas-console-plugin/backend/scm" +) + +// Tunable so tests can drive the SSE loop quickly. +var ( + buildPollInterval = 3 * time.Second + buildRediscoverInterval = 30 * time.Second + buildHeartbeatInterval = 15 * time.Second +) + +type buildStatusItem struct { + Key string `json:"key"` + BuildStatus string `json:"buildStatus"` // Building | Succeeded | Failed | None + Conclusion string `json:"conclusion,omitempty"` + RunURL string `json:"runURL,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + HeadSHA string `json:"headSHA,omitempty"` +} + +type buildSnapshot struct { + Functions []buildStatusItem `json:"functions"` +} + +func deriveBuildStatus(run *scm.WorkflowRun) string { + if run == nil { + return "None" + } + switch run.Status { + case "queued", "in_progress": + return "Building" + case "completed": + if run.Conclusion == "success" { + return "Succeeded" + } + return "Failed" + default: + return "None" + } +} + +func toBuildStatusItem(key string, run *scm.WorkflowRun) buildStatusItem { + item := buildStatusItem{Key: key, BuildStatus: deriveBuildStatus(run)} + if run != nil { + item.Conclusion = run.Conclusion + item.RunURL = run.HTMLURL + item.FailureReason = run.FailureReason + item.HeadSHA = run.HeadSHA + } + return item +} + +// buildStatusSnapshot fetches the latest run for each repo and builds a sorted snapshot. +func buildStatusSnapshot(ctx context.Context, client scm.Client, repos []scm.Repo) buildSnapshot { + items := make([]buildStatusItem, len(repos)) + g, ctx := errgroup.WithContext(ctx) + g.SetLimit(10) + for i, repo := range repos { + g.Go(func() error { + key := repo.Owner + "/" + repo.Name + run, err := client.LatestWorkflowRun(ctx, repo.Owner, repo.Name, repo.DefaultBranch) + if err != nil { + slog.Warn("failed to get workflow run", "repo", key, "err", err) + items[i] = buildStatusItem{Key: key, BuildStatus: "None"} + return nil + } + items[i] = toBuildStatusItem(key, run) + return nil + }) + } + _ = g.Wait() + sort.Slice(items, func(i, j int) bool { return items[i].Key < items[j].Key }) + return buildSnapshot{Functions: items} +} + +func (h *Handlers) HandleBuildStatus(w http.ResponseWriter, r *http.Request) { + pat, ok := extractSCMToken(r) + if !ok { + writeError(w, http.StatusUnauthorized, "X-SCM-Token header is required") + return + } + client := config.SCMRegistry.Client(scm.DefaultPlatform, pat) + + repos, err := client.ListRepos(r.Context()) + if err != nil { + if errors.Is(err, scm.ErrUnauthorized) { + writeError(w, http.StatusUnauthorized, "invalid SCM token") + return + } + slog.Error("build status: list repos failed", "err", err) + writeError(w, http.StatusBadGateway, "failed to list repositories") + return + } + + snap := buildStatusSnapshot(r.Context(), client, repos) + writeJSON(w, http.StatusOK, snap) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && go test ./handler/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/handler/build.go backend/handler/build_test.go +git commit -m "feat(handler): add build status snapshot endpoint" +``` + +### Task 8: SSE endpoint `GET /api/v1/func/build/watch` + +**Files:** +- Modify: `backend/handler/build.go` +- Test: `backend/handler/build_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `backend/handler/build_test.go` (add imports `"bufio"`, `"strings"`, `"sync"`, `"time"`): + +```go +var _ = Describe("HandleBuildWatch", func() { + It("emits an initial snapshot then a new snapshot on change", func() { + origPoll := buildPollInterval + origHeartbeat := buildHeartbeatInterval + buildPollInterval = 10 * time.Millisecond + buildHeartbeatInterval = time.Hour + DeferCleanup(func() { + buildPollInterval = origPoll + buildHeartbeatInterval = origHeartbeat + }) + + var mu sync.Mutex + calls := 0 + withSCMStub(&scmStub{ + listRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + latestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + mu.Lock() + defer mu.Unlock() + calls++ + if calls == 1 { + return &scm.WorkflowRun{Status: "in_progress"}, nil + } + return &scm.WorkflowRun{Status: "completed", Conclusion: "failure", FailureReason: "build / test"}, nil + }, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /watch", (&Handlers{}).HandleBuildWatch) + ts := httptest.NewServer(mux) + DeferCleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/watch", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("X-SCM-Token", "pat") + resp, err := ts.Client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.Header.Get("Content-Type")).To(Equal("text/event-stream")) + + reader := bufio.NewReader(resp.Body) + first := readSSEData(reader) + Expect(first).To(ContainSubstring(`"buildStatus":"Building"`)) + + second := readSSEData(reader) + Expect(second).To(ContainSubstring(`"buildStatus":"Failed"`)) + Expect(second).To(ContainSubstring(`"failureReason":"build / test"`)) + }) + + It("returns 401 without an SCM token", func() { + withSCMStub(&scmStub{}) + req := httptest.NewRequest(http.MethodGet, "/watch", nil) + w := httptest.NewRecorder() + (&Handlers{}).HandleBuildWatch(w, req) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) + +// readSSEData reads frames until it finds one with a data: line and returns that payload. +func readSSEData(reader *bufio.Reader) string { + var data []string + for { + line, err := reader.ReadString('\n') + if err != nil { + return strings.Join(data, "\n") + } + line = strings.TrimRight(line, "\n") + if line == "" { + if len(data) > 0 { + return strings.Join(data, "\n") + } + continue // heartbeat or blank separator, keep reading + } + if strings.HasPrefix(line, "data:") { + data = append(data, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./handler/` +Expected: FAIL (compile error: `HandleBuildWatch` undefined). + +- [ ] **Step 3: Implement `HandleBuildWatch`** + +Append to `backend/handler/build.go`: + +```go +func (h *Handlers) HandleBuildWatch(w http.ResponseWriter, r *http.Request) { + pat, ok := extractSCMToken(r) + if !ok { + writeError(w, http.StatusUnauthorized, "X-SCM-Token header is required") + return + } + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "streaming unsupported") + return + } + client := config.SCMRegistry.Client(scm.DefaultPlatform, pat) + ctx := r.Context() + + // Discover repos before switching to SSE so auth failures return a normal status. + repos, err := client.ListRepos(ctx) + if err != nil { + if errors.Is(err, scm.ErrUnauthorized) { + writeError(w, http.StatusUnauthorized, "invalid SCM token") + return + } + slog.Error("build watch: list repos failed", "err", err) + writeError(w, http.StatusBadGateway, "failed to list repositories") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + snap := buildStatusSnapshot(ctx, client, repos) + if err := writeSnapshotEvent(w, snap); err != nil { + return + } + flusher.Flush() + prev := snapshotKey(snap) + + poll := time.NewTicker(buildPollInterval) + defer poll.Stop() + rediscover := time.NewTicker(buildRediscoverInterval) + defer rediscover.Stop() + heartbeat := time.NewTicker(buildHeartbeatInterval) + defer heartbeat.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-heartbeat.C: + if _, err := io.WriteString(w, ":\n\n"); err != nil { + return + } + flusher.Flush() + case <-rediscover.C: + if latest, err := client.ListRepos(ctx); err != nil { + slog.Warn("build watch: rediscover failed", "err", err) + } else { + repos = latest + } + case <-poll.C: + next := buildStatusSnapshot(ctx, client, repos) + key := snapshotKey(next) + if key == prev { + continue + } + prev = key + if err := writeSnapshotEvent(w, next); err != nil { + return + } + flusher.Flush() + } + } +} + +func writeSnapshotEvent(w io.Writer, snap buildSnapshot) error { + data, err := json.Marshal(snap) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "event: build-status\ndata: %s\n\n", data) + return err +} + +func snapshotKey(snap buildSnapshot) string { + data, _ := json.Marshal(snap) + return string(data) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && go test ./handler/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/handler/build.go backend/handler/build_test.go +git commit -m "feat(handler): add build status SSE watch endpoint" +``` + +### Task 9: Wire routes in `main.go` + +**Files:** +- Modify: `backend/main.go` + +- [ ] **Step 1: Add the routes** + +After the `mux.HandleFunc("POST /api/v1/func/create", ...)` line, add: + +```go + mux.HandleFunc("GET /api/v1/func/build/status", h.HandleBuildStatus) + mux.HandleFunc("GET /api/v1/func/build/watch", h.HandleBuildWatch) +``` + +- [ ] **Step 2: Verify build** — `cd backend && go build ./...` → PASS. + +- [ ] **Step 3: Commit** + +```bash +git add backend/main.go +git commit -m "feat(backend): wire build status routes" +``` + +> Ask the user to restart the dev environment (`hack/dev.sh`) before manual verification; you cannot restart it yourself. + +--- + +## Phase 4: Frontend + +### Task 10: Types + +**Files:** +- Modify: `src/common/types.ts` + +- [ ] **Step 1: Extend `FunctionStatus`** + +Replace the `FunctionStatus` union with: + +```ts +export type FunctionStatus = + | 'CreatingRepo' + | 'Pushing' + | 'PushedToGitHub' + | 'Building' + | 'Deploying' + | 'Running' + | 'ScaledToZero' + | 'Error' + | 'BuildFailed' + | 'Unknown' + | 'NotDeployed'; +``` + +- [ ] **Step 2: Add the `BuildStatus` type** + +Append to `src/common/types.ts`: + +```ts +export interface BuildStatus { + buildStatus: 'Building' | 'Succeeded' | 'Failed' | 'None'; + conclusion?: string; + runURL?: string; + failureReason?: string; +} +``` + +- [ ] **Step 3: Verify typecheck** — `npm run type-check` (or `npx tsc --noEmit`) → PASS. + +### Task 11: `consoleFetchStreamStub` testing helper + +**Files:** +- Create: `src/common/testing/consoleFetchStreamStub.ts` + +- [ ] **Step 1: Create the stub** + +```ts +// Test double for the SSE stream consumed by useBuildStatus. +// Mirrors the setFixtures pattern in useK8sWatchResourceStub.ts: +// a module-level fixture that tests set, and a stub function wired into +// the mocked consoleFetch. + +let frames: string[] = []; +let keepOpen = false; + +export function setStreamFrames(newFrames: string[], opts?: { keepOpen?: boolean }) { + frames = newFrames; + keepOpen = opts?.keepOpen ?? false; +} + +export function resetStreamFrames() { + frames = []; + keepOpen = false; +} + +// buildStatusFrame formats a single SSE build-status event. +export function buildStatusFrame(functions: unknown[]): string { + return `event: build-status\ndata: ${JSON.stringify({ functions })}\n\n`; +} + +// consoleFetchStub matches the consoleFetch signature used by useBuildStatus: +// consoleFetch(url, options) -> Promise. +export const consoleFetchStub = (_url: string, options?: RequestInit): Promise => { + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const frame of frames) { + controller.enqueue(encoder.encode(frame)); + } + if (!keepOpen) { + controller.close(); + return; + } + const signal = options?.signal; + if (signal) { + signal.addEventListener('abort', () => { + try { + controller.close(); + } catch { + // already closed + } + }); + } + }, + }); + return Promise.resolve(new Response(stream, { status: 200 })); +}; +``` + +- [ ] **Step 2: Verify typecheck** — `npx tsc --noEmit` → PASS. + +### Task 12: `useBuildStatus` hook + +**Files:** +- Create: `src/common/clients/useBuildStatus.ts` +- Test: `src/common/clients/useBuildStatus.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/common/clients/useBuildStatus.test.tsx`: + +```tsx +import { renderHook, waitFor } from '@testing-library/react'; +import { PAT_KEY } from '../types'; + +const streamStub = await vi.hoisted( + async () => import('../testing/consoleFetchStreamStub'), +); + +vi.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + consoleFetch: streamStub.consoleFetchStub, +})); + +import { useBuildStatus } from './useBuildStatus'; + +describe('useBuildStatus', () => { + beforeEach(() => { + sessionStorage.setItem(PAT_KEY, 'test-pat'); + streamStub.resetStreamFrames(); + }); + + afterEach(() => { + sessionStorage.clear(); + }); + + it('parses a build-status frame into a keyed map', async () => { + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { key: 'alice/fn', buildStatus: 'Building' }, + { key: 'alice/gn', buildStatus: 'Failed', failureReason: 'build / test', runURL: 'u' }, + ]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(2)); + expect(result.current.get('alice/fn')?.buildStatus).toBe('Building'); + expect(result.current.get('alice/gn')?.failureReason).toBe('build / test'); + }); + + it('ignores heartbeat comment frames', async () => { + streamStub.setStreamFrames([ + ':\n\n', + streamStub.buildStatusFrame([{ key: 'alice/fn', buildStatus: 'Succeeded' }]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.get('alice/fn')?.buildStatus).toBe('Succeeded'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/common/clients/useBuildStatus.test.tsx` +Expected: FAIL (cannot resolve `./useBuildStatus`). + +- [ ] **Step 3: Create `src/common/clients/useBuildStatus.ts`** + +```ts +import { consoleFetch } from '@openshift-console/dynamic-plugin-sdk'; +import { useEffect, useState } from 'react'; +import { BuildStatus, PAT_KEY, PROXY_BASE } from '../types'; + +const RECONNECT_DELAY_MS = 3000; + +interface BuildStatusItem { + key: string; + buildStatus: BuildStatus['buildStatus']; + conclusion?: string; + runURL?: string; + failureReason?: string; + headSHA?: string; +} + +interface BuildSnapshot { + functions: BuildStatusItem[]; +} + +// useBuildStatus streams per-function GitHub Actions build status over SSE and +// returns it keyed by "owner/repo". The backend scopes the stream to the +// authenticated user, so no arguments are required. +export function useBuildStatus(): ReadonlyMap { + const [statuses, setStatuses] = useState>(new Map()); + + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + + async function run() { + while (!cancelled) { + const pat = sessionStorage.getItem(PAT_KEY); + if (!pat) return; + try { + const res = await consoleFetch(`${PROXY_BASE}/api/v1/func/build/watch`, { + headers: { 'X-SCM-Token': pat }, + signal: controller.signal, + }); + if (!res.body) return; + await readStream(res.body, (snap) => { + if (!cancelled) setStatuses(toMap(snap)); + }); + } catch { + if (cancelled) return; + } + // Stream ended or errored; back off, then reconnect. + await delay(RECONNECT_DELAY_MS, controller.signal); + } + } + + run(); + return () => { + cancelled = true; + controller.abort(); + }; + }, []); + + return statuses; +} + +async function readStream( + body: ReadableStream, + onSnapshot: (snap: BuildSnapshot) => void, +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const snap = parseFrame(frame); + if (snap) onSnapshot(snap); + } + } +} + +function parseFrame(frame: string): BuildSnapshot | null { + let event = ''; + const dataLines: string[] = []; + for (const line of frame.split('\n')) { + if (line.startsWith(':')) continue; // heartbeat / comment + if (line.startsWith('event:')) event = line.slice('event:'.length).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).trim()); + } + if (event && event !== 'build-status') return null; + if (dataLines.length === 0) return null; + try { + return JSON.parse(dataLines.join('\n')) as BuildSnapshot; + } catch { + return null; + } +} + +function toMap(snap: BuildSnapshot): ReadonlyMap { + return new Map( + (snap.functions ?? []).map((f) => [ + f.key, + { + buildStatus: f.buildStatus, + conclusion: f.conclusion, + runURL: f.runURL, + failureReason: f.failureReason, + }, + ]), + ); +} + +function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) return resolve(); + const id = setTimeout(resolve, ms); + signal.addEventListener('abort', () => { + clearTimeout(id); + resolve(); + }); + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/common/clients/useBuildStatus.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/common/types.ts src/common/testing/consoleFetchStreamStub.ts src/common/clients/useBuildStatus.ts src/common/clients/useBuildStatus.test.tsx +git commit -m "feat(frontend): add useBuildStatus SSE hook" +``` + +### Task 13: List page merge + +**Files:** +- Modify: `src/pages/function-list/components/FunctionTable.tsx` (add fields to `FunctionTableItem`) +- Modify: `src/pages/function-list/FunctionsListPage.tsx` + +- [ ] **Step 1: Extend `FunctionTableItem`** + +In `src/pages/function-list/components/FunctionTable.tsx`, extend the interface: + +```ts +export interface FunctionTableItem { + name: string; + repoName: string; + owner: string; + runtime: string; + status: FunctionStatus; + url: string; + replicas: number; + namespace: string; + mainResource?: K8sResourceCommon; + buildRunURL?: string; + failureReason?: string; +} +``` + +- [ ] **Step 2: Wire `useBuildStatus` into the list hook** + +In `src/pages/function-list/FunctionsListPage.tsx`: + +Add imports: + +```ts +import { useBuildStatus } from '../../common/clients/useBuildStatus'; +import { BuildStatus, ClusterFunction, FunctionListItem } from '../../common/types'; +``` + +(Replace the existing `ClusterFunction, FunctionListItem` import line with the one above.) + +In `newItem`, carry `owner`: + +```ts +function newItem(item: FunctionListItem): FunctionTableItem { + return { + name: item.name || item.repoName, + repoName: item.repoName, + owner: item.owner, + namespace: item.namespace, + runtime: item.runtime, + status: item.err ? 'Error' : 'NotDeployed', + url: '', + replicas: 0, + }; +} +``` + +Replace the `functions` memo (and add the `buildStatuses` call just above it): + +```ts + const { functions: clusterFunctions, loaded: clusterLoaded } = useCluster(functionNames); + const buildStatuses = useBuildStatus(); + + const functions = useMemo( + () => + functionItems.map((item) => { + const cf = clusterFunctions.get(item.name); + const enriched = cf ? enrichItem(item, cf) : item; + const build = buildStatuses.get(`${item.owner}/${item.repoName}`); + return build ? mergeBuild(enriched, build) : enriched; + }), + [functionItems, clusterFunctions, buildStatuses], + ); +``` + +Add the merge helper (next to `enrichItem`): + +```ts +function mergeBuild(item: FunctionTableItem, build: BuildStatus): FunctionTableItem { + if (build.buildStatus === 'Building') { + return { ...item, status: 'Building' }; + } + if (build.buildStatus === 'Failed' && item.status !== 'Running') { + return { + ...item, + status: 'BuildFailed', + buildRunURL: build.runURL, + failureReason: build.failureReason, + }; + } + // Succeeded / None: fall through to the cluster-derived status. + return item; +} +``` + +- [ ] **Step 3: Verify typecheck** — `npx tsc --noEmit` → PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/function-list/FunctionsListPage.tsx src/pages/function-list/components/FunctionTable.tsx +git commit -m "feat(frontend): merge build status into the functions list" +``` + +### Task 14: `StatusCell` rendering + list test + +**Files:** +- Modify: `src/pages/function-list/components/FunctionTable.tsx` +- Modify: `src/pages/function-list/FunctionsListPage.test.tsx` + +- [ ] **Step 1: Write the failing list test** + +In `src/pages/function-list/FunctionsListPage.test.tsx`, add a hoisted stream stub import next to the existing `clusterStub` hoist: + +```tsx +const streamStub = await vi.hoisted( + async () => import('../../common/testing/consoleFetchStreamStub'), +); +``` + +Add `consoleFetch` to the mocked SDK (inside the `@openshift-console/dynamic-plugin-sdk` factory's returned object, alongside `consoleFetchJSON`): + +```tsx + consoleFetch: streamStub.consoleFetchStub, +``` + +Reset frames in `beforeEach` (after `authenticateGithubFake()`): + +```tsx + streamStub.resetStreamFrames(); +``` + +Add a test (inside `describe('FunctionsListPage', ...)`): + +```tsx + it('shows BuildFailed with the failure reason from the build stream', async () => { + listFunctionsStub({ response: repoListItem(funcName) }); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { + key: `twoGiants/${funcName}`, + buildStatus: 'Failed', + failureReason: 'build / go test', + runURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }, + ]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Error: BuildFailed')).toBeInTheDocument(); + }); +``` + +> Confirm `repoListItem`'s owner is `twoGiants` (the fake auth login). If it differs, use the actual owner from `repoListItem` when building the key. Read the existing `repoListItem` helper in this test file before writing the key. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/pages/function-list/FunctionsListPage.test.tsx` +Expected: FAIL (no `Error: BuildFailed` text; `StatusCell` has no `BuildFailed` case). + +- [ ] **Step 3: Update `StatusCell`** + +In `src/pages/function-list/components/FunctionTable.tsx`: + +Add `Tooltip` to the PatternFly import: + +```ts +import { ActionList, ActionListItem, Button, Tooltip } from '@patternfly/react-core'; +``` + +Change the `StatusCell` invocation in the table body to pass the item: + +```tsx + + + +``` + +Replace `StatusCell`: + +```tsx +function StatusCell({ + status, + failureReason, + buildRunURL, +}: { + status: FunctionStatus; + failureReason?: string; + buildRunURL?: string; +}) { + const { t } = useTranslation('plugin__console-functions-plugin'); + + switch (status) { + case 'Running': + return ; + case 'Building': + case 'Deploying': + case 'CreatingRepo': + case 'Pushing': + case 'PushedToGitHub': + return ; + case 'Error': + return ; + case 'BuildFailed': { + const badge = ; + const withLink = buildRunURL ? ( + + {badge} + + ) : ( + badge + ); + return {withLink}; + } + case 'ScaledToZero': + case 'NotDeployed': + return ; + case 'Unknown': + return } />; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/pages/function-list/` +Expected: PASS (new test and existing tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/function-list/components/FunctionTable.tsx src/pages/function-list/FunctionsListPage.test.tsx +git commit -m "feat(frontend): render Building and BuildFailed statuses" +``` + +--- + +## Phase 5: E2e + +### Task 15: `setWorkflowRun` helper + build-status e2e test + +**Files:** +- Modify: `e2e/helpers/fakegithub.ts` +- Create: `e2e/use-cases/build-status/build-status.test.ts` + +- [ ] **Step 1: Add the `setWorkflowRun` helper** + +Append to `e2e/helpers/fakegithub.ts`: + +```ts +interface WorkflowStep { + name: string; + status: string; + conclusion: string; + number: number; +} + +interface WorkflowJob { + id: number; + name: string; + status: string; + conclusion: string; + steps: WorkflowStep[]; +} + +interface WorkflowRunInput { + headSha?: string; + status: string; // queued | in_progress | completed + conclusion?: string; // success | failure | ... + jobs?: WorkflowJob[]; +} + +export async function setWorkflowRun( + owner: string, + name: string, + branch: string, + run: WorkflowRunInput, +): Promise { + const url = fakeGithubUrl(); + const resp = await fetch(`${url}/_admin/actions/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner, + repo: name, + branch, + headSha: run.headSha ?? '', + status: run.status, + conclusion: run.conclusion ?? '', + jobs: run.jobs ?? [], + }), + }); + if (!resp.ok) { + throw new Error( + `Failed to set workflow run for ${owner}/${name} in fake GitHub: ${resp.status} ${await resp.text()}`, + ); + } +} +``` + +- [ ] **Step 2: Write the e2e test** + +Create `e2e/use-cases/build-status/build-status.test.ts`: + +```ts +import { test, expect } from '../../fixtures/authenticated-page'; +import { navigateToFunctionsList } from '../../helpers/navigation'; +import { seedRepo, setWorkflowRun } from '../../helpers/fakegithub'; +import { E2E_USER } from '../../helpers/constants'; + +const FUNC_NAME = 'build-status-func'; + +test.describe('Build status', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + await seedRepo(E2E_USER, FUNC_NAME, 'main', ['serverless-function'], [ + { + path: 'func.yaml', + mode: '100644', + content: `name: ${FUNC_NAME}\nruntime: node\nnamespace: default\n`, + }, + ]); + }); + + test('reflects an in-progress run then a failure over SSE', async ({ page }) => { + await setWorkflowRun(E2E_USER, FUNC_NAME, 'main', { + headSha: 'sha-building', + status: 'in_progress', + }); + + await navigateToFunctionsList(page); + const grid = page.getByRole('grid', { name: 'Functions' }); + await expect(grid).toBeVisible({ timeout: 30_000 }); + + const row = grid.locator('tbody tr').filter({ hasText: FUNC_NAME }); + await expect(row.getByText('Building')).toBeVisible({ timeout: 30_000 }); + + // Script a failure; the list streams over SSE, so it should update without a refresh. + await setWorkflowRun(E2E_USER, FUNC_NAME, 'main', { + headSha: 'sha-failed', + status: 'completed', + conclusion: 'failure', + jobs: [ + { + id: 1, + name: 'build', + status: 'completed', + conclusion: 'failure', + steps: [ + { name: 'checkout', status: 'completed', conclusion: 'success', number: 1 }, + { name: 'go test', status: 'completed', conclusion: 'failure', number: 2 }, + ], + }, + ], + }); + + await expect(row.getByText('BuildFailed')).toBeVisible({ timeout: 30_000 }); + }); +}); +``` + +- [ ] **Step 3: Run the e2e test** + +Run: `npm run test:e2e -- build-status` (confirm the exact e2e invocation in `package.json`; use `make dev-fake-gh` env as other e2e tests require). +Expected: PASS. The `Building` state appears on load, and `BuildFailed` appears after the second `setWorkflowRun` without navigating again. + +> The dev environment must be running with fakegithub. Ask the user to start/restart it (`make dev-fake-gh` / `hack/dev.sh`); you cannot start it yourself. + +- [ ] **Step 4: Commit** + +```bash +git add e2e/helpers/fakegithub.ts e2e/use-cases/build-status/build-status.test.ts +git commit -m "test(e2e): cover build status Building and BuildFailed over SSE" +``` + +--- + +## Phase 6: Revisit + +### Task 16: Reconcile backend with frontend findings + +- [ ] **Step 1:** Review whether the frontend needs any snapshot field not currently emitted (e.g. an explicit `branch` for key disambiguation). If so, add it to `buildStatusItem` and the `BuildStatusItem` TS interface together, with a test on each side. +- [ ] **Step 2:** Confirm the SSE reconnect behavior is acceptable in the running dev environment (watch the network panel: stream stays open, heartbeats arrive, status changes propagate). Note any tuning of `buildPollInterval` needed for GitHub rate limits. +- [ ] **Step 3:** Run the full suites: `cd backend && go test ./...` and `npx vitest run`. Both green. +- [ ] **Step 4:** Move this plan to `docs/plans/completed/` when the story is done. + +--- + +## Notes on conventions + +- No em dashes in code comments or docs (project style). +- Backend: stdlib + go-github only; SSE is `net/http` + `http.Flusher`, no new deps. +- Tests: red/green/refactor, one test at a time; stub the network boundary, never `vi.mock` our own hooks (SRVOCF-822 precedent). +- E2e runs against the real backend connected to fakegithub; no `page.route` GitHub mocking. diff --git a/e2e/helpers/fakegithub.ts b/e2e/helpers/fakegithub.ts index bc520971..b5aa5db7 100644 --- a/e2e/helpers/fakegithub.ts +++ b/e2e/helpers/fakegithub.ts @@ -71,3 +71,52 @@ export async function deleteRepoOnFakeGithub(owner: string, name: string): Promi ); } } + +interface WorkflowStep { + name: string; + status: string; + conclusion: string; + number: number; +} + +interface WorkflowJob { + id: number; + name: string; + status: string; + conclusion: string; + steps: WorkflowStep[]; +} + +interface WorkflowRunInput { + headSha?: string; + status: string; // queued | in_progress | completed + conclusion?: string; // success | failure | ... + jobs?: WorkflowJob[]; +} + +export async function setWorkflowRun( + owner: string, + name: string, + branch: string, + run: WorkflowRunInput, +): Promise { + const url = fakeGithubUrl(); + const resp = await fetch(`${url}/_admin/actions/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner, + repo: name, + branch, + headSha: run.headSha ?? '', + status: run.status, + conclusion: run.conclusion ?? '', + jobs: run.jobs ?? [], + }), + }); + if (!resp.ok) { + throw new Error( + `Failed to set workflow run for ${owner}/${name} in fake GitHub: ${resp.status} ${await resp.text()}`, + ); + } +} diff --git a/e2e/use-cases/list/build-status.test.ts b/e2e/use-cases/list/build-status.test.ts new file mode 100644 index 00000000..02b3e17a --- /dev/null +++ b/e2e/use-cases/list/build-status.test.ts @@ -0,0 +1,87 @@ +import { test, expect } from '../../fixtures/authenticated-page'; +import { navigateToFunctionsList } from '../../helpers/navigation'; +import { deleteRepoOnFakeGithub, seedRepo, setWorkflowRun } from '../../helpers/fakegithub'; +import { E2E_USER } from '../../helpers/constants'; + +const FUNC_NAME = 'build-status-func'; +const BRANCH = 'main'; +const FAILURE_REASON = 'build / go test'; + +test.describe('Build status', () => { + test.beforeEach(async () => { + await seedRepo( + E2E_USER, + FUNC_NAME, + BRANCH, + ['serverless-function'], + [ + { + path: 'func.yaml', + mode: '100644', + content: `name: ${FUNC_NAME}\nruntime: node\nnamespace: default\n`, + }, + ], + ); + }); + + test.afterEach(async () => { + await deleteRepoOnFakeGithub(E2E_USER, FUNC_NAME); + }); + + test('reflects an in-progress run then a failure over SSE', async ({ page }) => { + await test.step('script an in-progress run', async () => { + await setWorkflowRun(E2E_USER, FUNC_NAME, BRANCH, { + headSha: 'sha-building', + status: 'in_progress', + }); + }); + + await test.step('navigate to functions list and verify Building', async () => { + await navigateToFunctionsList(page); + const grid = page.getByRole('grid', { name: 'Functions' }); + await expect(grid).toBeVisible({ timeout: 30_000 }); + + const row = grid.locator(`tbody tr:has(td:text-is("${FUNC_NAME}"))`); + await expect(row).toBeVisible(); + await expect(row.getByText('Building')).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('script a failing run and verify BuildFailed updates over SSE', async () => { + // The list streams over SSE (~3s poll cadence), so the status should update + // without a manual refresh. + await setWorkflowRun(E2E_USER, FUNC_NAME, BRANCH, { + headSha: 'sha-failed', + status: 'completed', + conclusion: 'failure', + jobs: [ + { + id: 1, + name: 'build', + status: 'completed', + conclusion: 'failure', + steps: [ + { name: 'checkout', status: 'completed', conclusion: 'success', number: 1 }, + { name: 'go test', status: 'completed', conclusion: 'failure', number: 2 }, + ], + }, + ], + }); + + const grid = page.getByRole('grid', { name: 'Functions' }); + const row = grid.locator(`tbody tr:has(td:text-is("${FUNC_NAME}"))`); + await expect(row.getByText('BuildFailed')).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('verify a link to the run and the failure reason', async () => { + const grid = page.getByRole('grid', { name: 'Functions' }); + const row = grid.locator(`tbody tr:has(td:text-is("${FUNC_NAME}"))`); + + const runLink = row.locator('a[href*="/actions/runs/"]'); + await expect(runLink).toBeVisible(); + + // The failure reason is surfaced via a tooltip on the status badge. + await runLink.hover(); + await expect(page.getByRole('tooltip')).toContainText(FAILURE_REASON, { timeout: 20_000 }); + }); + }); +}); diff --git a/locales/en/plugin__console-functions-plugin.json b/locales/en/plugin__console-functions-plugin.json index b608da89..d0144d72 100644 --- a/locales/en/plugin__console-functions-plugin.json +++ b/locales/en/plugin__console-functions-plugin.json @@ -6,6 +6,8 @@ "Add key/value": "Add key/value", "Back to Functions": "Back to Functions", "Branch": "Branch", + "Build failed": "Build failed", + "Build in progress": "Build in progress", "Cancel": "Cancel", "Click \"Create new function\", choose a runtime, and add any environment variables (plain values or from a secret). Submitting creates a GitHub repository, pushes the function scaffold, and starts a GitHub Actions workflow that deploys the function to your cluster. It appears here as \"NotDeployed\" until the workflow finishes, then the status changes to \"Running\".": "Click \"Create new function\", choose a runtime, and add any environment variables (plain values or from a secret). Submitting creates a GitHub repository, pushes the function scaffold, and starts a GitHub Actions workflow that deploys the function to your cluster. It appears here as \"NotDeployed\" until the workflow finishes, then the status changes to \"Running\".", "Close": "Close", @@ -37,6 +39,8 @@ "GitHub Settings": "GitHub Settings", "Key": "Key", "Language": "Language", + "Latest build failed": "Latest build failed", + "Latest build failed: {{reason}}": "Latest build failed: {{reason}}", "Leave": "Leave", "Loading": "Loading", "Name": "Name", diff --git a/src/common/clients/useBuildStatus.test.tsx b/src/common/clients/useBuildStatus.test.tsx new file mode 100644 index 00000000..2354e9c2 --- /dev/null +++ b/src/common/clients/useBuildStatus.test.tsx @@ -0,0 +1,151 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { PAT_KEY } from '../types'; + +const streamStub = await vi.hoisted(async () => import('../testing/consoleFetchStreamStub')); + +vi.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + consoleFetch: streamStub.consoleFetchStub, +})); + +import { useBuildStatus } from './useBuildStatus'; + +describe('useBuildStatus', () => { + beforeEach(() => { + sessionStorage.setItem(PAT_KEY, 'test-pat'); + streamStub.resetStreamFrames(); + }); + + afterEach(() => { + sessionStorage.clear(); + vi.useRealTimers(); + }); + + it('parses a build-status frame into a keyed map', async () => { + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { key: 'alice/fn', buildStatus: 'Building' }, + { key: 'alice/gn', buildStatus: 'Failed', failureReason: 'build / test', runURL: 'u' }, + ]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(2)); + expect(result.current.get('alice/fn')?.buildStatus).toBe('Building'); + expect(result.current.get('alice/gn')?.failureReason).toBe('build / test'); + }); + + it('opens the stream with the request timeout disabled', async () => { + // consoleFetch applies a default ~60s timeout that aborts the request. For a + // long-lived SSE stream that would tear the connection down every minute + // regardless of heartbeats, so the hook must pass timeout 0 to disable it. + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([{ key: 'alice/fn', buildStatus: 'Building' }]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(streamStub.streamFetchLastArgs()[2]).toBe(0); + }); + + it('ignores heartbeat comment frames', async () => { + streamStub.setStreamFrames([ + ':\n\n', + streamStub.buildStatusFrame([{ key: 'alice/fn', buildStatus: 'Succeeded' }]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.get('alice/fn')?.buildStatus).toBe('Succeeded'); + }); + + it('reassembles a frame split across two stream chunks', async () => { + // A single build-status frame delivered as two separate reader.read() chunks; + // the split falls in the middle of the JSON payload ("func" | "tions"). + streamStub.setStreamFrames([ + 'event: build-status\ndata: {"func', + 'tions":[{"key":"a/b","buildStatus":"Building"}]}\n\n', + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.get('a/b')?.buildStatus).toBe('Building'); + }); + + it('applies the last snapshot when two frames arrive in one chunk', async () => { + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([{ key: 'a/b', buildStatus: 'Building' }]) + + streamStub.buildStatusFrame([{ key: 'a/b', buildStatus: 'Failed' }]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.get('a/b')?.buildStatus).toBe('Failed'); + }); + + it('stops reconnecting after an auth failure', async () => { + vi.useFakeTimers(); + streamStub.setStreamError(Object.assign(new Error('unauthorized'), { code: 401 })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { unmount } = renderHook(() => useBuildStatus()); + // Advance well past the 3s backoff window; a stopped stream must not retry. + await vi.advanceTimersByTimeAsync(10_000); + + expect(streamStub.streamFetchCalls()).toBe(1); + expect(errorSpy).toHaveBeenCalled(); + + unmount(); + }); + + it('restarts the stream when connectionId changes', async () => { + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([{ key: 'alice/fn', buildStatus: 'Building' }]), + ]); + + const { rerender } = renderHook(({ connectionId }) => useBuildStatus(connectionId), { + initialProps: { connectionId: 1 }, + }); + + await waitFor(() => expect(streamStub.streamFetchCalls()).toBe(1)); + + // A new connection (initial login or account switch) must tear down the old + // stream and open a fresh one carrying the new user's PAT. + rerender({ connectionId: 2 }); + + await waitFor(() => expect(streamStub.streamFetchCalls()).toBe(2)); + }); + + it('reconnects after a body-less response instead of stopping', async () => { + vi.useFakeTimers(); + streamStub.setNullBodyForNext(1); // first connect yields a 2xx with no body + + const { unmount } = renderHook(() => useBuildStatus()); + // A body-less response must not permanently stop the stream: after the 3s + // backoff the hook reconnects rather than giving up. + await vi.advanceTimersByTimeAsync(10_000); + + expect(streamStub.streamFetchCalls()).toBeGreaterThan(1); + + unmount(); + }); + + it('reconnects with backoff after a transient stream error', async () => { + vi.useFakeTimers(); + streamStub.setStreamError(new Error('network blip')); // no status code -> transient + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { unmount } = renderHook(() => useBuildStatus()); + // 0ms + retries at 3s/6s/9s within the window. + await vi.advanceTimersByTimeAsync(10_000); + + expect(streamStub.streamFetchCalls()).toBeGreaterThan(1); + expect(errorSpy).toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/src/common/clients/useBuildStatus.ts b/src/common/clients/useBuildStatus.ts new file mode 100644 index 00000000..cdfee373 --- /dev/null +++ b/src/common/clients/useBuildStatus.ts @@ -0,0 +1,161 @@ +import { consoleFetch } from '@openshift-console/dynamic-plugin-sdk'; +import { useEffect, useState } from 'react'; +import { BuildStatus, PAT_KEY, PROXY_BASE } from '../types'; + +const RECONNECT_DELAY_MS = 3000; + +interface BuildStatusItem { + key: string; + buildStatus: BuildStatus['buildStatus']; + conclusion?: string; + runURL?: string; + failureReason?: string; +} + +interface BuildSnapshot { + functions: BuildStatusItem[]; +} + +// useBuildStatus streams per-function GitHub Actions build status over SSE and +// returns it keyed by "owner/repo". The backend scopes the stream to the +// authenticated user. Pass the auth connectionId so the stream tears down and +// reconnects (with the current PAT) on in-place login and account switch. +export function useBuildStatus(connectionId = 0): ReadonlyMap { + const [statuses, setStatuses] = useState>(() => new Map()); + + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + + async function run() { + while (!cancelled) { + const pat = sessionStorage.getItem(PAT_KEY); + if (!pat) return; + try { + // Pass timeout 0 to disable consoleFetch's default (~60s) request + // timeout: it aborts the request when it fires, which would tear down + // this long-lived SSE stream every minute regardless of the backend's + // heartbeats. Our own AbortController (signal below) remains the only + // thing that ends the stream, on unmount or connectionId change. + const res = await consoleFetch( + `${PROXY_BASE}/api/v1/func/build/watch`, + { + headers: { 'X-SCM-Token': pat }, + signal: controller.signal, + }, + 0, + ); + // A 2xx with no body is unexpected; fall through to backoff-and-reconnect + // below rather than permanently stopping the stream. + if (res.body) { + await readStream(res.body, (snap) => { + if (!cancelled) setStatuses(toMap(snap)); + }); + } + } catch (err) { + if (cancelled) return; + if (isAuthError(err)) { + // A bad or expired PAT will not recover on retry, so stop the stream + // instead of reconnecting in a tight loop. The console has no logger + // utility, so we surface the diagnostic via console.error. + console.error( + 'useBuildStatus: build status stream unauthorized, not reconnecting', + err, + ); + return; + } + // Transient stream/network error: log so it is not silent, then fall + // through to the backoff-and-reconnect below. + console.error('useBuildStatus: build status stream error, reconnecting', err); + } + // Stream ended or errored transiently; back off, then reconnect. + await delay(RECONNECT_DELAY_MS, controller.signal); + } + } + + run(); + return () => { + cancelled = true; + controller.abort(); + }; + }, [connectionId]); + + return statuses; +} + +async function readStream( + body: ReadableStream, + onSnapshot: (snap: BuildSnapshot) => void, +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const snap = parseFrame(frame); + if (snap) onSnapshot(snap); + } + } +} + +function parseFrame(frame: string): BuildSnapshot | null { + let event = ''; + const dataLines: string[] = []; + for (const line of frame.split('\n')) { + if (line.startsWith(':')) continue; // heartbeat / comment + if (line.startsWith('event:')) event = line.slice('event:'.length).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).trim()); + } + if (event && event !== 'build-status') return null; + if (dataLines.length === 0) return null; + try { + return JSON.parse(dataLines.join('\n')) as BuildSnapshot; + } catch { + return null; + } +} + +function toMap(snap: BuildSnapshot): ReadonlyMap { + return new Map( + (snap.functions ?? []).map((f) => [ + f.key, + { + buildStatus: f.buildStatus, + conclusion: f.conclusion, + runURL: f.runURL, + failureReason: f.failureReason, + }, + ]), + ); +} + +// isAuthError reports whether a consoleFetch failure is a 401/403. consoleFetch +// throws an HttpError carrying the status on `code`; we also check `response.status` +// defensively without depending on the SDK error class at runtime. +function isAuthError(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const e = err as { code?: number; response?: { status?: number } }; + const status = e.code ?? e.response?.status; + return status === 401 || status === 403; +} + +function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) return resolve(); + const onAbort = () => { + clearTimeout(id); + resolve(); + }; + const id = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/src/common/testing/consoleFetchStreamStub.ts b/src/common/testing/consoleFetchStreamStub.ts new file mode 100644 index 00000000..604858fa --- /dev/null +++ b/src/common/testing/consoleFetchStreamStub.ts @@ -0,0 +1,79 @@ +// Test double for the SSE stream consumed by useBuildStatus. +// Mirrors the setFixtures pattern in useK8sWatchResourceStub.ts: +// a module-level fixture that tests set, and a stub function wired into +// the mocked consoleFetch. +// +// Each element of `frames` is enqueued as a separate ReadableStream chunk, so +// tests can split a single SSE frame across chunk boundaries to exercise the +// hook's cross-read buffering. + +let frames: string[] = []; +let error: unknown = null; +let calls = 0; +let nullBodyCalls = 0; +let lastArgs: unknown[] = []; + +export function setStreamFrames(newFrames: string[]) { + frames = newFrames; + error = null; +} + +// setNullBodyForNext makes the next n consoleFetch calls resolve 2xx with a null +// body, simulating a body-less response the hook must recover from. +export function setNullBodyForNext(n: number) { + nullBodyCalls = n; +} + +// setStreamError makes the next consoleFetch reject, simulating an HTTP or +// network failure. Attach a `code` (HTTP status) to simulate an auth failure. +export function setStreamError(err: unknown) { + error = err; +} + +export function resetStreamFrames() { + frames = []; + error = null; + calls = 0; + nullBodyCalls = 0; + lastArgs = []; +} + +// streamFetchCalls reports how many times the stubbed consoleFetch was invoked, +// so tests can assert reconnect versus stop behaviour. +export function streamFetchCalls(): number { + return calls; +} + +// streamFetchLastArgs reports the arguments of the most recent consoleFetch call +// (url, options, timeout), so tests can assert how the request was configured. +export function streamFetchLastArgs(): unknown[] { + return lastArgs; +} + +// buildStatusFrame formats a single SSE build-status event. +export function buildStatusFrame(functions: unknown[]): string { + return `event: build-status\ndata: ${JSON.stringify({ functions })}\n\n`; +} + +// consoleFetchStub stands in for consoleFetch(url, options, timeout): it records +// its arguments and serves the configured frames (or error) as the response body. +export const consoleFetchStub = (...args: unknown[]): Promise => { + calls++; + lastArgs = args; + if (error) return Promise.reject(error); + if (nullBodyCalls > 0) { + nullBodyCalls--; + return Promise.resolve(new Response(null, { status: 200 })); + } + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const chunk of frames) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); + return Promise.resolve(new Response(stream, { status: 200 })); +}; diff --git a/src/common/types.ts b/src/common/types.ts index c3a5bbdc..d2853386 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -77,10 +77,12 @@ export type FunctionStatus = | 'CreatingRepo' | 'Pushing' | 'PushedToGitHub' + | 'Building' | 'Deploying' | 'Running' | 'ScaledToZero' | 'Error' + | 'BuildFailed' | 'Unknown' | 'NotDeployed'; @@ -92,3 +94,10 @@ export interface ClusterFunction { readonly replicas: number; readonly mainResource: K8sResourceCommon; } + +export interface BuildStatus { + buildStatus: 'Building' | 'Succeeded' | 'Failed' | 'None'; + conclusion?: string; + runURL?: string; + failureReason?: string; +} diff --git a/src/pages/function-list/FunctionsListPage.test.tsx b/src/pages/function-list/FunctionsListPage.test.tsx index 2a37be82..e74abd09 100644 --- a/src/pages/function-list/FunctionsListPage.test.tsx +++ b/src/pages/function-list/FunctionsListPage.test.tsx @@ -11,6 +11,10 @@ import FunctionsListPage from './FunctionsListPage'; // https://vitest.dev/api/vi.html#vi-hoisted const sdkTestDoubles = await vi.hoisted(async () => import('../../common/testing/sdkTestDoubles')); +const streamStub = await vi.hoisted( + async () => import('../../common/testing/consoleFetchStreamStub'), +); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); @@ -33,6 +37,7 @@ vi.mock('@openshift-console/dynamic-plugin-sdk', async () => { ), consoleFetchJSON, + consoleFetch: streamStub.consoleFetchStub, SuccessStatus: ({ title }: { title: string }) => `Success: ${title}`, ProgressStatus: ({ title }: { title: string }) => `Progress: ${title}`, ErrorStatus: ({ title }: { title: string }) => `Error: ${title}`, @@ -51,6 +56,7 @@ describe('FunctionsListPage', () => { beforeEach(() => { logoutGithubFake(); authenticateGithubFake(); + streamStub.resetStreamFrames(); }); afterEach(() => { @@ -301,6 +307,131 @@ describe('FunctionsListPage', () => { expect(await screen.findByText('Error: Error')).toBeInTheDocument(); }); + it('shows Building as the primary status when the function is not running', async () => { + // No cluster fixture, so the function is NotDeployed: the build status is the + // most useful thing to show, so Building becomes the primary status. + listFunctionsStub({ responses: [repoListItem(funcName)] }); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([{ key: `twoGiants/${funcName}`, buildStatus: 'Building' }]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Progress: Building')).toBeInTheDocument(); + }); + + it('keeps Running with a build-in-progress indicator when the cluster is Running', async () => { + // Non-destructive: a serving function keeps its green Running status while a + // new revision builds; the build is surfaced only as a secondary spinner. + listFunctionsStub({ responses: [repoListItem(funcName)] }); + sdkTestDoubles.setWatchFixtures(sdkTestDoubles.funcFixture(funcName)); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([{ key: `twoGiants/${funcName}`, buildStatus: 'Building' }]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Success: Running')).toBeInTheDocument(); + expect(screen.getByLabelText('Build in progress')).toBeInTheDocument(); + expect(screen.queryByText('Progress: Building')).not.toBeInTheDocument(); + }); + + it('keeps Running with a build-failed indicator when the cluster is Running', async () => { + listFunctionsStub({ responses: [repoListItem(funcName)] }); + sdkTestDoubles.setWatchFixtures(sdkTestDoubles.funcFixture(funcName)); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { + key: `twoGiants/${funcName}`, + buildStatus: 'Failed', + failureReason: 'build / go test', + runURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }, + ]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Success: Running')).toBeInTheDocument(); + expect(screen.queryByText('Error: BuildFailed')).not.toBeInTheDocument(); + // The failed rebuild is surfaced as a secondary indicator linking to the run. + expect(await screen.findByRole('link', { name: 'Latest build failed' })).toHaveAttribute( + 'href', + 'https://github.com/twoGiants/my-func/actions/runs/1', + ); + }); + + it('keeps ScaledToZero with a build-failed indicator when the cluster is scaled to zero', async () => { + // A scaled-to-zero function is deployed and available (idle, cold-starts on + // demand), so a failed rebuild must not overwrite it with BuildFailed. + listFunctionsStub({ responses: [repoListItem(funcName)] }); + sdkTestDoubles.setWatchFixtures({ + knSvcs: [sdkTestDoubles.ksvcFixture(funcName, 'True')], + deps: [sdkTestDoubles.deploymentFixture(funcName, 0, 0)], + }); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { + key: `twoGiants/${funcName}`, + buildStatus: 'Failed', + failureReason: 'build / go test', + runURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }, + ]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Info: ScaledToZero')).toBeInTheDocument(); + expect(screen.queryByText('Error: BuildFailed')).not.toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Latest build failed' })).toHaveAttribute( + 'href', + 'https://github.com/twoGiants/my-func/actions/runs/1', + ); + }); + + it('shows BuildFailed with the failure reason and run link from the build stream', async () => { + listFunctionsStub({ responses: [repoListItem(funcName)] }); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { + key: `twoGiants/${funcName}`, + buildStatus: 'Failed', + failureReason: 'build / go test', + runURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }, + ]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Error: BuildFailed')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Error: BuildFailed' })).toHaveAttribute( + 'href', + 'https://github.com/twoGiants/my-func/actions/runs/1', + ); + }); + it('uses func.yaml name instead of repo name for cluster matching', async () => { listFunctionsStub({ responses: [repoListItem('my-repo', funcName, 'demo', 'node')] }); sdkTestDoubles.setWatchFixtures(sdkTestDoubles.funcFixture(funcName)); diff --git a/src/pages/function-list/FunctionsListPage.tsx b/src/pages/function-list/FunctionsListPage.tsx index 5bbcf463..9312516a 100644 --- a/src/pages/function-list/FunctionsListPage.tsx +++ b/src/pages/function-list/FunctionsListPage.tsx @@ -25,8 +25,9 @@ import { FunctionTable, FunctionTableItem } from './components/FunctionTable'; import { SetupGuide } from './components/SetupGuide'; import { UserAvatar } from '../../common/components/UserAvatar'; import { AuthContext, AuthProvider } from '../../common/context/AuthProvider'; -import { ClusterFunction, FunctionListItem } from '../../common/types'; +import { BuildStatus, ClusterFunction, FunctionListItem } from '../../common/types'; import { useCluster } from '../../common/clients/useCluster'; +import { useBuildStatus } from '../../common/clients/useBuildStatus'; import { listFunctions } from '../../common/clients/functionsClient'; import { errorMessage } from '../../common/utils/utils'; @@ -210,15 +211,18 @@ function useFunctionListPage(): { // to 'get resources from all namespaces' isAllNamespacesKey(namespace) ? undefined : namespace, ); + const buildStatuses = useBuildStatus(connectionId); const functions = useMemo( () => functionItems.map((item) => { // keyed by namespace/name - the same function name can exist in multiple namespaces const cf = clusterFunctions.get(`${item.namespace}/${item.name}`); - return cf ? enrichItem(item, cf) : item; + const enriched = cf ? enrichItem(item, cf) : item; + const build = buildStatuses.get(`${item.owner}/${item.repoName}`); + return build ? mergeBuild(enriched, build) : enriched; }), - [functionItems, clusterFunctions], + [functionItems, clusterFunctions, buildStatuses], ); const reposLoaded = !isAuthenticated || (namespaceLoaded && prevNamespace === namespace); @@ -247,6 +251,7 @@ function newItem(item: FunctionListItem): FunctionTableItem { return { name: item.name || item.repoName, repoName: item.repoName, + owner: item.owner, namespace: item.namespace, runtime: item.runtime, status: item.err ? 'Error' : 'NotDeployed', @@ -265,3 +270,50 @@ function enrichItem(item: FunctionTableItem, cf: ClusterFunction): FunctionTable mainResource: cf.mainResource, }; } + +// isAvailable reports whether a function is currently deployed and available: +// actively serving (`Running`) or idle but ready to cold-start (`ScaledToZero`). +// Both have deployed successfully at least once, so a subsequent build is a +// rebuild whose status must not misrepresent the function's availability. +function isAvailable(status: FunctionTableItem['status']): boolean { + return status === 'Running' || status === 'ScaledToZero'; +} + +function mergeBuild(item: FunctionTableItem, build: BuildStatus): FunctionTableItem { + if (isAvailable(item.status)) { + // Non-destructive over an available function: a function that is deployed + // and available keeps its cluster status even while a new revision builds + // or a rebuild fails, so availability is never misrepresented. The build + // activity is surfaced only as a secondary indicator (see BuildActivityIndicator). + if (build.buildStatus === 'Building') { + // The in-progress indicator is a non-clickable spinner, so no run URL is + // carried here (only the failed indicator links to the run). + return { ...item, buildActivity: 'Building' }; + } + if (build.buildStatus === 'Failed') { + return { + ...item, + buildActivity: 'Failed', + buildRunURL: build.runURL, + failureReason: build.failureReason, + }; + } + // Succeeded / None: nothing to overlay on an available function. + return item; + } + // Not currently deployed/available: the build status is the most useful thing + // to show, so it becomes the primary status. + if (build.buildStatus === 'Building') { + return { ...item, status: 'Building' }; + } + if (build.buildStatus === 'Failed') { + return { + ...item, + status: 'BuildFailed', + buildRunURL: build.runURL, + failureReason: build.failureReason, + }; + } + // Succeeded / None: fall through to the cluster-derived status. + return item; +} diff --git a/src/pages/function-list/components/FunctionTable.test.tsx b/src/pages/function-list/components/FunctionTable.test.tsx index ee7b7a64..17f5b93a 100644 --- a/src/pages/function-list/components/FunctionTable.test.tsx +++ b/src/pages/function-list/components/FunctionTable.test.tsx @@ -39,6 +39,7 @@ const mockFunctions: FunctionTableItem[] = [ { name: 'my-func', repoName: 'my-func', + owner: 'twoGiants', runtime: 'go', status: 'Running', url: 'http://my-func.demo.svc', @@ -50,6 +51,7 @@ const mockFunctions: FunctionTableItem[] = [ { name: 'idle-func', repoName: 'idle-func', + owner: 'twoGiants', runtime: 'node', status: 'NotDeployed', url: '', @@ -62,6 +64,7 @@ const mockFunctions: FunctionTableItem[] = [ const clusterOnlyFunction: FunctionTableItem = { name: 'cluster-only', repoName: '', + owner: '', runtime: 'node', status: 'Running', url: 'http://cluster-only.demo.svc', @@ -91,6 +94,7 @@ describe('FunctionTable', () => { const noRuntime: FunctionTableItem = { name: 'cluster-only', repoName: '', + owner: '', runtime: '', status: 'Running', url: 'http://cluster-only.demo.svc', @@ -143,6 +147,81 @@ describe('FunctionTable', () => { expect(screen.getByText('Success: Running')).toBeInTheDocument(); }); + it('keeps Running and shows a build-in-progress spinner when buildActivity is Building', () => { + const rebuilding: FunctionTableItem = { ...mockFunctions[0], buildActivity: 'Building' }; + + render( + + + , + ); + + expect(screen.getByText('Success: Running')).toBeInTheDocument(); + expect(screen.getByLabelText('Build in progress')).toBeInTheDocument(); + }); + + it('keeps Running and shows a warning icon linking to the run when buildActivity is Failed', () => { + const failedRebuild: FunctionTableItem = { + ...mockFunctions[0], + buildActivity: 'Failed', + failureReason: 'build / go test', + buildRunURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }; + + render( + + + , + ); + + expect(screen.getByText('Success: Running')).toBeInTheDocument(); + expect(screen.getByText('WarningIcon')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Latest build failed' })).toHaveAttribute( + 'href', + 'https://github.com/twoGiants/my-func/actions/runs/1', + ); + }); + + it('keeps ScaledToZero and shows a build-in-progress spinner when buildActivity is Building', () => { + const idleRebuilding: FunctionTableItem = { + ...mockFunctions[0], + status: 'ScaledToZero', + buildActivity: 'Building', + }; + + render( + + + , + ); + + expect(screen.getByText('Info: ScaledToZero')).toBeInTheDocument(); + expect(screen.getByLabelText('Build in progress')).toBeInTheDocument(); + }); + + it('keeps ScaledToZero and shows a warning icon linking to the run when buildActivity is Failed', () => { + const idleFailedRebuild: FunctionTableItem = { + ...mockFunctions[0], + status: 'ScaledToZero', + buildActivity: 'Failed', + failureReason: 'build / go test', + buildRunURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }; + + render( + + + , + ); + + expect(screen.getByText('Info: ScaledToZero')).toBeInTheDocument(); + expect(screen.getByText('WarningIcon')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Latest build failed' })).toHaveAttribute( + 'href', + 'https://github.com/twoGiants/my-func/actions/runs/1', + ); + }); + it('renders InfoStatus for NotDeployed functions', () => { render( @@ -185,6 +264,7 @@ describe('FunctionTable', () => { const fn: FunctionTableItem = { name: 'my-function', repoName: 'my-repo', + owner: 'twoGiants', runtime: 'node', status: 'Running', url: '', diff --git a/src/pages/function-list/components/FunctionTable.tsx b/src/pages/function-list/components/FunctionTable.tsx index 5edda32f..2405a8c9 100644 --- a/src/pages/function-list/components/FunctionTable.tsx +++ b/src/pages/function-list/components/FunctionTable.tsx @@ -7,7 +7,7 @@ import { SuccessStatus, useDeleteModal, } from '@openshift-console/dynamic-plugin-sdk'; -import { ActionList, ActionListItem, Button, Tooltip } from '@patternfly/react-core'; +import { ActionList, ActionListItem, Button, Icon, Spinner, Tooltip } from '@patternfly/react-core'; import { ExclamationTriangleIcon, PencilAltIcon, TrashIcon } from '@patternfly/react-icons'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { useTranslation } from 'react-i18next'; @@ -16,6 +16,7 @@ import { FunctionSource, FunctionStatus } from '../../../common/types'; export interface FunctionTableItem { name: string; repoName: string; + owner: string; runtime: string; status: FunctionStatus; url: string; @@ -23,6 +24,11 @@ export interface FunctionTableItem { namespace: string; source: FunctionSource; mainResource?: K8sResourceCommon; + buildRunURL?: string; + failureReason?: string; + // buildActivity is set only when the primary status is a serving `Running` + // that the build status must not overwrite (non-destructive build indicator). + buildActivity?: 'Building' | 'Failed'; } export function FunctionTable({ @@ -68,7 +74,12 @@ export function FunctionTable({ - + @@ -95,10 +106,39 @@ function TextOrDash({ value }: { value?: string }) { return <>{value || '—'}; } -function StatusCell({ status }: { status: FunctionStatus }) { +function StatusCell({ + status, + failureReason, + buildRunURL, + buildActivity, +}: { + status: FunctionStatus; + failureReason?: string; + buildRunURL?: string; + buildActivity?: 'Building' | 'Failed'; +}) { + const { t } = useTranslation('plugin__console-functions-plugin'); + switch (status) { + // Non-destructive build indicator: a function that is deployed and available + // (serving `Running` or idle `ScaledToZero`) keeps its cluster status; any + // in-progress or failed rebuild is shown only as a small secondary indicator + // so availability is never misrepresented. case 'Running': - return ; + return withBuildActivity( + , + buildActivity, + failureReason, + buildRunURL, + ); + case 'ScaledToZero': + return withBuildActivity( + , + buildActivity, + failureReason, + buildRunURL, + ); + case 'Building': case 'Deploying': case 'CreatingRepo': case 'Pushing': @@ -106,7 +146,11 @@ function StatusCell({ status }: { status: FunctionStatus }) { return ; case 'Error': return ; - case 'ScaledToZero': + case 'BuildFailed': { + const badge = ; + const withLink = buildRunURL ? {badge} : badge; + return {withLink}; + } case 'NotDeployed': return ; case 'Unknown': @@ -114,6 +158,94 @@ function StatusCell({ status }: { status: FunctionStatus }) { } } +// RunLink wraps content in an external link to a GitHub Actions run. Pass +// ariaLabel when the content has no visible text of its own (e.g. an icon) so +// the link still has an accessible name. +function RunLink({ + url, + ariaLabel, + children, +}: { + url: string; + ariaLabel?: string; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +// withBuildActivity renders a primary status badge and, when a rebuild is in +// progress or failed for an available function, appends the secondary build +// indicator next to it. +function withBuildActivity( + badge: React.ReactNode, + buildActivity?: 'Building' | 'Failed', + failureReason?: string, + buildRunURL?: string, +) { + if (!buildActivity) return <>{badge}; + return ( + + {badge} + + + ); +} + +// BuildActivityIndicator is the small secondary indicator shown next to an +// available status (`Running` or `ScaledToZero`) while a new revision builds or a rebuild fails: a +// spinner (tooltip "Build in progress") for an in-progress build, or a warning +// icon (tooltip "Latest build failed: ", link to the run) for a failed +// one. On a serving function the tooltip is phrased to make clear the function +// is still running and only the latest rebuild failed, not the function itself. +function BuildActivityIndicator({ + buildActivity, + failureReason, + buildRunURL, +}: { + buildActivity?: 'Building' | 'Failed'; + failureReason?: string; + buildRunURL?: string; +}) { + const { t } = useTranslation('plugin__console-functions-plugin'); + + if (buildActivity === 'Building') { + return ( + + + + ); + } + if (buildActivity === 'Failed') { + // status="danger" colors the icon red even inside the run link, which would + // otherwise tint it link-blue via inherited anchor color. + const icon = ( + + + + ); + const withLink = buildRunURL ? ( + + {icon} + + ) : ( + icon + ); + const tooltip = failureReason + ? t('Latest build failed: {{reason}}', { reason: failureReason }) + : t('Latest build failed'); + return {withLink}; + } + return null; +} + function UrlCell({ url }: { url?: string }) { if (!url) return ;