diff --git a/internal/cluster/orchestrator.go b/internal/cluster/orchestrator.go index ff5ba72..faf5840 100644 --- a/internal/cluster/orchestrator.go +++ b/internal/cluster/orchestrator.go @@ -21,6 +21,7 @@ import ( "strings" "sync" "time" + "unicode" "scm.bstein.dev/bstein/ananke/internal/config" "scm.bstein.dev/bstein/ananke/internal/execx" @@ -2225,12 +2226,12 @@ func (o *Orchestrator) serviceCheckReady(ctx context.Context, check config.Servi } bodyContains := strings.TrimSpace(check.BodyContains) - if bodyContains != "" && !strings.Contains(strings.ToLower(body), strings.ToLower(bodyContains)) { + if bodyContains != "" && !checklistContains(body, bodyContains) { return false, fmt.Sprintf("response missing expected marker %q", bodyContains) } bodyNotContains := strings.TrimSpace(check.BodyNotContains) - if bodyNotContains != "" && strings.Contains(strings.ToLower(body), strings.ToLower(bodyNotContains)) { + if bodyNotContains != "" && checklistContains(body, bodyNotContains) { return false, fmt.Sprintf("response contained forbidden marker %q", bodyNotContains) } @@ -2272,6 +2273,32 @@ func (o *Orchestrator) httpChecklistProbe(ctx context.Context, check config.Serv return resp.StatusCode, string(body), nil } +func checklistContains(body, marker string) bool { + bodyLower := strings.ToLower(body) + markerLower := strings.ToLower(marker) + if strings.Contains(bodyLower, markerLower) { + return true + } + bodyCompact := compactLowerNoSpace(bodyLower) + markerCompact := compactLowerNoSpace(markerLower) + if markerCompact == "" { + return true + } + return strings.Contains(bodyCompact, markerCompact) +} + +func compactLowerNoSpace(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if unicode.IsSpace(r) { + continue + } + b.WriteRune(r) + } + return b.String() +} + func (o *Orchestrator) waitForStabilityWindow(ctx context.Context) error { window := time.Duration(o.cfg.Startup.ServiceChecklistStabilitySec) * time.Second if window <= 0 { diff --git a/internal/cluster/orchestrator_test.go b/internal/cluster/orchestrator_test.go index 8f35eaf..72a9fca 100644 --- a/internal/cluster/orchestrator_test.go +++ b/internal/cluster/orchestrator_test.go @@ -192,3 +192,25 @@ func TestServiceCheckReadyRequiresBodyContains(t *testing.T) { t.Fatalf("expected service check to pass, detail=%s", detail) } } + +func TestServiceCheckReadyBodyContainsIgnoresWhitespace(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{\n \"database\": \"ok\"\n}\n")) + })) + defer srv.Close() + + orch := &Orchestrator{ + log: log.New(os.Stdout, "", 0), + } + ok, detail := orch.serviceCheckReady(context.Background(), config.ServiceChecklistCheck{ + Name: "grafana-api", + URL: srv.URL, + AcceptedStatuses: []int{200}, + BodyContains: `"database":"ok"`, + TimeoutSeconds: 5, + }) + if !ok { + t.Fatalf("expected whitespace-tolerant service check to pass, detail=%s", detail) + } +}