fix(felis): 启动失败的服可在面板重试或停止,玩家入服时直接告知启动失败

This commit is contained in:
Lemon-miaow committed 2026-09-26 22:08:29 +08:00
1 parent c1025274e1
commit 22d1e834ad
28 files changed
+775 -75

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
package api
import (
"net/http"
"testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/maintenance"
)
// A start that Failed holds desiredState Running already, so the wake that
// re-writes Running changes nothing. These pin the two ways out: a person's
// wake in the panel starts it over (RetryStart), and a player's join onto one
// whose retries are spent is told so (409 start_failed) and never resets them.
func failedServer(gaveUp bool) *ServerInfo {
return &ServerInfo{Name: "survival", AutostartPolicy: "public",
DesiredState: string(v1alpha1.DesiredRunning), Phase: string(v1alpha1.PhaseFailed),
AutoRestarts: v1alpha1.MaxAutoRestarts, StartGaveUp: gaveUp}
}
func TestWakeOfFailedServerRetriesStart(t *testing.T) {
mk := func(info *ServerInfo) (*API, *fakeCluster, *fakeRepo) {
repo := newFakeRepo()
cl := newFakeCluster()
cl.byName["survival"] = info
api := newTestAPI(repo, cl)
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}}
return api, cl, repo
}
wake := func(api *API) int {
return do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil).Code
}
t.Run("retries spent: the wake starts it over", func(t *testing.T) {
api, cl, repo := mk(failedServer(true))
if code := wake(api); code != http.StatusAccepted {
t.Fatalf("code = %d, want 202", code)
}
if len(cl.retried) != 1 || cl.retried[0] != "survival" {
t.Fatalf("retried = %v, want [survival]", cl.retried)
}
if len(repo.audits) != 1 || repo.audits[0].Action != "retry_start" {
t.Fatalf("audits = %+v, want one retry_start", repo.audits)
}
})
t.Run("inside the backoff: a person asking skips the wait", func(t *testing.T) {
api, cl, _ := mk(failedServer(false))
if code := wake(api); code != http.StatusAccepted || len(cl.retried) != 1 {
t.Fatalf("code = %d retried = %v, want 202 and one retry", code, cl.retried)
}
})
t.Run("stopped: a plain wake", func(t *testing.T) {
api, cl, repo := mk(&ServerInfo{Name: "survival", AutostartPolicy: "public",
DesiredState: string(v1alpha1.DesiredStopped), Phase: string(v1alpha1.PhaseStopped)})
if code := wake(api); code != http.StatusAccepted {
t.Fatalf("code = %d, want 202", code)
}
if len(cl.retried) != 0 || cl.desired["survival"] != v1alpha1.DesiredRunning {
t.Fatalf("retried = %v desired = %q, want a plain start", cl.retried, cl.desired["survival"])
}
if len(repo.audits) != 1 || repo.audits[0].Action != "wake" {
t.Fatalf("audits = %+v, want one wake", repo.audits)
}
})
t.Run("maintenance holds the world: refused, nothing retried", func(t *testing.T) {
api, cl, _ := mk(failedServer(true))
cl.wakeErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindRestore}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil)
if w.Code != http.StatusConflict || decodeErr(t, w) != "maintenance_in_progress" {
t.Fatalf("code = %d body %s, want 409 maintenance_in_progress", w.Code, w.Body.String())
}
if len(cl.retried) != 0 {
t.Fatalf("retried = %v, want none", cl.retried)
}
})
}
func TestInternalWakeOfFailedServer(t *testing.T) {
body := `{"mc_uuid":"` + wakeUUID + `"}`
t.Run("retries spent: 409 start_failed, budget and cooldown untouched", func(t *testing.T) {
api, cl := newInternalWakeAPI("public")
cl.byName["survival"] = failedServer(true)
api.WakeCooldown = time.Minute
repo := api.Repo.(*fakeRepo)
w := internalWake(api, body)
if w.Code != http.StatusConflict || decodeErr(t, w) != "start_failed" {
t.Fatalf("code = %d body %s, want 409 start_failed", w.Code, w.Body.String())
}
if len(cl.retried) != 0 {
t.Fatalf("a join must never reset the restart budget: retried = %v", cl.retried)
}
if len(repo.audits) != 0 {
t.Fatalf("nothing was woken, so nothing is audited: %+v", repo.audits)
}
// The owner fixes it and starts it from the panel; the next join is not
// held back by a cooldown the refused one never spent.
cl.byName["survival"] = &ServerInfo{Name: "survival", AutostartPolicy: "public",
DesiredState: string(v1alpha1.DesiredRunning), Phase: string(v1alpha1.PhaseStarting)}
if w := internalWake(api, body); w.Code != http.StatusAccepted {
t.Fatalf("join after the fix: code = %d body %s, want 202", w.Code, w.Body.String())
}
})
t.Run("inside the backoff: 202, the player waits for the next attempt", func(t *testing.T) {
api, cl := newInternalWakeAPI("public")
cl.byName["survival"] = failedServer(false)
if w := internalWake(api, body); w.Code != http.StatusAccepted {
t.Fatalf("code = %d body %s, want 202", w.Code, w.Body.String())
}
if len(cl.retried) != 0 {
t.Fatalf("a join must never reset the restart budget: retried = %v", cl.retried)
}
})
t.Run("forbidden player: 403 comes first", func(t *testing.T) {
api, cl := newInternalWakeAPI("ownerOnly")
info := failedServer(true)
info.AutostartPolicy = "ownerOnly"
cl.byName["survival"] = info
if w := internalWake(api, body); w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
})
}