Files
Lemon-miaow cb3ed94026 fix(panel): streamline system settings and server creation
Use shared action buttons and default to the login space. Allow staff to save startup-only experience settings while running and request a durable restart through the existing operator flow.

Grant the API PVC list permission needed to detect retained worlds before server creation, and show internal failures with a request ID. Cover permission, maintenance, restart and UI behavior with regression checks.
2026-10-04 16:26:03 +08:00

152 lines
6.1 KiB
Go

package operator_test
import (
"context"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
)
// requestStartRetry is felis-api's RetryStart as the operator sees it.
func requestStartRetry(t *testing.T, c client.Client) {
t.Helper()
s := getServer(t, c, "survival")
patch := client.MergeFrom(s.DeepCopy())
if s.Annotations == nil {
s.Annotations = map[string]string{}
}
s.Annotations[v1alpha1.AnnotationStartRetry] = "2026-07-01T12:00:00Z"
if err := c.Patch(context.Background(), s, patch); err != nil {
t.Fatalf("annotate: %v", err)
}
}
func podPresent(t *testing.T, c client.Client) bool {
t.Helper()
err := c.Get(context.Background(), types.NamespacedName{Namespace: "minecraft", Name: "survival-0"}, &corev1.Pod{})
if err != nil && !apierrors.IsNotFound(err) {
t.Fatalf("get pod: %v", err)
}
return err == nil
}
func TestExplicitRestartIsConsumedOnce(t *testing.T) {
srv := runningServer()
srv.Status.Phase = v1alpha1.PhaseRunning
srv.Annotations = map[string]string{v1alpha1.AnnotationRestart: "2026-07-01T12:00:00Z"}
r, c := newReconciler(t, fakeProber{}, srv, rconSecret(), gamePod())
reconcile(t, r, "survival")
s := getServer(t, c, "survival")
if s.Annotations[v1alpha1.AnnotationRestart] != "" || s.Spec.DesiredState != v1alpha1.DesiredRunning || s.Status.Phase != v1alpha1.PhaseStarting || podPresent(t, c) {
t.Fatalf("restart: desired=%s phase=%s request=%s pod=%v", s.Spec.DesiredState, s.Status.Phase, s.Annotations[v1alpha1.AnnotationRestart], podPresent(t, c))
}
if err := c.Create(context.Background(), gamePod()); err != nil {
t.Fatal(err)
}
reconcile(t, r, "survival")
if !podPresent(t, c) {
t.Fatal("consumed request deleted the replacement pod")
}
}
func TestStopSupersedesExplicitRestart(t *testing.T) {
srv := runningServer()
srv.Spec.DesiredState = v1alpha1.DesiredStopped
srv.Status.Phase = v1alpha1.PhaseRunning
srv.Annotations = map[string]string{v1alpha1.AnnotationRestart: "2026-07-01T12:00:00Z"}
r, c := newReconciler(t, fakeProber{}, srv, rconSecret(), gamePod())
reconcile(t, r, "survival")
s := getServer(t, c, "survival")
if s.Annotations[v1alpha1.AnnotationRestart] != "" || s.Spec.DesiredState != v1alpha1.DesiredStopped || s.Status.Phase == v1alpha1.PhaseStarting {
t.Fatalf("restart overrode stop: desired=%s phase=%s annotations=%v", s.Spec.DesiredState, s.Status.Phase, s.Annotations)
}
}
// Once the automatic restarts are spent, a retry request starts the server over
// with the whole budget back: the pod is recreated, the start re-anchored, and
// the next timeout is retried automatically again.
func TestStartRetryRevivesAServerThatGaveUp(t *testing.T) {
srv := runningServer()
srv.Spec.Startup.TimeoutSeconds = 30
srv.Spec.Motd.Failed = "broken"
r, c := newReconciler(t, fakeProber{}, srv, rconSecret(), gamePod())
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
clock := base
r.Now = func() metav1.Time { return metav1.NewTime(clock) }
at := func(offset time.Duration) *v1alpha1.MinecraftServer {
clock = base.Add(offset)
reconcile(t, r, "survival")
return getServer(t, c, "survival")
}
recreatePod := func() {
if err := c.Create(context.Background(), gamePod()); err != nil {
t.Fatal(err)
}
}
// Spend the three automatic restarts (autorestart_test.go pins the schedule).
at(0)
for _, due := range []time.Duration{90 * time.Second, 240 * time.Second, 510 * time.Second} {
at(due)
recreatePod()
}
s := at(time.Hour)
if !v1alpha1.StartGaveUp(&s.Status) {
t.Fatalf("setup: not given up: phase=%s restarts=%d", s.Status.Phase, s.Status.AutoRestarts)
}
if s.Status.LiveMotd != "broken" {
t.Fatalf("Failed advertises liveMotd %q, want the failed MOTD", s.Status.LiveMotd)
}
requestStartRetry(t, c)
s = at(2 * time.Hour)
if _, left := s.Annotations[v1alpha1.AnnotationStartRetry]; left {
t.Fatal("the retry request was left on the server")
}
if podPresent(t, c) {
t.Fatal("the retry kept the failed pod")
}
if s.Status.Phase != v1alpha1.PhaseStarting || s.Status.AutoRestarts != 0 || v1alpha1.StartGaveUp(&s.Status) {
t.Fatalf("after retry: phase=%s restarts=%d gaveUp=%v", s.Status.Phase, s.Status.AutoRestarts, v1alpha1.StartGaveUp(&s.Status))
}
if s.Status.StartRequestedAt == nil || !s.Status.StartRequestedAt.Time.Equal(base.Add(2*time.Hour)) {
t.Fatalf("start not re-anchored at the retry: %v", s.Status.StartRequestedAt)
}
recreatePod()
// The budget is really back: this start times out and is retried on its own.
if s := at(2*time.Hour + 89*time.Second); s.Status.Phase != v1alpha1.PhaseFailed || v1alpha1.StartGaveUp(&s.Status) {
t.Fatalf("retried start timing out: phase=%s gaveUp=%v, want Failed with retries left", s.Status.Phase, v1alpha1.StartGaveUp(&s.Status))
}
if s := at(2*time.Hour + 90*time.Second); s.Status.AutoRestarts != 1 || podPresent(t, c) {
t.Fatalf("retried start: restarts=%d pod=%v, want the first automatic restart", s.Status.AutoRestarts, podPresent(t, c))
}
}
// A request that finds the server anywhere but Failed is stale (the start
// already recovered, or someone stopped it): it is removed and nothing else.
func TestStaleStartRetryIsOnlyRemoved(t *testing.T) {
srv := runningServer()
srv.Annotations = map[string]string{v1alpha1.AnnotationStartRetry: "2026-07-01T12:00:00Z"}
anchor := metav1.NewTime(fixedNow().Add(-10 * time.Second))
srv.Status = v1alpha1.MinecraftServerStatus{Phase: v1alpha1.PhaseStarting, AutoRestarts: 2, StartRequestedAt: &anchor}
r, c := newReconciler(t, fakeProber{}, srv, rconSecret(), gamePod())
reconcile(t, r, "survival")
s := getServer(t, c, "survival")
if _, left := s.Annotations[v1alpha1.AnnotationStartRetry]; left {
t.Fatal("the stale retry request was left on the server")
}
if !podPresent(t, c) || s.Status.AutoRestarts != 2 || !s.Status.StartRequestedAt.Equal(&anchor) {
t.Fatalf("a stale request touched the start: pod=%v restarts=%d anchor=%v",
podPresent(t, c), s.Status.AutoRestarts, s.Status.StartRequestedAt)
}
}