diff --git a/README.md b/README.md index eba5e73..93ba4c3 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,29 @@ Alerts are evaluated when you read them, so PR ages keep advancing between refre The dashboard shows a refresh button beside each project when `manual_refresh` is on. +## Alert actions + +An action adds a button to alerts. Pressing it sends the alert as JSON to a webhook that you run, for example to re-run a failed pipeline or to open a ticket. Without an `actions` block the feature is off: the state document, the endpoints and the UI do not change. + +```yaml +actions: + - id: rerun # [a-z0-9-]+, appears in the payload and the endpoint + label: Re-run # button text + kinds: [workflow_failed] # alert kinds that show the button; omit for every kind + webhook: + url: https://example.internal/hooks/rerun + token_env: ACTION_TOKEN # name of an environment variable that holds a bearer token; optional + timeout: 10s + status_url: https://example.internal/hooks/rerun/status # optional + confirm: "Run this action for the selected alert?" # optional; omit for no dialog +``` + +- `POST /api/actions/{id}` takes `{"repo": "owner/name", "url": ""}`. It refuses cross-site requests like `POST /refresh`. The action must exist, the repository must be in the configured list, and a current alert with that repo and URL must have a kind listed in `kinds`. Otherwise the answer is 404. +- The server then POSTs `{"action": "", "alert": {"repo", "kind", "severity", "subject", "detail", "url", "since"}, "requestedAt": ""}` to `webhook.url`. When `token_env` is set, the request carries `Authorization: Bearer `. The server never logs the token and never follows redirects. +- A 2xx answer succeeds. If its body is JSON `{"state", "label", "link"}`, the UI shows it as the status of that alert. A non-2xx answer becomes an error message with the upstream body cut to 300 characters. An unreachable webhook gives 502. +- `GET /api/state` gains `actions` (`id`, `label`, `confirm`, `kinds`; no URLs and no tokens). When `status_url` is set, the server also fetches it with `GET` (same bearer token) and expects a JSON list of `{"repo", "url", "state", "label", "link"}` rows, newest first. It matches rows to alerts by repo and URL and adds `actionStatus` to each alert, keyed by action id. The result is cached for 10 seconds. If `status_url` is down, the state omits `actionStatus` and sets `actionsError`. +- A `link` that is not an http or https address is dropped. + ## Metrics All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time. @@ -84,6 +107,7 @@ All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time. | `release_info`, `release_published_timestamp_seconds` | repo, version, url | | `alerts` | severity | | `refreshes_total`, `repo_refresh_errors_total`, `last_refresh_duration_seconds`, `github_rate_limit_remaining` | none | +| `action_requests_total` (counter; result is `ok`, `rejected` or `error`; only with `actions`) | action, result | Example alert rules: diff --git a/config.example.yaml b/config.example.yaml index 633c6e1..665fddf 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -14,6 +14,18 @@ thresholds: # negative disables a check issue_stale_after: 720h # no activity this long = warning (aggregated per repo) workflow_stuck_after: 6h # queued / in progress this long = warning +# Optional buttons on alerts. Each one POSTs the alert as JSON to a webhook. Omit the block to turn the feature off. +# actions: +# - id: rerun # [a-z0-9-]+ +# label: Re-run # button text +# kinds: [workflow_failed] # omit for every alert kind +# webhook: +# url: https://example.internal/hooks/rerun +# token_env: ACTION_TOKEN # env var holding a bearer token; optional +# timeout: 10s +# status_url: https://example.internal/hooks/rerun/status # optional +# confirm: "Run this action for the selected alert?" # optional; omit for no dialog + max_items: 100 # per repo, issues and PRs concurrency: 2 diff --git a/internal/config/config.go b/internal/config/config.go index 323de96..7e88a7d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "os" + "regexp" "strings" "time" @@ -28,13 +29,49 @@ const ( MinRefresh = time.Minute dataStaleGrace = 30 * time.Minute defaultDataStaleFactor = 2 + DefaultActionTimeout = 10 * time.Second ) +var actionIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`) + +// Action is an operator-defined button on alerts. Pressing it POSTs the alert to a webhook. +type Action struct { + ID string `yaml:"id"` + Label string `yaml:"label"` + Confirm string `yaml:"confirm"` + StatusURL string `yaml:"status_url"` + // Token comes from the environment variable named by Webhook.TokenEnv, never from the file. + Token string `yaml:"-"` + Webhook Webhook `yaml:"webhook"` + // Kinds limits the action to these alert kinds; empty means every kind. + Kinds []string `yaml:"kinds"` +} + +type Webhook struct { + URL string `yaml:"url"` + TokenEnv string `yaml:"token_env"` + Timeout time.Duration `yaml:"timeout"` +} + +// AppliesTo reports whether the action is offered for an alert kind. +func (a Action) AppliesTo(kind string) bool { + if len(a.Kinds) == 0 { + return true + } + for _, k := range a.Kinds { + if k == kind { + return true + } + } + return false +} + type Config struct { // ManualRefresh enables POST /refresh and the rescan button; it defaults to on. ManualRefresh *bool `yaml:"manual_refresh"` Listen string `yaml:"listen"` Repos []string `yaml:"repos"` + Actions []Action `yaml:"actions"` GitHub GitHub `yaml:"github"` Thresholds Thresholds `yaml:"thresholds"` RefreshInterval time.Duration `yaml:"refresh_interval"` @@ -127,6 +164,10 @@ func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]b c.GitHub.PrivateKeyPEM = string(pem) } + if err := finalizeActions(c.Actions, getenv); err != nil { + return err + } + repos, err := normalizeRepos(c.Repos) if err != nil { return err @@ -135,6 +176,46 @@ func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]b return nil } +func finalizeActions(actions []Action, getenv func(string) string) error { + seen := make(map[string]bool, len(actions)) + for i := range actions { + a := &actions[i] + if !actionIDPattern.MatchString(a.ID) { + return fmt.Errorf("actions[%d].id %q must match [a-z0-9-]+", i, a.ID) + } + if seen[a.ID] { + return fmt.Errorf("actions[%d].id %q is used twice", i, a.ID) + } + seen[a.ID] = true + if a.Label == "" { + a.Label = a.ID + } + if !isHTTPURL(a.Webhook.URL) { + return fmt.Errorf("actions[%s].webhook.url %q must be an http or https address", a.ID, a.Webhook.URL) + } + if a.StatusURL != "" && !isHTTPURL(a.StatusURL) { + return fmt.Errorf("actions[%s].status_url %q must be an http or https address", a.ID, a.StatusURL) + } + if a.Webhook.Timeout < 0 { + return fmt.Errorf("actions[%s].webhook.timeout must not be negative", a.ID) + } + if a.Webhook.Timeout == 0 { + a.Webhook.Timeout = DefaultActionTimeout + } + if env := a.Webhook.TokenEnv; env != "" { + if a.Token = getenv(env); a.Token == "" { + return fmt.Errorf("actions[%s].webhook.token_env: $%s is empty", a.ID, env) + } + } + } + return nil +} + +func isHTTPURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" +} + func setDefault(d *time.Duration, def time.Duration) { if *d == 0 { *d = def diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 06a8b75..aa6c87f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" ) @@ -145,3 +146,78 @@ func TestWebBase(t *testing.T) { } } } + +func TestActionsDefaultOff(t *testing.T) { + c := valid() + if err := c.finalize(env(nil), keyFile); err != nil { + t.Fatal(err) + } + if len(c.Actions) != 0 { + t.Fatalf("actions must default to none: %+v", c.Actions) + } +} + +func TestActionsFinalize(t *testing.T) { + good := func() Action { + return Action{ID: "rerun", Webhook: Webhook{URL: "https://hooks.example.test/rerun", TokenEnv: "T"}} + } + tests := map[string]struct { + mutate func(*Action) + extra []Action + wantErr bool + }{ + "ok": {mutate: func(*Action) {}}, + "no token env": {mutate: func(a *Action) { a.Webhook.TokenEnv = "" }}, + "bad id upper": {mutate: func(a *Action) { a.ID = "Re_run" }, wantErr: true}, + "empty id": {mutate: func(a *Action) { a.ID = "" }, wantErr: true}, + "duplicate id": {mutate: func(*Action) {}, extra: []Action{good()}, wantErr: true}, + "bad webhook": {mutate: func(a *Action) { a.Webhook.URL = "ftp://x" }, wantErr: true}, + "no webhook": {mutate: func(a *Action) { a.Webhook.URL = "" }, wantErr: true}, + "bad status url": {mutate: func(a *Action) { a.StatusURL = "nope" }, wantErr: true}, + "status url ok": {mutate: func(a *Action) { a.StatusURL = "http://status.example.test/s" }}, + "negative timeout": {mutate: func(a *Action) { a.Webhook.Timeout = -time.Second }, wantErr: true}, + "token env unset": {mutate: func(a *Action) { a.Webhook.TokenEnv = "MISSING" }, wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + a := good() + tc.mutate(&a) + c := valid() + c.Actions = append([]Action{a}, tc.extra...) + err := c.finalize(env(map[string]string{"T": "s3cret"}), keyFile) + if (err != nil) != tc.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tc.wantErr) + } + if err != nil && strings.Contains(err.Error(), "s3cret") { + t.Fatal("error leaks token") + } + if err == nil { + got := c.Actions[0] + if got.Label != "rerun" || got.Webhook.Timeout != DefaultActionTimeout { + t.Fatalf("defaults wrong: %+v", got) + } + if a.Webhook.TokenEnv == "T" && got.Token != "s3cret" { + t.Fatalf("token not read: %+v", got) + } + } + }) + } +} + +func TestActionAppliesTo(t *testing.T) { + tests := []struct { + kind string + kinds []string + want bool + }{ + {kinds: nil, kind: "anything", want: true}, + {kinds: []string{"workflow_failed"}, kind: "workflow_failed", want: true}, + {kinds: []string{"workflow_failed"}, kind: "pr_waiting", want: false}, + {kinds: []string{"a", "b"}, kind: "b", want: true}, + } + for _, tc := range tests { + if got := (Action{Kinds: tc.kinds}).AppliesTo(tc.kind); got != tc.want { + t.Errorf("%v.AppliesTo(%q) = %v", tc.kinds, tc.kind, got) + } + } +} diff --git a/internal/web/actions.go b/internal/web/actions.go new file mode 100644 index 0000000..579447b --- /dev/null +++ b/internal/web/actions.go @@ -0,0 +1,343 @@ +package web + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/lukaszraczylo/ghtracker/internal/config" + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +const ( + statusCacheTTL = 10 * time.Second + maxActionBody = 4 << 10 + maxUpstreamBody = 1 << 20 + maxErrorChars = 300 + actionsUnavail = "action status unavailable" + resultOK = "ok" + resultRejected = "rejected" + resultFailed = "error" + actionMetricName = "action_requests_total" + contentTypeHeader = "Content-Type" +) + +// apiAction is the public description of an action; it never carries URLs or tokens. +type apiAction struct { + ID string `json:"id"` + Label string `json:"label"` + Confirm string `json:"confirm,omitempty"` + Kinds []string `json:"kinds"` +} + +// apiActionStatus is what the UI shows next to an alert after an action ran. +type apiActionStatus struct { + State string `json:"state"` + Label string `json:"label,omitempty"` + Link string `json:"link,omitempty"` +} + +type statusRow struct { + Repo string `json:"repo"` + URL string `json:"url"` + apiActionStatus +} + +type alertRef struct{ repo, url string } + +// actionRuntime holds one action and the cache of its status rows. +type actionRuntime struct { + fetched time.Time + rows map[alertRef]apiActionStatus + err error + cfg config.Action + mu sync.Mutex +} + +// Actions runs the configured webhook actions and merges their status into the state. +type Actions struct { + now func() time.Time + hc *http.Client + log *slog.Logger + counter *prometheus.CounterVec + byID map[string]*actionRuntime + list []*actionRuntime +} + +// NewActions builds the runner and registers ghtracker_action_requests_total on reg when reg is not nil. +func NewActions(cfgs []config.Action, log *slog.Logger, reg prometheus.Registerer) *Actions { + a := &Actions{ + now: time.Now, log: log, byID: make(map[string]*actionRuntime, len(cfgs)), + // Redirects would carry the bearer token to another host, so none are followed. + hc: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, + counter: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "ghtracker", Name: actionMetricName, + Help: "Action requests sent to webhooks, by action and result.", + }, []string{"action", "result"}), + } + for _, c := range cfgs { + rt := &actionRuntime{cfg: c} + a.list = append(a.list, rt) + a.byID[c.ID] = rt + for _, r := range []string{resultOK, resultRejected, resultFailed} { + a.counter.WithLabelValues(c.ID, r) + } + } + if reg != nil { + reg.MustRegister(a.counter) + } + return a +} + +// WithActions enables POST /api/actions/{id} and the action data in the state document. +func (s *Server) WithActions(a *Actions) *Server { + if a != nil && len(a.list) > 0 { + s.actions = a + } + return s +} + +// decorate adds the action list and per-alert status to a state document. +func (s *Server) decorate(ctx context.Context, st *apiState) { + if s.actions == nil { + return + } + for _, rt := range s.actions.list { + kinds := rt.cfg.Kinds + if kinds == nil { + kinds = []string{} + } + st.Actions = append(st.Actions, apiAction{ID: rt.cfg.ID, Label: rt.cfg.Label, Confirm: rt.cfg.Confirm, Kinds: kinds}) + if rt.cfg.StatusURL == "" { + continue + } + rows, err := s.actions.statusRows(ctx, rt) + if err != nil { + st.ActionsError = actionsUnavail + continue + } + for i := range st.Alerts { + al := &st.Alerts[i] + if !rt.cfg.AppliesTo(al.Kind) { + continue + } + if row, ok := rows[alertRef{strings.ToLower(al.Repo), al.URL}]; ok { + if al.ActionStatus == nil { + al.ActionStatus = map[string]apiActionStatus{} + } + al.ActionStatus[rt.cfg.ID] = row + } + } + } +} + +// statusRows returns the cached rows of an action's status_url; failures are cached for the TTL too. +func (a *Actions) statusRows(ctx context.Context, rt *actionRuntime) (map[alertRef]apiActionStatus, error) { + rt.mu.Lock() + defer rt.mu.Unlock() + if !rt.fetched.IsZero() && a.now().Sub(rt.fetched) < statusCacheTTL { + return rt.rows, rt.err + } + rows, err := a.fetchStatus(ctx, rt.cfg) + if err != nil { + a.log.Warn("action status unavailable", "action", rt.cfg.ID, "err", err) + } + rt.rows, rt.err, rt.fetched = rows, err, a.now() + return rows, err +} + +func (rt *actionRuntime) invalidate() { + rt.mu.Lock() + rt.fetched = time.Time{} + rt.mu.Unlock() +} + +func (a *Actions) fetchStatus(ctx context.Context, c config.Action) (map[alertRef]apiActionStatus, error) { + ctx, cancel := context.WithTimeout(ctx, c.Webhook.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.StatusURL, nil) + if err != nil { + return nil, errors.New("invalid status url") + } + req.Header.Set("Accept", "application/json") + setBearer(req, c.Token) + resp, err := a.hc.Do(req) + if err != nil { + return nil, errors.New("request failed") + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + var list []statusRow + if err := json.NewDecoder(io.LimitReader(resp.Body, maxUpstreamBody)).Decode(&list); err != nil { + return nil, errors.New("invalid reply") + } + out := make(map[alertRef]apiActionStatus, len(list)) + for _, r := range list { // the first row per alert wins, so put the newest first + k := alertRef{strings.ToLower(r.Repo), r.URL} + if _, seen := out[k]; !seen { + out[k] = cleanStatus(r.apiActionStatus) + } + } + return out, nil +} + +// cleanStatus drops a link that is not http(s), so the UI never renders a script URL as an anchor. +func cleanStatus(st apiActionStatus) apiActionStatus { + if u, err := url.Parse(st.Link); err != nil || (u.Scheme != "http" && u.Scheme != "https") { + st.Link = "" + } + return st +} + +func setBearer(req *http.Request, token string) { + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } +} + +type actionRequest struct { + Repo string `json:"repo"` + URL string `json:"url"` +} + +type webhookAlert struct { + Since time.Time `json:"since"` + Repo string `json:"repo"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Subject string `json:"subject"` + Detail string `json:"detail"` + URL string `json:"url"` +} + +type webhookPayload struct { + RequestedAt time.Time `json:"requestedAt"` + Action string `json:"action"` + Alert webhookAlert `json:"alert"` +} + +// runAction validates the request against the current state and forwards it to the action's webhook. +func (s *Server) runAction(w http.ResponseWriter, r *http.Request) { + if crossSite(r) { + http.Error(w, "cross-site action refused", http.StatusForbidden) + return + } + rt, ok := s.actions.byID[r.PathValue("id")] + if !ok { + writeJSONError(w, http.StatusNotFound, "unknown action") + return + } + var req actionRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionBody)).Decode(&req); err != nil || req.Repo == "" || req.URL == "" { + writeJSONError(w, http.StatusBadRequest, `body must be {"repo":"owner/name","url":"..."}`) + return + } + alert, code, msg := s.findAlert(rt.cfg, req) + if code != 0 { + writeJSONError(w, code, msg) + return + } + status, body, err := s.actions.post(r.Context(), rt.cfg, webhookPayload{ + Action: rt.cfg.ID, RequestedAt: s.now().UTC(), + Alert: webhookAlert{Repo: alert.Repo, Kind: alert.Kind, Severity: alert.Severity.String(), Subject: alert.Subject, + Detail: alert.Detail, URL: alert.URL, Since: alert.Since}, + }) + if err != nil { + s.actions.count(rt.cfg.ID, resultFailed) + s.log.Warn("action webhook failed", "action", rt.cfg.ID, "repo", alert.Repo, "err", err) + writeJSONError(w, http.StatusBadGateway, "action webhook unreachable") + return + } + s.log.Info("action webhook", "action", rt.cfg.ID, "repo", alert.Repo, "status", status) + if status/100 != 2 { + result, out := resultFailed, http.StatusBadGateway + switch status / 100 { + case 4: + result, out = resultRejected, status + case 5: + out = status + } + s.actions.count(rt.cfg.ID, result) + writeJSONError(w, out, fmt.Sprintf("action webhook answered HTTP %d: %s", status, truncate(string(body), maxErrorChars))) + return + } + s.actions.count(rt.cfg.ID, resultOK) + rt.invalidate() + var st apiActionStatus + if json.Unmarshal(body, &st) != nil { + st = apiActionStatus{} + } + w.Header().Set(contentTypeHeader, "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(cleanStatus(st)) +} + +// findAlert returns the current alert a request targets, or a status and message when it has none. +func (s *Server) findAlert(c config.Action, req actionRequest) (model.Alert, int, string) { + v := s.src.View() + tracked := false + for _, repo := range v.Repos { + if strings.EqualFold(repo.FullName, req.Repo) { + tracked = true + break + } + } + if !tracked { + return model.Alert{}, http.StatusNotFound, "unknown repository" + } + for _, a := range v.Alerts { + if strings.EqualFold(a.Repo, req.Repo) && a.URL == req.URL && c.AppliesTo(a.Kind) { + return a, 0, "" + } + } + return model.Alert{}, http.StatusNotFound, "no such alert" +} + +func (a *Actions) post(ctx context.Context, c config.Action, p webhookPayload) (int, []byte, error) { + ctx, cancel := context.WithTimeout(ctx, c.Webhook.Timeout) + defer cancel() + payload, err := json.Marshal(p) + if err != nil { + return 0, nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Webhook.URL, bytes.NewReader(payload)) + if err != nil { + return 0, nil, errors.New("invalid webhook url") + } + req.Header.Set(contentTypeHeader, "application/json") + setBearer(req, c.Token) + resp, err := a.hc.Do(req) + if err != nil { + return 0, nil, errors.New("request failed") + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamBody)) + if err != nil { + return 0, nil, errors.New("read reply failed") + } + return resp.StatusCode, body, nil +} + +func (a *Actions) count(action, result string) { a.counter.WithLabelValues(action, result).Inc() } + +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + if r := []rune(s); len(r) > n { + return string(r[:n]) + "..." + } + return s +} diff --git a/internal/web/actions_test.go b/internal/web/actions_test.go new file mode 100644 index 0000000..522d566 --- /dev/null +++ b/internal/web/actions_test.go @@ -0,0 +1,409 @@ +package web + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + + "github.com/lukaszraczylo/ghtracker/internal/config" +) + +const ( + testToken = "tok-SECRET-123" + alertURL = "https://github.com/o/r/actions/runs/1" + prURL = "https://github.com/o/r/pull/5" +) + +type fakeHook struct { + srv *httptest.Server + postBody string + statBody string + lastAuth string + lastBody string + lastMethod string + postCode int + statCode int + mu sync.Mutex + postCalls atomic.Int32 + statCalls atomic.Int32 +} + +func newFakeHook(t *testing.T) *fakeHook { + t.Helper() + f := &fakeHook{postCode: 200, postBody: `{"state":"queued","label":"Queued","link":"https://ci.example.test/run/9"}`, statCode: 200, statBody: `[]`} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + f.mu.Lock() + f.lastAuth, f.lastBody, f.lastMethod = r.Header.Get("Authorization"), string(b), r.Method + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/status" { + f.statCalls.Add(1) + w.WriteHeader(f.statCode) + _, _ = w.Write([]byte(f.statBody)) + return + } + f.postCalls.Add(1) + w.WriteHeader(f.postCode) + _, _ = w.Write([]byte(f.postBody)) + })) + t.Cleanup(f.srv.Close) + return f +} + +type actionHarness struct { + srv *Server + actions *Actions + logs *bytes.Buffer + clock time.Time +} + +func newActionServer(t *testing.T, mutate func(*config.Action), base string) *actionHarness { + t.Helper() + c := config.Action{ID: "rerun", Label: "Re-run", Kinds: []string{"workflow_failed"}, Confirm: "Run it?", + StatusURL: base + "/status", Token: testToken, + Webhook: config.Webhook{URL: base + "/hook", TokenEnv: "T", Timeout: 2 * time.Second}} + if mutate != nil { + mutate(&c) + } + h := &actionHarness{logs: &bytes.Buffer{}, clock: now} + log := slog.New(slog.NewTextHandler(h.logs, nil)) + h.actions = NewActions([]config.Action{c}, log, prometheus.NewRegistry()) + h.actions.now = func() time.Time { return h.clock } + h.srv = New(&fakeSource{v: testView()}, http.NotFoundHandler(), testUI(), true, log).WithActions(h.actions) + h.srv.now = func() time.Time { return now } + return h +} + +func (h *actionHarness) post(path, body string, hdr map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + for k, v := range hdr { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + h.srv.Handler().ServeHTTP(rec, req) + return rec +} + +func (h *actionHarness) state(t *testing.T) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(do(h.srv.Handler(), "GET", "/api/state").Body.Bytes(), &m); err != nil { + t.Fatal(err) + } + return m +} + +func reqBody(repo, url string) string { + b, _ := json.Marshal(map[string]string{"repo": repo, "url": url}) + return string(b) +} + +func (h *actionHarness) counter(result string) float64 { + return testutil.ToFloat64(h.actions.counter.WithLabelValues("rerun", result)) +} + +func TestActionRequest(t *testing.T) { + tests := []struct { + hdr map[string]string + mutate func(*config.Action) + name string + path string + body string + postBody string + wantResult string + wantBody string + postCode int + wantCode int + wantPosts int32 + }{ + {name: "ok passes status through", wantCode: 200, wantPosts: 1, wantResult: "ok", + wantBody: `"state":"queued"`}, + {name: "ok with empty body", postBody: "not json", wantCode: 200, wantPosts: 1, wantResult: "ok", wantBody: `"state":""`}, + {name: "repo case-insensitive", body: reqBody("O/R", alertURL), wantCode: 200, wantPosts: 1, wantResult: "ok"}, + {name: "javascript link dropped", postBody: `{"state":"x","link":"javascript:alert(1)"}`, + wantCode: 200, wantPosts: 1, wantResult: "ok", wantBody: `{"state":"x"}`}, + {name: "no token configured", mutate: func(a *config.Action) { a.Token = "" }, wantCode: 200, wantPosts: 1, wantResult: "ok"}, + {name: "upstream 4xx", postCode: 429, postBody: `{"error":"busy"}`, wantCode: 429, wantPosts: 1, wantResult: "rejected", wantBody: "HTTP 429"}, + {name: "upstream 5xx", postCode: 500, postBody: "boom", wantCode: 500, wantPosts: 1, wantResult: "error", wantBody: "boom"}, + {name: "upstream redirect not followed", postCode: 302, postBody: "moved", wantCode: 502, wantPosts: 1, wantResult: "error"}, + {name: "upstream body truncated", postCode: 500, postBody: strings.Repeat("x", 1000), wantCode: 500, wantPosts: 1, wantResult: "error", + wantBody: strings.Repeat("x", 300) + "..."}, + {name: "cross-site refused", hdr: map[string]string{"Sec-Fetch-Site": "cross-site"}, wantCode: 403}, + {name: "same-site refused", hdr: map[string]string{"Sec-Fetch-Site": "same-site"}, wantCode: 403}, + {name: "same-origin allowed", hdr: map[string]string{"Sec-Fetch-Site": "same-origin"}, wantCode: 200, wantPosts: 1, wantResult: "ok"}, + {name: "unknown action", path: "/api/actions/nope", wantCode: 404, wantBody: "unknown action"}, + {name: "unknown repo", body: reqBody("x/y", alertURL), wantCode: 404, wantBody: "unknown repository"}, + {name: "unknown alert", body: reqBody("o/r", alertURL+"9"), wantCode: 404, wantBody: "no such alert"}, + {name: "kind not in kinds", body: reqBody("o/r", prURL), wantCode: 404, wantBody: "no such alert"}, + {name: "empty kinds matches every kind", body: reqBody("o/r", prURL), mutate: func(a *config.Action) { a.Kinds = nil }, + wantCode: 200, wantPosts: 1, wantResult: "ok"}, + {name: "malformed body", body: `{`, wantCode: 400}, + {name: "missing url", body: `{"repo":"o/r"}`, wantCode: 400}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fh := newFakeHook(t) + if tc.postCode != 0 { + fh.postCode = tc.postCode + } + if tc.postBody != "" { + fh.postBody = tc.postBody + } + h := newActionServer(t, tc.mutate, fh.srv.URL) + path, body := tc.path, tc.body + if path == "" { + path = "/api/actions/rerun" + } + if body == "" { + body = reqBody("o/r", alertURL) + } + rec := h.post(path, body, tc.hdr) + if rec.Code != tc.wantCode { + t.Fatalf("code = %d, want %d: %s", rec.Code, tc.wantCode, rec.Body) + } + if fh.postCalls.Load() != tc.wantPosts { + t.Fatalf("webhook posts = %d, want %d", fh.postCalls.Load(), tc.wantPosts) + } + if tc.wantBody != "" && !strings.Contains(rec.Body.String(), tc.wantBody) { + t.Fatalf("body %q lacks %q", rec.Body, tc.wantBody) + } + if tc.wantResult != "" && h.counter(tc.wantResult) != 1 { + t.Fatalf("counter %s = %v, want 1", tc.wantResult, h.counter(tc.wantResult)) + } + if tc.wantPosts > 0 { + wantAuth := "Bearer " + testToken + if tc.mutate != nil && tc.name == "no token configured" { + wantAuth = "" + } + if fh.lastAuth != wantAuth { + t.Fatalf("auth = %q, want %q", fh.lastAuth, wantAuth) + } + } + if strings.Contains(h.logs.String(), testToken) || strings.Contains(rec.Body.String(), testToken) { + t.Fatal("token leaked into logs or response") + } + }) + } +} + +func TestActionWebhookPayload(t *testing.T) { + fh := newFakeHook(t) + h := newActionServer(t, nil, fh.srv.URL) + if rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil); rec.Code != 200 { + t.Fatal(rec.Body) + } + var got struct { + RequestedAt time.Time + Alert map[string]string + Action string + } + if err := json.Unmarshal([]byte(fh.lastBody), &got); err != nil { + t.Fatal(err) + } + if fh.lastMethod != "POST" || got.Action != "rerun" || !got.RequestedAt.Equal(now) { + t.Fatalf("payload: %+v (%s)", got, fh.lastBody) + } + want := map[string]string{"repo": "o/r", "kind": "workflow_failed", "severity": "crit", "subject": "CI", "url": alertURL} + for k, v := range want { + if got.Alert[k] != v { + t.Errorf("alert.%s = %q, want %q", k, got.Alert[k], v) + } + } + if got.Alert["detail"] == "" || got.Alert["since"] == "" { + t.Errorf("alert lacks detail or since: %v", got.Alert) + } +} + +func TestActionWebhookDown(t *testing.T) { + fh := newFakeHook(t) + base := fh.srv.URL + fh.srv.Close() + h := newActionServer(t, nil, base) + rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil) + if rec.Code != http.StatusBadGateway || h.counter("error") != 1 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + if strings.Contains(h.logs.String(), testToken) { + t.Fatal("token leaked into logs") + } +} + +func TestActionRouteAbsentWithoutActions(t *testing.T) { + h := newServer(&fakeSource{v: testView()}, testUI(), true).Handler() + if rec := do(h, "POST", "/api/actions/rerun"); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("code = %d", rec.Code) + } +} + +func TestStateUnchangedWithoutActions(t *testing.T) { + body := do(newServer(&fakeSource{v: testView()}, testUI(), true).Handler(), "GET", "/api/state").Body.String() + for _, key := range []string{`"actions"`, `"actionStatus"`, `"actionsError"`} { + if strings.Contains(body, key) { + t.Fatalf("state gained %s without actions: %s", key, body) + } + } +} + +func alertField(m map[string]any, kind, field string) any { + for _, a := range m["alerts"].([]any) { + if al := a.(map[string]any); al["kind"] == kind { + return al[field] + } + } + return nil +} + +func TestStateMergesActionStatus(t *testing.T) { + tests := []struct { + wantState any + wantLink any + mutate func(*config.Action) + name string + rows string + }{ + {name: "match by repo and url", rows: `[{"repo":"O/R","url":"` + alertURL + `","state":"running","label":"Running","link":"https://ci.example.test/1"}]`, + wantState: "running", wantLink: "https://ci.example.test/1"}, + {name: "first row wins", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"new"},{"repo":"o/r","url":"` + alertURL + `","state":"old"}]`, + wantState: "new"}, + {name: "other url ignored", rows: `[{"repo":"o/r","url":"` + alertURL + `x","state":"running"}]`}, + {name: "other repo ignored", rows: `[{"repo":"o/other","url":"` + alertURL + `","state":"running"}]`}, + {name: "unsafe link dropped", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"done","link":"javascript:x"}]`, wantState: "done"}, + {name: "kind filter respected", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"running"}]`, + mutate: func(a *config.Action) { a.Kinds = []string{"pr_waiting"} }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fh := newFakeHook(t) + fh.statBody = tc.rows + m := newActionServer(t, tc.mutate, fh.srv.URL).state(t) + if _, ok := m["actionsError"]; ok { + t.Fatalf("unexpected actionsError: %v", m["actionsError"]) + } + byAction, _ := alertField(m, "workflow_failed", "actionStatus").(map[string]any) + st, _ := byAction["rerun"].(map[string]any) + if tc.wantState == nil { + if st != nil { + t.Fatalf("status present: %v", st) + } + return + } + if st["state"] != tc.wantState || st["link"] != tc.wantLink { + t.Fatalf("status = %v, want state %v link %v", st, tc.wantState, tc.wantLink) + } + }) + } +} + +func TestStateListsActionsWithoutSecrets(t *testing.T) { + fh := newFakeHook(t) + h := newActionServer(t, nil, fh.srv.URL) + rec := do(h.srv.Handler(), "GET", "/api/state") + raw := rec.Body.String() + for _, secret := range []string{testToken, fh.srv.URL, "/hook", "/status"} { + if strings.Contains(raw, secret) { + t.Fatalf("state leaks %q", secret) + } + } + var m struct { + Actions []map[string]any + } + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil || len(m.Actions) != 1 { + t.Fatalf("%v %s", err, raw) + } + a := m.Actions[0] + if a["id"] != "rerun" || a["label"] != "Re-run" || a["confirm"] != "Run it?" || len(a["kinds"].([]any)) != 1 { + t.Fatalf("action = %v", a) + } +} + +func TestStateStatusDown(t *testing.T) { + tests := map[string]func(*fakeHook, *string){ + "server error": func(f *fakeHook, _ *string) { f.statCode = 500 }, + "invalid json": func(f *fakeHook, _ *string) { f.statBody = "nope" }, + "unreachable": func(f *fakeHook, base *string) { f.srv.Close(); *base = f.srv.URL }, + } + for name, breakIt := range tests { + t.Run(name, func(t *testing.T) { + fh := newFakeHook(t) + base := fh.srv.URL + breakIt(fh, &base) + h := newActionServer(t, nil, base) + m := h.state(t) + if m["actionsError"] != actionsUnavail { + t.Fatalf("actionsError = %v", m["actionsError"]) + } + if alertField(m, "workflow_failed", "actionStatus") != nil { + t.Fatal("actionStatus present while status is down") + } + if len(m["actions"].([]any)) != 1 { + t.Fatal("actions must still be listed so the buttons work") + } + if strings.Contains(h.logs.String(), testToken) { + t.Fatal("token leaked into logs") + } + }) + } +} + +func TestStateWithoutStatusURL(t *testing.T) { + fh := newFakeHook(t) + h := newActionServer(t, func(a *config.Action) { a.StatusURL = "" }, fh.srv.URL) + m := h.state(t) + if _, ok := m["actionsError"]; ok || fh.statCalls.Load() != 0 || len(m["actions"].([]any)) != 1 { + t.Fatalf("state: %v, status calls %d", m, fh.statCalls.Load()) + } +} + +func TestStatusCachedAndInvalidatedByRequest(t *testing.T) { + fh := newFakeHook(t) + h := newActionServer(t, nil, fh.srv.URL) + h.state(t) + h.state(t) + if fh.statCalls.Load() != 1 { + t.Fatalf("status calls = %d, want 1 inside the TTL", fh.statCalls.Load()) + } + h.clock = h.clock.Add(statusCacheTTL + time.Second) + h.state(t) + if fh.statCalls.Load() != 2 { + t.Fatalf("status calls = %d, want 2 after the TTL", fh.statCalls.Load()) + } + h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil) + h.state(t) + if fh.statCalls.Load() != 3 { + t.Fatalf("status calls = %d, want 3 after a request", fh.statCalls.Load()) + } +} + +func TestRedirectDoesNotForwardToken(t *testing.T) { + var leaked atomic.Bool + other := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + leaked.Store(true) + } + })) + t.Cleanup(other.Close) + fh := newFakeHook(t) + hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect) + })) + t.Cleanup(hook.Close) + h := newActionServer(t, func(a *config.Action) { a.Webhook.URL = hook.URL }, fh.srv.URL) + if rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil); rec.Code != http.StatusBadGateway { + t.Fatalf("code = %d", rec.Code) + } + if leaked.Load() { + t.Fatal("bearer token forwarded across a redirect") + } +} diff --git a/internal/web/api.go b/internal/web/api.go index 82cf359..1664c75 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -10,13 +10,15 @@ import ( // apiState is the JSON contract with the UI. Lists of healthy items stay on GitHub; // the UI gets counts, release info and the alerts that explain every non-green state. type apiState struct { - Now time.Time `json:"now"` LastRefresh time.Time `json:"lastRefresh"` + Now time.Time `json:"now"` + ActionsError string `json:"actionsError,omitempty"` + Actions []apiAction `json:"actions,omitempty"` Repos []apiRepo `json:"repos"` Alerts []apiAlert `json:"alerts"` Thresholds apiThresholds `json:"thresholds"` - Counts apiCounts `json:"counts"` Scan apiScan `json:"scan"` + Counts apiCounts `json:"counts"` IntervalSeconds int64 `json:"intervalSeconds"` Loaded bool `json:"loaded"` Refreshing bool `json:"refreshing"` @@ -85,13 +87,14 @@ type apiRelease struct { } type apiAlert struct { - Since time.Time `json:"since"` - Repo string `json:"repo"` - Kind string `json:"kind"` - Severity string `json:"severity"` - Subject string `json:"subject"` - Detail string `json:"detail"` - URL string `json:"url"` + Since time.Time `json:"since"` + ActionStatus map[string]apiActionStatus `json:"actionStatus,omitempty"` + Repo string `json:"repo"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Subject string `json:"subject"` + Detail string `json:"detail"` + URL string `json:"url"` } func seconds(d time.Duration) int64 { return int64(d / time.Second) } diff --git a/internal/web/server.go b/internal/web/server.go index 900e89b..5380488 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -41,6 +41,7 @@ type Server struct { lastRepo map[string]time.Time now func() time.Time src Source + actions *Actions ui fs.FS metrics http.Handler log *slog.Logger @@ -64,6 +65,9 @@ func (s *Server) Handler() http.Handler { if s.manualRefresh { mux.HandleFunc("POST /refresh", s.refresh) } + if s.actions != nil { + mux.HandleFunc("POST /api/actions/{id}", s.runAction) + } return securityHeaders(mux) } @@ -99,10 +103,12 @@ func (s *Server) asset(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, s.ui, name) } -func (s *Server) state(w http.ResponseWriter, _ *http.Request) { +func (s *Server) state(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - if err := json.NewEncoder(w).Encode(buildState(s.src.View(), s.manualRefresh)); err != nil { + st := buildState(s.src.View(), s.manualRefresh) + s.decorate(r.Context(), &st) + if err := json.NewEncoder(w).Encode(st); err != nil { s.log.Error("encode state", "err", err) } } @@ -117,8 +123,7 @@ func (s *Server) health(w http.ResponseWriter, _ *http.Request) { } func (s *Server) refresh(w http.ResponseWriter, r *http.Request) { - // Browsers label cross-site requests; refuse them so another page cannot spend the API budget. - if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" { + if crossSite(r) { http.Error(w, "cross-site refresh refused", http.StatusForbidden) return } @@ -140,6 +145,13 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) } +// crossSite reports whether the browser labelled the request as cross-site, so another page +// cannot spend the API budget or fire a webhook. +func crossSite(r *http.Request) bool { + site := r.Header.Get("Sec-Fetch-Site") + return site != "" && site != "same-origin" && site != "none" +} + // repoRefreshResponse is the full state after the refresh, so the UI can swap it in, plus the repo name. type repoRefreshResponse struct { Repo string `json:"repo"` @@ -185,7 +197,9 @@ func (s *Server) refreshRepo(w http.ResponseWriter, r *http.Request, repo string } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: buildState(v, s.manualRefresh)}); err != nil { + st := buildState(v, s.manualRefresh) + s.decorate(r.Context(), &st) + if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: st}); err != nil { s.log.Error("encode repo refresh", "err", err) } } diff --git a/main.go b/main.go index b576aec..e316c42 100644 --- a/main.go +++ b/main.go @@ -80,6 +80,10 @@ func run(ctx context.Context, cfgPath string, log *slog.Logger) error { } srv := web.New(coll, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), ui, *cfg.ManualRefresh, log) + if len(cfg.Actions) > 0 { + srv.WithActions(web.NewActions(cfg.Actions, log, reg)) + } + go coll.Run(ctx) hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: headerTimeout}