Files
Felis/internal/operator/events_test.go

133 lines
4.2 KiB
Go

package operator_test
import (
"context"
"errors"
"slices"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/operator"
)
// drain returns the Events recorded since the last drain.
func drain(rec *record.FakeRecorder) []string {
var out []string
for {
select {
case e := <-rec.Events:
out = append(out, e)
default:
return out
}
}
}
func expectEvents(t *testing.T, rec *record.FakeRecorder, step string, want ...string) {
t.Helper()
if got := drain(rec); !slices.Equal(got, want) {
t.Fatalf("%s: events = %q, want %q", step, got, want)
}
}
// Each phase change lands one Event, and a pass that changes nothing lands none.
func TestReconcilerRecordsThePhaseTimeline(t *testing.T) {
down := false
r, c := newReconciler(t, switchProber{down: &down}, runningServer())
rec := record.NewFakeRecorder(32)
r.Recorder = rec
reconcile(t, r, "survival")
expectEvents(t, rec, "first pass",
"Normal RconSecretCreated created the RCON password Secret survival-rcon",
"Normal PodNotReady New → Starting: waiting for pod TCP readiness")
reconcile(t, r, "survival")
expectEvents(t, rec, "still starting")
markPodReady(t, c, "survival")
reconcile(t, r, "survival")
expectEvents(t, rec, "ready", "Normal RconReached Starting → Running: server is accepting RCON")
reconcile(t, r, "survival")
expectEvents(t, rec, "steady")
down = true
for range 3 {
reconcile(t, r, "survival")
}
expectEvents(t, rec, "degraded", "Warning RconNotReachable Running → Starting: i/o timeout")
}
// A timed-out start and the pod recreation that retries it are Warnings.
func TestTimeoutAndAutoRestartAreWarnings(t *testing.T) {
srv := runningServer()
srv.Spec.Startup.TimeoutSeconds = 30
r, _ := newReconciler(t, fakeProber{}, srv, rconSecret(), gamePod())
rec := record.NewFakeRecorder(32)
r.Recorder = rec
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
clock := base
r.Now = func() metav1.Time { return metav1.NewTime(clock) }
reconcile(t, r, "survival")
drain(rec)
clock = base.Add(30 * time.Second)
reconcile(t, r, "survival")
expectEvents(t, rec, "timeout",
"Warning StartupTimeout Starting → Failed: pod did not become ready within startup timeout")
clock = base.Add(90 * time.Second)
reconcile(t, r, "survival")
expectEvents(t, rec, "retry",
"Warning AutoRestart Failed → Starting: start timed out; recreated the pod (attempt 1 of 3)")
}
// Idle auto-stop says why it stopped the server.
func TestIdleStopRecordsAnEvent(t *testing.T) {
srv := runningServer()
srv.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 60}
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, srv, rconSecret())
rec := record.NewFakeRecorder(32)
r.Recorder = rec
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
clock := base
r.Now = func() metav1.Time { return metav1.NewTime(clock) }
reconcile(t, r, "survival")
markPodReady(t, c, "survival")
reconcile(t, r, "survival")
drain(rec)
clock = base.Add(61 * time.Second)
reconcile(t, r, "survival")
expectEvents(t, rec, "idle", "Normal IdleStop no players online for 60s; set desiredState to Stopped")
}
// A pass that fails records nothing, even though it changed the phase in memory.
func TestFailedPassRecordsNoEvent(t *testing.T) {
r, c := newReconciler(t, fakeProber{}, runningServer(), rconSecret())
rec := record.NewFakeRecorder(32)
r.Recorder = rec
r.Client = failStatus{c}
req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "minecraft", Name: "survival"}}
if _, err := r.Reconcile(context.Background(), req); err == nil {
t.Fatal("want the status write error")
}
expectEvents(t, rec, "failed write")
}
// failStatus refuses every status write.
type failStatus struct{ client.Client }
func (f failStatus) Status() client.SubResourceWriter { return failingWriter{f.Client.Status()} }
type failingWriter struct{ client.SubResourceWriter }
func (failingWriter) Update(context.Context, client.Object, ...client.SubResourceUpdateOption) error {
return errors.New("status write refused")
}