diff --git a/plugins/test.sh b/plugins/test.sh index 75f1ceb..bc3b1d0 100644 --- a/plugins/test.sh +++ b/plugins/test.sh @@ -43,7 +43,9 @@ # fake proxy and a stub felis-api — a refresh registers, moves and drops # backends and lets go of a renamed subdomain, the login gate and host routing # admit only linked players, each wake refusal reaches the player as its own -# message, felis:control acts only for the connection's player and holds its +# message (the stub answers a wake from the server's state in felis-api's own +# order, so it cannot hand the router an answer the real API never gives), +# felis:control acts only for the connection's player and holds its # frame budget. They ride the module's verified dependency set, which is why # they live in Gradle rather than in the javac mains above. # diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/ControlChannelTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/ControlChannelTest.java index 708e062..66d6441 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/ControlChannelTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/ControlChannelTest.java @@ -216,20 +216,32 @@ public final class ControlChannelTest { } private static void wakeAndTransfer() { - // A WakeRequest parks the player; when the backend is ready the lobby hears - // TransferReady just before the proxy moves them. + // A WakeRequest for a stopped server parks the player; when the backend is ready + // the lobby hears TransferReady just before the proxy moves them. Fakes.FakePlayer p = player(true); p.current = lobby; - message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest("forged-name", "beta"))); - Fakes.await("beta woken", () -> api.count("POST " + SERVERS + "beta/wake") == 1); + message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest("forged-name", "gamma"))); + Fakes.await("gamma woken", () -> api.count("POST " + SERVERS + "gamma/wake") == 1); Fakes.await("waker queued", () -> router.waitingCount() == 2); - api.ready.put("beta", true); + api.ready.put("gamma", true); router.tick(); List r = frames(p); assertEq("one frame to the lobby", 1, r.size()); - assertEq("transfer ready", ControlFrame.TRANSFER_READY + " " + p.name + " beta", + assertEq("transfer ready", ControlFrame.TRANSFER_READY + " " + p.name + " gamma", r.get(0).type() + " " + r.get(0).player() + " " + r.get(0).server()); - assertEq("then moved", List.of("beta"), List.copyOf(p.connects)); + assertEq("then moved", List.of("gamma"), List.copyOf(p.connects)); + + // For a running one it is a join: TransferReady and the move at once, no wake. + Fakes.FakePlayer j = player(true); + j.current = lobby; + message(j.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest(j.name, "beta"))); + Fakes.await("running server joined", () -> j.connects.size() == 1); + List jr = frames(j); + assertEq("running server: one frame to the lobby", 1, jr.size()); + assertEq("running server: transfer ready", ControlFrame.TRANSFER_READY + " " + j.name + " beta", + jr.get(0).type() + " " + jr.get(0).player() + " " + jr.get(0).server()); + assertEq("running server: moved", List.of("beta"), List.copyOf(j.connects)); + assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake")); } private static void budget() { @@ -295,8 +307,12 @@ public final class ControlChannelTest { // Shaped like the operator's status: up is direct at addr; down is the fallback, // whose name ("login") sits in the address field. + // view is a server as GET /servers lists it, and the stub API's status, menu and + // wake answer for the same state: up at addr, or down on the "login" fallback. private static ServerView view(String name, boolean ready, String addr) { - return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "ownerOnly", + api.ready.put(name, ready); + api.address.put(name, addr); + return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "public", "Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20); } diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java b/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java index db8e387..d1f5869 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/Fakes.java @@ -33,6 +33,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BooleanSupplier; import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Fakes are the test doubles the routing tests run the real proxy classes against: @@ -432,13 +434,18 @@ final class Fakes { /** * Api is a stub felis-api internal face: link status from {@link #linked}, server - * status from {@link #ready}, wake answering 202 unless {@link #wakeError} holds an - * answer for the server, and join-events recorded. {@link #linkDown} makes the - * link-status route answer 500. + * status, menu and wake from one server state ({@link #ready}, {@link #phase}, + * {@link #desired}, {@link #gaveUp}, {@link #policy}, {@link #owner}), and + * join-events recorded. {@link #linkDown} makes the link-status route answer 500. * *

Status replies carry the endpoint the way the operator writes it: a server that * is up reports {@code direct} and its {@link #address}; one that is not reports * {@code fallback} with the fallback server's NAME, "login", in the address field. + * + *

The wake follows handleInternalWake step for step, so a test cannot give the + * router an answer the real API never gives: an up server is 202 ready for anyone, + * 403 and 409 start_failed come from the state, and {@link #wakeError} only holds + * what the state cannot say (a cooldown, the running cap, maintenance, a fault). */ static final class Api implements AutoCloseable { final Set linked = ConcurrentHashMap.newKeySet(); @@ -455,9 +462,19 @@ final class Fakes { final Map desired = new ConcurrentHashMap<>(); final Map restarts = new ConcurrentHashMap<>(); final Set gaveUp = ConcurrentHashMap.newKeySet(); + /** + * policy is a server's autostartPolicy, public unless set, and owner the player + * who owns it. Only public or the owner passes the wake's gate: ownerOnly, and an + * allowlist the stub keeps empty, refuse everyone else. + */ + final Map policy = new ConcurrentHashMap<>(); + final Map owner = new ConcurrentHashMap<>(); /** statusDown makes the status route answer 500. */ volatile boolean statusDown; - /** wakeError maps a server to "status code" (e.g. "403 forbidden"). */ + /** + * wakeError maps a server to "status code" (e.g. "429 cooldown"), answered where + * the real wake reaches its cooldown: after the gate and the start_failed check. + */ final Map wakeError = new ConcurrentHashMap<>(); volatile int joinStatus = 204; /** claimable names the servers the menu route reports as claimable. */ @@ -511,16 +528,13 @@ final class Fakes { reply(ex, 200, status(name, ready.getOrDefault(name, false))); return; case "POST wake": - if (!error(ex, wakeError.get(name))) { - // The real wake reply is this subset of the view: no endpoint. - reply(ex, 202, "{\"name\":\"" + name + "\",\"desiredState\":\"Running\"," - + "\"phase\":\"Stopped\",\"ready\":false}"); - } + wake(ex, name, bodies.get(method + " " + path)); return; case "GET menu": if (!error(ex, menuError.get(name))) { - reply(ex, 200, "{\"name\":\"" + name + "\",\"phase\":\"Stopped\",\"ready\":false," - + "\"playersOnline\":3,\"playersMax\":20,\"claimable\":" + claimable.contains(name) + "}"); + reply(ex, 200, "{\"name\":\"" + name + "\",\"phase\":\"" + phaseOf(name) + + "\",\"ready\":" + ready.getOrDefault(name, false) + + ",\"playersOnline\":3,\"playersMax\":20,\"claimable\":" + claimable.contains(name) + "}"); } return; case "POST claim": @@ -548,6 +562,52 @@ final class Fakes { return true; } + // wake answers the way handleInternalWake does, in its order: a server that is up + // and meant to stay up is 202 ready with no gate, nothing flipped; then the + // autostartPolicy gate on the body's mc_uuid; then 409 start_failed for a start + // whose retries are spent; then the cooldown and the rest of wakeError; then the + // flip to Running and a 202 with the phase as it stands. + private void wake(HttpExchange ex, String name, String body) throws IOException { + boolean up = ready.getOrDefault(name, false); + String want = desired.getOrDefault(name, "Running"); + if (up && "Running".equals(want)) { + reply(ex, 202, wakeReply(name, want, true)); + return; + } + UUID waker = mcUuid(body); + if (!"public".equals(policy.getOrDefault(name, "public")) + && (waker == null || !waker.equals(owner.get(name)))) { + error(ex, "403 forbidden"); + return; + } + if (gaveUp.contains(name) && "Running".equals(want)) { + error(ex, "409 start_failed"); + return; + } + if (error(ex, wakeError.get(name))) { + return; + } + desired.put(name, "Running"); + reply(ex, 202, wakeReply(name, "Running", up)); + } + + // The real wake reply is this subset of the view: no endpoint. + private String wakeReply(String name, String desiredState, boolean up) { + return "{\"name\":\"" + name + "\",\"desiredState\":\"" + desiredState + "\",\"phase\":\"" + + phaseOf(name) + "\",\"ready\":" + up + "}"; + } + + private static final Pattern MC_UUID = Pattern.compile("\"mc_uuid\"\\s*:\\s*\"([^\"]+)\""); + + private static UUID mcUuid(String body) { + Matcher m = MC_UUID.matcher(body == null ? "" : body); + return m.find() ? UUID.fromString(m.group(1)) : null; + } + + private String phaseOf(String name) { + return ready.getOrDefault(name, false) ? "Running" : phase.getOrDefault(name, "Stopped"); + } + private String status(String name, boolean up) { String addr = address.get(name); String endpoint = up @@ -556,7 +616,8 @@ final class Fakes { // Like the real ServerInfo, autoRestarts and startGaveUp are left out at 0/false. int restarts = this.restarts.getOrDefault(name, 0); return "{\"name\":\"" + name + "\",\"subdomain\":\"" + name + "\",\"phase\":\"" - + (up ? "Running" : phase.getOrDefault(name, "Stopped")) + "\",\"ready\":" + up + + phaseOf(name) + "\",\"ready\":" + up + + ",\"autostartPolicy\":\"" + policy.getOrDefault(name, "public") + "\"" + ",\"desiredState\":\"" + desired.getOrDefault(name, "Running") + "\"" + (restarts == 0 ? "" : ",\"autoRestarts\":" + restarts) + (gaveUp.contains(name) ? ",\"startGaveUp\":true" : "") diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java index 238b7a2..89e990b 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java @@ -162,7 +162,7 @@ public final class LegacyForwardingTest { } private static ServerView view(String name, boolean legacy) { - return new ServerView(name, name, "Running", true, null, "Running", "ClusterIP", "10.43.0.9:25565", 0, 20, legacy); + return new ServerView(name, name, "Running", true, null, "Running", "direct", "10.43.0.9:25565", 0, 20, legacy); } private static void assertEq(String what, Object want, Object got) { diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java index 2a68249..3e5b078 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java @@ -31,9 +31,9 @@ public final class ServerListSourceTest { private static volatile List answer; private static final ServerView LOGIN = new ServerView("login", null, "Running", true, - null, "Running", "ClusterIP", "10.43.0.17:25565", 0, 0); + null, "Running", "direct", "10.43.0.17:25565", 0, 0); private static final ServerView SURVIVAL = new ServerView("survival", "Survival", "Stopped", false, - "Anyone", "Stopped", "ClusterIP", "10.43.9.2:25565", 3, 20); + "public", "Stopped", "fallback", "login", 3, 20); public static void main(String[] args) throws Exception { Path dir = Files.createTempDirectory("felis-server-list"); @@ -84,10 +84,10 @@ public final class ServerListSourceTest { assertEq("survival subdomain", "Survival", s.subdomain()); assertEq("survival phase", "Stopped", s.phase()); assertEq("survival ready", false, s.ready()); - assertEq("survival policy", "Anyone", s.autostartPolicy()); + assertEq("survival policy", "public", s.autostartPolicy()); assertEq("survival desired", "Stopped", s.desiredState()); - assertEq("survival mode", "ClusterIP", s.endpointMode()); - assertEq("survival address", "10.43.9.2:25565", s.endpointAddress()); + assertEq("survival mode", "fallback", s.endpointMode()); + assertEq("survival address", "login", s.endpointAddress()); assertEq("survival online", 3, s.playersOnline()); assertEq("survival max", 20, s.playersMax()); } @@ -154,7 +154,7 @@ public final class ServerListSourceTest { private static void theLegacyForwardingMarkIsSaved(Path dir) throws Exception { Path file = fresh(dir); ServerView legacy = new ServerView("legacy18", "old", "Running", true, - null, "Running", "ClusterIP", "10.43.7.1:25565", 0, 20, true); + null, "Running", "direct", "10.43.7.1:25565", 0, 20, true); answer = List.of(legacy, SURVIVAL); new ServerListSource(ServerListSourceTest::fetch, file).next(); answer = null; @@ -167,7 +167,7 @@ public final class ServerListSourceTest { // The exact shape GET /api/v1/servers answers with, extra fields included. private static void theApiEnvelopeParses() { List v = ServerView.listFromJson("{\"servers\":[{\"name\":\"lobby\",\"subdomain\":\"\"," - + "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"ClusterIP\"," + + "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"direct\"," + "\"endpointAddress\":\"10.43.1.5:25565\",\"playersOnline\":1,\"playersMax\":100," + "\"owner\":\"x\"},\"junk\",{\"name\":\"legacy18\",\"legacyForwarding\":true}]}"); assertEq("non-object entries skipped", 2, v.size()); diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java index 88a3b68..83d69d3 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java @@ -71,6 +71,7 @@ public final class WaitingRouterTest { loginGate(); wakeRefusals(); queue(); + wakeAnswers(); longStart(); menuAndCommands(); joins(); @@ -97,6 +98,9 @@ public final class WaitingRouterTest { view("theta", false, "10.43.0.22:25565"), view("iota", false, "10.43.0.23:25565"), view("kappa", false, "10.43.0.24:25565"), + view("lambda", false, "10.43.0.25:25565"), + view("omicron", false, "10.43.0.26:25565"), + view("sigma", false, "10.43.0.27:25565"), view("fresh", false, null))); if (withGone) { list.add(view("gone", false, "10.43.0.9:25565")); @@ -216,17 +220,27 @@ public final class WaitingRouterTest { } private static void wakeRefusals() { + // The first two come from the server's state, as on the real API: a stranger's + // wake of an ownerOnly server, and a start whose automatic retries are spent. String[][] cases = { - {"403 forbidden", "You're not allowed to start « gamma »", null}, + {"ownerOnly, not the owner", "You're not allowed to start « gamma »", null}, + {"retries spent", "« gamma » failed to start and its automatic retries are spent", null}, {"409 maintenance_in_progress", "« gamma » is under maintenance", null}, - {"409 start_failed", "« gamma » failed to start and its automatic retries are spent", 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)"}, {"500 internal", "Couldn't start « gamma » right now", "wake gamma failed (status=500)"}, }; for (String[] c : cases) { - api.wakeError.put("gamma", c[0]); + switch (c[0]) { + case "ownerOnly, not the owner" -> api.policy.put("gamma", "ownerOnly"); + case "retries spent" -> { + api.phase.put("gamma", "Failed"); + api.restarts.put("gamma", 3); + api.gaveUp.add("gamma"); + } + default -> api.wakeError.put("gamma", c[0]); + } int before = router.waitingCount(); int warned = c[2] == null ? 0 : log.count("WARN", c[2]); Fakes.FakePlayer p = player("gamma.mc.test", true); @@ -238,6 +252,11 @@ public final class WaitingRouterTest { if (c[2] != null) { assertEq(c[0] + ": logged", warned + 1, log.count("WARN", c[2])); } + api.policy.remove("gamma"); + api.phase.remove("gamma"); + api.restarts.remove("gamma"); + api.gaveUp.remove("gamma"); + api.wakeError.remove("gamma"); } // 429: a wake is already in flight, so join the wait. api.wakeError.put("gamma", "429 cooldown"); @@ -331,6 +350,53 @@ public final class WaitingRouterTest { assertEq("registered: queue empty", 0, router.waitingCount()); } + // The wakes that are not refused, each answered from the server's state the way the + // real API answers it. + private static void wakeAnswers() { + assertEq("wake answers: queue empty to begin with", 0, router.waitingCount()); + + // The owner of an ownerOnly server may wake it, and the wake names the player + // who asked: the gate reads nothing else. + api.policy.put("lambda", "ownerOnly"); + Fakes.FakePlayer owner = player("lambda.mc.test", true); + api.owner.put("lambda", owner.id); + choose(owner); + assertEq("owner: released to the lobby", "lobby", allowedTo(release(owner))); + assertEq("owner: told it is starting", true, owner.said("Starting « lambda »")); + assertEq("owner: queued", 1, router.waitingCount()); + assertEq("the wake carried the joining player", true, + api.bodies.get("POST " + SERVERS + "lambda/wake").contains(owner.id.toString())); + + // Up since the last refresh: the host path still wakes, and the API answers 202 + // ready without the gate. Nobody is refused or told a start is under way, and + // the next drain moves them in. + api.policy.put("omicron", "ownerOnly"); + api.ready.put("omicron", true); + Fakes.FakePlayer friend = player("omicron.mc.test", true); + choose(friend); + assertEq("up behind a stale list: released to the lobby", "lobby", allowedTo(release(friend))); + assertEq("up behind a stale list: not refused", false, friend.said("not allowed")); + assertEq("up behind a stale list: no promise of a start", false, friend.said("Starting « omicron »")); + assertEq("up behind a stale list: queued", 2, router.waitingCount()); + router.tick(); + assertEq("up behind a stale list: moved at the next drain", List.of("omicron"), List.copyOf(friend.connects)); + assertEq("up behind a stale list: only the owner's wait is left", 1, router.waitingCount()); + + // Failed, but inside its restart backoff: the next attempt is coming, so the + // wake is a 202 and the player waits for it. + api.phase.put("sigma", "Failed"); + api.restarts.put("sigma", 1); + Fakes.FakePlayer patient = player("sigma.mc.test", true); + choose(patient); + assertEq("in the backoff: released to the lobby", "lobby", allowedTo(release(patient))); + assertEq("in the backoff: told it is starting", true, patient.said("Starting « sigma »")); + assertEq("in the backoff: queued", 2, router.waitingCount()); + + net.players.clear(); + router.tick(); + assertEq("wake answers: queue empty again", 0, router.waitingCount()); + } + // A modpack's cold start outlasts any fixed wait: the operator gives a start 300 s, // then recreates the pod up to three times with a 1, 2, 4 min backoff. The waiter // follows the server's own progress instead of a clock, and hears how it is going. @@ -446,9 +512,10 @@ public final class WaitingRouterTest { assertEq("only the menu entry tells the lobby", List.of(menu.name + "@epsilon"), List.copyOf(notified)); // A friend's running server: the menu and /felis go join it without waking it. - // The API refuses a non-owner's wake of an ownerOnly server, and that refusal - // was all a friend got from the green tile while every entry woke first. - api.wakeError.put("beta", "403 forbidden"); + // The API used to refuse a non-owner's wake of a running ownerOnly server, and + // that refusal was all a friend got from the green tile while every entry woke + // first. + api.policy.put("beta", "ownerOnly"); Fakes.FakePlayer friend = player(null, true); friend.current = lobby; router.enqueueFromMenu(friend.player, "beta"); @@ -462,7 +529,7 @@ public final class WaitingRouterTest { assertEq("running server via /felis go: joined", List.of("beta"), List.copyOf(friend2.connects)); assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake")); assertEq("running server: nobody refused", false, friend.said("not allowed") || friend2.said("not allowed")); - api.wakeError.remove("beta"); + api.policy.remove("beta"); // Asking for the server you stand on is answered at once, case-insensitively. Fakes.FakePlayer there = player(null, true); @@ -674,7 +741,7 @@ public final class WaitingRouterTest { if (addr != null) { api.address.put(name, addr); } - return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "ownerOnly", + return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "public", "Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20); }