SRVOCF-1038: Show build status and pipeline failures in the functions list - #177
matejvasek wants to merge 100 commits into
Conversation
Design for surfacing GitHub Actions build status and pipeline failures in the functions list, via an SSE stream from the backend (polling GH Actions) read with consoleFetch. Covers the status merge with the existing cluster watch, new Building/BuildFailed statuses, parameterless user-scoped endpoints, fakegithub Actions API with /_admin control, and the test strategy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add admin endpoints so tests and dev can script a repo's GitHub Actions workflow run status, conclusion, and jobs, making build status deterministic to exercise end to end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fetch the latest workflow run for a repo's default branch through the GitHub Actions REST API and, on failure, derive a "<job> / <step>" reason from the failed job. Includes fakegithub coverage and failureReason fallbacks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a snapshot endpoint and an SSE watch endpoint that streams per-repo build status, polling GitHub on an interval with heartbeats and periodic repo rediscovery. Per-repo errors are surfaced in the snapshot, and each snapshot is marshalled once with the bytes reused as the change key so unchanged polls are skipped. Wire the routes into main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stream build status over SSE via consoleFetch, sending the PAT in the X-SCM-Token header, with reconnect/backoff and stop-on-auth-error. Includes a consoleFetch stream test stub. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merge streamed build status into the functions list: render Building and BuildFailed (with a link to the run and a failure-reason tooltip), while letting a Running cluster status win over a stale Failed build. Also repairs the setup-guide test orphaned by a master helper rename. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pass the auth connectionId into useBuildStatus so the stream tears down and reconnects with the current PAT on in-place login and account switch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…limit usage The build-status poll loop hit GitHub every 3s per repo, exhausting the 5,000/hr core rate limit. Wire a per-client in-memory httpcache transport so unchanged responses come back as 304 Not Modified, which do not count against the primary rate limit. GitHub sends Cache-Control: max-age=60 on these responses, which would let the cache serve a stale build status for up to ~60s. A forceRevalidate transport sets Cache-Control: max-age=0 on every request so the cache always revalidates with a conditional request: unchanged status stays a free 304, but a real change is seen on the next poll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 2xx response with no body previously fell through the `if (!res.body) return;` guard and permanently stopped the SSE stream, so the build-status badges would silently freeze until the next connectionId change. Treat a body-less response like any other stream end: fall through to the backoff-and-reconnect path instead of giving up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LatestWorkflowRun took the newest run across *all* workflows in a repo
(ListRepositoryWorkflowRuns), so an unrelated workflow (lint, CodeQL, a cron)
could mask or misrepresent the func build: a passing lint run could hide a
failed build, or a failing unrelated workflow could paint a function red.
Filter to the func build workflow by file name via ListWorkflowRunsByFileName.
The identifier is func's own DefaultGitHubWorkflowFilename ("func-deploy.yaml"),
re-exported from the scaffold package as scaffold.WorkflowFilename so it stays in
sync with what we actually scaffold. The scm layer stays func-agnostic: the
workflow file name is passed in as a parameter, supplied by the func-aware
handler. A repo without that workflow file returns 404, which we map to a nil
run (no build signal) so non-func repos and not-yet-pushed workflows fall back
to the cluster-derived status instead of erroring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tream consoleFetch applies a default ~60s request timeout that aborts the request when it fires. On the long-lived build-status SSE stream that tore the connection down every minute regardless of the backend's 15s heartbeats, forcing a reconnect and a full initial snapshot re-fetch from GitHub each time. Pass timeout 0 to disable it so the stream is ended only by the hook's own AbortController (on unmount or connectionId change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e functions A function that is deployed and available (serving `Running` or idle `ScaledToZero`) now keeps its cluster status while a rebuild runs or fails, instead of being overwritten by `Building`/`BuildFailed`. The build activity is surfaced only 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: <reason>", link to the run) when the latest build failed. This stops an available function from flip-flopping to a build-centric status on every redeploy and keeps availability accurate. Deferred: giving a cluster `Error` (broken deployed revision) the same non-destructive treatment. `Error` is overloaded (it also covers a repo/list error with no cluster resource), so doing it right means gating on cluster presence rather than the status string. Noted in the design doc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… workflow
The fake's by-file-name runs endpoint
(/repos/{owner}/{repo}/actions/workflows/{workflow}/runs) shared a handler with
the repo-wide endpoint and ignored the {workflow} path segment, so both returned
every scripted run. That gave the fakegithub and e2e suites no fidelity for
workflow-file scoping: a regression where build status stopped querying only
func-deploy.yaml would go uncaught.
Give each scripted run a workflow-file identity (defaulting to
functions.WorkflowFilename so it stays in sync with what the client requests, and
overridable via the admin /_admin/actions/runs "workflow" field) and filter by
the {workflow} path segment on the by-file-name route. The repo-wide route still
returns all runs. Add a test asserting a run under a different workflow is not
returned when querying the func workflow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…id-stream Once the SSE stream is established, a per-repo LatestWorkflowRun error (including ErrUnauthorized) is logged and the last-known status is carried forward, and the 30s rediscover ListRepos error was only logged. So if the caller's PAT was revoked after connecting, every poll failed, the change-detection key never moved, no new frame was sent, and the client showed stale build status indefinitely without ever seeing an auth error to trigger re-auth. ListRepos is a single global call, so its ErrUnauthorized unambiguously means the token is no longer valid. End the stream in that case; the client's reconnect then hits the initial ListRepos, gets a 401 before the SSE upgrade, and its existing isAuthError path stops the loop / prompts re-auth. Detection latency is bounded by the rediscover interval. Non-auth rediscover errors still just log. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-repo build-status item carried an "Err" field populated with the raw error string from a failed workflow-run fetch. That string was never consumed by the frontend but was serialized onto the wire, exposing internal error detail to the browser. Drop the field: a failed fetch with no prior state now reports a plain "None" item and the cause is logged server-side instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pipeline controller notification For optional jobs, comment This repository is configured in: LGTM mode |
|
Skipping CI for Draft Pull Request. |
|
/test all |
|
/test e2e-aws |
|
@matejvasek: This pull request references SRVOCF-1038 which is a valid jira issue. Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the story to target the "5.1.0" version, but no target version was set. DetailsIn response to this:
Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository. |
Remove explanatory comments on WithSCMFactory and WithHeartbeat functions; their names and signatures are self-documenting. Shorten the multi-line comment in deriveBuildStatus to one line. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com>
|
/test e2e-aws |
| // 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) }() |
There was a problem hiding this comment.
Inline readSSEData here. No value in extracting it, readSSEDataWithin is already a small function.
| func toSnapshot(runs []scm.RepoRun) buildSnapshot { | ||
| items := make(map[string]buildStatusItem, len(runs)) | ||
| for _, rr := range runs { | ||
| items[rr.Repo.FullName()] = toBuildStatusItem(rr.Run) | ||
| } | ||
| return buildSnapshot{Functions: items} | ||
| } | ||
|
|
||
| func toBuildStatusItem(run *scm.WorkflowRun) buildStatusItem { | ||
| item := buildStatusItem{BuildStatus: deriveBuildStatus(run)} | ||
| if run != nil { | ||
| item.Conclusion = run.Conclusion | ||
| item.RunURL = run.HTMLURL | ||
| item.HeadSHA = run.HeadSHA | ||
| } | ||
| return item | ||
| } | ||
|
|
||
| func deriveBuildStatus(run *scm.WorkflowRun) string { | ||
| if run == nil { | ||
| return "None" | ||
| } | ||
| switch run.Status { | ||
| // "waiting", "requested", "pending" mean a run exists but has not finished. | ||
| 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: | ||
| // "skipped", "neutral", "stale" and "action_required" are not failures, report no signal. | ||
| return "None" | ||
| } | ||
| default: | ||
| return "None" | ||
| } | ||
| } |
There was a problem hiding this comment.
I see a few things here and it extends to watch.go:
-
buildStatusItemduplicatesscm.WorkflowRun. The only addition isBuildStatus(the derived string). That derivation belongs in the scm layer, not the handler, because it's business logic. The handler is the (REST) API layer and should only take care of managing requests and responses: get what it needs from the request, forward it downstream, and prepare data to be sent back in the proper format (SSE here). -
deriveBuildStatusis domain logic, not HTTP handling. Mapping GitHub Actions statuses (queued,in_progress,completed/failure) to build statuses (Building,Succeeded,Failed,None) is business logic. The handler should just serialize what the scm layer gives it. -
toSnapshottransforms[]scm.RepoRuninto a handler-local type. The handler should receive data ready to serialize.
Proposed refactoring: derive the build status in watch.go where the run is already constructed from run.GetStatus(). Add json tags to scm.RepoRun and scm.WorkflowRun so they serialize with proper field names. Then send []scm.RepoRun directly as the SSE data payload. The handler only does json.Marshal(runs) and puts it into the event: build-status\ndata: ...\n\n frame. buildStatusItem, buildSnapshot, toBuildStatusItem, deriveBuildStatus, and toSnapshot can go away.
The frontend adapts by converting the array to a keyed lookup in the hook (one conversion, one place).
Thoughts?
| if event.Err != nil { | ||
| slog.Error("build watch: stream error", "err", event.Err) | ||
| return | ||
| } |
There was a problem hiding this comment.
Why not send an SSE error event before closing when there is a stream error? You could then also better test for it.
There was a problem hiding this comment.
I was thinking about it. I even experimented with that locally. But chosen to defer.
There was even comment:
// End the stream so the client reconnects and takes its re-auth path.
// We could also potentially push error event to the stream.We could also potentially push error event to the stream.
But you only told me to delete the comment 😄 .
There was a problem hiding this comment.
As a reminder from our sync. We agreed on sending an error event.
| It("calls watch.Stop() when the request context is cancelled to halt polling", func() { | ||
| stopCalled := make(chan bool) | ||
|
|
||
| ch := make(chan scm.WorkflowRunsOrErr, 1) | ||
|
|
||
| // Create a mock watch that tracks if Stop() is called | ||
| mockWatch := &trackingWatch{ | ||
| ch: ch, | ||
| stopCalled: stopCalled, | ||
| } | ||
|
|
||
| stub := &scm.ClientStub{ | ||
| OnWatchWorkflowRuns: func(ctx context.Context, workflowFile string) (scm.WorkflowWatch, error) { | ||
| return mockWatch, nil | ||
| }, | ||
| } | ||
|
|
||
| mux := http.NewServeMux() | ||
| mux.HandleFunc("GET /watch", buildWatchWithStub(stub, handler.WithHeartbeat(fastHeartbeat))) | ||
| ts := httptest.NewServer(mux) | ||
| DeferCleanup(ts.Close) | ||
|
|
||
| ctx, cancel := context.WithCancel(context.Background()) | ||
| req, err := http.NewRequestWithContext(ctx, 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()) | ||
| DeferCleanup(func() { resp.Body.Close() }) | ||
|
|
||
| // Let the stream start | ||
| reader := bufio.NewReader(resp.Body) | ||
| _, err = reader.ReadString('\n') | ||
| Expect(err).NotTo(HaveOccurred()) | ||
|
|
||
| // Cancel the request context | ||
| cancel() | ||
|
|
||
| // The handler should call watch.Stop() to properly clean up the polling goroutine | ||
| select { | ||
| case <-stopCalled: | ||
| // Success: Stop was called | ||
| case <-time.After(2 * time.Second): | ||
| Fail("expected watch.Stop() to be called when request context is cancelled") | ||
| } | ||
| }) |
There was a problem hiding this comment.
Spying on the Stop() call seems not necessary if you derive the pollCtx in watch.go from the callers ctx. Then when the request context is cancelled (client disconnects), the poll goroutine stops automatically.
You made this design choice deliberately. Can you tell me why, I am curious?
It looks like in our case it would be fine to have the watch tied to the request lifetime.
There was a problem hiding this comment.
This works without the spy. Same test:
It("stops the watch when the client disconnects", func() {
ch := make(chan scm.WorkflowRunsOrErr)
stub := &scm.ClientStub{
OnWatchWorkflowRuns: func(ctx context.Context, workflowFile string) (scm.WorkflowWatch, error) {
return &testWatch{ch: ch}, nil
},
}
mux := http.NewServeMux()
mux.HandleFunc("GET /watch", buildWatchWithStub(stub, handler.WithHeartbeat(fastHeartbeat)))
ts := httptest.NewServer(mux)
DeferCleanup(ts.Close)
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, 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())
DeferCleanup(func() { resp.Body.Close() })
reader := bufio.NewReader(resp.Body)
_, err = reader.ReadString('\n')
Expect(err).NotTo(HaveOccurred())
cancel()
select {
case _, ok := <-ch:
Expect(ok).To(BeFalse(), "expected channel to close when request is cancelled")
case <-time.After(2 * time.Second):
Fail("expected watch channel to close when request is cancelled")
}
})
twoGiants
left a comment
There was a problem hiding this comment.
Another small backend batch review.
| // WatchWorkflowRuns implements scm.Client. Repo discovery runs synchronously so | ||
| // auth failures are returned to the caller rather than lost in the goroutine. | ||
| // The returned watch's lifetime is independent of ctx; call Stop() to terminate. | ||
| func (c *ghClient) WatchWorkflowRuns(ctx context.Context, workflowFile string) (scm.WorkflowWatch, error) { |
There was a problem hiding this comment.
nit
| func (c *ghClient) WatchWorkflowRuns(ctx context.Context, workflowFile string) (scm.WorkflowWatch, error) { | |
| func (c *ghClient) WatchWorkflowRuns(ctx context.Context, workflowFilename string) (scm.WorkflowWatch, error) { |
| if event.Err != nil { | ||
| slog.Error("build watch: stream error", "err", event.Err) | ||
| return | ||
| } |
There was a problem hiding this comment.
As a reminder from our sync. We agreed on sending an error event.
| It("calls watch.Stop() when the request context is cancelled to halt polling", func() { | ||
| stopCalled := make(chan bool) | ||
|
|
||
| ch := make(chan scm.WorkflowRunsOrErr, 1) | ||
|
|
||
| // Create a mock watch that tracks if Stop() is called | ||
| mockWatch := &trackingWatch{ | ||
| ch: ch, | ||
| stopCalled: stopCalled, | ||
| } | ||
|
|
||
| stub := &scm.ClientStub{ | ||
| OnWatchWorkflowRuns: func(ctx context.Context, workflowFile string) (scm.WorkflowWatch, error) { | ||
| return mockWatch, nil | ||
| }, | ||
| } | ||
|
|
||
| mux := http.NewServeMux() | ||
| mux.HandleFunc("GET /watch", buildWatchWithStub(stub, handler.WithHeartbeat(fastHeartbeat))) | ||
| ts := httptest.NewServer(mux) | ||
| DeferCleanup(ts.Close) | ||
|
|
||
| ctx, cancel := context.WithCancel(context.Background()) | ||
| req, err := http.NewRequestWithContext(ctx, 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()) | ||
| DeferCleanup(func() { resp.Body.Close() }) | ||
|
|
||
| // Let the stream start | ||
| reader := bufio.NewReader(resp.Body) | ||
| _, err = reader.ReadString('\n') | ||
| Expect(err).NotTo(HaveOccurred()) | ||
|
|
||
| // Cancel the request context | ||
| cancel() | ||
|
|
||
| // The handler should call watch.Stop() to properly clean up the polling goroutine | ||
| select { | ||
| case <-stopCalled: | ||
| // Success: Stop was called | ||
| case <-time.After(2 * time.Second): | ||
| Fail("expected watch.Stop() to be called when request context is cancelled") | ||
| } | ||
| }) |
There was a problem hiding this comment.
This works without the spy. Same test:
It("stops the watch when the client disconnects", func() {
ch := make(chan scm.WorkflowRunsOrErr)
stub := &scm.ClientStub{
OnWatchWorkflowRuns: func(ctx context.Context, workflowFile string) (scm.WorkflowWatch, error) {
return &testWatch{ch: ch}, nil
},
}
mux := http.NewServeMux()
mux.HandleFunc("GET /watch", buildWatchWithStub(stub, handler.WithHeartbeat(fastHeartbeat)))
ts := httptest.NewServer(mux)
DeferCleanup(ts.Close)
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, 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())
DeferCleanup(func() { resp.Body.Close() })
reader := bufio.NewReader(resp.Body)
_, err = reader.ReadString('\n')
Expect(err).NotTo(HaveOccurred())
cancel()
select {
case _, ok := <-ch:
Expect(ok).To(BeFalse(), "expected channel to close when request is cancelled")
case <-time.After(2 * time.Second):
Fail("expected watch channel to close when request is cancelled")
}
})
twoGiants
left a comment
There was a problem hiding this comment.
Next review batch for the backend.
Going down deeper I noticed that the WatchWorflowRun resides in the wrong place, the SCM, which is about code management -> watching a CI pipeline run to completion is a different concern.
See my comments and proposals below.
| } | ||
| }) | ||
|
|
||
| Describe("build status vocabulary", func() { |
There was a problem hiding this comment.
After migrating the logic for this test to the watcher, don't forget to move the test too.
| // testWatch wraps a channel for testing; it implements scm.WorkflowWatch. | ||
| type testWatch struct { | ||
| ch chan scm.WorkflowRunsOrErr | ||
| stopOnce sync.Once | ||
| } | ||
|
|
||
| func (w *testWatch) ResultChan() <-chan scm.WorkflowRunsOrErr { return w.ch } | ||
| func (w *testWatch) Stop() { | ||
| w.stopOnce.Do(func() { | ||
| close(w.ch) | ||
| }) | ||
| } |
There was a problem hiding this comment.
Move the test double next to the interface and rename to WorkflowWatchFake. There are now a few of those, please reduce them to a minimum. We probably need one WorkflowWatchFake and one WorkflowWatchStub and that's it.
|
|
||
| DescribeTable("maps run status and conclusion to a build status", | ||
| func(status, conclusion, expected string) { | ||
| Expect(buildStatusFor(&scm.WorkflowRun{Status: status, Conclusion: conclusion})).To(Equal(expected)) |
There was a problem hiding this comment.
Can you inline the buildStatusFor function here? Will be a bit easier to read this code.
| Expect(ok).To(BeTrue(), "expected a frame for the snapshot") | ||
|
|
||
| // Decoded into a local mirror of the DTO, so a change to the JSON | ||
| // tags the frontend reads fails here. |
There was a problem hiding this comment.
You can also remove the comments here in the test which were meant for me and which mention "frontend". Our API is consumed by a frontend client now, but it could be any client.
| ID int64 | ||
| Status string // queued | in_progress | completed | ||
| Conclusion string // success | failure | cancelled | timed_out | "" | ||
| HeadSHA string |
There was a problem hiding this comment.
-
Conclusion: is defined but not used anywhere except when you derive the status later in the handler. We agreed on deriving the status when you collect the data you need for it in the watch.go -> it means you don't need to store the Conclusion. It's also unused in the UI. Drop it. -
Status: as aligned in sync, should only store our final status. We don't need to mirror and store GitHubs status only to derive our own later. -
ID: unused in BE and UI. Why keep it? For a later scenario? For which? In any case you can add it back later once needed. I'd drop it. -
HeadSHA: unused in BE and UI. I'd do the same as withID. -
HTMLURL: rename to just URL or RunURL, but I thinkWorkflowRun.URLreads better thenWorkflowRun.RunURL.
I'd word the comment decoupled from Github and the WorkflowRun or more general CIRun should probably look like that:
// CIRun is the latest CI run for a function's repository. A nil
// *CIRun means the repository has no runs on its default branch.
type CIRun struct {
Status string // Building | Succeeded | Failed | None
URL string // link to the run in the CI provider
}| // FullName is the "owner/name" identifier, matching GitHub's full_name field. | ||
| // Used to correlate a repo across cluster and build state. |
| GetVariable(ctx context.Context, owner, repo, name string) (string, error) | ||
| StoreVariable(ctx context.Context, owner, repo, name, value string) error | ||
| DeleteRepo(ctx context.Context, owner, repo string) error | ||
| WatchWorkflowRuns(ctx context.Context, workflowFile string) (WorkflowWatch, error) |
There was a problem hiding this comment.
WatchWorkflowRuns doesn't belong on the SCM client interface. SCM is about code management (repos, files, commits). Watching a CI pipeline run to completion is a different concern.
This should live in its own package (e.g. ci or pipeline) with its own client interface. The current GitHub Actions implementation would be one provider, with Tekton coming next and potentially others later. Keeping it separate now avoids having to pull it out when we add Tekton support.
Strictly speaking, StoreSecret, StoreVariable, and GetVariable are also CI concerns living on the SCM interface. Let's create a ticket to pull those out too in a follow-up PR, no need to do now.
There was a problem hiding this comment.
Yes, I myself was not sure whether I should create new package. Give the SCM already contained the mentioned variable/secret API I assumed that SCM == complete forge with all batteries included. But given that we should add Tekton latter it would make sense to have it separate.
| return &stubWatch{ch: ch}, nil | ||
| } | ||
|
|
||
| type stubWatch struct { |
There was a problem hiding this comment.
Can you move this stub implementation below the WorkflowWatch interface and rename it to WorkflowWatchStub?
twoGiants
left a comment
There was a problem hiding this comment.
Next batch of backend review. I'm done with scm/github/client.go, next will be watch.go.
| "github.com/openshift/faas-console-plugin/backend/scm" | ||
| ) | ||
|
|
||
| // Option customizes a client returned by New or NewWithBaseURL. |
There was a problem hiding this comment.
When you move the watch implementation to a new package you can move out all the watch specific configuration there too.
Lets align on a structure of such files, wdyt about:
- The core struct(s) at the top
- The constructors / factory functions next
- Builder pattern types and functions next
- The core struct(s) methods and functions next
- Supporting struct(s) next
- Supporting struct(s) methods and functions next
Lets put what we agree on into the STYLEGUIDE.md.
| cacheTransport := httpcache.NewMemoryCacheTransport() | ||
| httpClient := &http.Client{Transport: &forceRevalidate{next: cacheTransport}, Timeout: 30 * time.Second} |
There was a problem hiding this comment.
Wdyt? -> Extracting forceRevalidate, drainOnClose, and the cache setup into backend/scm/github/httpcache.go with a newHTTPCache() constructor. NewWithBaseURL calls it and gets back a ready-to-use http.RoundTripper.
Keeps client.go focused on the SCM operations.
| // 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() |
There was a problem hiding this comment.
It took me a while to understand the http cache role and the comments and the forceRevalidate and drainOnClose workaround. Claude was of great help.
What I got now is the following.
First about httpcache. It does quite a few things and two of them are:
- Freshness caching: serve from cache without contacting the server (the
max-agewindow) - Validation caching: store ETags, send
If-None-Match, replay cached body on 304 - and "many" more things.
We disable 1. entirely with forceRevalidate. We only use 2., we don't use any other features.
We're pulling in an archived dependency with a known bug, working around that bug with drainOnClose, and then disabling part of its functionality with forceRevalidate and don't use the rest. All to get ETag storage and 304 replay.
I see a case for writing this small peace of code ourself. Here is a draft (tested):
type cacheEntry struct {
etag string
body []byte
}
type etagCache struct {
next http.RoundTripper
mu sync.Mutex
entries map[string]*cacheEntry
}
func newETagCache(next http.RoundTripper) *etagCache {
return &etagCache{next: next, entries: make(map[string]*cacheEntry)}
}
func (c *etagCache) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodGet {
return c.next.RoundTrip(req)
}
key := req.URL.String()
c.mu.Lock()
entry := c.entries[key]
c.mu.Unlock()
req = req.Clone(req.Context())
if entry != nil {
req.Header.Set("If-None-Match", entry.etag)
}
resp, err := c.next.RoundTrip(req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotModified && entry != nil {
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(entry.body))
resp.StatusCode = http.StatusOK
return resp, nil
}
etag := resp.Header.Get("ETag")
if etag != "" && resp.Body != nil {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
c.mu.Lock()
c.entries[key] = &cacheEntry{etag: etag, body: body}
c.mu.Unlock()
resp.Body = io.NopCloser(bytes.NewReader(body))
}
return resp, nil
}In any case, I'd extract that code into a etagcache.go if we use my proposal. And if you want to keep the existing code then into httpcache.go as proposed the comment above.
Wdyt?
There was a problem hiding this comment.
TBH I would not use httpcache library if it was not already our indirect dependency, I would implement it by myself.
There was a problem hiding this comment.
Given the two issues (max-age,body-not-read-to-end) I would probably chosen to implement it ourselves.
| // auth failures are returned to the caller rather than lost in the goroutine. | ||
| // The returned watch's lifetime is independent of ctx; call Stop() to terminate. | ||
| func (c *ghClient) WatchWorkflowRuns(ctx context.Context, workflowFile string) (scm.WorkflowWatch, error) { | ||
| repos, err := c.ListRepos(ctx) |
There was a problem hiding this comment.
#185 has merged and with it we started only listing repos that were created by the present cluster.
This returns all GitHub repos without applying the cluster URL filter that the list handler uses. The watcher ends up polling repos that belong to other clusters which are never displayed since list never returns them. It should use the same filtered set that /func/list computes, either by accepting a repo list from the caller or by applying the same CLUSTER_API_URL variable check here.
Here is the logic of exclusion in the list endpoint - https://github.com/openshift/faas-console-plugin/blob/master/backend/handler/list.go#L151-L161
There was a problem hiding this comment.
Surely should be fixed, but it's not critical, worst case: I get build statuses for a functions what we are not interested in on this specific cluster.
There was a problem hiding this comment.
Sure. Not critical. But should be done. I am fine with a follow up ticket.
…ntrol Introduce a shared ticker abstraction (backend/ticker package) for dependency injection of ticker behavior in handler and watch loops. Add explicit tick control for deterministic test sequencing. Refactor ETag caching test to verify observable behavior (rate-limit errors) rather than implementation details. New backend/ticker package: - Ticker interface with Chan() and Stop() methods for abstraction - Factory type for factory-based injection - New(duration) constructor for real time.Ticker in production - SilentTickerFactory() for tests without heartbeats (nil channels) - CreateFakeTickerFactory() returning (tick func, factory) tuple for explicit on-demand tick control with atomic re-invocation guard - tickerFake implementation with unexported tick() method Integration with handler and watch: - handler/build.go: Wire WithHeartbeatTickerFactory option, heartbeat sends periodic ":" SSE comments - scm/github/client.go: Wire WithWatchTickerFactories option for poll and rediscover loops - Both packages import from backend/ticker (consolidated single source of truth) ETag caching test improvements: - Verify caching via observable behavior: rate-limit error (429) proves caching failure; successful 304 response proves caching works - Use state machine pattern: handler indexed by atomic counter returns different responses (rate limit, status change) for each request - Test three payload sizes (0, 14K, 100K) across HTTP framing boundary - Add DeferCleanup(w.Stop) to 9 WatchWorkflowRuns() test cases to prevent goroutine leaks from ticker and watch goroutines - Fix timeout units: 300*time.Second -> 2*time.Second (prevents 5-minute hangs on assertion failures) Test mechanism: - Unbuffered ticker channel blocks send until receiver processes tick, providing implicit synchronization without explicit WaitGroup - Atomic counter incremented by handler on each request, enabling state machine indexing for deterministic responses - If caching fails: second request lacks If-None-Match -> handler returns 429 - If caching works: second request includes If-None-Match -> handler returns 304 with status change Benefits: - Single source of truth for ticker abstractions (eliminates duplication) - Test verifies actual caching behavior, not internal HTTP headers - No data races (atomic operations, proper resource cleanup) - Deterministic test execution (implicit channel ordering) - Proper resource cleanup prevents goroutine leaks in test suite Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com>
…ibeTable
Convert the parametrized ETag caching test from a for-loop pattern to Ginkgo's
idiomatic DescribeTable API. This improves readability and test naming.
Changes:
- Replace `for _, payload := range []int{...}` loop with `DescribeTable`
- Move test description to DescribeTable headline (cleaner intent)
- Declare test cases upfront via Entry() calls instead of fmt.Sprintf
- Test body becomes the parametrized function argument
Benefits:
- Test cases are declarative upfront (easier to scan all cases at once)
- Ginkgo generates distinct test names for each entry:
`revalidates... [0 bytes of padding]`, `[14_000 bytes of padding]`, etc.
- Eliminates closure complexity (no loop variable capture)
- More idiomatic Ginkgo (standard pattern in v2+)
- Test reporting is clearer (each entry is distinct in test output)
The test logic and behavior are unchanged - this is a stylistic refactoring
to follow Ginkgo conventions.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Matej Vašek <matejvasek@gmail.com>
|
@twoGiants I pushed the Ticker refactor. |
pmeida
left a comment
There was a problem hiding this comment.
The SSE watcher does rediscover new repos every 30s, but that data is only merged onto rows already in functionItems - so it's orphaned until listFunctions re-runs. A browser refresh reconnects SSE from scratch anyway, making the rediscovery interval effectively invisible to the user. Its only practical effect today is pruning build status for repos that were removed.
Not auto-discovering functions created by other users is acceptable UX - a manual refresh to sync is reasonable. The issue is the naming: "rediscovery" implies new-repo detection, but in practice it only handles removals.
Wanted to leave this note. Not sure if we can do something now. Maybe it can originate a follow up.
Gofmt cleanup: remove unnecessary blank line at end of file. Fixes linting issue from golangci-lint gofmt check. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com>
- Rephrase workflow conclusion comment to be provider-agnostic: GitHub conclusions are named but the derivation is a general concept. - Remove reviewer-directed comments from test setup that explain the code to the reviewer rather than documenting it for future readers. Test names and structure already explain intent. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Replace hardcoded "default" namespace with PRESEEDED_FUNC_NAMESPACE constant from test helpers. Aligns with test framework conventions and eliminates magic strings. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com>
Move stubWatch implementation from end of file to immediately after the WorkflowWatch interface it implements. Readers expect the test double right after the interface definition. Kept in scm/client.go (not moved under scm/github/) because the stub serves the interface, not any specific provider implementation. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com>
When the build status watch encounters an error, send an SSE event: error frame to the client before closing the connection. This allows the client to distinguish between a server crash (silent disconnect) and a stream failure (error frame), enabling proper error handling and user feedback. Extract the error event writing into a writeErrorEvent helper function to properly handle and check the write error, matching the pattern of writeSnapshotEvent. Log if the error event write itself fails. Update the test to verify both that the error event is properly formatted (event: error with error message in data:) and that the stream closes after the error event is sent. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com>
Handle error events from the build status SSE stream. When an error event is received from the server, invoke error listeners with the error message. Also improve the streaming loop control: use `continue` to skip processing when streaming is disabled, ensuring the generator is fully consumed even when not actively streaming (allows proper cleanup on stream close). Signed-off-by: Matej Vašek <matejvasek@gmail.com> Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…ns with OCP platform - Remove 'Building' as a primary FunctionStatus; first-time creation now shows NotDeployed (primary) + build indicator (secondary), consistent with how rebuilds of running functions are displayed - Replace Spinner with RhUiSyncIcon (spin) for the secondary build indicator, aligned with OCP platform conventions - Replace ProgressStatus with InfoStatus for Deploying primary status - Extend withBuildActivity to NotDeployed so the secondary indicator can appear alongside it Signed-off-by: Pedro Almeida <pmeida@redhat.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com>
|
/test e2e-aws |
Rename the test helper function from `createMockEventSource` to `createFakeEventSource` for consistency with testing terminology: - "Mock" typically implies behavior verification (spies, assertions on calls) - "Fake" is appropriate for a fully-functional test double This clarifies the intent: the helper creates a fully-functional fake EventSource for testing, not a mock for verifying interactions. Signed-off-by: Matej Vašek <matejvasek@gmail.com>
Remove the `afterEach` hook that calls `vi.useRealTimers()`. The tests no longer use fake timers, making this cleanup code dead and unnecessary. Signed-off-by: Matej Vašek <matejvasek@gmail.com>
|
/test e2e-aws |
Replace manual reader.read() loop with for await...of to iterate directly over the ReadableStream. Removes unnecessary getReader() call and the try/finally block that was releasing the lock. Simplifies the frame parsing loop and improves readability. Signed-off-by: Matej Vašek <matejvasek@gmail.com>
Import and use the actual BuildSnapshot type from functionsClient instead of an inline type alias for the emitSnapshot parameter. This ensures the test helper stays in sync with the real type definition. Signed-off-by: Matej Vašek <matejvasek@gmail.com>
Rename forEachDeferred to invokeListeners and consolidate the open-state check into one place. Add try-catch error handling to match production behavior where listener errors don't break other listeners. This simplifies the four emit methods by removing repeated if(open) checks. Signed-off-by: Matej Vašek <matejvasek@gmail.com>
Import and use the actual BuildSnapshot type from functionsClient instead of inline type definitions. This ensures test type safety and keeps tests in sync with the real type definition. Signed-off-by: Matej Vašek <matejvasek@gmail.com>
Type the buildStatusFrame helper parameter with BuildSnapshot['functions'] instead of Record<string, unknown>. This ties the test helper to the real type definition so it stays in sync with changes. Signed-off-by: Matej Vašek <matejvasek@gmail.com>
|
/test e2e-aws |
Make invokeListeners throw when emit is called on a closed event source. The old test only checked that updates stopped after unmount, but unmount alone stops processing updates regardless of whether close() was called. The new test expects a throw, which explicitly verifies close() was called. Signed-off-by: Matej Vašek <matejvasek@gmail.com>
|
@matejvasek: The following test failed, say
Full PR test history. Your PR dashboard. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here. |
Summary
/api/v1/func/build/watch), user-scoped like/listscm.Client.WatchWorkflowRunspolls GitHub Actions behind one channel per connection, scoped tofunc-deploy.yaml, ETag-cached to keep 304s free against the rate limituseBuildStatus, list merge); Playwright e2e against the real backend and fakegithubdocs/design/2026-08-26-SRVOCF-1038-build-status-design.md, implementation plan indocs/plans/completed/2026-08-26-SRVOCF-1038-build-status.mdFixes SRVOCF-1038
How the build status stream works
The backend polls GitHub for workflow runs and the browser subscribes to the result. Server-Sent Events (SSE) is a long-lived HTTP response of
Content-Type: text/event-streamthat the server keeps open and appends text frames to. It is one-way (server to client), which is all we need here.Why polling and not webhooks. GitHub can push
workflow_runevents to a webhook, and that would be lower latency, but it is a much bigger system: a publicly reachable route into the cluster, a webhook plus signing secret registered and kept in sync on every function repo, signature verification, and server-side state to fan each event out to the right browser session. Polling needs none of that. It runs inside the existing user-scoped request, holds no state beyond the life of the connection, and unchanged polls are 304s, so the steady-state cost is close to zero. The push we do need, backend to browser, is the one SSE gives us.Wire format. Frames are separated by a blank line. A line starting with
:is a comment (our heartbeat, which keeps proxies from closing an idle connection). Everything else the client ignores unless the frame'sevent:isbuild-status:Each frame is a full snapshot, not a delta: the map is keyed by
owner/repoand the client replaces its state wholesale. That makes the client stateless with respect to ordering and missed frames, and it means a reconnect needs no catch-up protocol.Auth failures stay ordinary HTTP. Once a response is a stream you can no longer change its status code, so
HandleBuildWatchdoes repo discovery before writing any headers. A revoked PAT is therefore a plain401, not a half-written stream (backend/handler/build.go:50-59).Change-only emission. The poller compares each new snapshot to the previous one and only sends on the channel when it differs, so an idle list produces nothing but heartbeats (
backend/scm/github/watch.go:42-53). Polls that find nothing new are304 Not Modifiedthanks to an ETag-caching transport, and 304s do not count against GitHub's primary rate limit.Why not
EventSource? The browser's built-in SSE client cannot set request headers, which would force the PAT into the URL (where it lands in logs and history). So the client usesconsoleFetchwithtimeout: 0and readsresponse.bodyas aReadableStream, splitting frames on\n\nitself (src/common/clients/useBuildStatus.ts). The cost is that we implement reconnect by hand: 3s backoff on a dropped stream, and a hard stop on 401/403 since a bad token will not fix itself. Full rationale in the design doc under "Transport decision: SSE over consoleFetch stream".Where it lands in the UI.
useBuildStatusreturns a map thatFunctionsListPagemerges with the cluster status per function. The merge is non-destructive: a function the cluster knows about keeps its cluster badge and the build shows only as a secondary indicator (spinner or red warning icon linking to the run).Suggested review order
The change is easier to follow outside-in rather than by diff order:
docs/design/2026-08-26-SRVOCF-1038-build-status-design.mdfor the what and why, especially the status merge table.backend/scm/github/watch.gofor the polling loop, change detection, and per-repo error carry-forward.backend/handler/build.gofor the SSE framing and the auth-before-headers ordering.src/common/clients/useBuildStatus.tsfor the client-side frame parsing and reconnect.src/pages/function-list/FunctionsListPage.tsx(mergeBuild) andcomponents/FunctionTable.tsx(StatusCell) for how the two statuses combine and render.e2e/use-cases/list/build-status.test.tsis the end-to-end story in one file.SSE resources
EventSource(the API we deliberately do not use, see above)response.bodygives us instead)http.Flusher(why every frame is followed by aFlush())proxy_buffering/X-Accel-Buffering(why the handler sets that header)Checklist
docs/ARCHITECTURE.md(if there are relevant changes to our layered architecture)Additional Info
Deployingfor a moment mid-rollout and gating onRunning/ScaledToZeromade every redeploy flicker throughBuilding.🤖 Generated with Claude Code