notify.webhooks existed and setup never asked for one, so a new site learnt of a parked model or a lost node from its users. `llmgw server init --webhook URL` (repeatable) and a Notification webhooks field on both setup screens take them now. Slack's address, Mattermost's and Rocket.Chat's /hooks/ and Discord's …/slack are sent Slack's text, any other the event as JSON; slack: or json: in front says which. Once the control plane runs, they are added to the cluster's settings beside the webhooks already there, none twice, and setup waits for the control planes to start again on the change before sending each a test, saying which answered. A webhook's path is its secret, so it is shown by host only. A failure is said, not fatal: the cluster serves without its alerts, and ctl notify test tries again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
4.7 KiB
Go
123 lines
4.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
|
)
|
|
|
|
func TestAWebhooksFormatIsWhatItsAddressTakes(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"https://hooks.slack.com/services/T1/B2/abc": "slack",
|
|
"https://chat.example.com/hooks/xyz": "slack", // Mattermost, Rocket.Chat
|
|
"https://discord.com/api/webhooks/1/abc/slack": "slack",
|
|
"https://alerts.example.com/llmgw": "json",
|
|
"json:https://hooks.slack.com/services/T1/B2/abc": "json",
|
|
"slack:https://alerts.example.com/llmgw": "slack",
|
|
" https://events.pagerduty.com/integration/x/enqueue ": "json",
|
|
} {
|
|
w, err := parseWebhook(in)
|
|
if err != nil || w.Format != want || strings.HasPrefix(w.URL, "slack:") || strings.HasPrefix(w.URL, "json:") {
|
|
t.Errorf("%q: %+v %v, want %s", in, w, err, want)
|
|
}
|
|
}
|
|
for _, bad := range []string{"hooks.slack.com/x", "ftp://example.com/x", "slack:", "https://"} {
|
|
if _, err := parseWebhook(bad); err == nil {
|
|
t.Errorf("%q was taken", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWebhooksAreAddedToTheSettingsBesideTheOnesThere(t *testing.T) {
|
|
doc, err := settings.FromBytes("settings", []byte("router:\n retries: 2\nnotify:\n webhooks:\n"+
|
|
" - url: https://alerts.example.com/a\n events: [node_lost]\n secret_file: /etc/llmgw/secrets/hook\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hooks, _ := parseWebhooks([]string{"https://alerts.example.com/a, https://hooks.slack.com/services/T/B/c"})
|
|
changed, err := withWebhooks(doc, hooks)
|
|
if err != nil || !changed {
|
|
t.Fatalf("changed %v: %v", changed, err)
|
|
}
|
|
b, _ := doc.Bytes()
|
|
got := string(b)
|
|
for _, want := range []string{"retries: 2", "url: https://alerts.example.com/a", "events: [node_lost]",
|
|
"secret_file: /etc/llmgw/secrets/hook", "url: https://hooks.slack.com/services/T/B/c", "format: slack"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("%q not in:\n%s", want, got)
|
|
}
|
|
}
|
|
if strings.Count(got, "alerts.example.com/a") != 1 {
|
|
t.Errorf("a webhook already there was added again:\n%s", got)
|
|
}
|
|
if changed, _ := withWebhooks(doc, hooks); changed {
|
|
t.Error("adding the same webhooks again changed the settings")
|
|
}
|
|
}
|
|
|
|
func TestSetupPutsItsWebhooksInTheSettingsAndTestsThemOnceInForce(t *testing.T) {
|
|
var mu sync.Mutex
|
|
body, tests := "router:\n retries: 2\n", 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/settings":
|
|
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 1, Body: body})
|
|
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/settings":
|
|
var req adminapi.SettingsRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
body = req.Body
|
|
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 2, Body: body})
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/notify/test":
|
|
tests++
|
|
// The first answer comes from the control plane before it
|
|
// started again on the change: it knows no webhooks.
|
|
res := []notify.Result{}
|
|
if tests > 1 {
|
|
res = []notify.Result{{URL: notify.Redact("https://hooks.slack.com/services/T/B/c")},
|
|
{URL: notify.Redact("https://ops.example.com/in"), Err: "502 Bad Gateway"}}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(res)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "server.yaml"),
|
|
[]byte("admin_listen: "+strings.TrimPrefix(srv.URL, "http://")+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out bytes.Buffer
|
|
in := &serverInit{etcDir: dir, out: &out,
|
|
webhooks: []string{"https://hooks.slack.com/services/T/B/c", "https://ops.example.com/in"}}
|
|
err := in.ensureWebhooks(t.Context())
|
|
if err == nil || !strings.Contains(err.Error(), "502") || !strings.Contains(err.Error(), "ctl notify test") {
|
|
t.Errorf("a webhook that did not answer: %v", err)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if !strings.Contains(body, "format: slack") || !strings.Contains(body, "url: https://ops.example.com/in") ||
|
|
!strings.Contains(body, "retries: 2") || tests < 2 {
|
|
t.Errorf("settings %q after %d tests", body, tests)
|
|
}
|
|
if strings.Contains(out.String(), "/services/T/B/c") {
|
|
t.Errorf("a webhook's secret path was printed:\n%s", out.String())
|
|
}
|
|
if !strings.Contains(out.String(), "reached https://hooks.slack.com/…") {
|
|
t.Errorf("the one that answered is not said:\n%s", out.String())
|
|
}
|
|
}
|