2026-05-05 13:24:25 -03:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-03 02:30:50 -05:00
|
|
|
"bytes"
|
2026-05-05 13:24:25 -03:00
|
|
|
"context"
|
2026-10-03 02:30:50 -05:00
|
|
|
"errors"
|
|
|
|
|
"log"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"scm.bstein.dev/bstein/ananke/internal/cluster"
|
|
|
|
|
"scm.bstein.dev/bstein/ananke/internal/execx"
|
2026-05-05 13:24:25 -03:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"scm.bstein.dev/bstein/ananke/internal/config"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// TestDaemonMaybeRunPostStartAutoHeal runs one orchestration or CLI step.
|
|
|
|
|
// Signature: TestDaemonMaybeRunPostStartAutoHeal(t *testing.T).
|
|
|
|
|
// Why: covers the daemon-side interval and on-battery guards for the new
|
|
|
|
|
// post-start repair loop.
|
|
|
|
|
func TestDaemonMaybeRunPostStartAutoHeal(t *testing.T) {
|
|
|
|
|
calls := 0
|
|
|
|
|
d := &Daemon{
|
|
|
|
|
cfg: config.Config{
|
|
|
|
|
Startup: config.Startup{
|
|
|
|
|
PostStartAutoHealSeconds: 10,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
postStartAutoHealOverride: func(context.Context) error {
|
|
|
|
|
calls++
|
|
|
|
|
return nil
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 02:02:04 -05:00
|
|
|
d.cfg.Coordination.Role = "peer"
|
2026-05-05 13:24:25 -03:00
|
|
|
var last time.Time
|
|
|
|
|
d.maybeRunPostStartAutoHeal(context.Background(), &last, false)
|
2026-10-03 02:02:04 -05:00
|
|
|
if calls != 0 || !last.IsZero() {
|
|
|
|
|
t.Fatal("peer must not mutate shared cluster recovery state")
|
|
|
|
|
}
|
|
|
|
|
d.cfg.Coordination.Role = "coordinator"
|
|
|
|
|
d.maybeRunPostStartAutoHeal(context.Background(), &last, false)
|
2026-05-05 13:24:25 -03:00
|
|
|
if calls != 1 {
|
|
|
|
|
t.Fatalf("expected first auto-heal invocation, got %d", calls)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
d.maybeRunPostStartAutoHeal(context.Background(), &last, false)
|
|
|
|
|
if calls != 1 {
|
|
|
|
|
t.Fatalf("expected interval guard to suppress second call, got %d", calls)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
last = time.Now().Add(-11 * time.Second)
|
|
|
|
|
d.maybeRunPostStartAutoHeal(context.Background(), &last, true)
|
|
|
|
|
if calls != 1 {
|
|
|
|
|
t.Fatalf("expected on-battery guard to suppress call, got %d", calls)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
last = time.Now().Add(-11 * time.Second)
|
|
|
|
|
d.maybeRunPostStartAutoHeal(context.Background(), &last, false)
|
|
|
|
|
if calls != 2 {
|
|
|
|
|
t.Fatalf("expected second allowed auto-heal call, got %d", calls)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-03 02:30:50 -05:00
|
|
|
|
|
|
|
|
// TestDaemonRecoveryFailureDoesNotStopMonitoring exercises optional repair isolation.
|
|
|
|
|
// Signature: TestDaemonRecoveryFailureDoesNotStopMonitoring(t *testing.T).
|
|
|
|
|
// Why: a missing or failed repair helper must not terminate UPS monitoring.
|
|
|
|
|
func TestDaemonRecoveryFailureDoesNotStopMonitoring(t *testing.T) {
|
|
|
|
|
var messages bytes.Buffer
|
|
|
|
|
d := &Daemon{cfg: config.Config{Startup: config.Startup{PostStartAutoHealSeconds: 60}}, log: log.New(&messages, "", 0)}
|
|
|
|
|
var last time.Time
|
|
|
|
|
d.maybeRunPostStartAutoHeal(context.Background(), &last, false)
|
|
|
|
|
if !last.IsZero() {
|
|
|
|
|
t.Fatal("missing helper must not consume an interval")
|
|
|
|
|
}
|
|
|
|
|
if err := d.runPostStartAutoHeal(context.Background()); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
d.orch = cluster.New(config.Config{}, &execx.Runner{DryRun: true}, nil, d.log)
|
|
|
|
|
if err := d.runPostStartAutoHeal(context.Background()); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
d.postStartAutoHealOverride = func(context.Context) error { return errors.New("synthetic repair failure") }
|
|
|
|
|
d.maybeRunPostStartAutoHeal(context.Background(), &last, false)
|
|
|
|
|
if last.IsZero() || !strings.Contains(messages.String(), "synthetic repair failure") {
|
|
|
|
|
t.Fatal("failed repair must be logged and rate limited")
|
|
|
|
|
}
|
|
|
|
|
}
|