133 lines
4.2 KiB
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")
|
|
}
|