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.
152 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|