package agent
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strings"
"sync"
)
var (
tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
spaceRe = regexp.MustCompile(`\s+`)
)
// checkHealth ruft spec.URL ab und prüft Status sowie optional
und
// ein Element mit Text. Bewusst einfacher Textabgleich statt HTML-Parser:
// Es geht um "liefert die Seite noch das Erwartete", nicht um Validierung.
func checkHealth(ctx context.Context, client *http.Client, spec HealthSpec) error {
ctx, cancel := context.WithTimeout(ctx, spec.Timeout.Duration)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, spec.URL, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != spec.ExpectStatus {
return fmt.Errorf("Status %d statt %d", resp.StatusCode, spec.ExpectStatus)
}
if spec.ExpectTitle == "" && spec.ExpectContains == nil {
return nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if spec.ExpectTitle != "" {
titles := elementTexts(string(body), "title")
if len(titles) == 0 || titles[0] != strings.TrimSpace(spec.ExpectTitle) {
return fmt.Errorf(" %q statt %q", strings.Join(titles, ""), spec.ExpectTitle)
}
}
if c := spec.ExpectContains; c != nil {
for _, text := range elementTexts(string(body), c.Tag) {
if strings.Contains(text, c.Text) {
return nil
}
}
return fmt.Errorf("kein <%s> mit %q", c.Tag, c.Text)
}
return nil
}
// elementTexts liefert den Textinhalt aller -Elemente (ohne innere Tags,
// Leerraum zusammengefasst). Groß-/Kleinschreibung des Tags ist egal.
func elementTexts(html, tag string) []string {
t := regexp.QuoteMeta(tag)
re := regexp.MustCompile(`(?is)<` + t + `\b[^>]*>(.*?)` + t + `\s*>`)
var out []string
for _, m := range re.FindAllStringSubmatch(html, -1) {
text := tagRe.ReplaceAllString(m[1], " ")
out = append(out, strings.TrimSpace(spaceRe.ReplaceAllString(text, " ")))
}
return out
}
// healthTracker merkt sich den Zustand je Dienst: "down" erst nach
// threshold Fehlschlägen in Folge, "up" nach einem Erfolg.
type healthTracker struct {
mu sync.Mutex
state map[string]*healthState
}
type healthState struct {
fails int
down bool
}
// report verbucht ein Ergebnis und meldet, ob sich up/down geändert hat.
func (t *healthTracker) report(name string, threshold int, ok bool) bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.state == nil {
t.state = map[string]*healthState{}
}
st := t.state[name]
if st == nil {
st = &healthState{}
t.state[name] = st
}
if ok {
st.fails = 0
if st.down {
st.down = false
return true
}
return false
}
st.fails++
if !st.down && st.fails >= threshold {
st.down = true
return true
}
return false
}
// down liefert die ausgefallenen Dienste, sortiert (nil, wenn keiner).
func (t *healthTracker) down() []string {
t.mu.Lock()
defer t.mu.Unlock()
var out []string
for name, st := range t.state {
if st.down {
out = append(out, name)
}
}
slices.Sort(out)
return out
}
// retain vergisst Dienste, die es nicht mehr gibt.
func (t *healthTracker) retain(keep map[string]bool) {
t.mu.Lock()
defer t.mu.Unlock()
for name := range t.state {
if !keep[name] {
delete(t.state, name)
}
}
}