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>
186 lines
5.4 KiB
Go
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
|
|
}
|