Loading docs/operations.md +22 −0 Changes for docs/operations.md: 22 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -48,6 +48,28 @@ and gains no failover. A multi-node shape would need, at least, storage that can pod to another node and leader election in felis-operator (controller-runtime's `LeaderElection`) so a second replica can stand by. ### While felis-api restarts An installer rerun that changes felis-api, a node restart or a crashed pod takes the API away until its new pod is ready: about 12 s on the reference VM (`kubectl rollout restart` to Available). Its Deployment keeps one replica with the Recreate strategy, so the old pod is gone before the new one starts. Two pods at once would be wrong for felis-api: the uploads volume is ReadWriteOnce, a chunked upload is serialized inside the process, and the build reconciler, restore settler, registry pruner, upload reapers and audit retention run in-process without leader election, so each would run twice. During the window: - Players already on a server stay there; game servers keep running. - A player leaving the login gate or joining a server by its address is admitted when felis-api confirmed their link within the last 10 minutes; anyone else is told login verification is temporarily unavailable. - The login gate retries a new login for up to 60 s and tells the player it is retrying, so a restart shorter than that only delays the login. - Wakes, stops, `/link` and the panel wait for the API. - A Velocity restart in the window routes on `/opt/felis/velocity/plugins/felis-link/last-servers.json`, the last server list the API answered with, until a refresh succeeds (every 15 s). ## 2. Sizing ### What the platform itself uses Loading plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +1 −12 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java: 1 added line, 12 removed lines. Original line number Diff line number Diff line Loading @@ -7,7 +7,6 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; Loading Loading @@ -57,17 +56,7 @@ public final class FelisApiClient { /** listServers returns the lifecycle view of every MinecraftServer (GET /servers). */ public List<ServerView> listServers() throws LinkException { Map<?, ?> obj = getObject("/api/v1/servers", 200); Object arr = obj.get("servers"); List<ServerView> out = new ArrayList<>(); if (arr instanceof List) { for (Object e : (List<?>) arr) { if (e instanceof Map) { out.add(ServerView.fromJson((Map<?, ?>) e)); } } } return out; return ServerView.listFrom(getObject("/api/v1/servers", 200)); } /** serverStatus reads one server's current lifecycle view (internal status). */ Loading plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java +63 −0 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java: 63 added lines, 0 removed lines. Original line number Diff line number Diff line package best.lolicon.felis.link; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; /** Loading Loading @@ -62,6 +65,66 @@ public final class ServerView { intval(o, "playersMax")); } /** * listFromJson reads the {@code {"servers":[...]}} envelope {@code GET /servers} * answers with, which is also the shape of the proxy's saved server list. Entries * that are not objects are skipped; text that is not such an envelope throws * IllegalArgumentException. */ public static List<ServerView> listFromJson(String text) { Object root = Json.parse(text); if (!(root instanceof Map)) { throw new IllegalArgumentException("server list: not a JSON object"); } return listFrom((Map<?, ?>) root); } static List<ServerView> listFrom(Map<?, ?> envelope) { Object arr = envelope.get("servers"); List<ServerView> out = new ArrayList<>(); if (arr instanceof List) { for (Object e : (List<?>) arr) { if (e instanceof Map) { out.add(fromJson((Map<?, ?>) e)); } } } return out; } /** listToJson renders views in the envelope {@link #listFromJson(String)} reads back. */ public static String listToJson(Collection<ServerView> views) { StringBuilder b = new StringBuilder("{\"servers\":["); boolean first = true; for (ServerView v : views) { if (!first) { b.append(','); } first = false; b.append('{'); field(b, "name", v.name, true); field(b, "subdomain", v.subdomain, false); field(b, "phase", v.phase, false); b.append(",\"ready\":").append(v.ready); field(b, "autostartPolicy", v.autostartPolicy, false); field(b, "desiredState", v.desiredState, false); field(b, "endpointMode", v.endpointMode, false); field(b, "endpointAddress", v.endpointAddress, false); b.append(",\"playersOnline\":").append(v.playersOnline); b.append(",\"playersMax\":").append(v.playersMax); b.append('}'); } return b.append("]}").toString(); } // field appends "key":"value", or "key":null for an absent value. private static void field(StringBuilder b, String key, String value, boolean first) { if (!first) { b.append(','); } b.append(Json.quote(key)).append(':').append(value == null ? "null" : Json.quote(value)); } public String name() { return name; } Loading plugins/test.sh +11 −1 Changes for plugins/test.sh: 11 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -10,7 +10,9 @@ # round-trips every frame kind (spec §12), no server name can re-aim a # felis-api request path, only the lobby and the login gate may drive # felis:control (and each only with its own frames), the link-status outage # fallback fails closed outside its window, /invite prompts cannot double-fire # fallback fails closed outside its window, a proxy restarted during an API # outage routes on the last saved server list (and only until a fetch # succeeds), /invite prompts cannot double-fire # or outlive their TTL, the invite card really is a green/red clickable # prompt, and the op-login approval card names the account and leaves its # name for the admin to type. InviteCardTest and OpApprovalCardTest need the Loading Loading @@ -81,6 +83,14 @@ java -cp "$work/policy-classes" best.lolicon.felis.velocity.ControlPolicyTest echo "==> LinkGateTest (outage fallback + frame budget, velocity)" java -cp "$work/policy-classes" best.lolicon.felis.velocity.LinkGateTest echo "==> ServerListSourceTest (saved server list for a restart during an outage, velocity)" mkdir -p "$work/list-classes" javac -d "$work/list-classes" \ plugins/shared/src/main/java/best/lolicon/felis/link/*.java \ plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerListSource.java \ plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java java -cp "$work/list-classes" best.lolicon.felis.velocity.ServerListSourceTest echo "==> InviteBookTest (/invite prompt store, velocity)" mkdir -p "$work/velocity-classes" javac -d "$work/velocity-classes" \ Loading plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +18 −5 Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java: 18 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -70,6 +70,8 @@ import java.util.regex.Pattern; ) public final class FelisVelocityPlugin { private static final Duration REGISTRATION_REFRESH = Duration.ofSeconds(15); // The last server list felis-api answered with, for a restart during an API outage. private static final String SERVER_LIST_FILE = "last-servers.json"; private static final Duration WAIT_POLL = Duration.ofSeconds(2); private static final Duration INVITE_TTL = Duration.ofSeconds(120); // Long enough that spraying cards at a room is tedious, short enough that showing three Loading @@ -91,6 +93,7 @@ public final class FelisVelocityPlugin { private LinkClient linkClient; private FelisApiClient apiClient; private ServerRegistry registry; private ServerListSource serverList; private WaitingRouter router; private boolean onlineMode; private boolean routingActive; Loading Loading @@ -133,6 +136,7 @@ public final class FelisVelocityPlugin { this.apiClient = new FelisApiClient(config.linkConfig()); this.registry = new ServerRegistry(proxy, logger, config.rootDomain()); this.serverList = new ServerListSource(apiClient::listServers, dataDirectory.resolve(SERVER_LIST_FILE)); this.router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.loginServer(), config.lobbyServer()); MotdResponder motd = new MotdResponder(registry); Loading Loading @@ -213,14 +217,23 @@ public final class FelisVelocityPlugin { if (router != null) { router.pruneLinks(); } try { List<ServerView> servers = apiClient.listServers(); registry.refresh(servers); } catch (LinkException e) { ServerListSource.Result r = serverList.next(); if (r.servers != null) { registry.refresh(r.servers); } if (r.restored) { logger.warn("Felis: felis-api is unreachable at startup (status={}): {}; routing to the {} backends " + "in the saved server list until it answers.", r.failure.statusCode(), r.failure.getMessage(), r.servers.size()); } else if (r.failure != null) { // Keep existing registrations on a control-plane blip (spec §11): a // transient failure must never deregister live backends. logger.warn("Felis: server list refresh failed (status={}): {}; keeping current registrations.", e.statusCode(), e.getMessage()); r.failure.statusCode(), r.failure.getMessage()); } if (r.fileError != null) { logger.warn("Felis: saved server list {}: {}", r.failure == null ? "not written" : "not usable", r.fileError.toString()); } } Loading Loading
docs/operations.md +22 −0 Changes for docs/operations.md: 22 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -48,6 +48,28 @@ and gains no failover. A multi-node shape would need, at least, storage that can pod to another node and leader election in felis-operator (controller-runtime's `LeaderElection`) so a second replica can stand by. ### While felis-api restarts An installer rerun that changes felis-api, a node restart or a crashed pod takes the API away until its new pod is ready: about 12 s on the reference VM (`kubectl rollout restart` to Available). Its Deployment keeps one replica with the Recreate strategy, so the old pod is gone before the new one starts. Two pods at once would be wrong for felis-api: the uploads volume is ReadWriteOnce, a chunked upload is serialized inside the process, and the build reconciler, restore settler, registry pruner, upload reapers and audit retention run in-process without leader election, so each would run twice. During the window: - Players already on a server stay there; game servers keep running. - A player leaving the login gate or joining a server by its address is admitted when felis-api confirmed their link within the last 10 minutes; anyone else is told login verification is temporarily unavailable. - The login gate retries a new login for up to 60 s and tells the player it is retrying, so a restart shorter than that only delays the login. - Wakes, stops, `/link` and the panel wait for the API. - A Velocity restart in the window routes on `/opt/felis/velocity/plugins/felis-link/last-servers.json`, the last server list the API answered with, until a refresh succeeds (every 15 s). ## 2. Sizing ### What the platform itself uses Loading
plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +1 −12 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java: 1 added line, 12 removed lines. Original line number Diff line number Diff line Loading @@ -7,7 +7,6 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; Loading Loading @@ -57,17 +56,7 @@ public final class FelisApiClient { /** listServers returns the lifecycle view of every MinecraftServer (GET /servers). */ public List<ServerView> listServers() throws LinkException { Map<?, ?> obj = getObject("/api/v1/servers", 200); Object arr = obj.get("servers"); List<ServerView> out = new ArrayList<>(); if (arr instanceof List) { for (Object e : (List<?>) arr) { if (e instanceof Map) { out.add(ServerView.fromJson((Map<?, ?>) e)); } } } return out; return ServerView.listFrom(getObject("/api/v1/servers", 200)); } /** serverStatus reads one server's current lifecycle view (internal status). */ Loading
plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java +63 −0 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java: 63 added lines, 0 removed lines. Original line number Diff line number Diff line package best.lolicon.felis.link; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; /** Loading Loading @@ -62,6 +65,66 @@ public final class ServerView { intval(o, "playersMax")); } /** * listFromJson reads the {@code {"servers":[...]}} envelope {@code GET /servers} * answers with, which is also the shape of the proxy's saved server list. Entries * that are not objects are skipped; text that is not such an envelope throws * IllegalArgumentException. */ public static List<ServerView> listFromJson(String text) { Object root = Json.parse(text); if (!(root instanceof Map)) { throw new IllegalArgumentException("server list: not a JSON object"); } return listFrom((Map<?, ?>) root); } static List<ServerView> listFrom(Map<?, ?> envelope) { Object arr = envelope.get("servers"); List<ServerView> out = new ArrayList<>(); if (arr instanceof List) { for (Object e : (List<?>) arr) { if (e instanceof Map) { out.add(fromJson((Map<?, ?>) e)); } } } return out; } /** listToJson renders views in the envelope {@link #listFromJson(String)} reads back. */ public static String listToJson(Collection<ServerView> views) { StringBuilder b = new StringBuilder("{\"servers\":["); boolean first = true; for (ServerView v : views) { if (!first) { b.append(','); } first = false; b.append('{'); field(b, "name", v.name, true); field(b, "subdomain", v.subdomain, false); field(b, "phase", v.phase, false); b.append(",\"ready\":").append(v.ready); field(b, "autostartPolicy", v.autostartPolicy, false); field(b, "desiredState", v.desiredState, false); field(b, "endpointMode", v.endpointMode, false); field(b, "endpointAddress", v.endpointAddress, false); b.append(",\"playersOnline\":").append(v.playersOnline); b.append(",\"playersMax\":").append(v.playersMax); b.append('}'); } return b.append("]}").toString(); } // field appends "key":"value", or "key":null for an absent value. private static void field(StringBuilder b, String key, String value, boolean first) { if (!first) { b.append(','); } b.append(Json.quote(key)).append(':').append(value == null ? "null" : Json.quote(value)); } public String name() { return name; } Loading
plugins/test.sh +11 −1 Changes for plugins/test.sh: 11 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -10,7 +10,9 @@ # round-trips every frame kind (spec §12), no server name can re-aim a # felis-api request path, only the lobby and the login gate may drive # felis:control (and each only with its own frames), the link-status outage # fallback fails closed outside its window, /invite prompts cannot double-fire # fallback fails closed outside its window, a proxy restarted during an API # outage routes on the last saved server list (and only until a fetch # succeeds), /invite prompts cannot double-fire # or outlive their TTL, the invite card really is a green/red clickable # prompt, and the op-login approval card names the account and leaves its # name for the admin to type. InviteCardTest and OpApprovalCardTest need the Loading Loading @@ -81,6 +83,14 @@ java -cp "$work/policy-classes" best.lolicon.felis.velocity.ControlPolicyTest echo "==> LinkGateTest (outage fallback + frame budget, velocity)" java -cp "$work/policy-classes" best.lolicon.felis.velocity.LinkGateTest echo "==> ServerListSourceTest (saved server list for a restart during an outage, velocity)" mkdir -p "$work/list-classes" javac -d "$work/list-classes" \ plugins/shared/src/main/java/best/lolicon/felis/link/*.java \ plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerListSource.java \ plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java java -cp "$work/list-classes" best.lolicon.felis.velocity.ServerListSourceTest echo "==> InviteBookTest (/invite prompt store, velocity)" mkdir -p "$work/velocity-classes" javac -d "$work/velocity-classes" \ Loading
plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +18 −5 Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java: 18 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -70,6 +70,8 @@ import java.util.regex.Pattern; ) public final class FelisVelocityPlugin { private static final Duration REGISTRATION_REFRESH = Duration.ofSeconds(15); // The last server list felis-api answered with, for a restart during an API outage. private static final String SERVER_LIST_FILE = "last-servers.json"; private static final Duration WAIT_POLL = Duration.ofSeconds(2); private static final Duration INVITE_TTL = Duration.ofSeconds(120); // Long enough that spraying cards at a room is tedious, short enough that showing three Loading @@ -91,6 +93,7 @@ public final class FelisVelocityPlugin { private LinkClient linkClient; private FelisApiClient apiClient; private ServerRegistry registry; private ServerListSource serverList; private WaitingRouter router; private boolean onlineMode; private boolean routingActive; Loading Loading @@ -133,6 +136,7 @@ public final class FelisVelocityPlugin { this.apiClient = new FelisApiClient(config.linkConfig()); this.registry = new ServerRegistry(proxy, logger, config.rootDomain()); this.serverList = new ServerListSource(apiClient::listServers, dataDirectory.resolve(SERVER_LIST_FILE)); this.router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.loginServer(), config.lobbyServer()); MotdResponder motd = new MotdResponder(registry); Loading Loading @@ -213,14 +217,23 @@ public final class FelisVelocityPlugin { if (router != null) { router.pruneLinks(); } try { List<ServerView> servers = apiClient.listServers(); registry.refresh(servers); } catch (LinkException e) { ServerListSource.Result r = serverList.next(); if (r.servers != null) { registry.refresh(r.servers); } if (r.restored) { logger.warn("Felis: felis-api is unreachable at startup (status={}): {}; routing to the {} backends " + "in the saved server list until it answers.", r.failure.statusCode(), r.failure.getMessage(), r.servers.size()); } else if (r.failure != null) { // Keep existing registrations on a control-plane blip (spec §11): a // transient failure must never deregister live backends. logger.warn("Felis: server list refresh failed (status={}): {}; keeping current registrations.", e.statusCode(), e.getMessage()); r.failure.statusCode(), r.failure.getMessage()); } if (r.fileError != null) { logger.warn("Felis: saved server list {}: {}", r.failure == null ? "not written" : "not usable", r.fileError.toString()); } } Loading