mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-18 01:27:30 +00:00
feat(web): add opt-in webhook alert actions
An optional actions block in the config adds buttons to alerts. POST
/api/actions/{id} checks that the repository and the current alert match,
then sends the alert as JSON to the configured webhook with an optional
bearer token. The request refuses cross-site callers like POST /refresh.
GET /api/state lists the actions and merges per-alert status rows from an
optional status_url, cached for 10 seconds. Without an actions block the
API output does not change.
The ghtracker_action_requests_total counter records each result.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user