Files
felhom-agent/internal/selfheal/selfheal_test.go
T

135 lines
4.5 KiB
Go

package selfheal
import (
"context"
"io"
"log/slog"
"testing"
"time"
)
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 1}))
}
// fakeHeal is a controllable Heal: healthy toggles, and it counts remediation invocations.
type fakeHeal struct {
healthy bool
remediated int
}
func (f *fakeHeal) Name() string { return "fake" }
func (f *fakeHeal) Healthy(context.Context) (bool, string) {
return f.healthy, "fake detail"
}
func (f *fakeHeal) Remediate(context.Context) error {
f.remediated++
f.healthy = true
return nil
}
// THE BYO GATE (red-proof): a byo Manager must NEVER reach a remedy, even with an unhealthy heal.
func TestManager_ByoNeverRemediates(t *testing.T) {
h := &fakeHeal{healthy: false}
m := &Manager{Appliance: false, Heals: []Heal{h}, Logger: quietLogger()}
m.RunOnce(context.Background())
if h.remediated != 0 {
t.Fatalf("byo host must NEVER remediate, got %d invocations", h.remediated)
}
}
// An appliance Manager remediates an unhealthy heal.
func TestManager_ApplianceRemediates(t *testing.T) {
h := &fakeHeal{healthy: false}
m := &Manager{Appliance: true, Heals: []Heal{h}, Logger: quietLogger()}
m.RunOnce(context.Background())
if h.remediated != 1 {
t.Fatalf("appliance must remediate an unhealthy heal once, got %d", h.remediated)
}
}
// A healthy heal is never remediated (either mode).
func TestManager_HealthyNoRemediate(t *testing.T) {
for _, appliance := range []bool{true, false} {
h := &fakeHeal{healthy: true}
m := &Manager{Appliance: appliance, Heals: []Heal{h}, Logger: quietLogger()}
m.RunOnce(context.Background())
if h.remediated != 0 {
t.Fatalf("appliance=%t: a healthy heal must not be remediated, got %d", appliance, h.remediated)
}
}
}
// THE BYO GATE at the networking layer: force the networking check unhealthy under byo and assert the
// privileged Start is invoked ZERO times (the gate is before any exec).
func TestNetworkingHeal_ByoZeroPrivilegedInvocations(t *testing.T) {
starts := 0
n := &NetworkingHeal{
IsActive: func(context.Context) bool { return false },
HasRoute: func(context.Context) bool { return false },
Start: func(context.Context) error { starts++; return nil },
Sleep: func(context.Context, time.Duration) {},
Logger: quietLogger(),
}
m := &Manager{Appliance: false, Heals: []Heal{n}, Logger: quietLogger()}
m.RunOnce(context.Background())
if starts != 0 {
t.Fatalf("byo networking heal must invoke `systemctl start` ZERO times, got %d", starts)
}
}
// The remedy state machine: recovers on the 2nd attempt (start brings it up) → returns nil, one INFO.
func TestNetworkingHeal_RecoversMidAttempts(t *testing.T) {
defer withFastBackoffs()()
active := false
attempts := 0
n := &NetworkingHeal{
IsActive: func(context.Context) bool { return active },
HasRoute: func(context.Context) bool { return true },
Start: func(context.Context) error {
attempts++
if attempts >= 2 {
active = true // the 2nd start brings networking up
}
return nil
},
Sleep: func(context.Context, time.Duration) {},
Logger: quietLogger(),
}
if err := n.Remediate(context.Background()); err != nil {
t.Fatalf("remedy should recover by attempt 2, got err %v", err)
}
if attempts != 2 {
t.Fatalf("recovery should have taken 2 start attempts, got %d", attempts)
}
}
// The remedy gives up after all attempts if networking never comes up — a terminal error, logged as
// give-up by the Manager.
func TestNetworkingHeal_GivesUpAfterAllAttempts(t *testing.T) {
defer withFastBackoffs()()
starts := 0
n := &NetworkingHeal{
IsActive: func(context.Context) bool { return false }, // never recovers
HasRoute: func(context.Context) bool { return false },
Start: func(context.Context) error { starts++; return nil },
Sleep: func(context.Context, time.Duration) {},
Logger: quietLogger(),
}
err := n.Remediate(context.Background())
if err == nil {
t.Fatal("remedy must return a terminal error when networking never recovers")
}
if starts != len(networkingBackoffs) {
t.Fatalf("remedy must try exactly %d times, got %d", len(networkingBackoffs), starts)
}
}
// withFastBackoffs swaps the real 10/30/60s backoffs for near-zero waits (the Sleep seam is faked
// anyway, but keep the count/shape). Returns a restore func.
func withFastBackoffs() func() {
orig := networkingBackoffs
networkingBackoffs = []time.Duration{time.Millisecond, time.Millisecond, time.Millisecond}
return func() { networkingBackoffs = orig }
}