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("<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) } } }