138 lines
3.3 KiB
Go
138 lines
3.3 KiB
Go
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 <title> 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("<title> %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 <tag>-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)
|
|
}
|
|
}
|
|
}
|