Unverified Commit 0fb43232 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(velocity): 保存最近一次服务器列表,控制面不可用时重启代理仍能路由登录

parent 7bf81a09
Loading
Loading
Loading
Loading
+22 −0
Changes for docs/operations.md: 22 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+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
@@ -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;
@@ -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). */
+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;

/**
@@ -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;
    }
+11 −1
Changes for plugins/test.sh: 11 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
@@ -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" \
+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
@@ -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
@@ -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;
@@ -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);
@@ -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