Files
kacerrandClaude Opus 5 88a15ecdb5 Add the provider health Monitor
A leader-only runnable checks every configured provider concurrently
under one timeout per round (first round at start, then every interval),
keeps the latest results for the metrics collector, logs targets going
down, changing reason or coming back, and publishes one ProviderHealth
object per provider: created with a managed-by label when missing, then
a status merge patch. Per-target lastTransitionTime survives rounds in
which ready does not flip. Objects of providers that are no longer
configured are deleted once at start; with checks turned off, all are.
Reads go through the API reader, so no cluster-wide informer is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:20:52 +00:00

136 lines
5.3 KiB
Go

package providerhealth
import (
"strings"
"testing"
"time"
"unicode/utf8"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
func readyAt(name string, at time.Time) crawlv1alpha1.ProviderTarget {
return crawlv1alpha1.ProviderTarget{Name: name, Ready: true, Reason: provider.ReasonReachable, LastTransitionTime: metav1.NewTime(at)}
}
func TestBuildStatus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
prev crawlv1alpha1.ProviderHealthStatus
result Result
wantStatus metav1.ConditionStatus
wantReason string
wantMessage string
wantTargetsReady string
// wantTransitions is each target's lastTransitionTime, in order.
wantTransitions []time.Time
}{
{
name: "all ready",
result: Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1"), up("nas2")}, CheckedAt: t1},
wantStatus: metav1.ConditionTrue,
wantReason: reasonAllTargetsReady,
wantMessage: "2 of 2 targets ready",
wantTargetsReady: "2/2",
wantTransitions: []time.Time{t1, t1},
},
{
name: "one target down",
result: Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1"), down("nas2", provider.ReasonUnreachable)}, CheckedAt: t1},
wantStatus: metav1.ConditionFalse,
wantReason: reasonTargetsNotReady,
wantMessage: "nas2: Unreachable",
wantTargetsReady: "1/2",
wantTransitions: []time.Time{t1, t1},
},
{
name: "only a flip moves a target's transition time",
prev: crawlv1alpha1.ProviderHealthStatus{Targets: []crawlv1alpha1.ProviderTarget{readyAt("nas1", t0), readyAt("nas2", t0)}},
result: Result{Type: "kvm", Supported: true, CheckedAt: t1,
Targets: []provider.TargetStatus{up("nas1"), down("nas2", provider.ReasonTimeout)}},
wantStatus: metav1.ConditionFalse,
wantReason: reasonTargetsNotReady,
wantMessage: "nas2: Timeout",
wantTargetsReady: "1/2",
wantTransitions: []time.Time{t0, t1},
},
{
name: "no checker",
result: Result{Type: "gcp", CheckedAt: t1},
wantStatus: metav1.ConditionUnknown,
wantReason: reasonChecksNotImplemented,
},
{
name: "nothing to check",
result: Result{Type: "kvm", Supported: true, CheckedAt: t1},
wantStatus: metav1.ConditionUnknown,
wantReason: reasonNoTargets,
wantTargetsReady: "0/0",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := buildStatus(tc.prev, tc.result)
cond := apimeta.FindStatusCondition(got.Conditions, crawlv1alpha1.ConditionReady)
if cond == nil || cond.Status != tc.wantStatus || cond.Reason != tc.wantReason {
t.Fatalf("Ready = %+v, want %s/%s", cond, tc.wantStatus, tc.wantReason)
}
if tc.wantMessage != "" && cond.Message != tc.wantMessage {
t.Errorf("Ready message = %q, want %q", cond.Message, tc.wantMessage)
}
if got.TargetsReady != tc.wantTargetsReady {
t.Errorf("targetsReady = %q, want %q", got.TargetsReady, tc.wantTargetsReady)
}
if got.Type != tc.result.Type || got.LastCheckTime == nil || !got.LastCheckTime.Time.Equal(t1) {
t.Errorf("type/lastCheckTime = %q/%v, want %q/%s", got.Type, got.LastCheckTime, tc.result.Type, t1)
}
if len(got.Targets) != len(tc.wantTransitions) {
t.Fatalf("got %d targets, want %d", len(got.Targets), len(tc.wantTransitions))
}
for i, want := range tc.wantTransitions {
if lt := got.Targets[i].LastTransitionTime.Time; !lt.Equal(want) {
t.Errorf("target %s lastTransitionTime = %s, want %s", got.Targets[i].Name, lt, want)
}
}
})
}
}
func TestBuildStatus_conditionKeepsTransitionWhileUnchanged(t *testing.T) {
t.Parallel()
first := buildStatus(crawlv1alpha1.ProviderHealthStatus{},
Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1")}, CheckedAt: t0})
firstCond := apimeta.FindStatusCondition(first.Conditions, crawlv1alpha1.ConditionReady)
second := buildStatus(first, Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1")}, CheckedAt: t1})
cond := apimeta.FindStatusCondition(second.Conditions, crawlv1alpha1.ConditionReady)
if !cond.LastTransitionTime.Time.Equal(t0) {
t.Errorf("Ready lastTransitionTime = %s, want %s from the first round", cond.LastTransitionTime, t0)
}
if !firstCond.LastTransitionTime.Time.Equal(t0) || len(first.Conditions) != 1 {
t.Errorf("buildStatus modified the previous status: %+v", first.Conditions)
}
}
func TestBuildStatus_truncatesLongMessages(t *testing.T) {
t.Parallel()
long := strings.Repeat("é", 3000)
got := buildStatus(crawlv1alpha1.ProviderHealthStatus{}, Result{Type: "kvm", Supported: true, CheckedAt: t1,
Targets: []provider.TargetStatus{{Name: "nas1", Reason: provider.ReasonUnreachable, Message: long}}})
if n := utf8.RuneCountInString(got.Targets[0].Message); n != maxMessageLen {
t.Errorf("message has %d characters, want %d", n, maxMessageLen)
}
if !strings.HasSuffix(got.Targets[0].Message, "…") {
t.Error("truncated message does not end with an ellipsis")
}
}