fix(velocity): 休眠服 MOTD 按唤醒策略说明谁能唤醒,闲置回收期间加入给出回收提示
This commit is contained in:
10 files changed
+76
-7
No files matched your search
+3
-1
@@ -1276,7 +1276,9 @@ paths:
|
||||
start failed and its automatic retries are spent (ServerInfo.startGaveUp);
|
||||
the server stays down until a person starts it from the panel.
|
||||
server_retiring: the owner gave the server up or an admin is deleting it;
|
||||
it stays down until the reaper archives it.
|
||||
it stays down until the reaper archives it. world_reclaiming: the idle
|
||||
reaper is archiving the world; afterwards the server is released with an
|
||||
empty world.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
@@ -221,6 +221,7 @@ per-server cooldown → global running cap**. Map the API result:
|
||||
|---|---|---|---|
|
||||
| `403` | `forbidden` | `autostartPolicy=allowlist` and UUID not allowlisted, or `ownerOnly` and caller is not owner | Add the UUID / claim the server / set `autostartPolicy=public` |
|
||||
| `409` | `maintenance_in_progress` | A restore, backup or file write holds the server's world volume (§3b) | Wait for the Job to finish |
|
||||
| `409` | `world_reclaiming` | The idle reaper is archiving the world (§3b item 3); afterwards the server is released with an empty world | Nothing to wait for; the old world stays in the archive |
|
||||
| `429` | (cooldown) | Wake retried within the 30s per-server `WakeCooldown` | Wait out the cooldown |
|
||||
| `503` | `at_capacity` | Global `MaxRunningServers` cap reached | Stop another server or raise the cap |
|
||||
|
||||
@@ -231,7 +232,8 @@ teleports when `ready=true`.
|
||||
|
||||
The Velocity-side consumption of these codes (`403` → "You're not allowed to
|
||||
start «server»"; `409 maintenance_in_progress` → "«server» is under
|
||||
maintenance", not queued; `429` → re-queue; other → "Couldn't start … Try again
|
||||
maintenance", not queued; `409 world_reclaiming` → "«server» sat idle too
|
||||
long and its world is being archived", not queued; `429` → re-queue; other → "Couldn't start … Try again
|
||||
shortly.") lives in the Java plugin and is **[CODE-ONLY]** — the codes it reacts
|
||||
to are produced by the Go-tested `authorizeWakeByUUID` / cooldown limiter, so
|
||||
grade the two halves separately.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
"felis.lolicon.best/internal/maintenance"
|
||||
"felis.lolicon.best/internal/naming"
|
||||
)
|
||||
|
||||
@@ -192,8 +193,16 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// A 409 maintenance_in_progress tells velocity nothing is coming up until the
|
||||
// restore/backup/file write finishes, so it does not enqueue the player.
|
||||
// restore/backup/file write finishes, so it does not enqueue the player. The
|
||||
// idle reaper gets its own code: when it lets go, the world is archived and the
|
||||
// server released, so "try again shortly" would send the player back to a server
|
||||
// that is no longer the one they knew.
|
||||
if err := a.Cluster.SetDesiredState(r.Context(), name, v1alpha1.DesiredRunning); err != nil {
|
||||
var busy *MaintenanceBusyError
|
||||
if errors.As(err, &busy) && busy.Kind == maintenance.KindReap {
|
||||
writeError(w, r, errWorldReclaiming)
|
||||
return
|
||||
}
|
||||
a.writeLookupError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -210,6 +219,12 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// errWorldReclaiming refuses a join-driven wake while the idle reaper archives the
|
||||
// server's world. Once it is done the server is released with an empty world and
|
||||
// the old one stays in the archive.
|
||||
var errWorldReclaiming = newError(http.StatusConflict, "world_reclaiming",
|
||||
"this server sat idle too long and its world is being archived; afterwards it is released with an empty world")
|
||||
|
||||
// internalClaimRequest is the velocity `Claim & Start` body: the verified
|
||||
// online-mode UUID of the player claiming an ownerless server (spec §9.3, §12).
|
||||
type internalClaimRequest struct {
|
||||
|
||||
@@ -58,6 +58,20 @@ func TestWakeRefusedDuringMaintenance(t *testing.T) {
|
||||
t.Fatalf("wake after maintenance: code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
// The idle reaper is no wait-and-retry: when it lets go the world is archived
|
||||
// and the server released, so velocity must be able to tell the player that.
|
||||
t.Run("internal wake during an idle reap -> 409 world_reclaiming", func(t *testing.T) {
|
||||
api, cl := newInternalWakeAPI("public")
|
||||
cl.wakeErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindReap}
|
||||
w := internalWake(api, `{"mc_uuid":"`+wakeUUID+`"}`)
|
||||
if w.Code != http.StatusConflict || decodeErr(t, w) != "world_reclaiming" {
|
||||
t.Fatalf("code = %d body %s, want 409 world_reclaiming", w.Code, w.Body.String())
|
||||
}
|
||||
if _, set := cl.desired["survival"]; set {
|
||||
t.Fatal("a refused wake must not flip desiredState")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// maintenanceOp is one world-volume operation as the external face serves it.
|
||||
|
||||
@@ -3280,7 +3280,7 @@ export interface operations {
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
/** @description Nothing was started. maintenance_in_progress: a restore, backup or file write holds the server's world volume. start_failed: the last start failed and its automatic retries are spent (ServerInfo.startGaveUp); the server stays down until a person starts it from the panel. server_retiring: the owner gave the server up or an admin is deleting it; it stays down until the reaper archives it. */
|
||||
/** @description Nothing was started. maintenance_in_progress: a restore, backup or file write holds the server's world volume. start_failed: the last start failed and its automatic retries are spent (ServerInfo.startGaveUp); the server stays down until a person starts it from the panel. server_retiring: the owner gave the server up or an admin is deleting it; it stays down until the reaper archives it. world_reclaiming: the idle reaper is archiving the world; afterwards the server is released with an empty world. */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
|
||||
@@ -84,7 +84,7 @@ public final class FelisApiClient {
|
||||
* joining player (spec §9.1, §14). The reply (202) carries the current phase
|
||||
* and ready flag so the caller can decide whether to wait. A 403 (policy gate),
|
||||
* 409 {@code maintenance_in_progress} (a restore, backup or file write holds the
|
||||
* world), 429 (cooldown), or 503 {@code at_capacity} (running cap) arrives as a
|
||||
* world) or {@code world_reclaiming} (the idle reaper is archiving it), 429 (cooldown), or 503 {@code at_capacity} (running cap) arrives as a
|
||||
* LinkException the caller branches on.
|
||||
*/
|
||||
public ServerView wake(String name, UUID mcUuid) throws LinkException {
|
||||
|
||||
@@ -74,7 +74,16 @@ public final class MotdResponder {
|
||||
if ("Running".equals(v.desiredState())) {
|
||||
return "启动中… / starting…";
|
||||
}
|
||||
return "休眠中,加入即唤醒 / sleeping — join to wake";
|
||||
// The ping carries no identity either, so a sleeping server says who can wake
|
||||
// it, by the same autostartPolicy gate the join runs into: ownerOnly or unset
|
||||
// admits the owner alone.
|
||||
if ("public".equals(v.autostartPolicy())) {
|
||||
return "休眠中,加入即唤醒 / sleeping — join to wake";
|
||||
}
|
||||
if ("allowlist".equals(v.autostartPolicy())) {
|
||||
return "休眠中,名单内玩家加入即唤醒 / sleeping — allowlisted players can wake it";
|
||||
}
|
||||
return "休眠中,仅服主可唤醒 / sleeping — only the owner can wake it";
|
||||
}
|
||||
|
||||
static NamedTextColor statusColor(ServerView v) {
|
||||
|
||||
@@ -727,6 +727,18 @@ public final class WaitingRouter {
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
if ("world_reclaiming".equals(e.errorCode())) {
|
||||
// The idle reaper is archiving the world. When it is done the
|
||||
// server is released with an empty world, so there is nothing
|
||||
// to wait for, and "try again shortly" would mislead.
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "「" + serverName + "」闲置太久,世界正在归档回收,现在不能启动。"
|
||||
+ "回收后它会变成可认领的空服,旧世界留在归档里。"
|
||||
: "« " + serverName + " » sat idle too long and its world is being archived, so it can't start."
|
||||
+ " Afterwards it becomes a claimable empty server; the old world stays in the archive.",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
if ("server_retiring".equals(e.errorCode())) {
|
||||
// The owner gave the server up or an admin is deleting it: it
|
||||
// stays down until the reaper archives it, unless that is
|
||||
|
||||
@@ -156,8 +156,16 @@ public final class ServerRegistryTest {
|
||||
assertEq("motd: retries spent", "启动失败,等服主处理 / failed to start — the owner has to restart it",
|
||||
MotdResponder.statusLine(gaveUp));
|
||||
assertEq("motd: retries spent is red", NamedTextColor.RED, MotdResponder.statusColor(gaveUp));
|
||||
assertEq("motd: sleeping", "休眠中,加入即唤醒 / sleeping — join to wake",
|
||||
// A sleeping server says who can wake it: a stranger reading "join to wake" on
|
||||
// an ownerOnly server joined only to be refused.
|
||||
assertEq("motd: sleeping, ownerOnly", "休眠中,仅服主可唤醒 / sleeping — only the owner can wake it",
|
||||
MotdResponder.statusLine(down("a", "a")));
|
||||
assertEq("motd: sleeping, public", "休眠中,加入即唤醒 / sleeping — join to wake",
|
||||
MotdResponder.statusLine(sleeping("public")));
|
||||
assertEq("motd: sleeping, allowlist", "休眠中,名单内玩家加入即唤醒 / sleeping — allowlisted players can wake it",
|
||||
MotdResponder.statusLine(sleeping("allowlist")));
|
||||
assertEq("motd: sleeping, no policy", "休眠中,仅服主可唤醒 / sleeping — only the owner can wake it",
|
||||
MotdResponder.statusLine(sleeping(null)));
|
||||
|
||||
System.out.println("ServerRegistryTest OK (" + checks + " checks)");
|
||||
}
|
||||
@@ -170,6 +178,10 @@ public final class ServerRegistryTest {
|
||||
return new ServerView(name, sub, "Stopped", false, "ownerOnly", "Stopped", "fallback", "login", 0, 0);
|
||||
}
|
||||
|
||||
private static ServerView sleeping(String policy) {
|
||||
return new ServerView("a", "a", "Stopped", false, policy, "Stopped", "fallback", "login", 0, 0);
|
||||
}
|
||||
|
||||
private static ServerView unstarted(String name, String sub) {
|
||||
return new ServerView(name, sub, "", false, "ownerOnly", "Stopped", null, null, 0, 0);
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ public final class WaitingRouterTest {
|
||||
{"retries spent", "« gamma » failed to start and its automatic retries are spent", null},
|
||||
{"409 maintenance_in_progress", "« gamma » is under maintenance", null},
|
||||
{"409 server_retiring", "« gamma » has been given up or is being deleted", null},
|
||||
{"409 world_reclaiming", "« gamma » sat idle too long and its world is being archived", null},
|
||||
{"409 conflict", "Couldn't start « gamma » right now", "wake gamma failed (status=409)"},
|
||||
{"503 at_capacity", "The cluster is at capacity right now", null},
|
||||
{"503 unavailable", "Couldn't start « gamma » right now", "wake gamma failed (status=503)"},
|
||||
@@ -259,6 +260,8 @@ public final class WaitingRouterTest {
|
||||
assertEq(c[0] + ": no promise of a start", false, p.said("Starting « gamma »"));
|
||||
if (c[2] != null) {
|
||||
assertEq(c[0] + ": logged", warned + 1, log.count("WARN", c[2]));
|
||||
} else {
|
||||
assertEq(c[0] + ": no generic failure on top", false, p.said("Couldn't start « gamma » right now"));
|
||||
}
|
||||
api.policy.remove("gamma");
|
||||
api.phase.remove("gamma");
|
||||
|
||||
Reference in new issue
Block a user