package controller import ( "strings" "testing" "time" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/health" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" ) type fakeSnapshotter struct { snap health.Snapshot ok bool } func (f fakeSnapshotter) Snapshot(types.NamespacedName) (health.Snapshot, bool) { return f.snap, f.ok } // TestReconcile_healthRepresentation covers the reconciler's half of the // health split: turning the engine's Snapshot into the Healthy condition, // the latency fields, and ultimately the Ready/Unhealthy phases. func TestReconcile_healthRepresentation(t *testing.T) { t.Parallel() probeTime := time.Now() freshHash := specHash(managedProxy(), "") runningStub := func() *stubProvider { return &stubProvider{ getInst: &provider.Instance{ID: "stub-id-1", IP: "10.1.2.3", State: provider.StateRunning}, } } tests := []struct { name string proxy *crawlv1alpha1.Proxy stub *stubProvider health HealthSnapshotter wantPhase crawlv1alpha1.ProxyPhase verify func(t *testing.T, r *ProxyReconciler) }{ { name: "running and healthy becomes Ready", proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash)), stub: runningStub(), health: fakeSnapshotter{ok: true, snap: health.Snapshot{ Healthy: true, Latency: 37 * time.Millisecond, LastProbe: probeTime, }}, wantPhase: crawlv1alpha1.PhaseReady, verify: func(t *testing.T, r *ProxyReconciler) { p := getProxy(t, r) assertCondition(t, p, crawlv1alpha1.ConditionHealthy, metav1.ConditionTrue, ReasonProbeSucceeded) if p.Status.LatencyMillis != 37 { t.Errorf("latencyMillis = %d, want 37", p.Status.LatencyMillis) } if p.Status.LastHealthCheckTime == nil { t.Error("lastHealthCheckTime not set") } }, }, { name: "running but unhealthy becomes Unhealthy", proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash)), stub: runningStub(), health: fakeSnapshotter{ok: true, snap: health.Snapshot{ Healthy: false, LastProbe: probeTime, LastError: "CONNECT refused", ConsecutiveFailures: 3, }}, wantPhase: crawlv1alpha1.PhaseUnhealthy, verify: func(t *testing.T, r *ProxyReconciler) { p := getProxy(t, r) assertCondition(t, p, crawlv1alpha1.ConditionHealthy, metav1.ConditionFalse, ReasonProbeFailed) cond := apimeta.FindStatusCondition(p.Status.Conditions, crawlv1alpha1.ConditionHealthy) if !strings.Contains(cond.Message, "CONNECT refused") { t.Errorf("condition message %q does not carry the probe error", cond.Message) } }, }, { name: "no verdict yet stays Provisioning without a Healthy condition", proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash)), stub: runningStub(), health: fakeSnapshotter{ok: false}, wantPhase: crawlv1alpha1.PhaseProvisioning, verify: func(t *testing.T, r *ProxyReconciler) { p := getProxy(t, r) if apimeta.FindStatusCondition(p.Status.Conditions, crawlv1alpha1.ConditionHealthy) != nil { t.Error("Healthy condition present without an engine verdict") } }, }, { name: "external proxy with a healthy verdict becomes Ready", proxy: managedProxy(func(p *crawlv1alpha1.Proxy) { p.Finalizers = nil p.Spec = crawlv1alpha1.ProxySpec{ Mode: crawlv1alpha1.ModeExternal, Endpoint: &crawlv1alpha1.EndpointSpec{Host: "203.0.113.7"}, } }), stub: &stubProvider{}, health: fakeSnapshotter{ok: true, snap: health.Snapshot{ Healthy: true, Latency: 5 * time.Millisecond, LastProbe: probeTime, }}, wantPhase: crawlv1alpha1.PhaseReady, verify: func(t *testing.T, r *ProxyReconciler) { assertCondition(t, getProxy(t, r), crawlv1alpha1.ConditionHealthy, metav1.ConditionTrue, ReasonProbeSucceeded) }, }, { name: "creating a replacement clears the stale Healthy verdict", proxy: managedProxy(func(p *crawlv1alpha1.Proxy) { p.Status.Conditions = []metav1.Condition{{ Type: crawlv1alpha1.ConditionHealthy, Status: metav1.ConditionTrue, Reason: ReasonProbeSucceeded, LastTransitionTime: metav1.Now(), }} p.Status.LatencyMillis = 42 p.Status.LastHealthCheckTime = &metav1.Time{Time: probeTime} }), stub: &stubProvider{createID: "stub-id-2"}, health: fakeSnapshotter{ok: false}, wantPhase: crawlv1alpha1.PhaseProvisioning, verify: func(t *testing.T, r *ProxyReconciler) { p := getProxy(t, r) if apimeta.FindStatusCondition(p.Status.Conditions, crawlv1alpha1.ConditionHealthy) != nil { t.Error("stale Healthy condition survived instance creation") } if p.Status.LatencyMillis != 0 || p.Status.LastHealthCheckTime != nil { t.Error("stale latency fields survived instance creation") } }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() r := newTestReconciler(t, tc.stub, tc.proxy) r.Health = tc.health if _, err := doReconcile(t, r); err != nil { t.Fatalf("Reconcile: %v", err) } if got := getProxy(t, r).Status.Phase; got != tc.wantPhase { t.Errorf("phase = %s, want %s", got, tc.wantPhase) } tc.verify(t, r) }) } } // A nil Health snapshotter must disable representation entirely. func TestReconcile_nilHealthSnapshotter(t *testing.T) { t.Parallel() freshHash := specHash(managedProxy(), "") r := newTestReconciler(t, &stubProvider{getInst: &provider.Instance{ID: "stub-id-1", IP: "10.1.2.3", State: provider.StateRunning}}, managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash))) if _, err := doReconcile(t, r); err != nil { t.Fatalf("Reconcile: %v", err) } p := getProxy(t, r) if apimeta.FindStatusCondition(p.Status.Conditions, crawlv1alpha1.ConditionHealthy) != nil { t.Error("Healthy condition written with no snapshotter configured") } if p.Status.Phase != crawlv1alpha1.PhaseProvisioning { t.Errorf("phase = %s, want Provisioning", p.Status.Phase) } }