feat(velocity): legacy forwarding 列表跟随 CR 的 forwarding=legacy 标签实时更新 (#15)
This commit is contained in:
15 files changed
+522
-14
No files matched your search
+13
-10
@@ -38,10 +38,12 @@
|
||||
# FELIS_NANO_PROXY_CIDR the proxy allowed to reach a non-loopback nano bind, as an address
|
||||
# with a prefix length (for example 10.0.0.7/32). firewalld opens the
|
||||
# port to that source only; unset, it opens nothing
|
||||
# FELIS_LEGACY_FORWARDING_SERVERS comma-separated backends that receive their identity
|
||||
# through the handshake address instead of modern forwarding
|
||||
# (default: legacy18). Read once at Velocity start, so changing it
|
||||
# means re-running this script and restarting the proxy.
|
||||
# FELIS_LEGACY_FORWARDING_SERVERS comma-separated backends that always receive their
|
||||
# identity through the handshake address instead of modern forwarding
|
||||
# (default: legacy18). A floor: any server whose MinecraftServer CR is
|
||||
# labelled felis.lolicon.best/forwarding=legacy joins it while the
|
||||
# proxy runs (docs/operations.md), so a new 1.8 backend needs a label,
|
||||
# not a re-run. Changing the floor itself means re-running this script.
|
||||
# FELIS_VELOCITY_XMX maximum heap of the Velocity proxy, as <n>M or <n>G (default: 1G;
|
||||
# at least 256M). docs/operations.md sizes it by player count.
|
||||
# FELIS_VELOCITY_FORK_JAR path to a Felis-Legacy Velocity fork build to install as the
|
||||
@@ -2741,12 +2743,13 @@ install_velocity_service() {
|
||||
# fork reads this list from -Dfelis.legacy-forwarding.servers and forwards those servers legacy;
|
||||
# every other backend keeps modern+secret untouched.
|
||||
#
|
||||
# The list is a JVM system property, so it is fixed for the life of the proxy process and a
|
||||
# change needs a Velocity restart. FELIS_LEGACY_FORWARDING_SERVERS makes that reachable
|
||||
# without editing this script, which is as far as a startup property can go. Having it follow
|
||||
# the MinecraftServer CRs instead is a larger change: the forwarding decision lives in the
|
||||
# fork's patch to Velocity core, not in the Felis plugin, so core would need to read state the
|
||||
# plugin owns and refreshes.
|
||||
# This value is the floor of the list. The felis-velocity plugin adds every server whose
|
||||
# MinecraftServer CR is labelled felis.lolicon.best/forwarding=legacy by rewriting the same
|
||||
# property on each server-list refresh (LegacyForwarding.java), and drops it again when the
|
||||
# label goes; the floor always stays in. A fork carrying patch 0004 re-reads the property on
|
||||
# every backend connection, so a label applies from the next connection. A fork with 0003
|
||||
# alone reads it once, after the plugin's first refresh, so a label applies at the next proxy
|
||||
# restart. Stock Velocity ignores it, and the plugin logs a warning for a labelled server.
|
||||
#
|
||||
# The -D below is double-quoted in ExecStart on purpose. The fork trims each element, so it
|
||||
# accepts "legacy18, legacy112", but systemd splits ExecStart on whitespace before java ever
|
||||
|
||||
@@ -482,6 +482,12 @@ components:
|
||||
playerCountUnknown:
|
||||
type: boolean
|
||||
description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can.
|
||||
legacyForwarding:
|
||||
type: boolean
|
||||
description: >-
|
||||
Present and true when the CR carries the label felis.lolicon.best/forwarding=legacy.
|
||||
The proxy then forwards this server's players BungeeCord-style in the handshake
|
||||
address instead of modern forwarding (Felis-Legacy Velocity fork only).
|
||||
|
||||
FleetServer:
|
||||
description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView).
|
||||
|
||||
@@ -383,6 +383,38 @@ version, in this order, each step one release:
|
||||
3. A later release stops serving `v1alpha1`. Felis itself reads through one Go type at a
|
||||
time, so the operator and felis-api switch in the release that moves storage.
|
||||
|
||||
### Legacy-forwarded backends [VM-VERIFIED]
|
||||
|
||||
A 1.8-era backend sits behind ViaVersion, which drops modern forwarding's login plugin
|
||||
message on the way down to protocol 47, so the proxy has to hand that server the
|
||||
player's identity BungeeCord-style, in the handshake address. Only the Felis-Legacy
|
||||
Velocity fork can do that per server. Mark the server's CR and the proxy picks it up at
|
||||
its next server-list refresh (every 15 s):
|
||||
|
||||
```sh
|
||||
kubectl -n minecraft label minecraftserver <name> felis.lolicon.best/forwarding=legacy
|
||||
kubectl -n minecraft label minecraftserver <name> felis.lolicon.best/forwarding- # back to modern
|
||||
journalctl -u felis-velocity | grep 'legacy forwarding list'
|
||||
```
|
||||
|
||||
The installer's `FELIS_LEGACY_FORWARDING_SERVERS` (default `legacy18`) stays in the list
|
||||
whatever the labels say. What a label does depends on the proxy the host runs:
|
||||
|
||||
| Proxy | A label applies |
|
||||
|---|---|
|
||||
| Fork with patch 0004 (`build-velocity.sh` default arm) | from the next connection to that server |
|
||||
| Fork with 0003 alone (`--deployed`) | at the next `systemctl restart felis-velocity` |
|
||||
| Stock Velocity | never; the log line is a warning naming the server |
|
||||
|
||||
On the test VM (fork with 0004) labelling a server logged `legacy forwarding list is now
|
||||
[legacy18,resolvecheck]` 12 s later, and removing the label logged the list back to
|
||||
`[legacy18]`. The fork's own test (`FelisLegacyForwardingTest`) covers the next
|
||||
connection following the rewritten list.
|
||||
|
||||
Legacy forwarding carries no secret. A marked server believes any identity that reaches
|
||||
its game port, which `felis-allow-game-from-velocity` limits to the proxy and the node
|
||||
itself; anything else running on the node can reach it too.
|
||||
|
||||
## 5. Disaster recovery
|
||||
|
||||
The procedures are in §16: what a database bundle holds, restoring one on the same host,
|
||||
|
||||
@@ -34,6 +34,9 @@ type ServerInfo struct {
|
||||
// PlayerCountUnknown is true while the operator cannot read the player
|
||||
// count over RCON; idle auto-stop waits until it can.
|
||||
PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
|
||||
// LegacyForwarding mirrors the CR's forwarding=legacy label: the proxy
|
||||
// forwards this server's players in the handshake address (#15).
|
||||
LegacyForwarding bool `json:"legacyForwarding,omitempty"`
|
||||
}
|
||||
|
||||
// CreateServerInput is the validated, structured create-server form (spec §15).
|
||||
|
||||
@@ -446,6 +446,7 @@ func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo {
|
||||
IdleStopSeconds: idleStopSeconds(ms),
|
||||
PlayerCountUnknown: ms.Status.Phase == v1alpha1.PhaseRunning &&
|
||||
meta.IsStatusConditionFalse(ms.Status.Conditions, v1alpha1.ConditionPlayersCounted),
|
||||
LegacyForwarding: ms.Labels[v1alpha1.LabelForwarding] == v1alpha1.ForwardingLegacy,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -253,3 +254,39 @@ func TestSubdomainOf(t *testing.T) {
|
||||
t.Fatalf("SubdomainOf(no subdomain) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The forwarding=legacy label reaches the proxy as legacyForwarding on the server
|
||||
// list (#15); any other value, and no label, leaves the server on modern
|
||||
// forwarding and keeps the key out of the JSON.
|
||||
func TestServerListCarriesLegacyForwarding(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
if err := v1alpha1.AddToScheme(scheme); err != nil {
|
||||
t.Fatalf("scheme: %v", err)
|
||||
}
|
||||
labeled := func(name, value string) *v1alpha1.MinecraftServer {
|
||||
ms := testServer(name, name)
|
||||
ms.Labels = map[string]string{v1alpha1.LabelForwarding: value}
|
||||
return ms
|
||||
}
|
||||
k := NewK8sCluster(fake.NewClientBuilder().WithScheme(scheme).WithObjects(
|
||||
labeled("legacy18", "legacy"), labeled("shouty", "LEGACY"), labeled("modern", "modern"),
|
||||
testServer("plain", "plain"),
|
||||
).Build(), "minecraft")
|
||||
infos, err := k.ListServers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListServers: %v", err)
|
||||
}
|
||||
for _, i := range infos {
|
||||
b, err := json.Marshal(i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := i.Name == "legacy18"
|
||||
if i.LegacyForwarding != want || strings.Contains(string(b), `"legacyForwarding":true`) != want {
|
||||
t.Errorf("%s: legacyForwarding = %v, JSON %s; want %v", i.Name, i.LegacyForwarding, b, want)
|
||||
}
|
||||
if !want && strings.Contains(string(b), "legacyForwarding") {
|
||||
t.Errorf("%s: JSON carries legacyForwarding although it is off: %s", i.Name, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,22 @@ const (
|
||||
// LabelSystemRole identifies setup-owned system servers. Its value is the
|
||||
// reserved role name (for example, "login" or "lobby").
|
||||
LabelSystemRole = GroupName + "/system-role"
|
||||
// LabelForwarding set to ForwardingLegacy makes the proxy hand this server the
|
||||
// player's identity BungeeCord-style, in the handshake address, instead of the
|
||||
// proxy-wide modern forwarding (#15). It is for a 1.8-era backend behind
|
||||
// ViaVersion, which drops modern forwarding's login plugin message; such a
|
||||
// backend trusts whatever reaches its game port, so nothing but the proxy may.
|
||||
// Only the Felis-Legacy Velocity fork acts on it. A label rather than a spec
|
||||
// field: the CRD needs no change, so an existing install takes it as is; the
|
||||
// operator copies no CR label onto the pod, so setting it never restarts the
|
||||
// server; and felis-api never writes it, so marking a server takes kubectl on
|
||||
// the cluster, which fits a switch that drops the forwarding secret.
|
||||
LabelForwarding = GroupName + "/forwarding"
|
||||
)
|
||||
|
||||
// ForwardingLegacy is the LabelForwarding value that selects legacy forwarding.
|
||||
const ForwardingLegacy = "legacy"
|
||||
|
||||
// DesiredState is the operator-facing intent toggle (spec §4 spec.desiredState).
|
||||
type DesiredState string
|
||||
|
||||
|
||||
@@ -2346,6 +2346,8 @@ export interface components {
|
||||
idleStopSeconds: number;
|
||||
/** @description Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can. */
|
||||
playerCountUnknown?: boolean;
|
||||
/** @description Present and true when the CR carries the label felis.lolicon.best/forwarding=legacy. The proxy then forwards this server's players BungeeCord-style in the handshake address instead of modern forwarding (Felis-Legacy Velocity fork only). */
|
||||
legacyForwarding?: boolean;
|
||||
};
|
||||
/** @description One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). */
|
||||
FleetServer: components["schemas"]["ServerInfo"] & {
|
||||
|
||||
@@ -62,6 +62,9 @@ export interface ServerStatus {
|
||||
idleStopSeconds: number;
|
||||
/** True while the operator cannot read the player count; idle stop waits. */
|
||||
playerCountUnknown?: boolean;
|
||||
/** True when the CR is labelled forwarding=legacy: the proxy forwards this
|
||||
* server's players BungeeCord-style (a 1.8 backend behind ViaVersion). */
|
||||
legacyForwarding?: boolean;
|
||||
}
|
||||
|
||||
/** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
|
||||
|
||||
@@ -34,10 +34,19 @@ public final class ServerView {
|
||||
private final String endpointAddress;
|
||||
private final int playersOnline;
|
||||
private final int playersMax;
|
||||
private final boolean legacyForwarding;
|
||||
|
||||
public ServerView(String name, String subdomain, String phase, boolean ready,
|
||||
String autostartPolicy, String desiredState, String endpointMode,
|
||||
String endpointAddress, int playersOnline, int playersMax) {
|
||||
this(name, subdomain, phase, ready, autostartPolicy, desiredState, endpointMode,
|
||||
endpointAddress, playersOnline, playersMax, false);
|
||||
}
|
||||
|
||||
public ServerView(String name, String subdomain, String phase, boolean ready,
|
||||
String autostartPolicy, String desiredState, String endpointMode,
|
||||
String endpointAddress, int playersOnline, int playersMax,
|
||||
boolean legacyForwarding) {
|
||||
this.name = name;
|
||||
this.subdomain = subdomain;
|
||||
this.phase = phase;
|
||||
@@ -48,6 +57,7 @@ public final class ServerView {
|
||||
this.endpointAddress = endpointAddress;
|
||||
this.playersOnline = playersOnline;
|
||||
this.playersMax = playersMax;
|
||||
this.legacyForwarding = legacyForwarding;
|
||||
}
|
||||
|
||||
/** fromJson builds a view from a parsed felis-api object, tolerating absent fields. */
|
||||
@@ -62,7 +72,8 @@ public final class ServerView {
|
||||
str(o, "endpointMode"),
|
||||
str(o, "endpointAddress"),
|
||||
intval(o, "playersOnline"),
|
||||
intval(o, "playersMax"));
|
||||
intval(o, "playersMax"),
|
||||
bool(o, "legacyForwarding"));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +123,7 @@ public final class ServerView {
|
||||
field(b, "endpointAddress", v.endpointAddress, false);
|
||||
b.append(",\"playersOnline\":").append(v.playersOnline);
|
||||
b.append(",\"playersMax\":").append(v.playersMax);
|
||||
b.append(",\"legacyForwarding\":").append(v.legacyForwarding);
|
||||
b.append('}');
|
||||
}
|
||||
return b.append("]}").toString();
|
||||
@@ -167,6 +179,15 @@ public final class ServerView {
|
||||
return playersMax;
|
||||
}
|
||||
|
||||
/**
|
||||
* legacyForwarding is true when the server's CR is labelled
|
||||
* {@code felis.lolicon.best/forwarding=legacy}: the proxy forwards its players
|
||||
* BungeeCord-style instead of modern forwarding ({@code LegacyForwarding}).
|
||||
*/
|
||||
public boolean legacyForwarding() {
|
||||
return legacyForwarding;
|
||||
}
|
||||
|
||||
private static String str(Map<?, ?> o, String key) {
|
||||
Object v = o.get(key);
|
||||
return v instanceof String ? (String) v : null;
|
||||
|
||||
+10
-1
@@ -20,7 +20,8 @@
|
||||
# lasts and when it ends, the link-status outage
|
||||
# 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
|
||||
# succeeds), a CR marked forwarding=legacy joins the fork's legacy-forwarding
|
||||
# list above the installer's floor, /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
|
||||
@@ -127,6 +128,14 @@ javac -d "$work/list-classes" \
|
||||
plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java
|
||||
java -cp "$work/list-classes" best.lolicon.felis.velocity.ServerListSourceTest
|
||||
|
||||
echo "==> LegacyForwardingTest (CR-marked servers reach the fork's legacy-forwarding list, velocity)"
|
||||
mkdir -p "$work/legacy-classes"
|
||||
javac -d "$work/legacy-classes" \
|
||||
plugins/shared/src/main/java/best/lolicon/felis/link/*.java \
|
||||
plugins/velocity/src/main/java/best/lolicon/felis/velocity/LegacyForwarding.java \
|
||||
plugins/velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java
|
||||
java -cp "$work/legacy-classes" best.lolicon.felis.velocity.LegacyForwardingTest
|
||||
|
||||
echo "==> ApiPoolTest (bounded felis-api pool, non-overlapping repeats, velocity)"
|
||||
mkdir -p "$work/pool-classes"
|
||||
javac -d "$work/pool-classes" \
|
||||
|
||||
@@ -113,6 +113,7 @@ public final class FelisVelocityPlugin {
|
||||
private FelisApiClient apiClient;
|
||||
private ServerRegistry registry;
|
||||
private ServerListSource serverList;
|
||||
private LegacyForwarding legacyForwarding;
|
||||
private WaitingRouter router;
|
||||
private boolean onlineMode;
|
||||
private boolean routingActive;
|
||||
@@ -158,6 +159,8 @@ 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.legacyForwarding = new LegacyForwarding(System.getProperty(LegacyForwarding.PROPERTY),
|
||||
LegacyForwarding.detect(proxy.getClass().getClassLoader()));
|
||||
this.router = new WaitingRouter(proxy, logger, apiClient, registry, this,
|
||||
config.loginServer(), config.lobbyServer());
|
||||
MotdResponder motd = new MotdResponder(registry);
|
||||
@@ -178,6 +181,9 @@ public final class FelisVelocityPlugin {
|
||||
repeating(REGISTRATION_REFRESH, this::refreshRegistrations);
|
||||
repeating(WAIT_POLL, router::tick);
|
||||
repeating(STATS_INTERVAL, this::logStats);
|
||||
// Velocity accepts no connection until this handler returns, so the first
|
||||
// refresh above reached even a fork that reads the legacy list only once.
|
||||
legacyForwarding.accepting();
|
||||
|
||||
this.routingActive = true;
|
||||
logger.info("Felis routing ready: rootDomain={}, login={}, lobby={}. /link, /felis and /invite registered.",
|
||||
@@ -285,6 +291,14 @@ public final class FelisVelocityPlugin {
|
||||
}
|
||||
ServerListSource.Result r = serverList.next();
|
||||
if (r.servers != null) {
|
||||
// The forwarding mode goes first, so a newly registered legacy backend is
|
||||
// never reachable under modern forwarding.
|
||||
LegacyForwarding.Update lf = legacyForwarding.apply(r.servers);
|
||||
if (lf != null && lf.warning) {
|
||||
logger.warn(lf.message);
|
||||
} else if (lf != null) {
|
||||
logger.info(lf.message);
|
||||
}
|
||||
registry.refresh(r.servers);
|
||||
}
|
||||
if (r.failure == null) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package best.lolicon.felis.velocity;
|
||||
|
||||
import best.lolicon.felis.link.ServerView;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* LegacyForwarding keeps the Felis-Legacy fork's per-server legacy-forwarding list, the
|
||||
* system property {@value #PROPERTY}, in step with felis-api's server list (#15). A
|
||||
* server whose CR carries {@code felis.lolicon.best/forwarding=legacy} joins the list,
|
||||
* and leaves it when the label goes. The value the proxy was started with (bootstrap's
|
||||
* {@code -D}, {@code legacy18} by default) is a floor that always stays in, so an
|
||||
* install upgraded without labelling its CRs forwards exactly as before.
|
||||
*
|
||||
* <p>The fork reads the property itself; this class only rewrites it. What a rewrite
|
||||
* achieves depends on the proxy build, which {@link #detect(ClassLoader)} tells apart:
|
||||
* <ul>
|
||||
* <li>stock Velocity has one proxy-wide mode and never reads the property, so a
|
||||
* marked server keeps modern forwarding;</li>
|
||||
* <li>a fork carrying patch 0003 alone parses the property once, when the first
|
||||
* player reaches a backend. The first rewrite runs inside ProxyInitializeEvent,
|
||||
* which Velocity waits for before it accepts a connection, so that one lands;
|
||||
* a later change needs a proxy restart;</li>
|
||||
* <li>a fork carrying 0004 as well re-reads the property on every backend
|
||||
* connection, so a change applies from the next one.</li>
|
||||
* </ul>
|
||||
*/
|
||||
final class LegacyForwarding {
|
||||
static final String PROPERTY = "felis.legacy-forwarding.servers";
|
||||
static final String CONNECTION_CLASS = "com.velocitypowered.proxy.connection.backend.VelocityServerConnection";
|
||||
|
||||
/** Proxy is what the running Velocity build does with {@link #PROPERTY}. */
|
||||
enum Proxy {
|
||||
STOCK,
|
||||
FORK_READS_ONCE,
|
||||
FORK_LIVE
|
||||
}
|
||||
|
||||
/** Update is what one {@link #apply} changed, worded for the proxy log. */
|
||||
static final class Update {
|
||||
final String value;
|
||||
final List<String> added;
|
||||
final List<String> removed;
|
||||
/** True when the running proxy forwards by the new list from the next connection. */
|
||||
final boolean effective;
|
||||
/** True when the change leaves a server forwarded other than its CR asks. */
|
||||
final boolean warning;
|
||||
final String message;
|
||||
|
||||
private Update(String value, List<String> added, List<String> removed, Proxy proxy, boolean effective) {
|
||||
this.value = value;
|
||||
this.added = added;
|
||||
this.removed = removed;
|
||||
this.effective = effective;
|
||||
String change = "legacy forwarding list is now [" + value + "] (added " + added + ", removed " + removed + ")";
|
||||
if (effective) {
|
||||
this.warning = false;
|
||||
this.message = "Felis: " + change + ".";
|
||||
} else if (proxy == Proxy.FORK_READS_ONCE) {
|
||||
this.warning = true;
|
||||
this.message = "Felis: " + change + ", but this Felis-Legacy build read the list when the first player "
|
||||
+ "reached a backend and keeps it until it restarts. Restart felis-velocity to apply it.";
|
||||
} else {
|
||||
this.warning = !added.isEmpty();
|
||||
this.message = "Felis: " + change + ". This proxy is stock Velocity, which forwards every backend the "
|
||||
+ "same way and ignores the list"
|
||||
+ (added.isEmpty() ? "." : ": " + added + " keep modern forwarding until felis-velocity runs "
|
||||
+ "the Felis-Legacy fork.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final Set<String> floor;
|
||||
private final Proxy proxy;
|
||||
private Set<String> current;
|
||||
private boolean accepting;
|
||||
|
||||
LegacyForwarding(String startupValue, Proxy proxy) {
|
||||
this.floor = parse(startupValue);
|
||||
this.current = floor;
|
||||
this.proxy = proxy;
|
||||
}
|
||||
|
||||
/**
|
||||
* accepting marks the end of ProxyInitializeEvent: from here on a fork that reads
|
||||
* the list once may already have read it.
|
||||
*/
|
||||
synchronized void accepting() {
|
||||
accepting = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* apply rewrites the property for a server list felis-api answered with (or the
|
||||
* saved one restored at startup), and returns what changed, or null when nothing did.
|
||||
*/
|
||||
synchronized Update apply(Collection<ServerView> servers) {
|
||||
Set<String> next = new TreeSet<>(floor);
|
||||
for (ServerView v : servers) {
|
||||
if (v.legacyForwarding() && v.name() != null && !v.name().isEmpty()) {
|
||||
next.add(v.name());
|
||||
}
|
||||
}
|
||||
if (next.equals(current)) {
|
||||
return null;
|
||||
}
|
||||
List<String> added = new ArrayList<>();
|
||||
for (String n : next) {
|
||||
if (!current.contains(n)) {
|
||||
added.add(n);
|
||||
}
|
||||
}
|
||||
List<String> removed = new ArrayList<>();
|
||||
for (String n : current) {
|
||||
if (!next.contains(n)) {
|
||||
removed.add(n);
|
||||
}
|
||||
}
|
||||
String value = String.join(",", next);
|
||||
System.setProperty(PROPERTY, value);
|
||||
current = next;
|
||||
boolean effective = proxy == Proxy.FORK_LIVE || (proxy == Proxy.FORK_READS_ONCE && !accepting);
|
||||
return new Update(value, added, removed, proxy, effective);
|
||||
}
|
||||
|
||||
/** parse reads the property's comma-separated form the way the fork does. */
|
||||
static Set<String> parse(String value) {
|
||||
Set<String> out = new TreeSet<>();
|
||||
if (value == null) {
|
||||
return out;
|
||||
}
|
||||
for (String s : value.split(",")) {
|
||||
String t = s.trim();
|
||||
if (!t.isEmpty()) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static Proxy detect(ClassLoader loader) {
|
||||
return detect(loader, CONNECTION_CLASS);
|
||||
}
|
||||
|
||||
/**
|
||||
* detect looks for the methods the fork patches add to the backend connection class.
|
||||
* The class is loaded without being initialized: initializing it would run a 0003-only
|
||||
* fork's static parse of the property before {@link #apply} had rewritten it.
|
||||
*/
|
||||
static Proxy detect(ClassLoader loader, String className) {
|
||||
boolean perServer = false;
|
||||
boolean live = false;
|
||||
try {
|
||||
Class<?> c = Class.forName(className, false, loader);
|
||||
for (Method m : c.getDeclaredMethods()) {
|
||||
perServer |= m.getName().equals("effectiveForwardingMode");
|
||||
live |= m.getName().equals("felisLegacyForwardingServers");
|
||||
}
|
||||
} catch (ClassNotFoundException | LinkageError e) {
|
||||
return Proxy.STOCK;
|
||||
}
|
||||
if (live && perServer) {
|
||||
return Proxy.FORK_LIVE;
|
||||
}
|
||||
return perServer ? Proxy.FORK_READS_ONCE : Proxy.STOCK;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package best.lolicon.felis.velocity;
|
||||
|
||||
import best.lolicon.felis.link.ServerView;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* LegacyForwardingTest covers how a CR's forwarding=legacy label reaches the fork's
|
||||
* {@code felis.legacy-forwarding.servers} property (#15): marked servers join and leave
|
||||
* it, the startup value stays in as a floor, an unchanged list rewrites nothing, and the
|
||||
* report says whether the running proxy acts on the change (stock, a fork that reads
|
||||
* the list once, a fork that re-reads it). Detection must not initialize the proxy's
|
||||
* connection class. Framework free: a failed assertion throws and the process exits
|
||||
* non-zero.
|
||||
*
|
||||
* <p>Run: {@code javac -d <out> shared/src/main/java/best/lolicon/felis/link/*.java
|
||||
* velocity/src/main/java/best/lolicon/felis/velocity/LegacyForwarding.java
|
||||
* velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java && java -cp <out>
|
||||
* best.lolicon.felis.velocity.LegacyForwardingTest}.
|
||||
*/
|
||||
public final class LegacyForwardingTest {
|
||||
|
||||
private static int checks;
|
||||
|
||||
public static void main(String[] args) {
|
||||
markedServersJoinAndLeaveAboveTheFloor();
|
||||
anUnchangedListRewritesNothing();
|
||||
aForkThatReadsOnceTakesOnlyTheStartupList();
|
||||
stockVelocityReportsMarkedServersAsIgnored();
|
||||
detectionTellsTheBuildsApartWithoutInitializing();
|
||||
System.out.println("LegacyForwardingTest OK (" + checks + " checks)");
|
||||
}
|
||||
|
||||
private static void markedServersJoinAndLeaveAboveTheFloor() {
|
||||
System.setProperty(LegacyForwarding.PROPERTY, " legacy18 ,");
|
||||
LegacyForwarding lf = new LegacyForwarding(System.getProperty(LegacyForwarding.PROPERTY),
|
||||
LegacyForwarding.Proxy.FORK_LIVE);
|
||||
|
||||
LegacyForwarding.Update u = lf.apply(List.of(view("beta", true), view("modern", false), view("alpha", true)));
|
||||
assertEq("marked servers join the floor, sorted", "alpha,beta,legacy18", System.getProperty(LegacyForwarding.PROPERTY));
|
||||
assertEq("update carries the value", "alpha,beta,legacy18", u.value);
|
||||
assertEq("added", List.of("alpha", "beta"), u.added);
|
||||
assertEq("nothing removed", List.of(), u.removed);
|
||||
assertEq("a live fork acts on it", true, u.effective);
|
||||
assertEq("no warning", false, u.warning);
|
||||
|
||||
// beta loses its label; the floor's own server is listed unmarked and stays in.
|
||||
u = lf.apply(List.of(view("alpha", true), view("beta", false), view("legacy18", false)));
|
||||
assertEq("an unlabelled server leaves, the floor stays", "alpha,legacy18",
|
||||
System.getProperty(LegacyForwarding.PROPERTY));
|
||||
assertEq("removed", List.of("beta"), u.removed);
|
||||
assertEq("nothing added", List.of(), u.added);
|
||||
|
||||
u = lf.apply(List.of());
|
||||
assertEq("an empty list falls back to the floor", "legacy18", System.getProperty(LegacyForwarding.PROPERTY));
|
||||
assertEq("removed alpha", List.of("alpha"), u.removed);
|
||||
}
|
||||
|
||||
private static void anUnchangedListRewritesNothing() {
|
||||
System.setProperty(LegacyForwarding.PROPERTY, "legacy18");
|
||||
LegacyForwarding lf = new LegacyForwarding("legacy18", LegacyForwarding.Proxy.FORK_LIVE);
|
||||
assertEq("floor alone is no change", null, lf.apply(List.of(view("modern", false))));
|
||||
|
||||
lf.apply(List.of(view("old", true)));
|
||||
System.setProperty(LegacyForwarding.PROPERTY, "sentinel");
|
||||
assertEq("same marks again is no change", null, lf.apply(List.of(view("old", true), view("modern", false))));
|
||||
assertEq("the property is left alone", "sentinel", System.getProperty(LegacyForwarding.PROPERTY));
|
||||
|
||||
System.clearProperty(LegacyForwarding.PROPERTY);
|
||||
LegacyForwarding none = new LegacyForwarding(null, LegacyForwarding.Proxy.FORK_LIVE);
|
||||
assertEq("no floor and no marks is no change", null, none.apply(List.of(view("modern", false))));
|
||||
assertEq("the property stays unset", null, System.getProperty(LegacyForwarding.PROPERTY));
|
||||
assertEq("a nameless marked entry is skipped", null, none.apply(List.of(view(null, true), view("", true))));
|
||||
}
|
||||
|
||||
private static void aForkThatReadsOnceTakesOnlyTheStartupList() {
|
||||
LegacyForwarding lf = new LegacyForwarding("legacy18", LegacyForwarding.Proxy.FORK_READS_ONCE);
|
||||
LegacyForwarding.Update u = lf.apply(List.of(view("old", true)));
|
||||
assertEq("before accepting, the rewrite lands", true, u.effective);
|
||||
assertEq("no warning before accepting", false, u.warning);
|
||||
|
||||
lf.accepting();
|
||||
u = lf.apply(List.of(view("old", true), view("older", true)));
|
||||
assertEq("the property still follows the CRs", "legacy18,old,older", System.getProperty(LegacyForwarding.PROPERTY));
|
||||
assertEq("after accepting, it waits for a restart", false, u.effective);
|
||||
assertEq("which is a warning", true, u.warning);
|
||||
assertEq("naming the restart", true, u.message.contains("Restart felis-velocity"));
|
||||
}
|
||||
|
||||
private static void stockVelocityReportsMarkedServersAsIgnored() {
|
||||
LegacyForwarding lf = new LegacyForwarding("", LegacyForwarding.Proxy.STOCK);
|
||||
LegacyForwarding.Update u = lf.apply(List.of(view("old", true)));
|
||||
assertEq("stock never acts on it", false, u.effective);
|
||||
assertEq("a marked server on stock is a warning", true, u.warning);
|
||||
assertEq("naming the server and the fork", true,
|
||||
u.message.contains("[old] keep modern forwarding") && u.message.contains("Felis-Legacy fork"));
|
||||
|
||||
u = lf.apply(List.of(view("old", false)));
|
||||
assertEq("dropping a mark on stock is no warning", false, u.warning);
|
||||
}
|
||||
|
||||
private static void detectionTellsTheBuildsApartWithoutInitializing() {
|
||||
ClassLoader cl = LegacyForwardingTest.class.getClassLoader();
|
||||
String p = LegacyForwardingTest.class.getName() + "$";
|
||||
assertEq("no connection class", LegacyForwarding.Proxy.STOCK, LegacyForwarding.detect(cl, p + "Absent"));
|
||||
assertEq("the real name is absent here too", LegacyForwarding.Proxy.STOCK, LegacyForwarding.detect(cl));
|
||||
assertEq("stock connection", LegacyForwarding.Proxy.STOCK, LegacyForwarding.detect(cl, p + "StockConnection"));
|
||||
assertEq("0003 alone", LegacyForwarding.Proxy.FORK_READS_ONCE, LegacyForwarding.detect(cl, p + "ReadsOnceConnection"));
|
||||
assertEq("0003 + 0004", LegacyForwarding.Proxy.FORK_LIVE, LegacyForwarding.detect(cl, p + "LiveConnection"));
|
||||
assertEq("the reload hook alone is not the fork", LegacyForwarding.Proxy.STOCK,
|
||||
LegacyForwarding.detect(cl, p + "HookOnlyConnection"));
|
||||
assertEq("no connection class was initialized", 0, initialized);
|
||||
}
|
||||
|
||||
// ---- fixtures ----
|
||||
|
||||
// Each fixture class counts its own initialization; detect must leave it at zero.
|
||||
static int initialized;
|
||||
|
||||
static final class StockConnection {
|
||||
static {
|
||||
initialized++;
|
||||
}
|
||||
|
||||
Object startHandshake() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static final class ReadsOnceConnection {
|
||||
static {
|
||||
initialized++;
|
||||
}
|
||||
|
||||
Object effectiveForwardingMode() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static final class LiveConnection {
|
||||
static {
|
||||
initialized++;
|
||||
}
|
||||
|
||||
Object effectiveForwardingMode() {
|
||||
return null;
|
||||
}
|
||||
|
||||
static java.util.Set<String> felisLegacyForwardingServers() {
|
||||
return java.util.Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
static final class HookOnlyConnection {
|
||||
static {
|
||||
initialized++;
|
||||
}
|
||||
|
||||
static java.util.Set<String> felisLegacyForwardingServers() {
|
||||
return java.util.Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static void assertEq(String what, Object want, Object got) {
|
||||
if (want == null ? got != null : !want.equals(got)) {
|
||||
throw new AssertionError(what + ": want " + want + ", got " + got);
|
||||
}
|
||||
checks++;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ public final class ServerListSourceTest {
|
||||
theSavedListIsHandedOutOnce(dir.resolve("c"));
|
||||
noSavedListMeansNothingToRestore(dir.resolve("d"));
|
||||
aMalformedSavedListIsReportedAndIgnored(dir.resolve("e"));
|
||||
theLegacyForwardingMarkIsSaved(dir.resolve("f"));
|
||||
theApiEnvelopeParses();
|
||||
} finally {
|
||||
try (Stream<Path> walk = Files.walk(dir)) {
|
||||
@@ -148,15 +149,32 @@ public final class ServerListSourceTest {
|
||||
assertEq("rewritten", true, Files.readString(file, StandardCharsets.UTF_8).contains("\"10.43.0.17:25565\""));
|
||||
}
|
||||
|
||||
// A proxy restarted during an outage must keep forwarding a marked 1.8 backend the
|
||||
// legacy way (#15), so the mark rides the saved list like every other field.
|
||||
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);
|
||||
answer = List.of(legacy, SURVIVAL);
|
||||
new ServerListSource(ServerListSourceTest::fetch, file).next();
|
||||
answer = null;
|
||||
ServerListSource.Result r = new ServerListSource(ServerListSourceTest::fetch, file).next();
|
||||
assertEq("restored", true, r.restored);
|
||||
assertEq("the mark survives", true, r.servers.get(0).legacyForwarding());
|
||||
assertEq("an unmarked server stays unmarked", false, r.servers.get(1).legacyForwarding());
|
||||
}
|
||||
|
||||
// The exact shape GET /api/v1/servers answers with, extra fields included.
|
||||
private static void theApiEnvelopeParses() {
|
||||
List<ServerView> v = ServerView.listFromJson("{\"servers\":[{\"name\":\"lobby\",\"subdomain\":\"\","
|
||||
+ "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"ClusterIP\","
|
||||
+ "\"endpointAddress\":\"10.43.1.5:25565\",\"playersOnline\":1,\"playersMax\":100,"
|
||||
+ "\"owner\":\"x\"},\"junk\"]}");
|
||||
assertEq("non-object entries skipped", 1, v.size());
|
||||
+ "\"owner\":\"x\"},\"junk\",{\"name\":\"legacy18\",\"legacyForwarding\":true}]}");
|
||||
assertEq("non-object entries skipped", 2, v.size());
|
||||
assertEq("lobby name", "lobby", v.get(0).name());
|
||||
assertEq("lobby max", 100, v.get(0).playersMax());
|
||||
assertEq("an absent mark is false", false, v.get(0).legacyForwarding());
|
||||
assertEq("the mark parses", true, v.get(1).legacyForwarding());
|
||||
try {
|
||||
ServerView.listFromJson("[]");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
|
||||
Reference in new issue
Block a user