Files
llmgw/internal/cli/serverinit_notify.go
T
Ilker ManapandClaude Opus 5.5 d1e91868ee Setup asks where alerts go, and sends each a test
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>
2026-10-14 13:55:48 +02:00

186 lines
5.4 KiB
Go

package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"time"
"go.yaml.in/yaml/v3"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
)
// Where the cluster says what went wrong: a model parked, a node lost, a
// budget spent. notify.webhooks existed, and setup never asked for one, so a
// site learnt of a lost node from its users.
// parseWebhook reads a webhook as setup takes it: an address, with "slack:"
// or "json:" in front to say what it is sent, else what its address says.
func parseWebhook(s string) (notify.Webhook, error) {
s = strings.TrimSpace(s)
format := ""
for _, f := range []string{"slack", "json"} {
if rest, ok := strings.CutPrefix(s, f+":"); ok && !strings.HasPrefix(rest, "//") {
format, s = f, rest
}
}
u, err := url.Parse(s)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return notify.Webhook{}, fmt.Errorf("webhook %q: an http(s) address, with slack: or json: in front to say its format", s)
}
if format == "" {
format = webhookFormat(u)
}
return notify.Webhook{URL: s, Format: format}, nil
}
// webhookFormat is what an address takes: Slack's own, and the incoming
// webhooks that read Slack's {"text": …} — Mattermost's and Rocket.Chat's
// /hooks/, Discord's …/slack — are sent that; anything else the event as
// JSON.
func webhookFormat(u *url.URL) string {
switch {
case u.Host == "hooks.slack.com",
strings.Contains(u.Path, "/hooks/"),
strings.HasSuffix(u.Path, "/slack"):
return "slack"
}
return "json"
}
// parseWebhooks reads a list of them, by commas or as given.
func parseWebhooks(list []string) ([]notify.Webhook, error) {
var out []notify.Webhook
for _, item := range list {
for _, s := range strings.Split(item, ",") {
if strings.TrimSpace(s) == "" {
continue
}
w, err := parseWebhook(s)
if err != nil {
return nil, err
}
out = append(out, w)
}
}
return out, nil
}
// webhookEntry is one of notify.webhooks as the settings document has it.
type webhookEntry struct {
URL string `yaml:"url"`
Format string `yaml:"format,omitempty"`
Events []string `yaml:"events,omitempty,flow"`
SecretFile string `yaml:"secret_file,omitempty"`
}
// withWebhooks adds these webhooks to a settings document's notify.webhooks,
// and says whether that changed it: one already there, by address, is left
// as it is, with whatever events and secret it was given.
func withWebhooks(doc *settings.Doc, add []notify.Webhook) (bool, error) {
path := []string{"notify", "webhooks"}
var list []webhookEntry
if n := doc.Node(path); n != nil {
if err := n.Decode(&list); err != nil {
return false, fmt.Errorf("notify.webhooks in the cluster's settings: %w", err)
}
}
changed := false
for _, w := range add {
if !slices.ContainsFunc(list, func(e webhookEntry) bool { return e.URL == w.URL }) {
list = append(list, webhookEntry{URL: w.URL, Format: w.Format})
changed = true
}
}
if !changed {
return false, nil
}
b, err := yaml.Marshal(list)
if err != nil {
return false, err
}
return true, doc.SetYAML(path, string(b))
}
// ensureWebhooks puts the webhooks given into the cluster's settings and
// sends each a test, once the control plane runs on them. Said, not fatal: a
// cluster without its alerts still serves.
func (in *serverInit) ensureWebhooks(ctx context.Context) error {
if len(in.webhooks) == 0 {
return nil
}
hooks, err := parseWebhooks(in.webhooks)
if err != nil {
return err
}
c := in.adminAPI()
var cur adminapi.SettingsView
if err := c.get(ctx, "/api/v1/settings", &cur); err != nil {
return err
}
doc, err := settings.FromBytes("settings", []byte(cur.Body))
if err != nil {
return err
}
changed, err := withWebhooks(doc, hooks)
if err != nil {
return err
}
if changed {
b, err := doc.Bytes()
if err != nil {
return err
}
req, _ := json.Marshal(adminapi.SettingsRequest{Base: cur.Version, Body: string(b), Note: "notification webhooks, from setup"})
if err := c.put(ctx, "/api/v1/settings", req, &cur); err != nil {
return err
}
in.done("notification webhooks in the cluster's settings (version %d)", cur.Version)
} else {
in.kept("notification webhooks (already in the cluster's settings)")
}
// The control planes start again on the change; the test is sent by
// one that knows every webhook asked for.
var res []notify.Result
err = waitFor(ctx, 2*time.Minute, "the control plane to run on its webhooks", func() (bool, error) {
res = nil
if c.post(ctx, "/api/v1/notify/test", nil, &res) != nil {
return false, nil
}
for _, w := range hooks {
// Answered by host: the path of a webhook is its secret.
if !slices.ContainsFunc(res, func(r notify.Result) bool { return r.URL == notify.Redact(w.URL) }) {
return false, nil
}
}
return true, nil
})
if err != nil {
return err
}
var failed []string
for _, r := range res {
if !slices.ContainsFunc(hooks, func(w notify.Webhook) bool { return notify.Redact(w.URL) == r.URL }) {
continue
}
if r.Err != "" {
failed = append(failed, r.URL+": "+r.Err)
continue
}
in.done("a test notification reached %s", r.URL)
}
if len(failed) > 0 {
return errors.New("a test notification did not arrive: " + strings.Join(failed, "; ") +
" (kept in the settings: 'llmgw ctl notify test' tries again)")
}
return nil
}