diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index 40b6cb689a..17fb984879 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -72,11 +72,13 @@ public void register(GlobalMessageHandler messages, BungeeMethod method) { messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_BROADCAST) { @Override public void onReceive(JsonEnvelope msg) { handleVoteBroadcast(msg); } }); - messages.addListener(new GlobalMessageListener("Status") { + messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_STATUS) { @Override public void onReceive(JsonEnvelope msg) { HashMap out = new HashMap<>(); - out.put("server", nvl(msg.getFields().get("server"))); - sendSubChannel(messages, "statusokay", out); + out.put(VotingPluginWire.K_SERVER, nvl(plugin.getOptions().getServer())); + String requestId = nvl(msg.getFields().get(VotingPluginWire.K_REQUEST_ID)); + if (!requestId.isEmpty()) out.put(VotingPluginWire.K_REQUEST_ID, requestId); + sendSubChannel(messages, VotingPluginWire.SUB_STATUS_OKAY, out); } }); messages.addListener(new GlobalMessageListener("ServerName") { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index ec141fd5f0..c20417319c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -228,7 +228,8 @@ private String quickSetupRevision(String preset, String current) throws IOExcept } private static String quickSetupFile(String preset) { - if ("standalone".equals(preset) || "proxy-backend".equals(preset)) return "BungeeSettings.yml"; + if ("standalone".equals(preset) || "proxy-backend".equals(preset) + || "proxy-method".equals(preset)) return "BungeeSettings.yml"; if ("vote-site".equals(preset) || "easy-reward".equals(preset) || "sync-vote-sites".equals(preset)) return "VoteSites.yml"; if ("common-settings".equals(preset)) return "Config.yml"; @@ -258,6 +259,13 @@ private QuickProposal quickProposal(String preset, Map options, } return new QuickProposal(fileName, yaml.saveToString()); } + if ("proxy-method".equals(preset)) { + BungeeMethod method = BungeeMethod.valueOf(bungeeMethodOption(options)); + validateProxyMethod(method, yaml); + yaml.set("UseBungeecord", true); + yaml.set("BungeeMethod", method.name()); + return new QuickProposal(fileName, yaml.saveToString()); + } if ("vote-site".equals(preset)) { String name = option(options, "name", "[A-Za-z0-9_-]{1,64}"); String root = "VoteSites." + name; @@ -322,6 +330,65 @@ private boolean caseInsensitiveYmlFiles() throws IOException { return key != null && settings.getBoolean(key, false); } + private void validateProxyMethod(BungeeMethod method, YamlConfiguration settings) { + String server = settings.getString("Server", "").trim(); + if (server.isEmpty() || "PleaseSet".equalsIgnoreCase(server)) { + throw new IllegalArgumentException("Server must be set to this backend's unique proxy server name"); + } + switch (method) { + case PLUGINMESSAGING: + if (settings.getString("PluginMessageChannel", "vp:vp").isBlank()) { + throw new IllegalArgumentException("PluginMessageChannel must be set"); + } + break; + case REDIS: + configuredHostAndPort(settings, "Redis.Host", "Redis.Port", 6379, "Redis"); + break; + case MQTT: + validBroker(settings.getString("MQTT.BrokerURL", "tcp://localhost:1883")); + String clientId = settings.getString("MQTT.ClientID", server); + if (clientId == null || clientId.isBlank()) throw new IllegalArgumentException("MQTT.ClientID must be set"); + break; + case SOCKETS: + configuredHostAndPort(settings, "BungeeServer.Host", "BungeeServer.Port", 1297, "BungeeServer"); + break; + case MYSQL: + try { + YamlConfiguration main = parse(readRaw(resolve("Config.yml"), false)); + if (!"MYSQL".equalsIgnoreCase(main.getString("DataStorage", "")) + || main.getString("Database.Host", "").isBlank() + || main.getString("Database.Database", "").isBlank()) { + throw new IllegalArgumentException("Config.yml DataStorage must be MYSQL with Database.Host and Database.Database set"); + } + } catch (IOException e) { + throw new IllegalArgumentException("Config.yml could not be checked for MYSQL settings"); + } + break; + default: + throw new IllegalArgumentException("BungeeMethod is unsupported"); + } + } + + private static void configuredHostAndPort(YamlConfiguration yaml, String hostPath, String portPath, + int defaultPort, String label) { + String host = yaml.getString(hostPath, ""); + int port = yaml.getInt(portPath, defaultPort); + if (host == null || host.isBlank() || port < 1 || port > 65535) { + throw new IllegalArgumentException(label + " host and port must be set"); + } + } + + private static void validBroker(String value) { + try { + java.net.URI broker = java.net.URI.create(value == null ? "" : value.trim()); + if (!Set.of("tcp", "ssl", "ws", "wss").contains(broker.getScheme()) || broker.getHost() == null) { + throw new IllegalArgumentException(); + } + } catch (RuntimeException e) { + throw new IllegalArgumentException("MQTT.BrokerURL must be a valid tcp, ssl, ws, or wss endpoint"); + } + } + private static void mergeVoteSites(YamlConfiguration source, YamlConfiguration target, boolean ignoreCase) { List targetSecretValues = commentSecretReplacements(target); String sourceRootKey = matchingKey(source, "VoteSites", true); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index bca5e1c249..cf52ddfc36 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -45,7 +45,7 @@ public final class BackendControlConnector implements AutoCloseable { private static final long SHUTDOWN_TIMEOUT_SECONDS = 65; private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Set CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1", - "config.quick-setup.v1", "config.vote-sites-sync.v1"); + "config.quick-setup.v1", "config.vote-sites-sync.v1", "config.proxy-method.v1"); private final VotingPluginMain plugin; private final Path dataDirectory; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index f00a6cf647..841ed2eba7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -171,6 +172,7 @@ public abstract class VotingPluginProxy { private final Map pendingPresenceHandoffs = new HashMap<>(); private final Set pendingBackendRecoverySnapshots = ConcurrentHashMap.newKeySet(); private final Map controlEnrollmentNextAllowed = new ConcurrentHashMap<>(); + private final Map pendingCommunicationTests = new ConcurrentHashMap<>(); private volatile ControlConnector controlConnector; private volatile HostedControlManager hostedControlManager; private final Object controlLifecycleLock = new Object(); @@ -1521,11 +1523,10 @@ && isPresenceGenerationValid(snapshot.backendIncarnationId, snapshot.backendStar } }); - globalMessageProxyHandler.addListener(new GlobalMessageListener("statusokay") { + globalMessageProxyHandler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_STATUS_OKAY) { @Override public void onReceive(JsonEnvelope message) { - String server = message.getFields().getOrDefault("server", ""); - log("Status okay for " + server); + handleStatusOkay(message); } }); @@ -2402,6 +2403,7 @@ public void prepareForRuntimeReplacement() { /** Best-effort remainder of runtime teardown after the Control overlap gate has succeeded. */ public void completeRuntimeReplacementShutdown() { + cancelCommunicationTests("Proxy runtime stopped before the backend replied"); runCleanup("vote cache", () -> getVoteCacheHandler().saveVoteCache()); runCleanup("proxy MySQL messenger", () -> { if (getProxyMysqlMessenger() != null) getProxyMysqlMessenger().shutdown(); @@ -2891,6 +2893,100 @@ public void status() { } } + /** Runs a correlated, non-vote round trip over the active backend transport. */ + public CompletableFuture testBackendCommunication(String requestedServer, + long timeoutMillis) { + String server = requestedServer == null ? "" : requestedServer.trim(); + BungeeMethod activeMethod = method; + if (server.isEmpty() || !getAllAvailableServers().contains(server)) { + return CompletableFuture.completedFuture(CommunicationTestResult.failure(server, activeMethod, + "UNKNOWN_BACKEND", "The backend is not configured on this proxy")); + } + if (activeMethod == null || globalMessageProxyHandler == null) { + return CompletableFuture.completedFuture(CommunicationTestResult.failure(server, activeMethod, + "TRANSPORT_UNAVAILABLE", "The proxy communication transport is not running")); + } + if (activeMethod == BungeeMethod.PLUGINMESSAGING && !isSomeoneOnlineServerForVoteRouting(server)) { + return CompletableFuture.completedFuture(CommunicationTestResult.failure(server, activeMethod, + "PLAYER_REQUIRED", "Plugin messaging requires an online player on the selected backend")); + } + ScheduledExecutorService scheduler = getScheduler(); + if (scheduler == null) { + return CompletableFuture.completedFuture(CommunicationTestResult.failure(server, activeMethod, + "TRANSPORT_UNAVAILABLE", "The proxy scheduler is not running")); + } + long boundedTimeout = Math.max(500L, Math.min(timeoutMillis, 30000L)); + UUID requestId = UUID.randomUUID(); + CompletableFuture result = new CompletableFuture<>(); + PendingCommunicationTest pending = new PendingCommunicationTest(server, activeMethod, System.nanoTime(), result); + pendingCommunicationTests.put(requestId, pending); + result.whenComplete((ignored, failure) -> pendingCommunicationTests.remove(requestId, pending)); + try { + if (!sendCommunicationTestEnvelopeNow(server, VotingPluginWire.status(server, requestId))) { + result.complete(CommunicationTestResult.failure(server, activeMethod, "TRANSPORT_UNAVAILABLE", + "The active transport could not accept the communication test")); + return result; + } + scheduler.schedule(() -> result.complete(CommunicationTestResult.failure(server, activeMethod, + "TIMEOUT", "No correlated reply arrived before the timeout")), boundedTimeout, TimeUnit.MILLISECONDS); + } catch (RuntimeException failure) { + result.complete(CommunicationTestResult.failure(server, activeMethod, "SEND_FAILED", + "The proxy could not send the communication test")); + } + return result; + } + + /** Sends a diagnostic immediately and reports whether the active transport accepted it. */ + protected boolean sendCommunicationTestEnvelopeNow(String server, JsonEnvelope envelope) { + return sendProxyBroadcastEnvelopeNow(server, envelope); + } + + protected void handleStatusOkay(JsonEnvelope message) { + String server = message.getFields().getOrDefault(VotingPluginWire.K_SERVER, ""); + String request = message.getFields().getOrDefault(VotingPluginWire.K_REQUEST_ID, ""); + if (request.isEmpty()) { + log("Status okay for " + server); + return; + } + UUID requestId; + try { + requestId = UUID.fromString(request); + } catch (IllegalArgumentException ignored) { + debug("Ignored status reply with an invalid request ID from " + server); + return; + } + PendingCommunicationTest pending = pendingCommunicationTests.get(requestId); + if (pending == null || !pending.server().equals(server)) { + debug("Ignored unexpected status reply from " + server); + return; + } + long roundTripMillis = Math.max(0L, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - pending.startedAtNanos())); + pending.result().complete(CommunicationTestResult.success(server, pending.method(), roundTripMillis)); + } + + private void cancelCommunicationTests(String message) { + pendingCommunicationTests.forEach((requestId, pending) -> pending.result().complete( + CommunicationTestResult.failure(pending.server(), pending.method(), "TRANSPORT_STOPPED", message))); + pendingCommunicationTests.clear(); + } + + public record CommunicationTestResult(boolean success, String code, String message, String server, + String method, long roundTripMillis) { + private static CommunicationTestResult success(String server, BungeeMethod method, long roundTripMillis) { + return new CommunicationTestResult(true, "OK", "Backend replied over the active transport", server, + method == null ? "" : method.name(), roundTripMillis); + } + + private static CommunicationTestResult failure(String server, BungeeMethod method, String code, String message) { + return new CommunicationTestResult(false, code, message, server, + method == null ? "" : method.name(), -1L); + } + } + + private record PendingCommunicationTest(String server, BungeeMethod method, long startedAtNanos, + CompletableFuture result) { } + private void sendVoteDelayRejected(String player, String uuid, String service, boolean playerOnline, String playerServer) { if (!playerOnline || playerServer == null || !getAllAvailableServers().contains(playerServer)) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java index b6ca6aec52..3c5eeff944 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java @@ -22,6 +22,22 @@ default void persistControlProxyRouting(boolean sendVotesToAllServers, List { }); + } + + /** Atomically validates the freshly loaded file before persisting the proxy communication method. */ + default void persistControlProxyMethod(String method, String expectedRevision, + ControlProxyMethodValidator validator) throws IOException { + throw new UnsupportedOperationException("Control proxy method writes are unavailable"); + } + + @FunctionalInterface + interface ControlProxyMethodValidator { + void validate(VotingPluginProxyConfig current); + } + /** Restores the most recent Control-created backup. */ default void rollbackControlProxyRouting() throws IOException { throw new UnsupportedOperationException("Control configuration rollback is unavailable"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index 23de6628a6..29becc646e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -170,10 +170,20 @@ public static JsonEnvelope status(String server) { return base(SUB_STATUS).put(K_SERVER, safe(server)).build(); } + public static JsonEnvelope status(String server, UUID requestId) { + return base(SUB_STATUS).put(K_SERVER, safe(server)) + .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()).build(); + } + public static JsonEnvelope statusOkay(String server) { return base(SUB_STATUS_OKAY).put(K_SERVER, safe(server)).build(); } + public static JsonEnvelope statusOkay(String server, UUID requestId) { + return base(SUB_STATUS_OKAY).put(K_SERVER, safe(server)) + .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()).build(); + } + public static JsonEnvelope serverName(String server) { return base(SUB_SERVER_NAME).put(K_SERVER, safe(server)).build(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java index bd8a1fdb43..ee1ff540a4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java @@ -17,7 +17,9 @@ import java.util.Map; import java.util.Set; +import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.proxy.control.ProxyMethodConfiguration; import com.bencodez.votingplugin.proxy.control.ProxyRoutingConfiguration; import com.bencodez.votingplugin.util.DurableFiles; @@ -579,6 +581,49 @@ private static ProxyRoutingConfiguration routing(Configuration configuration) { configuration.getStringList("BlockedServers")); } + @Override + public synchronized void persistControlProxyMethod(String method, String expectedRevision, + ControlProxyMethodValidator validator) throws IOException { + Path target = new File(bungee.getDataFolder(), "bungeeconfig.yml").toPath(); + Path stage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-stage"); + Path backupStage = null; + Path backup = target.resolveSibling(target.getFileName() + ".control-backup"); + try { + backupStage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-backup-stage"); + byte[] sourceSnapshot = Files.readAllBytes(target); + Configuration latest = ConfigurationProvider.getProvider(YamlConfiguration.class).load(target.toFile()); + ProxyMethodConfiguration current = new ProxyMethodConfiguration( + BungeeMethod.getByName(latest.getString("BungeeMethod", "SOCKETS"))); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target)) + || !current.revision().equals(expectedRevision)) throw new StaleControlRevisionException(); + BungeeConfig fresh = new BungeeConfig(bungee); + fresh.loadControlConfiguration(); + validator.validate(fresh); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + latest.set("BungeeMethod", method); + ConfigurationProvider.getProvider(YamlConfiguration.class).save(latest, stage.toFile()); + byte[] installedSnapshot = Files.readAllBytes(stage); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) throw new StaleControlRevisionException(); + Files.write(backupStage, sourceSnapshot); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) throw new StaleControlRevisionException(); + atomicReplace(backupStage, backup); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) throw new StaleControlRevisionException(); + controlInstalledSnapshot = installedSnapshot; + try { + atomicReplace(stage, target); + } catch (IOException failure) { + if (!(failure instanceof DurableFiles.PublishedException)) controlInstalledSnapshot = null; + throw failure; + } + data = latest; + } finally { + Files.deleteIfExists(stage); + if (backupStage != null) Files.deleteIfExists(backupStage); + } + } + @Override public synchronized void rollbackControlProxyRouting() throws IOException { Path target = new File(bungee.getDataFolder(), "bungeeconfig.yml").toPath(); @@ -739,14 +784,14 @@ public Configuration sectionOrNull(Configuration root, String key) { @Override public boolean hasDatabaseConfigured() { // New-style: Database: { Host: ..., ... } - Configuration db = sectionOrNull(data, "Database"); - if (db != null) { - return db.getString("Host", "") != null && !db.getString("Host", "").isEmpty(); - } + Configuration db = sectionOrNull(data, "Database"); + if (db != null) { + return !db.getString("Host", "").isEmpty() && !db.getString("Database", "").isEmpty(); + } // Legacy-style: Host/Port/... at root String host = data.getString("Host", ""); - return host != null && !host.isEmpty(); + return host != null && !host.isEmpty() && !data.getString("Database", "").isEmpty(); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index 8ee78d1778..9bb34ab892 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -27,6 +27,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.LongSupplier; import java.util.function.Supplier; import java.util.regex.Pattern; @@ -53,6 +54,11 @@ public final class ControlConnector implements AutoCloseable { private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Set BASE_CAPABILITIES = Set.of("presence.snapshot"); private static final String CONFIGURATION_CAPABILITY = "config.proxy-routing.v1"; + private static final String COMMUNICATION_TEST_CAPABILITY = "config.transport-test.v1"; + private static final String COMMUNICATION_TEST_PRESET = "communication-test"; + private static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1"; + private static final String PROXY_METHOD_PRESET = "proxy-method"; + private static final String INTERNAL_OPERATION_TYPE = "_controlOperationType"; private static final long MAX_BACKOFF_MILLIS = TimeUnit.MINUTES.toMillis(5); private static final long OPERATION_SHUTDOWN_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(65); @@ -64,6 +70,9 @@ public final class ControlConnector implements AutoCloseable { private final UUID sessionId; private final LongSupplier jitterSource; private final ProxyRoutingConfigurationService configurationService; + private final ProxyMethodConfigurationService methodConfigurationService; + private final Function> communicationTest; + private final Runnable runtimeReplacement; private final Path dataDirectory; private final Route route; private final boolean recovering; @@ -86,14 +95,14 @@ public ControlConnector(Settings settings, ScheduledExecutorService scheduler, T Supplier> snapshotSource, Consumer logger, UUID sessionId, LongSupplier jitterSource) { this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, null, - null, null, false, null); + null, null, false, null, null, null, null); } ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, Supplier> snapshotSource, Consumer logger, UUID sessionId, LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService) { this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, - null, null, false, null); + null, null, false, null, null, null, null); } ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, @@ -101,14 +110,16 @@ public ControlConnector(Settings settings, ScheduledExecutorService scheduler, T LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService, Map recoveredTasks) { this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, - null, null, false, null); + null, null, false, null, null, null, null); completedTasks.putAll(recoveredTasks); } private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, Supplier> snapshotSource, Consumer logger, UUID sessionId, LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService, Path dataDirectory, - Route route, boolean recovering, Runnable recoveryComplete) { + Route route, boolean recovering, Runnable recoveryComplete, + Function> communicationTest, + ProxyMethodConfigurationService methodConfigurationService, Runnable runtimeReplacement) { this.settings = Objects.requireNonNull(settings, "settings"); this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); this.transport = Objects.requireNonNull(transport, "transport"); @@ -117,6 +128,9 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, this.sessionId = Objects.requireNonNull(sessionId, "sessionId"); this.jitterSource = Objects.requireNonNull(jitterSource, "jitterSource"); this.configurationService = configurationService; + this.methodConfigurationService = methodConfigurationService; + this.communicationTest = communicationTest; + this.runtimeReplacement = runtimeReplacement; this.dataDirectory = dataDirectory; this.route = route; this.recovering = recovering; @@ -175,7 +189,9 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio ControlConnector connector = new ControlConnector(settings, proxy.getScheduler(), transport, snapshot, message -> proxy.log("[Control] " + message), UUID.randomUUID(), () -> ThreadLocalRandom.current().nextLong(), new ProxyRoutingConfigurationService(proxy), dataDirectory, - route, recovering, proxy::restartControlServicesAfterRecovery); + route, recovering, proxy::restartControlServicesAfterRecovery, + server -> proxy.testBackendCommunication(server, 5000L), new ProxyMethodConfigurationService(proxy), + () -> proxy.reloadCore(true)); if (recovered != null) connector.completedTasks.putAll(recovered.results()); return connector; } @@ -269,7 +285,6 @@ void cycle() { operation.whenComplete((ignored, failure) -> { try { activeRequest = null; - finishCycle(); if (!closed) { if (failure == null) { onSuccess(); @@ -284,10 +299,15 @@ void cycle() { operationDone.completeExceptionally(failure); } if (activeOperation == operationDone) activeOperation = null; + finishCycle(); } }); } + boolean hasActiveOperation() { + return activeOperation != null; + } + private void finishCycle() { Runnable replacement = null; synchronized (operationLifecycle) { @@ -322,7 +342,9 @@ private void handlePrimaryResponse(Response response, boolean registration) { if (!contains(accepted, "presence.snapshot")) { throw new ProtocolException(); } - configurationAccepted = contains(accepted, CONFIGURATION_CAPABILITY); + configurationAccepted = contains(accepted, CONFIGURATION_CAPABILITY) + || contains(accepted, COMMUNICATION_TEST_CAPABILITY) + || contains(accepted, PROXY_METHOD_CAPABILITY); } } @@ -446,14 +468,15 @@ private CompletableFuture handleClaimResponse(Response response) { } } if (result == null) { - TaskResult executed = executeTask(operationId, task); - JsonObject resultJson = executed.json(); - resultJson.addProperty("attemptId", requireString(task, "attemptId")); - result = new StoredResult(resultJson, true, false); - synchronized (operationLifecycle) { - completedTasks.put(operationId, result); - } - persistCompleted(); + return executeTask(operationId, task).thenCompose(executed -> { + JsonObject resultJson = executed.json(); + resultJson.addProperty("attemptId", requireString(task, "attemptId")); + resultJson.addProperty(INTERNAL_OPERATION_TYPE, requireString(task, "type")); + StoredResult completed = new StoredResult(resultJson, true, false); + synchronized (operationLifecycle) { completedTasks.put(operationId, completed); } + persistCompleted(); + return submitCompletedResult(operationId, completed); + }); } return submitCompletedResult(operationId, result); } @@ -505,8 +528,22 @@ private void acknowledgeCompletedResult(UUID operationId, StoredResult result) { throw failure; } boolean drained; - synchronized (operationLifecycle) { drained = completedTasks.isEmpty(); } - if (recovering && drained && recoveryComplete != null) recoveryComplete.run(); + boolean replaceRuntime = requiresRuntimeReplacement(result) && runtimeReplacement != null; + synchronized (operationLifecycle) { + drained = completedTasks.isEmpty(); + if (replaceRuntime) deferredReplacement = runtimeReplacement; + } + if (recovering && drained && recoveryComplete != null && !replaceRuntime) recoveryComplete.run(); + } + + static boolean requiresRuntimeReplacement(StoredResult result) { + JsonObject body = result.result(); + if (!body.has("success") || !body.get("success").getAsBoolean() || !body.has("configuration") + || !body.has(INTERNAL_OPERATION_TYPE) + || !"APPLY".equals(body.get(INTERNAL_OPERATION_TYPE).getAsString())) return false; + JsonObject configuration = body.getAsJsonObject("configuration"); + return configuration != null && configuration.has("preset") + && PROXY_METHOD_PRESET.equals(configuration.get("preset").getAsString()); } private static boolean taskLeaseExpired(Response response) { @@ -541,6 +578,7 @@ private void persistCompleted() { private void persistIntent(UUID operationId, TaskResult anticipated, String attemptId) { JsonObject result = anticipated.json(); result.addProperty("attemptId", attemptId); + result.addProperty(INTERNAL_OPERATION_TYPE, "APPLY"); synchronized (operationLifecycle) { completedTasks.put(operationId, new StoredResult(result, false, false)); } @@ -576,7 +614,13 @@ private static StoredResult abortedIntent(StoredResult pending) { private boolean anticipatedResultIsInstalled(StoredResult pending) { JsonObject result = pending.result(); - return result.has("revision") && result.get("revision").getAsString().equals(configurationService.read().revision()); + if (!result.has("revision")) return false; + JsonObject configuration = result.getAsJsonObject("configuration"); + if (configuration != null && isProxyMethod(configuration) && methodConfigurationService != null) { + return result.get("revision").getAsString().equals(methodConfigurationService.read().revision()); + } + return configurationService != null + && result.get("revision").getAsString().equals(configurationService.read().revision()); } private static StoredResult committedForAttempt(StoredResult pending, String attemptId) { @@ -585,34 +629,126 @@ private static StoredResult committedForAttempt(StoredResult pending, String att return new StoredResult(result, true, false); } - private TaskResult executeTask(UUID operationId, JsonObject task) { - if (configurationService == null) return TaskResult.failure("UNSUPPORTED", "Configuration control is unavailable"); + private CompletableFuture executeTask(UUID operationId, JsonObject task) { + JsonObject requested = task.getAsJsonObject("configuration"); + if (isCommunicationTest(requested)) return executeCommunicationTest(task, requested); + if (isProxyMethod(requested)) return executeProxyMethod(operationId, task, requested); + if (configurationService == null) return completed(TaskResult.failure("UNSUPPORTED", "Configuration control is unavailable")); String type = requireString(task, "type"); try { ProxyRoutingConfiguration current = configurationService.read(); - if ("READ".equals(type)) return TaskResult.success(current.revision(), current, List.of(), false); + if ("READ".equals(type)) return completed(TaskResult.success(current.revision(), current, List.of(), false)); ProxyRoutingConfiguration proposal = parseConfiguration(task.getAsJsonObject("configuration")); configurationService.validate(proposal); List changes = proposal.changesFrom(current); - if ("PREVIEW".equals(type)) return TaskResult.success(current.revision(), current, changes, false); - if (!"APPLY".equals(type)) return TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported"); + if ("PREVIEW".equals(type)) return completed(TaskResult.success(current.revision(), current, changes, false)); + if (!"APPLY".equals(type)) return completed(TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported")); persistIntent(operationId, TaskResult.success(proposal.revision(), proposal, changes, true), requireString(task, "attemptId")); configurationService.apply(proposal, requireString(task, "expectedRevision")); ProxyRoutingConfiguration applied = configurationService.read(); - return TaskResult.success(applied.revision(), applied, changes, true); + return completed(TaskResult.success(applied.revision(), applied, changes, true)); } catch (ProxyRoutingConfigurationService.StaleRevisionException e) { - return TaskResult.failure("STALE_REVISION", "Configuration changed after preview"); + return completed(TaskResult.failure("STALE_REVISION", "Configuration changed after preview")); } catch (ProxyRoutingConfigurationService.ApplyFailureException e) { - return new TaskResult(false, "RELOAD_FAILED", "Reload failed after persistence", null, null, List.of(), - false, e.rolledBack()); + return completed(new TaskResult(false, "RELOAD_FAILED", "Reload failed after persistence", null, null, + List.of(), false, e.rolledBack())); } catch (IllegalArgumentException e) { - return TaskResult.failure("VALIDATION_ERROR", e.getMessage()); + return completed(TaskResult.failure("VALIDATION_ERROR", e.getMessage())); } catch (IOException | RuntimeException e) { - return TaskResult.failure("APPLY_FAILED", "Configuration operation failed"); + return completed(TaskResult.failure("APPLY_FAILED", "Configuration operation failed")); } } + private CompletableFuture executeProxyMethod(UUID operationId, JsonObject task, JsonObject requested) { + if (methodConfigurationService == null) { + return completed(TaskResult.failure("UNSUPPORTED", "Proxy method control is unavailable")); + } + String type = requireString(task, "type"); + try { + ProxyMethodConfiguration current = methodConfigurationService.read(); + if ("READ".equals(type)) { + JsonObject response = requested.deepCopy(); + JsonObject responseOptions = response.getAsJsonObject("options"); + if (responseOptions == null) { + responseOptions = new JsonObject(); + response.add("options", responseOptions); + } + responseOptions.addProperty("method", current.method().name()); + return completed(TaskResult.success(current.revision(), response, List.of(), false, + "Current proxy method is " + current.method().name())); + } + JsonObject options = requested.getAsJsonObject("options"); + ProxyMethodConfiguration proposal = new ProxyMethodConfiguration( + ProxyMethodConfigurationService.canonical(requireString(options, "method"))); + methodConfigurationService.validate(proposal); + List changes = proposal.changesFrom(current); + if ("PREVIEW".equals(type)) return completed(TaskResult.success(current.revision(), requested.deepCopy(), + changes, false, "Required settings are present; runtime restart will follow apply")); + if (!"APPLY".equals(type)) return completed(TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported")); + persistIntent(operationId, TaskResult.success(proposal.revision(), requested.deepCopy(), changes, false, + "Proxy method persisted; runtime restart is waiting for result acknowledgement"), + requireString(task, "attemptId")); + methodConfigurationService.apply(proposal, requireString(task, "expectedRevision")); + return completed(TaskResult.success(proposal.revision(), requested.deepCopy(), changes, false, + "Proxy method persisted; runtime restart follows acknowledgement")); + } catch (ProxyMethodConfigurationService.StaleRevisionException e) { + return completed(TaskResult.failure("STALE_REVISION", "Proxy method changed after preview")); + } catch (ProxyMethodConfigurationService.ApplyFailureException e) { + return completed(new TaskResult(false, "APPLY_FAILED", "Proxy method persistence failed", null, null, + List.of(), false, e.rolledBack())); + } catch (IllegalArgumentException e) { + return completed(TaskResult.failure("VALIDATION_ERROR", e.getMessage())); + } catch (IOException | RuntimeException e) { + return completed(TaskResult.failure("APPLY_FAILED", "Proxy method operation failed")); + } + } + + private CompletableFuture executeCommunicationTest(JsonObject task, JsonObject requested) { + if (!"READ".equals(requireString(task, "type"))) { + return completed(TaskResult.failure("UNSUPPORTED_TASK", "Communication tests are read-only")); + } + if (communicationTest == null || configurationService == null) { + return completed(TaskResult.failure("UNSUPPORTED", "Communication testing is unavailable")); + } + JsonObject options = requested == null ? null : requested.getAsJsonObject("options"); + String server = options == null ? "" : requireString(options, "server").trim(); + if (server.isEmpty() || server.length() > 100) { + return completed(TaskResult.failure("VALIDATION_ERROR", "A valid backend server is required")); + } + String revision; + try { + revision = configurationService.read().revision(); + } catch (RuntimeException failure) { + return completed(TaskResult.failure("READ_FAILED", "Could not read the active proxy configuration")); + } + return communicationTest.apply(server).handle((result, failure) -> { + if (failure != null || result == null) { + return TaskResult.failure("TEST_FAILED", "The communication test could not complete"); + } + if (!result.success()) return TaskResult.failure(result.code(), result.message()); + String summary = result.server() + " replied via " + result.method() + " in " + + result.roundTripMillis() + " ms"; + return TaskResult.success(revision, requested.deepCopy(), List.of(summary), false, result.message()); + }); + } + + private static boolean isCommunicationTest(JsonObject requested) { + return requested != null && requested.has("domain") && requested.has("preset") + && "quick-setup".equals(requested.get("domain").getAsString()) + && COMMUNICATION_TEST_PRESET.equals(requested.get("preset").getAsString()); + } + + private static boolean isProxyMethod(JsonObject requested) { + return requested != null && requested.has("domain") && requested.has("preset") + && "quick-setup".equals(requested.get("domain").getAsString()) + && PROXY_METHOD_PRESET.equals(requested.get("preset").getAsString()); + } + + private static CompletableFuture completed(TaskResult result) { + return CompletableFuture.completedFuture(result); + } + private static ProxyRoutingConfiguration parseConfiguration(JsonObject body) { if (body == null || !body.has("sendVotesToAllServers") || !body.has("blockedServers")) { throw new IllegalArgumentException("configuration is incomplete"); @@ -624,6 +760,7 @@ private static ProxyRoutingConfiguration parseConfiguration(JsonObject body) { private Request resultRequest(UUID operationId, StoredResult result) { JsonObject body = result.result().deepCopy(); + body.remove(INTERNAL_OPERATION_TYPE); body.addProperty("sessionId", sessionId.toString()); return new Request("POST", "/api/v1/nodes/" + settings.nodeId() + "/operations/" + operationId + "/result", body.toString()); @@ -675,6 +812,8 @@ private void addCapabilities(JsonObject body) { JsonArray advertised = new JsonArray(); BASE_CAPABILITIES.stream().sorted().forEach(advertised::add); if (configurationService != null) advertised.add(CONFIGURATION_CAPABILITY); + if (communicationTest != null) advertised.add(COMMUNICATION_TEST_CAPABILITY); + if (methodConfigurationService != null) advertised.add(PROXY_METHOD_CAPABILITY); body.add("capabilities", advertised); JsonArray required = new JsonArray(); required.add("presence.snapshot"); @@ -818,14 +957,14 @@ public record Request(String method, String path, String body) { } public record Response(int statusCode, String body) { } private record TaskResult(boolean success, String code, String message, String revision, - ProxyRoutingConfiguration configuration, List changes, boolean reloaded, boolean rolledBack) { + JsonObject configuration, List changes, boolean reloaded, boolean rolledBack) { private JsonObject json() { JsonObject body = new JsonObject(); body.addProperty("success", success); body.addProperty("code", code); body.addProperty("message", message); if (revision != null) body.addProperty("revision", revision); - if (configuration != null) body.add("configuration", configurationJson(configuration)); + if (configuration != null) body.add("configuration", configuration); JsonArray listed = new JsonArray(); changes.forEach(listed::add); body.add("changes", listed); @@ -836,7 +975,11 @@ private JsonObject json() { private static TaskResult success(String revision, ProxyRoutingConfiguration configuration, List changes, boolean reloaded) { - return new TaskResult(true, "OK", "Operation completed", revision, configuration, changes, reloaded, false); + return success(revision, configurationJson(configuration), changes, reloaded, "Operation completed"); + } + private static TaskResult success(String revision, JsonObject configuration, List changes, + boolean reloaded, String message) { + return new TaskResult(true, "OK", message, revision, configuration, changes, reloaded, false); } private static TaskResult failure(String code, String message) { return new TaskResult(false, code, message == null ? "Operation failed" : message, null, null, List.of(), false, false); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfiguration.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfiguration.java new file mode 100644 index 0000000000..d7fe1c6e12 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfiguration.java @@ -0,0 +1,30 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; + +import com.bencodez.votingplugin.proxy.BungeeMethod; + +/** Small, revisioned proxy transport selection exposed to Control. */ +public record ProxyMethodConfiguration(BungeeMethod method) { + public ProxyMethodConfiguration { + if (method == null) throw new IllegalArgumentException("proxy method is required"); + } + + public String revision() { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(method.name().getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + public List changesFrom(ProxyMethodConfiguration current) { + return method == current.method ? List.of() : List.of("change proxy method from " + + current.method.name() + " to " + method.name()); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java new file mode 100644 index 0000000000..1f76ca4dd7 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java @@ -0,0 +1,138 @@ +package com.bencodez.votingplugin.proxy.control; + +import java.io.IOException; +import java.net.URI; +import java.util.Map; +import java.util.Set; + +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Preflights and atomically persists proxy transport-method changes. */ +public final class ProxyMethodConfigurationService { + private final VotingPluginProxy proxy; + + public ProxyMethodConfigurationService(VotingPluginProxy proxy) { + this.proxy = proxy; + } + + public ProxyMethodConfiguration read() { + return new ProxyMethodConfiguration(BungeeMethod.getByName(proxy.getConfig().getBungeeMethod())); + } + + public void validate(ProxyMethodConfiguration proposal) { + validate(proposal, proxy.getConfig()); + } + + private void validate(ProxyMethodConfiguration proposal, VotingPluginProxyConfig config) { + switch (proposal.method()) { + case PLUGINMESSAGING: + if (blank(config.getPluginMessageChannel())) { + throw new IllegalArgumentException("PluginMessageChannel must be set"); + } + break; + case REDIS: + validHostPort(config.getRedisHost(), config.getRedisPort(), "Redis"); + break; + case MQTT: + validBroker(config.getMqttBrokerURL()); + if (blank(config.getMqttClientID())) throw new IllegalArgumentException("MQTT.ClientID must be set"); + break; + case SOCKETS: + if (config.getBungeePort() < 1 || config.getBungeePort() > 65535) { + throw new IllegalArgumentException("BungeeServer.Port must be set"); + } + List blockedServers = config.getBlockedServers(); + for (String server : config.getSpigotServers()) { + if (blockedServers != null && blockedServers.contains(server)) continue; + Map backend = config.getSpigotServerConfiguration(server); + Object host = backend.get("Host"); + Object port = backend.get("Port"); + int socketPort = port == null ? 1298 : port instanceof Number ? ((Number) port).intValue() : 0; + if (!(host instanceof String) || blank((String) host) + || socketPort < 1 || socketPort > 65535) { + throw new IllegalArgumentException("SpigotServers." + server + " Host and Port must be set for SOCKETS"); + } + } + break; + case MYSQL: + if (!config.hasDatabaseConfigured()) { + throw new IllegalArgumentException("The proxy database Host must be configured for MYSQL"); + } + break; + default: + throw new IllegalArgumentException("proxy method is unsupported"); + } + } + + public void apply(ProxyMethodConfiguration proposal, String expectedRevision) throws IOException { + if (expectedRevision == null) throw new StaleRevisionException(); + try { + proxy.getConfig().persistControlProxyMethod(proposal.method().name(), expectedRevision, + latest -> validate(proposal, latest)); + proxy.getConfig().verifyControlProxyRoutingInstalled(); + } catch (VotingPluginProxyConfig.StaleControlRevisionException e) { + throw new StaleRevisionException(); + } catch (IllegalArgumentException validation) { + throw validation; + } catch (DurableFiles.PublishedException published) { + throw rollbackAfterFailure(published); + } catch (IOException failure) { + throw failure; + } catch (RuntimeException failure) { + throw rollbackAfterFailure(failure); + } + } + + private ApplyFailureException rollbackAfterFailure(Exception failure) { + boolean rolledBack = false; + try { + proxy.getConfig().rollbackControlProxyRouting(); + rolledBack = true; + } catch (Exception rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + return new ApplyFailureException(rolledBack, failure); + } + + public static BungeeMethod canonical(String value) { + if (value == null) throw new IllegalArgumentException("proxy method is required"); + try { + return BungeeMethod.valueOf(value.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("proxy method is unsupported"); + } + } + + private static void validHostPort(String host, int port, String label) { + if (blank(host) || port < 1 || port > 65535) { + throw new IllegalArgumentException(label + " host and port must be set"); + } + } + + private static void validBroker(String value) { + try { + URI broker = URI.create(value == null ? "" : value.trim()); + if (!Set.of("tcp", "ssl", "ws", "wss").contains(broker.getScheme()) || broker.getHost() == null) { + throw new IllegalArgumentException(); + } + } catch (RuntimeException e) { + throw new IllegalArgumentException("MQTT.BrokerURL must be a valid tcp, ssl, ws, or wss endpoint"); + } + } + + private static boolean blank(String value) { + return value == null || value.isBlank(); + } + + @SuppressWarnings("serial") + public static final class StaleRevisionException extends RuntimeException { } + @SuppressWarnings("serial") + public static final class ApplyFailureException extends RuntimeException { + private final boolean rolledBack; + private ApplyFailureException(boolean rolledBack, Throwable cause) { super(cause); this.rolledBack = rolledBack; } + public boolean rolledBack() { return rolledBack; } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java index 1e60a509ec..008aa7716d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java @@ -20,7 +20,9 @@ import org.checkerframework.checker.nullness.qual.NonNull; import com.bencodez.simpleapi.file.velocity.VelocityYMLFile; +import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.proxy.control.ProxyMethodConfiguration; import com.bencodez.votingplugin.proxy.control.ProxyRoutingConfiguration; import com.bencodez.votingplugin.util.DurableFiles; @@ -92,6 +94,49 @@ private static ProxyRoutingConfiguration routing(ConfigurationNode configuration configuration.node("BlockedServers").getList(String.class, List.of())); } + @Override + public synchronized void persistControlProxyMethod(String method, String expectedRevision, + ControlProxyMethodValidator validator) throws IOException { + Path target = configurationFile.toPath(); + Path stage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-stage"); + Path backupStage = null; + Path backup = target.resolveSibling(target.getFileName() + ".control-backup"); + YamlConfigurationLoader sourceLoader = YamlConfigurationLoader.builder().path(target).build(); + try { + backupStage = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".control-backup-stage"); + byte[] sourceSnapshot = Files.readAllBytes(target); + ConfigurationNode latest = sourceLoader.load(); + ProxyMethodConfiguration current = new ProxyMethodConfiguration(BungeeMethod.getByName( + latest.node("BungeeMethod").getString("PLUGINMESSAGING"))); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target)) + || !current.revision().equals(expectedRevision)) throw new StaleControlRevisionException(); + VelocityConfig fresh = new VelocityConfig(target.toFile()); + fresh.loadControlConfiguration(); + validator.validate(fresh); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) { + throw new StaleControlRevisionException(); + } + latest.node("BungeeMethod").set(method); + YamlConfigurationLoader.builder().path(stage).build().save(latest); + byte[] installedSnapshot = Files.readAllBytes(stage); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) throw new StaleControlRevisionException(); + Files.write(backupStage, sourceSnapshot); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) throw new StaleControlRevisionException(); + atomicReplace(backupStage, backup); + if (!java.util.Arrays.equals(sourceSnapshot, Files.readAllBytes(target))) throw new StaleControlRevisionException(); + controlInstalledSnapshot = installedSnapshot; + try { + atomicReplace(stage, target); + } catch (IOException failure) { + if (!(failure instanceof DurableFiles.PublishedException)) controlInstalledSnapshot = null; + throw failure; + } + } finally { + Files.deleteIfExists(stage); + if (backupStage != null) Files.deleteIfExists(backupStage); + } + } + @Override public synchronized void rollbackControlProxyRouting() throws IOException { Path target = configurationFile.toPath(); @@ -747,20 +792,24 @@ public boolean hasDatabaseConfigured() { ConfigurationNode db = getNode("Database"); if (isSection(db)) { String host = getString(getNode("Database", "Host"), ""); - return host != null && !host.isEmpty(); + String database = getString(getNode("Database", "Database"), ""); + return host != null && !host.isEmpty() && database != null && !database.isEmpty(); } // Legacy-style: Host at root String host = getString(getNode("Host"), ""); if (host != null && !host.isEmpty()) { - return true; + String database = getString(getNode("Database"), ""); + return database != null && !database.isEmpty(); } // Optional older style some setups used: MySQL.Host ConfigurationNode mysql = getNode("MySQL"); if (isSection(mysql)) { String mysqlHost = getString(getNode("MySQL", "Host"), ""); - return mysqlHost != null && !mysqlHost.isEmpty(); + String mysqlDatabase = getString(getNode("MySQL", "Database"), ""); + return mysqlHost != null && !mysqlHost.isEmpty() + && mysqlDatabase != null && !mysqlDatabase.isEmpty(); } return false; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index e3544577c8..9ff3aad685 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -460,6 +460,40 @@ class BackendConfigurationServiceTest { assertTrue(mqtt.proposal().content().contains("BungeeMethod: MQTT")); } + @Test void proxyMethodSwitchPreflightsRequiredBackendSettings() throws Exception { + Path settings = directory.resolve("BungeeSettings.yml"); + Files.writeString(settings, "UseBungeecord: true\nServer: lobby\nBungeeMethod: PLUGINMESSAGING\n" + + "PluginMessageChannel: vp:vp\nRedis:\n Host: ''\n Port: 6379\n" + + "MQTT:\n ClientID: lobby\n BrokerURL: bad\nBungeeServer:\n Host: ''\n Port: 1297\n"); + Files.writeString(directory.resolve("Config.yml"), + "DataStorage: SQLITE\nDatabase:\n Host: ''\n Database: ''\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "REDIS"))); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "MQTT"))); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "SOCKETS"))); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "MYSQL"))); + + Files.writeString(settings, Files.readString(settings).replace("Host: ''", "Host: localhost") + .replace("BrokerURL: bad", "BrokerURL: tcp://localhost:1883")); + BackendConfigurationService.QuickPreview redis = service.previewQuickSetup("proxy-method", + Map.of("method", "REDIS")); + assertTrue(redis.proposal().content().contains("BungeeMethod: REDIS")); + } + + @Test void pluginMessagingMethodRequiresBackendIdentityForDiagnostics() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "UseBungeecord: true\nServer: PleaseSet\nBungeeMethod: REDIS\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("proxy-method", + Map.of("method", "PLUGINMESSAGING"))); + } + @Test void voteSitesSyncAddsAndUpdatesDefinitionsWithoutTouchingRewardsOrTargetOnlySites() throws Exception { Path voteSites = directory.resolve("VoteSites.yml"); Files.writeString(voteSites, "VoteSites:\n" diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java index 8fe3dfdc05..500b7b0203 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -217,13 +217,32 @@ class ControlConnectorTest { connector.cycle(); AtomicBoolean replacementStarted = new AtomicBoolean(); - assertTrue(connector.deferReplacementUntilSafe(() -> replacementStarted.set(true))); + assertTrue(connector.deferReplacementUntilSafe(() -> { + assertFalse(connector.hasActiveOperation()); + replacementStarted.set(true); + })); assertFalse(replacementStarted.get()); transport.resultSubmission.complete(new Response(200, "{}")); assertTrue(replacementStarted.get()); } + @Test void onlySuccessfulProxyMethodApplyRequestsRuntimeReplacement() { + JsonObject result = new JsonObject(); + result.addProperty("success", true); + JsonObject configuration = new JsonObject(); + configuration.addProperty("preset", "proxy-method"); + result.add("configuration", configuration); + result.addProperty("_controlOperationType", "READ"); + assertFalse(ControlConnector.requiresRuntimeReplacement(new StoredResult(result, true, false))); + result.addProperty("_controlOperationType", "PREVIEW"); + assertFalse(ControlConnector.requiresRuntimeReplacement(new StoredResult(result, true, false))); + result.addProperty("_controlOperationType", "APPLY"); + assertTrue(ControlConnector.requiresRuntimeReplacement(new StoredResult(result, true, false))); + result.addProperty("success", false); + assertFalse(ControlConnector.requiresRuntimeReplacement(new StoredResult(result, true, false))); + } + @Test void lostResultResponseIsResubmittedBeforeAnotherOperationClaim() { connector.close(); ProxyRoutingConfiguration current = new ProxyRoutingConfiguration(false, List.of()); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java new file mode 100644 index 0000000000..4e6a25da79 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java @@ -0,0 +1,124 @@ +package com.bencodez.votingplugin.proxy.control; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; + +class ProxyMethodConfigurationServiceTest { + private VotingPluginProxy proxy; + private VotingPluginProxyConfig config; + private ProxyMethodConfigurationService service; + + @BeforeEach + void setUp() { + proxy = mock(VotingPluginProxy.class); + config = mock(VotingPluginProxyConfig.class); + when(proxy.getConfig()).thenReturn(config); + when(proxy.getAllConfiguredServers()).thenReturn(Set.of("lobby")); + service = new ProxyMethodConfigurationService(proxy); + } + + @Test + void validatesRequiredSettingsForEveryTransport() { + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.PLUGINMESSAGING))); + when(config.getPluginMessageChannel()).thenReturn("vp:vp"); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.PLUGINMESSAGING))); + + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.REDIS))); + when(config.getRedisHost()).thenReturn("localhost"); + when(config.getRedisPort()).thenReturn(6379); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.REDIS))); + + when(config.getMqttBrokerURL()).thenReturn("not-a-broker"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MQTT))); + when(config.getMqttBrokerURL()).thenReturn("tcp://localhost:1883"); + when(config.getMqttClientID()).thenReturn("proxy"); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MQTT))); + + when(config.getBungeePort()).thenReturn(1297); + when(proxy.getAllConfiguredServers()).thenReturn(Set.of("lobby", "blocked")); + when(config.getSpigotServers()).thenReturn(List.of("lobby", "blocked")); + when(config.getBlockedServers()).thenReturn(List.of("blocked")); + when(config.getSpigotServerConfiguration("lobby")).thenReturn(Map.of("Host", "localhost")); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.SOCKETS))); + + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MYSQL))); + when(config.hasDatabaseConfigured()).thenReturn(true); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MYSQL))); + } + + @Test + void validatesEveryPersistedSocketRouteEvenWhenTheProxyNoLongerRegistersIt() { + when(config.getBungeePort()).thenReturn(1297); + when(config.getBlockedServers()).thenReturn(List.of()); + when(config.getSpigotServers()).thenReturn(List.of("stale-route")); + when(config.getSpigotServerConfiguration("stale-route")) + .thenReturn(Map.of("Host", "localhost", "Port", "bad")); + + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.SOCKETS))); + } + + @Test + void rejectsUnknownMethodNamesInsteadOfFallingBack() { + assertThrows(IllegalArgumentException.class, () -> ProxyMethodConfigurationService.canonical("unknown")); + } + + @Test + void readsUnknownPersistedMethodsUsingTheRuntimeFallback() { + when(config.getBungeeMethod()).thenReturn("unknown"); + assertEquals(BungeeMethod.PLUGINMESSAGING, service.read().method()); + } + + @Test + void applyValidatesTheFreshlyLoadedPersistedSnapshot() throws Exception { + when(config.getBungeeMethod()).thenReturn("PLUGINMESSAGING"); + when(config.getRedisHost()).thenReturn("localhost"); + when(config.getRedisPort()).thenReturn(6379); + VotingPluginProxyConfig fresh = mock(VotingPluginProxyConfig.class); + doAnswer(invocation -> { + VotingPluginProxyConfig.ControlProxyMethodValidator validator = invocation.getArgument(2); + validator.validate(fresh); + return null; + }).when(config).persistControlProxyMethod(org.mockito.ArgumentMatchers.eq("REDIS"), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + + ProxyMethodConfiguration proposal = new ProxyMethodConfiguration(BungeeMethod.REDIS); + assertThrows(IllegalArgumentException.class, () -> service.apply(proposal, service.read().revision())); + } + + @Test + void applyDoesNotRejectAValidFreshSnapshotUsingStaleCachedSettings() throws Exception { + when(config.getBungeeMethod()).thenReturn("PLUGINMESSAGING"); + VotingPluginProxyConfig fresh = mock(VotingPluginProxyConfig.class); + when(fresh.getRedisHost()).thenReturn("localhost"); + when(fresh.getRedisPort()).thenReturn(6379); + doAnswer(invocation -> { + VotingPluginProxyConfig.ControlProxyMethodValidator validator = invocation.getArgument(2); + validator.validate(fresh); + return null; + }).when(config).persistControlProxyMethod(org.mockito.ArgumentMatchers.eq("REDIS"), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + + ProxyMethodConfiguration proposal = new ProxyMethodConfiguration(BungeeMethod.REDIS); + assertDoesNotThrow(() -> service.apply(proposal, service.read().revision())); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java index 13660a25ab..d4edbd3281 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.proxy.velocity; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -12,6 +13,8 @@ import org.junit.jupiter.api.io.TempDir; import com.bencodez.votingplugin.proxy.control.ProxyRoutingConfiguration; +import com.bencodez.votingplugin.proxy.control.ProxyMethodConfiguration; +import com.bencodez.votingplugin.proxy.control.ProxyMethodConfigurationService; class VelocityConfigControlTest { @TempDir Path directory; @@ -52,4 +55,53 @@ void rollbackRejectsASymbolicBackupSidecar() throws Exception { assertTrue(Files.readString(file).contains("lobby")); assertTrue(Files.readString(external).contains("preserved")); } + + @Test + void controlPersistsOnlyTheRevisionedProxyMethod() throws Exception { + Path file = directory.resolve("velocity.yml"); + Files.writeString(file, "BungeeMethod: PLUGINMESSAGING\nRedis:\n Host: localhost\n Port: 6379\n"); + VelocityConfig config = new VelocityConfig(file.toFile()); + config.loadControlConfiguration(); + ProxyMethodConfiguration current = new ProxyMethodConfiguration( + ProxyMethodConfigurationService.canonical(config.getBungeeMethod())); + + config.persistControlProxyMethod("REDIS", current.revision()); + config.loadControlConfiguration(); + + assertEquals("REDIS", config.getBungeeMethod()); + assertTrue(Files.readString(file).contains("Host: localhost")); + } + + @Test + void controlCanRepairAnUnknownPersistedProxyMethod() throws Exception { + Path file = directory.resolve("velocity.yml"); + Files.writeString(file, "BungeeMethod: RETIRED_METHOD\nRedis:\n Host: localhost\n Port: 6379\n"); + VelocityConfig config = new VelocityConfig(file.toFile()); + config.loadControlConfiguration(); + ProxyMethodConfiguration current = new ProxyMethodConfiguration( + com.bencodez.votingplugin.proxy.BungeeMethod.getByName(config.getBungeeMethod())); + + config.persistControlProxyMethod("REDIS", current.revision()); + config.loadControlConfiguration(); + + assertEquals("REDIS", config.getBungeeMethod()); + } + + @Test + void controlValidatesTheFreshOnDiskSnapshotBeforeChangingMethods() throws Exception { + Path file = directory.resolve("velocity.yml"); + Files.writeString(file, "BungeeMethod: PLUGINMESSAGING\nRedis:\n Host: localhost\n Port: 6379\n"); + VelocityConfig config = new VelocityConfig(file.toFile()); + config.loadControlConfiguration(); + ProxyMethodConfiguration current = new ProxyMethodConfiguration( + com.bencodez.votingplugin.proxy.BungeeMethod.getByName(config.getBungeeMethod())); + Files.writeString(file, "BungeeMethod: PLUGINMESSAGING\nRedis:\n Host: ''\n Port: 6379\n"); + + assertThrows(IllegalArgumentException.class, () -> config.persistControlProxyMethod("REDIS", + current.revision(), fresh -> { + if (fresh.getRedisHost().isBlank()) throw new IllegalArgumentException("Redis host must be set"); + })); + + assertTrue(Files.readString(file).contains("BungeeMethod: PLUGINMESSAGING")); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index f507d910e2..b6c587a5a1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -124,6 +124,70 @@ void immediatePluginMessageReportsActualDeliveryResult() { VotingPluginWire.voteBroadcast("uuid", "Player", "Service", 100L, "", false))); } + @Test + void communicationTestCompletesOnlyForTheCorrelatedBackendReply() throws Exception { + votingPluginProxy.setMethod(BungeeMethod.MQTT); + java.util.concurrent.ScheduledExecutorService scheduler = Mockito + .mock(java.util.concurrent.ScheduledExecutorService.class); + com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler messageHandler = Mockito + .mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + votingPluginProxy.setSchedulerForTest(scheduler); + votingPluginProxy.setGlobalMessageProxyHandlerForTest(messageHandler); + + java.util.concurrent.CompletableFuture result = votingPluginProxy + .testBackendCommunication("Server1", 5000L); + String requestId = votingPluginProxy.getLastCommunicationTestEnvelope().getFields() + .get(VotingPluginWire.K_REQUEST_ID); + assertFalse(result.isDone()); + + votingPluginProxy.handleStatusOkayForTest(VotingPluginWire.statusOkay("Server2", + java.util.UUID.fromString(requestId))); + assertFalse(result.isDone()); + votingPluginProxy.handleStatusOkayForTest(VotingPluginWire.statusOkay("Server1", + java.util.UUID.fromString(requestId))); + + VotingPluginProxy.CommunicationTestResult completed = result.get(); + assertTrue(completed.success()); + assertEquals("Server1", completed.server()); + assertEquals("MQTT", completed.method()); + assertTrue(completed.roundTripMillis() >= 0L); + } + + @Test + void communicationTestReportsUnavailableWhenTheActiveTransportCannotSend() throws Exception { + java.util.concurrent.ScheduledExecutorService scheduler = Mockito + .mock(java.util.concurrent.ScheduledExecutorService.class); + votingPluginProxy.setSchedulerForTest(scheduler); + votingPluginProxy.setGlobalMessageProxyHandlerForTest(Mockito + .mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class)); + votingPluginProxy.setCommunicationTestDeliveryResult(false); + + for (BungeeMethod unavailable : new BungeeMethod[] { BungeeMethod.MQTT, BungeeMethod.SOCKETS }) { + votingPluginProxy.setMethod(unavailable); + VotingPluginProxy.CommunicationTestResult result = votingPluginProxy + .testBackendCommunication("Server1", 5000L).get(); + + assertFalse(result.success()); + assertEquals("TRANSPORT_UNAVAILABLE", result.code()); + assertEquals(unavailable.name(), result.method()); + } + Mockito.verifyNoInteractions(scheduler); + } + + @Test + void pluginMessagingCommunicationTestExplainsOnlinePlayerRequirement() throws Exception { + votingPluginProxy.setMethod(BungeeMethod.PLUGINMESSAGING); + votingPluginProxy.setGlobalMessageProxyHandlerForTest(Mockito + .mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class)); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(false).when(spyProxy).isSomeoneOnlineServer(Mockito.eq("Server1")); + + VotingPluginProxy.CommunicationTestResult result = spyProxy.testBackendCommunication("Server1", 5000L).get(); + + assertFalse(result.success()); + assertEquals("PLAYER_REQUIRED", result.code()); + } + @Test void pluginMessagingLegacyLoginUsesTheProxyCurrentServerAndUuid() { String uuid = java.util.UUID.randomUUID().toString(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java index 2bfbe1e7c6..ecdd7e6d19 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java @@ -23,6 +23,8 @@ public class VotingPluginProxyTestImpl extends VotingPluginProxy { private final List warnings = new ArrayList<>(); private VotingPluginProxyConfig config; private boolean pluginMessageDeliveryResult = true; + private boolean communicationTestDeliveryResult = true; + private JsonEnvelope lastCommunicationTestEnvelope; private boolean playerOnline = true; private ScheduledExecutorService scheduler; @@ -210,10 +212,28 @@ public boolean sendProxyBroadcastImmediately(String server, JsonEnvelope envelop return sendProxyBroadcastEnvelopeNow(server, envelope); } + @Override + protected boolean sendCommunicationTestEnvelopeNow(String server, JsonEnvelope envelope) { + lastCommunicationTestEnvelope = envelope; + return communicationTestDeliveryResult; + } + + public void setCommunicationTestDeliveryResult(boolean communicationTestDeliveryResult) { + this.communicationTestDeliveryResult = communicationTestDeliveryResult; + } + + public JsonEnvelope getLastCommunicationTestEnvelope() { + return lastCommunicationTestEnvelope; + } + public void handleLoginMessageForTest(JsonEnvelope envelope) { handleLoginMessage(envelope); } + public void handleStatusOkayForTest(JsonEnvelope envelope) { + handleStatusOkay(envelope); + } + public void setGlobalMessageProxyHandlerForTest(GlobalMessageProxyHandler handler) { try { java.lang.reflect.Field field = VotingPluginProxy.class.getDeclaredField("globalMessageProxyHandler"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java index e8e59cba4e..7aacf8ccae 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java @@ -209,6 +209,20 @@ public void blankVerifierIsAValidHostedRoutePreflight() { assertEquals("", request.verifier); } + @Test + public void statusRoundTripCarriesCorrelationId() { + UUID requestId = UUID.randomUUID(); + + JsonEnvelope request = VotingPluginWire.status("survival", requestId); + JsonEnvelope response = VotingPluginWire.statusOkay("survival", requestId); + + assertEquals(VotingPluginWire.SUB_STATUS, request.getSubChannel()); + assertEquals("survival", request.getFields().get(VotingPluginWire.K_SERVER)); + assertEquals(requestId.toString(), request.getFields().get(VotingPluginWire.K_REQUEST_ID)); + assertEquals(VotingPluginWire.SUB_STATUS_OKAY, response.getSubChannel()); + assertEquals(requestId.toString(), response.getFields().get(VotingPluginWire.K_REQUEST_ID)); + } + @Test public void malformedControlEnrollmentIsRejected() { JsonEnvelope malformed = JsonEnvelope.builder(VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST)