diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index ec3f46cd..08da5dcf 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -28,6 +28,8 @@ public final class ConfigurationOperations implements AutoCloseable { public static final String FILE_CAPABILITY = "config.files.v1"; public static final String QUICK_SETUP_CAPABILITY = "config.quick-setup.v1"; public static final String VOTE_SITES_SYNC_CAPABILITY = "config.vote-sites-sync.v1"; + public static final String TRANSPORT_TEST_CAPABILITY = "config.transport-test.v1"; + public static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1"; private static final int MAX_OPERATIONS = 1000; private static final int MAX_FILE_OPERATIONS = 16; static final int MAX_RETAINED_CHANGE_BYTES = 256 * 1024; @@ -60,15 +62,18 @@ public synchronized OperationView createPreview(List nodeIds, ProxyRouti public synchronized OperationView createRead(List nodeIds, ManagedConfiguration selector) { if (selector == null) selector = ManagedConfiguration.proxy(new ProxyRoutingConfiguration(false, List.of())); + selector.validateProposal(); return create("READ", validateTargets(nodeIds, selector.capability()), selector, null); } public synchronized OperationView createPreview(List nodeIds, ManagedConfiguration configuration) { if (configuration == null) throw invalid("configuration is required"); configuration.validateProposal(); + ValidatedTargets targets = validateTargets(nodeIds, configuration.capability()); + validateProxyMethodTargets(targets, configuration); byte[] token = new byte[32]; random.nextBytes(token); - return create("PREVIEW", validateTargets(nodeIds, configuration.capability()), configuration, + return create("PREVIEW", targets, configuration, Base64.getUrlEncoder().withoutPadding().encodeToString(token)); } @@ -84,9 +89,13 @@ public synchronized OperationView createApply(UUID previewId, String approvalTok approvalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8))) { throw new ValidationException("APPROVAL_REQUIRED", "A valid unused preview approval is required", List.of()); } + ValidatedTargets targets = validateTargets(new ArrayList<>(preview.states.keySet()), + preview.configuration.capability()); + validateProxyMethodTargets(targets, preview.configuration); + rejectOverlappingProxyMethodApply(targets, preview.configuration); Map revisions = new LinkedHashMap<>(); preview.results.forEach((node, result) -> revisions.put(node, result.revision())); - StoredOperation apply = store("APPLY", new ArrayList<>(preview.states.keySet()), preview.configuration, + StoredOperation apply = store("APPLY", targets, preview.configuration, null, revisions, preview.id); preview.approvalUsed = true; try { @@ -118,6 +127,8 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { Instant leased = operation.leasedAt.get(nodeId); if ("QUEUED".equals(state) || ("IN_PROGRESS".equals(state) && leased != null && !now.isBefore(leased.plus(LEASE)))) { + if (cancelChangedProxyMethodRole(operation, node)) continue; + if (deferProxyMethodApply(operation, node)) continue; if (!node.online() || !node.acceptedCapabilities().contains(operation.configuration.capability())) { audit.append("TASK_CANCELLED", operation.id, nodeId, "CAPABILITY_LOST"); operation.results.put(nodeId, boundedResult(operation, new ConfigurationTaskResult(sessionId(node), false, @@ -149,6 +160,137 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { return null; } + private boolean deferProxyMethodApply(StoredOperation operation, NodeStatus node) { + if (!"APPLY".equals(operation.type) + || !ManagedConfiguration.QUICK_SETUP.equals(operation.configuration.domain()) + || !ManagedConfiguration.PROXY_METHOD.equals(operation.configuration.preset()) + || "BUKKIT".equalsIgnoreCase(operation.targetPlatforms.get(node.nodeId()))) return false; + List backends = operation.targetPlatforms.entrySet().stream() + .filter(entry -> "BUKKIT".equalsIgnoreCase(entry.getValue())) + .map(Map.Entry::getKey).toList(); + for (String backendId : backends) { + String state = operation.states.get(backendId); + NodeStatus backend = registry.find(backendId); + if ("COMPLETE".equals(state)) { + ConfigurationTaskResult result = operation.results.get(backendId); + if (result == null || !result.success() || completedBackendStillValid(operation, backendId, backend, + result)) continue; + audit.append("TASK_INVALIDATED", operation.id, backendId, "DEPENDENCY_CHANGED"); + UUID backendSession = backend == null ? result.sessionId() : sessionId(backend); + releaseResultDetails(result); + operation.results.put(backendId, boundedResult(operation, new ConfigurationTaskResult(backendSession, + false, "DEPENDENCY_CHANGED", "Backend identity changed after apply; preview again", null, + (ManagedConfiguration) null, List.of(), false, false, null))); + continue; + } + if ("IN_PROGRESS".equals(state)) { + Instant leased = operation.leasedAt.get(backendId); + if (leased != null && clock.instant().isBefore(leased.plus(LEASE))) continue; + if (backend != null && backend.online() + && backend.acceptedCapabilities().contains(operation.configuration.capability())) { + audit.append("TASK_REQUEUED", operation.id, backendId, "LEASE_EXPIRED"); + operation.states.put(backendId, "QUEUED"); + operation.leasedAt.remove(backendId); + operation.attemptIds.remove(backendId); + continue; + } + } else if (!"QUEUED".equals(state)) { + continue; + } + if (backend != null && backend.online() + && backend.acceptedCapabilities().contains(operation.configuration.capability())) continue; + audit.append("TASK_CANCELLED", operation.id, backendId, "CAPABILITY_LOST"); + UUID backendSession = backend == null ? operation.targetSessions.get(backendId) : sessionId(backend); + operation.results.put(backendId, boundedResult(operation, new ConfigurationTaskResult(backendSession, false, + "CAPABILITY_LOST", "Backend became unavailable before proxy method apply", null, + (ManagedConfiguration) null, List.of(), false, false, null))); + operation.states.put(backendId, "COMPLETE"); + operation.leasedAt.remove(backendId); + operation.attemptIds.remove(backendId); + } + if (backends.stream().anyMatch(id -> !"COMPLETE".equals(operation.states.get(id)))) return true; + if (backends.stream().noneMatch(id -> operation.results.get(id) == null + || !operation.results.get(id).success())) { + Set expectedBackends = new HashSet<>(backends); + Set currentBackends = new HashSet<>(); + node.backends().forEach(backend -> currentBackends.add(backend.backendId())); + if (expectedBackends.equals(currentBackends)) return false; + audit.append("TASK_CANCELLED", operation.id, node.nodeId(), "TOPOLOGY_CHANGED"); + operation.results.put(node.nodeId(), boundedResult(operation, new ConfigurationTaskResult(sessionId(node), + false, "DEPENDENCY_CHANGED", "Proxy topology changed after approval; preview again", null, + (ManagedConfiguration) null, List.of(), false, false, null))); + operation.states.put(node.nodeId(), "COMPLETE"); + operation.leasedAt.remove(node.nodeId()); + operation.attemptIds.remove(node.nodeId()); + return true; + } + audit.append("TASK_CANCELLED", operation.id, node.nodeId(), "BACKEND_APPLY_FAILED"); + operation.results.put(node.nodeId(), boundedResult(operation, new ConfigurationTaskResult(sessionId(node), false, + "DEPENDENCY_FAILED", "A backend failed the proxy method apply", null, + (ManagedConfiguration) null, List.of(), false, false, null))); + operation.states.put(node.nodeId(), "COMPLETE"); + operation.leasedAt.remove(node.nodeId()); + operation.attemptIds.remove(node.nodeId()); + return true; + } + + private boolean completedBackendStillValid(StoredOperation operation, String backendId, NodeStatus backend, + ConfigurationTaskResult result) { + return backend != null && backend.online() + && "BUKKIT".equalsIgnoreCase(operation.targetPlatforms.get(backendId)) + && "BUKKIT".equalsIgnoreCase(backend.platform()) + && backend.acceptedCapabilities().contains(operation.configuration.capability()) + && result.sessionId().equals(sessionId(backend)); + } + + private boolean cancelChangedProxyMethodRole(StoredOperation operation, NodeStatus node) { + if (!"APPLY".equals(operation.type) + || !ManagedConfiguration.QUICK_SETUP.equals(operation.configuration.domain()) + || !ManagedConfiguration.PROXY_METHOD.equals(operation.configuration.preset())) return false; + String expectedPlatform = operation.targetPlatforms.get(node.nodeId()); + if (expectedPlatform == null || expectedPlatform.equalsIgnoreCase(node.platform())) return false; + audit.append("TASK_CANCELLED", operation.id, node.nodeId(), "TARGET_ROLE_CHANGED"); + operation.results.put(node.nodeId(), boundedResult(operation, new ConfigurationTaskResult(sessionId(node), + false, "TARGET_CHANGED", "Node platform changed after approval; preview again", null, + (ManagedConfiguration) null, List.of(), false, false, null))); + operation.states.put(node.nodeId(), "COMPLETE"); + operation.leasedAt.remove(node.nodeId()); + operation.attemptIds.remove(node.nodeId()); + return true; + } + + private void rejectOverlappingProxyMethodApply(ValidatedTargets targets, ManagedConfiguration configuration) { + if (!ManagedConfiguration.QUICK_SETUP.equals(configuration.domain()) + || !ManagedConfiguration.PROXY_METHOD.equals(configuration.preset())) return; + Set requested = new HashSet<>(targets.nodeIds()); + boolean conflict = operations.values().stream().anyMatch(operation -> "APPLY".equals(operation.type) + && !operation.complete() + && ManagedConfiguration.QUICK_SETUP.equals(operation.configuration.domain()) + && ManagedConfiguration.PROXY_METHOD.equals(operation.configuration.preset()) + && operation.states.keySet().stream().anyMatch(requested::contains)); + if (conflict) { + throw new ValidationException("OPERATION_CONFLICT", + "Another proxy method apply is still running for this network", List.of()); + } + } + + private void validateProxyMethodTargets(ValidatedTargets targets, ManagedConfiguration configuration) { + if (!ManagedConfiguration.QUICK_SETUP.equals(configuration.domain()) + || !ManagedConfiguration.PROXY_METHOD.equals(configuration.preset())) return; + List proxies = targets.nodeIds().stream() + .filter(id -> !"BUKKIT".equalsIgnoreCase(targets.platforms().get(id))) + .map(registry::find).filter(Objects::nonNull).toList(); + if (proxies.size() != 1 || proxies.get(0).backends().isEmpty()) { + throw invalid("proxy method requires one proxy and its reported backends"); + } + java.util.Set expected = new java.util.LinkedHashSet<>(); + expected.add(proxies.get(0).nodeId()); + proxies.get(0).backends().forEach(backend -> expected.add(backend.backendId())); + if (!expected.equals(new java.util.LinkedHashSet<>(targets.nodeIds()))) { + throw invalid("proxy method targets must match the complete reported backend network"); + } + } + public synchronized OperationView complete(UUID operationId, String nodeId, ConfigurationTaskResult result) { validateResult(result); return registry.withSession(nodeId, result.sessionId(), @@ -182,7 +324,7 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co return view(operation); } - private OperationView create(String type, List targets, ManagedConfiguration config, String token) { + private OperationView create(String type, ValidatedTargets targets, ManagedConfiguration config, String token) { StoredOperation operation = store(type, targets, config, token, Map.of(), null); try { audit.append("OPERATION_CREATED", operation.id, null, type); @@ -193,7 +335,7 @@ private OperationView create(String type, List targets, ManagedConfigura return view(operation); } - private StoredOperation store(String type, List targets, ManagedConfiguration config, + private StoredOperation store(String type, ValidatedTargets targets, ManagedConfiguration config, String token, Map revisions, UUID protectedOperation) { prune(); ensureFileCapacity(config, protectedOperation); @@ -201,9 +343,10 @@ private StoredOperation store(String type, List targets, ManagedConfigur "Too many retained operations", List.of()); UUID id = UUID.randomUUID(); LinkedHashMap states = new LinkedHashMap<>(); - targets.forEach(node -> states.put(node, "QUEUED")); + targets.nodeIds().forEach(node -> states.put(node, "QUEUED")); StoredOperation result = new StoredOperation(id, type, config, token, clock.instant(), states, - new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(revisions)); + new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(revisions), + new LinkedHashMap<>(targets.platforms()), new LinkedHashMap<>(targets.sessions())); operations.put(id, result); return result; } @@ -306,20 +449,24 @@ private static String truncateUtf8(String value, int maximumBytes) { } private void releaseResultDetails(StoredOperation operation) { - for (ConfigurationTaskResult result : operation.results.values()) { - retainedMessageBytes -= result.message().getBytes(StandardCharsets.UTF_8).length; - for (String change : result.changes()) { - retainedChangeBytes -= change.getBytes(StandardCharsets.UTF_8).length; - } + operation.results.values().forEach(this::releaseResultDetails); + } + + private void releaseResultDetails(ConfigurationTaskResult result) { + retainedMessageBytes -= result.message().getBytes(StandardCharsets.UTF_8).length; + for (String change : result.changes()) { + retainedChangeBytes -= change.getBytes(StandardCharsets.UTF_8).length; } if (retainedChangeBytes < 0) retainedChangeBytes = 0; if (retainedMessageBytes < 0) retainedMessageBytes = 0; } - private List validateTargets(List nodeIds, String capability) { + private ValidatedTargets validateTargets(List nodeIds, String capability) { if (nodeIds == null || nodeIds.isEmpty() || nodeIds.size() > 100) throw invalid("nodeIds must contain 1 to 100 entries"); Set unique = new HashSet<>(); List result = new ArrayList<>(); + Map platforms = new LinkedHashMap<>(); + Map sessions = new LinkedHashMap<>(); for (String nodeId : nodeIds) { if (nodeId == null || !unique.add(nodeId)) throw invalid("nodeIds must be unique"); NodeStatus node = registry.find(nodeId); @@ -327,8 +474,10 @@ private List validateTargets(List nodeIds, String capability) { throw new ValidationException("NODE_UNAVAILABLE", "Node cannot accept configuration operations", List.of(nodeId)); } result.add(nodeId); + platforms.put(nodeId, node.platform()); + sessions.put(nodeId, node.sessionId()); } - return List.copyOf(result); + return new ValidatedTargets(List.copyOf(result), Map.copyOf(platforms), Map.copyOf(sessions)); } private OperationView view(StoredOperation operation) { @@ -387,6 +536,9 @@ public record OperationView(UUID operationId, String type, String state, Instant ManagedConfiguration configuration, Map nodeStates, Map results, String approvalToken) { } + private record ValidatedTargets(List nodeIds, Map platforms, + Map sessions) { } + private static final class StoredOperation { private final UUID id; private final String type; @@ -398,6 +550,8 @@ private static final class StoredOperation { private final LinkedHashMap leasedAt; private final LinkedHashMap attemptIds; private final LinkedHashMap expectedRevisions; + private final LinkedHashMap targetPlatforms; + private final LinkedHashMap targetSessions; private boolean approvalUsed; private StoredOperation(UUID id, String type, ManagedConfiguration configuration, String approvalToken, @@ -405,11 +559,15 @@ private StoredOperation(UUID id, String type, ManagedConfiguration configuration LinkedHashMap results, LinkedHashMap leasedAt, LinkedHashMap attemptIds, - LinkedHashMap expectedRevisions) { + LinkedHashMap expectedRevisions, + LinkedHashMap targetPlatforms, + LinkedHashMap targetSessions) { this.id = id; this.type = type; this.configuration = configuration; this.approvalToken = approvalToken; this.createdAt = createdAt; this.states = states; this.results = results; this.leasedAt = leasedAt; this.attemptIds = attemptIds; this.expectedRevisions = expectedRevisions; + this.targetPlatforms = targetPlatforms; + this.targetSessions = targetSessions; } private boolean complete() { return states.values().stream().allMatch("COMPLETE"::equals); } private boolean fileOperation() { diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 78f7002d..8366f07d 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -27,6 +27,7 @@ public final class InMemoryNodeRegistry implements NodeRegistry { public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot", ConfigurationOperations.CAPABILITY, ConfigurationOperations.FILE_CAPABILITY, ConfigurationOperations.QUICK_SETUP_CAPABILITY, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, + ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY, "config.file-comments.v1"); private static final Pattern ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 20087036..45ab9ad5 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -388,6 +388,7 @@ private void route(HttpExchange exchange) throws IOException { send(exchange, 200, Map.of("items", page.items(), "offset", offset, "limit", limit, "registryRevision", registryPage.revision(), "total", registryPage.total(), "backendItemsReturned", page.backendItemsReturned(), + "backendItemsTruncatedNodeIds", page.backendItemsTruncatedNodeIds(), "backendItemsTruncated", page.backendItemsTruncated())); return; } @@ -554,22 +555,24 @@ private void handlePasswordLogin(HttpExchange exchange, PasswordRequest request, } static BackendPage boundedNodePage(List nodes) { - if (nodes.isEmpty()) return new BackendPage(List.of(), 0, false); + if (nodes.isEmpty()) return new BackendPage(List.of(), 0, false, List.of()); int perNode = Math.min(MAX_BACKENDS_PER_NODE_SUMMARY, Math.max(1, MAX_BACKENDS_PER_NODE_PAGE / nodes.size())); int returned = 0; boolean truncated = false; + java.util.ArrayList truncatedNodeIds = new java.util.ArrayList<>(); java.util.ArrayList items = new java.util.ArrayList<>(nodes.size()); for (NodeStatus node : nodes) { int count = Math.min(perNode, node.backends().size()); returned += count; truncated |= count < node.backends().size(); + if (count < node.backends().size()) truncatedNodeIds.add(node.nodeId()); items.add(new NodeStatus(node.nodeId(), node.sessionId(), node.displayName(), node.platform(), node.pluginVersion(), node.protocolVersion(), node.advertisedCapabilities(), node.acceptedCapabilities(), node.detectedPlugins(), node.backends().subList(0, count), node.snapshotSequence(), node.lastSeen(), node.lastAuthenticatedUpdate(), node.online())); } - return new BackendPage(List.copyOf(items), returned, truncated); + return new BackendPage(List.copyOf(items), returned, truncated, List.copyOf(truncatedNodeIds)); } private T read(HttpExchange exchange, Class type) throws IOException { @@ -861,7 +864,8 @@ private record WebResource(String classpath, String contentType) { } private record PasswordRequest(String password) { } private record SetupRequest(String setupCode, String password) { } private record EnrollmentRequest(String nodeId) { } - record BackendPage(List items, int backendItemsReturned, boolean backendItemsTruncated) { } + record BackendPage(List items, int backendItemsReturned, boolean backendItemsTruncated, + List backendItemsTruncatedNodeIds) { } static final class AuthFailureLimiter { private final java.util.function.LongSupplier nanoTime; diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java index 056fd4ea..70ce2118 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -11,6 +11,8 @@ public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, public static final String FILE = "file"; public static final String QUICK_SETUP = "quick-setup"; public static final String VOTE_SITES_SYNC = "sync-vote-sites"; + public static final String COMMUNICATION_TEST = "communication-test"; + public static final String PROXY_METHOD = "proxy-method"; public static final int MAX_CONTENT = 512 * 1024; public ManagedConfiguration { @@ -60,6 +62,16 @@ public void validateProposal() { && (options.size() != 1 || !options.containsKey("sourceContent"))) { throw new IllegalArgumentException("VoteSites sync requires sourceContent"); } + if (QUICK_SETUP.equals(domain) && COMMUNICATION_TEST.equals(preset) + && (options.size() != 1 || options.get("server") == null + || !options.get("server").matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"))) { + throw new IllegalArgumentException("communication test requires one valid server"); + } + if (QUICK_SETUP.equals(domain) && PROXY_METHOD.equals(preset) + && (options.size() != 1 || !List.of("PLUGINMESSAGING", "REDIS", "MQTT", "SOCKETS", "MYSQL") + .contains(options.get("method")))) { + throw new IllegalArgumentException("proxy method requires one supported method"); + } } public String capability() { @@ -67,7 +79,9 @@ public String capability() { case PROXY_ROUTING -> "config.proxy-routing.v1"; case FILE -> "config.files.v1"; case QUICK_SETUP -> VOTE_SITES_SYNC.equals(preset) - ? "config.vote-sites-sync.v1" : "config.quick-setup.v1"; + ? "config.vote-sites-sync.v1" : COMMUNICATION_TEST.equals(preset) + ? "config.transport-test.v1" : PROXY_METHOD.equals(preset) + ? "config.proxy-method.v1" : "config.quick-setup.v1"; default -> throw new IllegalStateException("unsupported configuration domain"); }; } diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index deaeac71..620cafb1 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -156,7 +156,8 @@ select { width: 100%; margin-bottom: 16px; } .check-row { display: flex; align-items: center; gap: 10px; } label span { color: var(--muted); font-weight: 400; } .operation-actions { display: flex; flex-wrap: wrap; gap: 9px; } -#operation-status, #file-operation-status, #quick-operation-status, #vote-sites-sync-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid var(--border); border-radius: 9px; background: #050c16; color: #c6d5e8; white-space: pre-wrap; } +.method-buttons { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 9px; } +#operation-status, #file-operation-status, #quick-operation-status, #vote-sites-sync-status, #transport-test-status, #proxy-method-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid var(--border); border-radius: 9px; background: #050c16; color: #c6d5e8; white-space: pre-wrap; } #file-operation-status { margin: 0; border: 0; border-top: 1px solid var(--border); border-radius: 0; } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin: 16px 0; } .form-grid label { color: var(--muted); } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 4d2cb49f..15283f8a 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -83,6 +83,15 @@ const voteSitesSyncCapability = document.querySelector('#vote-sites-sync-capabil const previewVoteSitesSync = document.querySelector('#preview-vote-sites-sync'); const applyVoteSitesSync = document.querySelector('#apply-vote-sites-sync'); const voteSitesSyncStatus = document.querySelector('#vote-sites-sync-status'); +const transportTestProxy = document.querySelector('#transport-test-proxy'); +const transportTestBackend = document.querySelector('#transport-test-backend'); +const transportTestCapability = document.querySelector('#transport-test-capability'); +const runTransportTest = document.querySelector('#run-transport-test'); +const transportTestStatus = document.querySelector('#transport-test-status'); +const proxyMethodProxy = document.querySelector('#proxy-method-proxy'); +const proxyMethodCapability = document.querySelector('#proxy-method-capability'); +const proxyMethodButtons = [...document.querySelectorAll('[data-proxy-method]')]; +const proxyMethodStatus = document.querySelector('#proxy-method-status'); const enrollmentCard = document.querySelector('#enrollment-card'); const enrollmentForm = document.querySelector('#enrollment-form'); const enrollmentSubmit = enrollmentForm.querySelector('button[type="submit"]'); @@ -94,6 +103,7 @@ const refreshEnrollments = document.querySelector('#refresh-enrollments'); const PAGE_SIZE = 100; const MAX_CONFIGURATION_TARGETS = 100; const MAX_SYNC_TARGETS = 100; +const MAX_OPERATION_TARGETS = 100; const MAX_REGISTRY_SCAN_ATTEMPTS = 3; let authenticated = false; let csrfToken = ''; @@ -106,6 +116,7 @@ let nodeIndex = new Map(); let enrollmentIds = new Set(); let enrollmentsLoaded = false; let backendTopologyTruncated = false; +let backendTopologyTruncatedNodeIds = new Set(); let approvedPreview = null; let approvedFilePreview = null; let approvedQuickPreview = null; @@ -113,6 +124,9 @@ let approvedVoteSitesPreview = null; let voteSitesSourceId = ''; let voteSitesTargetIds = new Set(); let voteSitesTargetsInitialized = false; +let transportTestProxyId = ''; +let transportTestBackendId = ''; +let proxyMethodProxyId = ''; let nodeCapabilities = new Map(); let nodePlugins = new Map(); let inputGeneration = 0; @@ -123,6 +137,7 @@ let enrollmentInFlight = false; let enrollmentRefreshRequested = false; let enrollmentMutationInFlight = false; let configurationOperationsInFlight = 0; +let proxyMethodWorkflowInFlight = false; function text(element, value) { element.textContent = value; @@ -175,6 +190,9 @@ function applyAuthenticatedSession(body) { voteSitesSourceId = ''; voteSitesTargetIds.clear(); voteSitesTargetsInitialized = false; + transportTestProxyId = ''; + transportTestBackendId = ''; + proxyMethodProxyId = ''; configurationContent.value = ''; inputGeneration++; logout.hidden = false; @@ -213,6 +231,8 @@ function friendlyCapability(capability) { 'config.files.v1': 'Full configuration', 'config.file-comments.v1': 'Comments preserved', 'config.vote-sites-sync.v1': 'VoteSites sync', + 'config.transport-test.v1': 'Communication test', + 'config.proxy-method.v1': 'Proxy method', 'config.quick-setup.v1': 'Quick Setup', 'config.proxy-routing.v1': 'Proxy routing' })[capability]; @@ -574,6 +594,106 @@ function renderVoteSitesSync() { voteSitesSyncCapability.className = `pill ${readyCount > 0 ? 'online' : 'neutral'}`; } +function transportTestProxies() { + return allNodeItems.filter(node => isProxy(node) && node.online && + node.acceptedCapabilities.includes('config.transport-test.v1')); +} + +function renderTransportTest() { + const proxies = transportTestProxies(); + if (!proxies.some(node => node.nodeId === transportTestProxyId)) { + transportTestProxyId = proxies.find(node => node.nodeId === selectedServerId)?.nodeId || proxies[0]?.nodeId || ''; + transportTestBackendId = ''; + } + const proxy = proxies.find(node => node.nodeId === transportTestProxyId); + const reported = Array.isArray(proxy?.backends) ? proxy.backends : []; + const backendChoices = new Map(reported.map(backend => [backend.backendId, backend])); + allNodeItems.filter(isBackend).forEach(node => { + if (!backendChoices.has(node.nodeId)) backendChoices.set(node.nodeId, + {backendId: node.nodeId, displayName: node.displayName}); + }); + const backends = [...backendChoices.values()]; + if (!backends.some(backend => backend.backendId === transportTestBackendId)) { + transportTestBackendId = backends[0]?.backendId || ''; + } + + const proxyPlaceholder = text(document.createElement('option'), proxies.length ? 'Choose a proxy' : 'No capable proxies'); + proxyPlaceholder.value = ''; + transportTestProxy.replaceChildren(proxyPlaceholder, ...proxies.map(node => { + const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); + option.value = node.nodeId; + return option; + })); + transportTestProxy.value = transportTestProxyId; + + const backendPlaceholder = text(document.createElement('option'), backends.length ? 'Choose a backend' : 'No backends reported'); + backendPlaceholder.value = ''; + transportTestBackend.replaceChildren(backendPlaceholder, ...backends.map(backend => { + const option = text(document.createElement('option'), `${backend.displayName} · ${backend.backendId}`); + option.value = backend.backendId; + return option; + })); + transportTestBackend.value = transportTestBackendId; + + text(transportTestCapability, proxies.length > 0 + ? `${proxies.length} test-capable ${proxies.length === 1 ? 'proxy' : 'proxies'}` : 'Waiting for a capable proxy'); + transportTestCapability.className = `pill ${proxies.length > 0 ? 'online' : 'neutral'}`; +} + +function proxyMethodCandidates() { + return allNodeItems.filter(node => isProxy(node) && node.online && + node.acceptedCapabilities.includes('config.proxy-method.v1')); +} + +function proxyMethodNetworkFor(items, truncatedNodeIds, proxyId) { + const index = new Map(items.map(node => [node.nodeId, node])); + const proxy = index.get(proxyId); + const proxyReady = Boolean(proxy?.online && proxy.acceptedCapabilities.includes('config.proxy-method.v1')); + const reported = Array.isArray(proxy?.backends) ? proxy.backends : []; + const backends = reported.map(backend => index.get(backend.backendId)).filter(Boolean); + const unavailable = reported.filter(backend => { + const node = index.get(backend.backendId); + return !node || !isBackend(node) || !node.online || !node.acceptedCapabilities.includes('config.proxy-method.v1'); + }); + return {proxy, proxyReady, reported, backends, unavailable, topologyComplete: !truncatedNodeIds.has(proxyId), + nodeIds: proxy ? [proxy.nodeId, ...backends.map(node => node.nodeId)] : []}; +} + +function proxyMethodNetwork() { + return proxyMethodNetworkFor(allNodeItems, backendTopologyTruncatedNodeIds, proxyMethodProxyId); +} + +function proxyMethodNetworkSignature(network) { + return JSON.stringify({proxyReady: network.proxyReady, topologyComplete: network.topologyComplete, nodeIds: [...network.nodeIds].sort(), + unavailable: network.unavailable.map(backend => backend.backendId).sort()}); +} + +function renderProxyMethod() { + const proxies = proxyMethodCandidates(); + if (!proxies.some(node => node.nodeId === proxyMethodProxyId)) { + proxyMethodProxyId = proxies.find(node => node.nodeId === selectedServerId)?.nodeId || proxies[0]?.nodeId || ''; + } + const placeholder = text(document.createElement('option'), proxies.length ? 'Choose a proxy' : 'No capable proxies'); + placeholder.value = ''; + proxyMethodProxy.replaceChildren(placeholder, ...proxies.map(node => { + const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); + option.value = node.nodeId; + return option; + })); + proxyMethodProxy.value = proxyMethodProxyId; + const network = proxyMethodNetwork(); + const ready = network.proxyReady && network.topologyComplete && network.reported.length > 0 && + network.nodeIds.length <= MAX_OPERATION_TARGETS && network.unavailable.length === 0; + const description = !network.proxyReady ? 'Waiting for a connected, capable proxy' + : !network.topologyComplete ? 'Backend topology is truncated; switching disabled' + : network.nodeIds.length > MAX_OPERATION_TARGETS ? `Network exceeds the ${MAX_OPERATION_TARGETS}-node operation limit` + : network.reported.length === 0 ? 'No backends reported' + : network.unavailable.length > 0 ? `${network.unavailable.length} backends unavailable` + : `${network.nodeIds.length} nodes ready`; + text(proxyMethodCapability, description); + proxyMethodCapability.className = `pill ${ready ? 'online' : 'neutral'}`; +} + function renderNodeViews() { nodes.replaceChildren(); nodes.classList.toggle('empty', visibleNodeItems.length === 0); @@ -586,6 +706,8 @@ function renderNodeViews() { renderTopology(); renderSelectedServer(); renderVoteSitesSync(); + renderTransportTest(); + renderProxyMethod(); } function resetServerConfigurationForms(status) { @@ -608,7 +730,7 @@ function selectPrimaryServer(nodeId) { renderNodeViews(); } -function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) { +function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 || proxyMethodWorkflowInFlight) { const primaryCapabilities = nodeCapabilities.get(selectedServerId) || []; const routingReady = authenticated && primaryCapabilities.includes('config.proxy-routing.v1') && targets('config.proxy-routing.v1').length > 0 && !busy; @@ -627,6 +749,11 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; previewVoteSitesSync.disabled = !voteSitesReady; applyVoteSitesSync.disabled = !voteSitesReady || !approvedVoteSitesPreview; + runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendId || busy; + const methodNetwork = proxyMethodNetwork(); + const methodReady = authenticated && methodNetwork.proxyReady && methodNetwork.topologyComplete && methodNetwork.reported.length > 0 && + methodNetwork.nodeIds.length <= MAX_OPERATION_TARGETS && methodNetwork.unavailable.length === 0 && !busy; + proxyMethodButtons.forEach(button => { button.disabled = !methodReady; }); } function targets(capability) { @@ -751,12 +878,16 @@ function discardAuthenticationState(reason) { voteSitesSourceId = ''; voteSitesTargetIds.clear(); voteSitesTargetsInitialized = false; + transportTestProxyId = ''; + transportTestBackendId = ''; + proxyMethodProxyId = ''; selectedServerId = ''; visibleNodeItems = []; allNodeItems = []; enrollmentIds.clear(); enrollmentsLoaded = false; backendTopologyTruncated = false; + backendTopologyTruncatedNodeIds = new Set(); nodeIndex.clear(); nodeCapabilities.clear(); nodePlugins.clear(); @@ -770,6 +901,8 @@ function discardAuthenticationState(reason) { text(fileOperationStatus, ''); text(quickOperationStatus, ''); text(voteSitesSyncStatus, ''); + text(transportTestStatus, ''); + text(proxyMethodStatus, ''); nodes.replaceChildren(); nodes.classList.add('empty'); text(nodes, 'Authenticate to view the network.'); @@ -885,6 +1018,7 @@ async function loadAllNodes() { const items = []; const pageMetadata = new Map(); const ids = new Set(); + const truncatedNodeIds = new Set(); let revision = null; let total = null; let truncated = false; @@ -904,11 +1038,12 @@ async function loadAllNodes() { page.items.forEach(node => ids.add(node.nodeId)); items.push(...page.items); truncated ||= Boolean(page.backendItemsTruncated); + (page.backendItemsTruncatedNodeIds || []).forEach(nodeId => truncatedNodeIds.add(nodeId)); pageMetadata.set(offset, { backendItemsReturned: page.backendItemsReturned, backendItemsTruncated: Boolean(page.backendItemsTruncated) }); - if (items.length === total) return {items, truncated, pageMetadata}; + if (items.length === total) return {items, truncated, truncatedNodeIds, pageMetadata}; if (page.items.length === 0 || items.length > total) { throw Object.assign(new Error('Node registry changed during pagination.'), {code: 'REGISTRY_CHANGED'}); } @@ -931,6 +1066,7 @@ async function loadNodes() { visibleNodeItems = registry.items.slice(pageOffset, pageOffset + PAGE_SIZE); allNodeItems = registry.items; backendTopologyTruncated = registry.truncated; + backendTopologyTruncatedNodeIds = registry.truncatedNodeIds; nodeIndex = new Map(registry.items.map(node => [node.nodeId, node])); const previousCapabilities = nodeCapabilities; nodeCapabilities = new Map(registry.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); @@ -1354,6 +1490,82 @@ applyVoteSitesSync.addEventListener('click', async () => { } catch (error) { text(voteSitesSyncStatus, error.message); } }); +transportTestProxy.addEventListener('change', () => { + transportTestProxyId = transportTestProxy.value; + transportTestBackendId = ''; + renderTransportTest(); + text(transportTestStatus, 'Choose a backend, then run a live non-vote communication test.'); + updateConfigurationButtons(); +}); + +transportTestBackend.addEventListener('change', () => { + transportTestBackendId = transportTestBackend.value; + text(transportTestStatus, 'Ready to test the active VotingPlugin transport.'); + updateConfigurationButtons(); +}); + +runTransportTest.addEventListener('click', async () => { + const proxyId = transportTestProxyId; + const server = transportTestBackendId; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/read', { + nodeIds: [proxyId], + configuration: {domain: 'quick-setup', preset: 'communication-test', options: {server}} + }, transportTestStatus); + if (proxyId !== transportTestProxyId || server !== transportTestBackendId) { + text(transportTestStatus, 'The proxy or backend changed while testing. Run the test again.'); + return; + } + text(transportTestStatus, operationSummary(operation)); + } catch (error) { text(transportTestStatus, error.message); } +}); + +proxyMethodProxy.addEventListener('change', () => { + proxyMethodProxyId = proxyMethodProxy.value; + renderProxyMethod(); + const network = proxyMethodNetwork(); + text(proxyMethodStatus, network.unavailable.length > 0 + ? `Cannot switch yet. Enroll, update, and connect: ${network.unavailable.map(backend => backend.displayName).join(', ')}.` + : 'Choose a method to preflight every node before applying.'); + updateConfigurationButtons(); +}); + +proxyMethodButtons.forEach(button => button.addEventListener('click', async () => { + const method = button.dataset.proxyMethod; + const network = proxyMethodNetwork(); + if (!network.proxyReady || !network.topologyComplete || network.reported.length === 0 || + network.nodeIds.length > MAX_OPERATION_TARGETS || network.unavailable.length > 0 || proxyMethodWorkflowInFlight) return; + proxyMethodWorkflowInFlight = true; + updateConfigurationButtons(); + try { + const preview = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds: network.nodeIds, + configuration: {domain: 'quick-setup', preset: 'proxy-method', options: {method}} + }, proxyMethodStatus); + if (preview.state !== 'SUCCEEDED' || !preview.approvalToken) return; + if (!window.confirm(`Switch ${network.nodeIds.length} VotingPlugin nodes to ${method}? ` + + 'The proxy runtime will restart after Control records the result.')) return; + const refreshedRegistry = await loadAllNodes(); + const refreshedNetwork = proxyMethodNetworkFor(refreshedRegistry.items, refreshedRegistry.truncatedNodeIds, + proxyMethodProxyId); + if (proxyMethodProxyId !== network.proxy.nodeId || + proxyMethodNetworkSignature(refreshedNetwork) !== proxyMethodNetworkSignature(network) || + refreshedNetwork.nodeIds.length > MAX_OPERATION_TARGETS) { + text(proxyMethodStatus, 'The complete proxy topology changed while preflighting. Refresh and choose the method again.'); + return; + } + const applied = await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: preview.operationId, approvalToken: preview.approvalToken + }, proxyMethodStatus); + text(proxyMethodStatus, `${operationSummary(applied)}\nReconnect the proxy if needed, then run the communication test.`); + } catch (error) { + text(proxyMethodStatus, error.message); + } finally { + proxyMethodWorkflowInFlight = false; + updateConfigurationButtons(); + } +})); + [configurationContent, quickName, quickService, quickUrl, quickDelay, quickRewardScope, quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake, quickHideSiteWarning, quickDisableUpdates, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 45f8e295..cca2f4d9 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -136,12 +136,51 @@

Servers

Global VotingPlugin settings

Network

-

Routing currently uses the existing proxy capability. Method checks, transport tests, and VoteSites sync will appear here as nodes advertise those capabilities.

+

Manage network-wide VotingPlugin behavior and verify the live proxy-to-backend transport without sending a vote.

-
-
NextProxy methodOne-click selection with required-setting checks
-
NextCommunication testNon-vote connectivity checks without rewards
+
+
+
+

Network transport

+

Proxy method

+

Choose a method to preflight its required settings on the proxy and every reported backend, then approve the coordinated switch.

+
+ Waiting for a complete network +
+ +
+ + + + + +
+
Choose a proxy. All of its reported backends must be online and method-capable.
+
+
+
+
+

Live diagnostic

+

Test proxy communication

+

Send a correlated status request over the active VotingPlugin transport. No vote, totals, commands, or rewards are processed.

+
+ Waiting for a capable proxy +
+
+ + +
+
+ +
+
Choose a proxy and one of its configured backends.
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 7882ffdc..b1ce356f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -8,8 +8,10 @@ import com.bencodez.votingplugin.control.protocol.ConfigurationTask; import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; +import com.bencodez.votingplugin.control.protocol.BackendServerIdentity; import com.bencodez.votingplugin.control.protocol.NodeRegistration; import com.bencodez.votingplugin.control.protocol.ProxyRoutingConfiguration; +import com.bencodez.votingplugin.control.protocol.PresenceSnapshot; import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; import com.bencodez.votingplugin.control.protocol.Heartbeat; import com.fasterxml.jackson.databind.JsonNode; @@ -484,6 +486,207 @@ class ConfigurationOperationsTest { assertFalse(completed.results().get("backend-a").configuration().options().containsKey("sourceContent")); } + @Test void communicationTestUsesItsOwnReadOnlyCapability() throws Exception { + ManagedConfiguration diagnostic = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.COMMUNICATION_TEST, Map.of("server", "survival")); + assertEquals(ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, diagnostic.capability()); + + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "VELOCITY", "test", 1, + Set.of(ConfigurationOperations.TRANSPORT_TEST_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + + ConfigurationOperations.OperationView read = operations.createRead(List.of("proxy-a"), diagnostic); + ConfigurationTask task = operations.claim("proxy-a", session); + assertEquals("READ", task.type()); + assertEquals("communication-test", task.configuration().preset()); + assertEquals("survival", task.configuration().options().get("server")); + assertEquals(read.operationId(), task.operationId()); + assertThrows(IllegalArgumentException.class, () -> new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, + null, List.of(), null, null, ManagedConfiguration.COMMUNICATION_TEST, Map.of()).validateProposal()); + } + + @Test void proxyMethodUsesItsOwnCoordinatedCapability() throws Exception { + ManagedConfiguration method = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "REDIS")); + assertEquals(ConfigurationOperations.PROXY_METHOD_CAPABILITY, method.capability()); + + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID proxySession = UUID.randomUUID(); + UUID backendSession = UUID.randomUUID(); + registry.register(new NodeRegistration("proxy-a", proxySession, "Proxy A", "VELOCITY", "test", 1, + Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY), Set.of())); + registry.replacePresence("proxy-a", new PresenceSnapshot(proxySession, 1, 1, + List.of(new BackendServerIdentity("lobby", "Lobby", true, true, 0)))); + registry.register(new NodeRegistration("lobby", backendSession, "Lobby", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + + ConfigurationOperations.OperationView preview = operations.createPreview(List.of("proxy-a", "lobby"), method); + assertEquals("proxy-method", operations.claim("proxy-a", proxySession).configuration().preset()); + assertEquals("proxy-method", operations.claim("lobby", backendSession).configuration().preset()); + assertEquals(2, preview.nodeStates().size()); + assertThrows(IllegalArgumentException.class, () -> new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, + null, List.of(), null, null, ManagedConfiguration.PROXY_METHOD, + Map.of("method", "UNKNOWN")).validateProposal()); + } + + @Test void proxyMethodApplyKeepsEvictedBackendsAsFailedDependencies() throws Exception { + ProxyMethodFixture fixture = proxyMethodApplyFixture(Duration.ofSeconds(30)); + fixture.clock().advance(Duration.ofSeconds(30)); + fixture.registry().heartbeat("proxy-a", new Heartbeat(fixture.proxySession(), 1, + Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY), Set.of())); + fixture.registry().register(new NodeRegistration("other", UUID.randomUUID(), "Other", "BUKKIT", "test", 1, + Set.of(), Set.of())); + assertEquals(null, fixture.registry().find("lobby")); + + assertEquals(null, fixture.operations().claim("proxy-a", fixture.proxySession())); + + ConfigurationOperations.OperationView result = fixture.operations().get(fixture.applyId()); + assertEquals("COMPLETED_WITH_ERRORS", result.state()); + assertEquals("CAPABILITY_LOST", result.results().get("lobby").code()); + assertEquals("DEPENDENCY_FAILED", result.results().get("proxy-a").code()); + } + + @Test void proxyMethodApplyDoesNotCancelAnOfflineBackendWithAnActiveLease() throws Exception { + ProxyMethodFixture fixture = proxyMethodApplyFixture(Duration.ofSeconds(30)); + assertNotNull(fixture.operations().claim("lobby", fixture.backendSession())); + fixture.clock().advance(Duration.ofSeconds(31)); + fixture.registry().heartbeat("proxy-a", new Heartbeat(fixture.proxySession(), 1, + Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY), Set.of())); + + assertEquals(null, fixture.operations().claim("proxy-a", fixture.proxySession())); + + ConfigurationOperations.OperationView result = fixture.operations().get(fixture.applyId()); + assertEquals("IN_PROGRESS", result.nodeStates().get("lobby")); + assertEquals("QUEUED", result.nodeStates().get("proxy-a")); + assertFalse(result.results().containsKey("lobby")); + } + + @Test void proxyMethodApplyCancelsAnUnavailableBackendAfterItsLeaseExpires() throws Exception { + ProxyMethodFixture fixture = proxyMethodApplyFixture(Duration.ofSeconds(30)); + assertNotNull(fixture.operations().claim("lobby", fixture.backendSession())); + fixture.clock().advance(Duration.ofMinutes(2)); + fixture.registry().heartbeat("proxy-a", new Heartbeat(fixture.proxySession(), 1, + Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY), Set.of())); + + assertEquals(null, fixture.operations().claim("proxy-a", fixture.proxySession())); + + ConfigurationOperations.OperationView result = fixture.operations().get(fixture.applyId()); + assertEquals("COMPLETED_WITH_ERRORS", result.state()); + assertEquals("CAPABILITY_LOST", result.results().get("lobby").code()); + assertEquals("DEPENDENCY_FAILED", result.results().get("proxy-a").code()); + } + + @Test void proxyMethodApplyRequeuesAnAvailableBackendAfterItsLeaseExpires() throws Exception { + ProxyMethodFixture fixture = proxyMethodApplyFixture(Duration.ofMinutes(5)); + ConfigurationTask first = fixture.operations().claim("lobby", fixture.backendSession()); + fixture.clock().advance(Duration.ofMinutes(2)); + + assertEquals(null, fixture.operations().claim("proxy-a", fixture.proxySession())); + ConfigurationTask retry = fixture.operations().claim("lobby", fixture.backendSession()); + + assertNotNull(retry); + assertFalse(first.attemptId().equals(retry.attemptId())); + assertEquals("IN_PROGRESS", fixture.operations().get(fixture.applyId()).nodeStates().get("lobby")); + } + + @Test void proxyMethodApplyRejectsTopologyChangesAfterApproval() throws Exception { + ProxyMethodFixture fixture = proxyMethodApplyFixture(Duration.ofMinutes(5)); + ConfigurationTask backendApply = fixture.operations().claim("lobby", fixture.backendSession()); + ManagedConfiguration method = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "REDIS")); + fixture.operations().complete(fixture.applyId(), "lobby", new ConfigurationTaskResult( + fixture.backendSession(), true, "OK", "applied", "c".repeat(64), method, List.of(), false, false, + backendApply.attemptId())); + fixture.registry().replacePresence("proxy-a", new PresenceSnapshot(fixture.proxySession(), 2, 2, List.of( + new BackendServerIdentity("lobby", "Lobby", true, true, 0), + new BackendServerIdentity("new-backend", "New Backend", true, true, 0)))); + + assertEquals(null, fixture.operations().claim("proxy-a", fixture.proxySession())); + + ConfigurationOperations.OperationView result = fixture.operations().get(fixture.applyId()); + assertEquals("COMPLETED_WITH_ERRORS", result.state()); + assertEquals("DEPENDENCY_CHANGED", result.results().get("proxy-a").code()); + } + + @Test void proxyMethodApplyRejectsAChangedTargetRole() throws Exception { + ProxyMethodFixture fixture = proxyMethodApplyFixture(Duration.ofMinutes(5)); + ConfigurationTask backendApply = fixture.operations().claim("lobby", fixture.backendSession()); + ManagedConfiguration method = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "REDIS")); + fixture.operations().complete(fixture.applyId(), "lobby", new ConfigurationTaskResult( + fixture.backendSession(), true, "OK", "applied", "c".repeat(64), method, List.of(), false, false, + backendApply.attemptId())); + UUID replacementSession = UUID.randomUUID(); + fixture.registry().register(new NodeRegistration("proxy-a", replacementSession, "Proxy A", "BUKKIT", + "test", 1, Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY), Set.of())); + + assertEquals(null, fixture.operations().claim("proxy-a", replacementSession)); + + ConfigurationOperations.OperationView result = fixture.operations().get(fixture.applyId()); + assertEquals("COMPLETED_WITH_ERRORS", result.state()); + assertEquals("TARGET_CHANGED", result.results().get("proxy-a").code()); + } + + @Test void proxyMethodApplyRevalidatesCompletedBackendIdentityBeforeProxyRelease() throws Exception { + ProxyMethodFixture fixture = proxyMethodApplyFixture(Duration.ofMinutes(5)); + ConfigurationTask backendApply = fixture.operations().claim("lobby", fixture.backendSession()); + ManagedConfiguration method = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "REDIS")); + fixture.operations().complete(fixture.applyId(), "lobby", new ConfigurationTaskResult( + fixture.backendSession(), true, "OK", "applied", "c".repeat(64), method, List.of(), false, false, + backendApply.attemptId())); + fixture.registry().register(new NodeRegistration("lobby", UUID.randomUUID(), "Lobby", "VELOCITY", "test", 1, + Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY), Set.of())); + + assertEquals(null, fixture.operations().claim("proxy-a", fixture.proxySession())); + + ConfigurationOperations.OperationView result = fixture.operations().get(fixture.applyId()); + assertEquals("COMPLETED_WITH_ERRORS", result.state()); + assertEquals("DEPENDENCY_CHANGED", result.results().get("lobby").code()); + assertEquals("DEPENDENCY_FAILED", result.results().get("proxy-a").code()); + } + + @Test void overlappingProxyMethodAppliesAreSerialized() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-08-25T00:00:00Z")); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(5)); + UUID proxySession = UUID.randomUUID(); + UUID backendSession = UUID.randomUUID(); + Set capabilities = Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY); + registry.register(new NodeRegistration("proxy-a", proxySession, "Proxy A", "VELOCITY", "test", 1, + capabilities, Set.of())); + registry.replacePresence("proxy-a", new PresenceSnapshot(proxySession, 1, 1, + List.of(new BackendServerIdentity("lobby", "Lobby", true, true, 0)))); + registry.register(new NodeRegistration("lobby", backendSession, "Lobby", "BUKKIT", "test", 1, + capabilities, Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ManagedConfiguration method = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "REDIS")); + ConfigurationOperations.OperationView firstPreview = operations.createPreview( + List.of("proxy-a", "lobby"), method); + ConfigurationOperations.OperationView secondPreview = operations.createPreview( + List.of("proxy-a", "lobby"), method); + firstPreview = completeProxyMethodPreview(operations, firstPreview, method, proxySession, backendSession, + "a", "b"); + secondPreview = completeProxyMethodPreview(operations, secondPreview, method, proxySession, backendSession, + "c", "d"); + UUID firstApply = operations.createApply(firstPreview.operationId(), firstPreview.approvalToken()).operationId(); + ConfigurationTask backendApply = operations.claim("lobby", backendSession); + operations.complete(firstApply, "lobby", new ConfigurationTaskResult(backendSession, true, "OK", "applied", + "e".repeat(64), method, List.of(), false, false, backendApply.attemptId())); + + ConfigurationOperations.OperationView approvedSecond = secondPreview; + assertEquals("OPERATION_CONFLICT", assertThrows(ValidationException.class, + () -> operations.createApply(approvedSecond.operationId(), approvedSecond.approvalToken())).code()); + } + @Test void fileAndQuickSetupOperationsRequireTheirNegotiatedCapabilities() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); @@ -643,6 +846,50 @@ private static final class MutableClock extends Clock { @Override public Instant instant() { return instant; } } + private ConfigurationOperations.OperationView completeProxyMethodPreview(ConfigurationOperations operations, + ConfigurationOperations.OperationView preview, ManagedConfiguration method, UUID proxySession, + UUID backendSession, String proxyRevision, String backendRevision) { + ConfigurationTask proxyTask = operations.claim("proxy-a", proxySession); + ConfigurationTask backendTask = operations.claim("lobby", backendSession); + operations.complete(preview.operationId(), "proxy-a", new ConfigurationTaskResult(proxySession, true, + "OK", "valid", proxyRevision.repeat(64), method, List.of(), false, false, proxyTask.attemptId())); + return operations.complete(preview.operationId(), "lobby", new ConfigurationTaskResult(backendSession, true, + "OK", "valid", backendRevision.repeat(64), method, List.of(), false, false, + backendTask.attemptId())); + } + + private ProxyMethodFixture proxyMethodApplyFixture(Duration offlineTimeout) throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-08-25T00:00:00Z")); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, offlineTimeout); + UUID proxySession = UUID.randomUUID(); + UUID backendSession = UUID.randomUUID(); + Set capabilities = Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY); + registry.register(new NodeRegistration("proxy-a", proxySession, "Proxy A", "VELOCITY", "test", 1, + capabilities, Set.of())); + registry.replacePresence("proxy-a", new PresenceSnapshot(proxySession, 1, 1, + List.of(new BackendServerIdentity("lobby", "Lobby", true, true, 0)))); + registry.register(new NodeRegistration("lobby", backendSession, "Lobby", "BUKKIT", "test", 1, + capabilities, Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ManagedConfiguration method = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "REDIS")); + ConfigurationOperations.OperationView preview = operations.createPreview(List.of("proxy-a", "lobby"), method); + ConfigurationTask proxyPreview = operations.claim("proxy-a", proxySession); + ConfigurationTask backendPreview = operations.claim("lobby", backendSession); + operations.complete(preview.operationId(), "proxy-a", new ConfigurationTaskResult(proxySession, true, + "OK", "valid", "a".repeat(64), method, List.of(), false, false, proxyPreview.attemptId())); + preview = operations.complete(preview.operationId(), "lobby", new ConfigurationTaskResult(backendSession, + true, "OK", "valid", "b".repeat(64), method, List.of(), false, false, + backendPreview.attemptId())); + UUID applyId = operations.createApply(preview.operationId(), preview.approvalToken()).operationId(); + return new ProxyMethodFixture(clock, registry, operations, proxySession, backendSession, applyId); + } + + private record ProxyMethodFixture(MutableClock clock, InMemoryNodeRegistry registry, + ConfigurationOperations operations, UUID proxySession, + UUID backendSession, UUID applyId) { } + private static NodeRegistration registration(UUID sessionId) { return new NodeRegistration("proxy-a", sessionId, "Proxy A", "VELOCITY", "test", 1, Set.of("presence.snapshot", ConfigurationOperations.CAPABILITY), Set.of("presence.snapshot")); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index e3658aac..59b8cc18 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -86,6 +86,12 @@ class ControlHttpServerTest { assertTrue(script.body().contains("authenticationGeneration")); assertTrue(script.body().contains("if (loginInFlight) return")); assertTrue(script.body().contains("backendItemsTruncated")); + assertTrue(script.body().contains("topologyComplete: !truncatedNodeIds.has(proxyId)")); + assertTrue(script.body().contains("proxyReady: network.proxyReady")); + assertTrue(script.body().contains("option.value = backend.backendId")); + assertTrue(script.body().contains("return allNodeItems.filter(node => isProxy(node)")); + assertTrue(script.body().contains("MAX_OPERATION_TARGETS = 100")); + assertTrue(script.body().contains("proxyMethodNetworkSignature(refreshedNetwork)")); assertTrue(web.body().contains("Easy vote reward")); assertTrue(web.body().contains("First-run setup")); assertTrue(web.body().contains("Node enrollment")); @@ -116,12 +122,18 @@ class ControlHttpServerTest { assertTrue(script.body().contains("'config.files.v1': 'Full configuration'")); assertTrue(script.body().contains("'config.file-comments.v1': 'Comments preserved'")); assertTrue(script.body().contains("'config.vote-sites-sync.v1': 'VoteSites sync'")); + assertTrue(script.body().contains("'config.transport-test.v1': 'Communication test'")); + assertTrue(script.body().contains("'config.proxy-method.v1': 'Proxy method'")); assertTrue(script.body().contains("preset: 'sync-vote-sites'")); assertTrue(script.body().contains("return allNodeItems.filter(node => isBackend(node)")); assertTrue(script.body().contains("A sync target became unavailable")); assertTrue(script.body().contains("MAX_SYNC_TARGETS = 100")); assertTrue(script.body().contains("The sync source became unavailable")); assertTrue(script.body().contains("sourceContent: source")); + assertTrue(script.body().contains("preset: 'communication-test'")); + assertTrue(script.body().contains("runTransportTest.addEventListener")); + assertTrue(script.body().contains("preset: 'proxy-method'")); + assertTrue(script.body().contains("proxyMethodButtons.forEach")); assertTrue(script.body().contains("handleEditorKeydown")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); @@ -156,6 +168,7 @@ class ControlHttpServerTest { assertTrue(page.backendItemsTruncated()); assertTrue(page.backendItemsReturned() <= ControlHttpServer.MAX_BACKENDS_PER_NODE_PAGE); assertEquals(100, page.items().size()); + assertEquals(100, page.backendItemsTruncatedNodeIds().size()); assertTrue(page.items().stream().allMatch(node -> node.backends().size() == 40)); }