From 9c10175a312539bccc6e6cd0389fc4a4e386ad6d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:41:33 -0600 Subject: [PATCH 01/10] Stage VotingPlugin updates from Control --- AGENTS.md | 5 + README.md | 20 +- docs/control-management.md | 18 +- .../control/ControlApplication.java | 9 +- .../control/domain/DeploymentOperations.java | 599 ++++++++++++++++++ .../control/domain/InMemoryNodeRegistry.java | 2 +- .../control/http/ControlHttpServer.java | 207 +++++- .../control/protocol/DeploymentRequest.java | 37 ++ .../control/protocol/DeploymentResult.java | 17 + .../control/protocol/DeploymentTask.java | 12 + .../protocol/DeploymentTaskResult.java | 7 + src/main/resources/web/app.js | 145 ++++- src/main/resources/web/index.html | 9 + .../domain/DeploymentOperationsTest.java | 238 +++++++ .../control/http/ControlHttpServerTest.java | 94 ++- 15 files changed, 1396 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/DeploymentOperations.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentRequest.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentResult.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentTask.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentTaskResult.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/domain/DeploymentOperationsTest.java diff --git a/AGENTS.md b/AGENTS.md index be530aa6..85c3314a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,11 @@ The CI definition is `.github/workflows/maven.yml`. The shaded runnable artifact inspection gating on `VoteLogging.Enabled`, document that a restart is required after either toggle, and do not claim enabled/available/readable are interchangeable states. Serialize APPLY operations and retries that share a target so retained creation order is also successful completion order for restart-session warnings. +14. `plugin.deploy.v1` stages only a verified VotingPlugin JAR for the next process restart. Keep uploads and expanded ZIP + content bounded, content-addressed, private, symlink-safe, and atomically published. A node download requires its exact + live session, attempt, and unexpired lease; each node independently verifies the hash and plugin identity. Never hot + reload, automatically restart, or include older nodes that did not negotiate the exact capability. A Control restart + invalidates in-progress download authority and requires an explicit retry. ## Paired protocol workflow diff --git a/README.md b/README.md index 945f11c7..308ac1ea 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ VotingPlugin Control is a separate, local-first administration service for a VotingPlugin network. It provides authenticated discovery of multiple BungeeCord and Velocity proxies, direct Bukkit backend enrollment, full VotingPlugin -YAML configuration control, guided setup, redacted snapshots/drift comparison, durable operation history, and typed -read-only vote/data diagnostics. The local WebUI uses the same versioned API. Control does not process votes, and +YAML configuration control, guided setup, redacted snapshots/drift comparison, durable operation history, verified +next-restart VotingPlugin JAR staging, and typed read-only vote/data diagnostics. The local WebUI uses the same versioned API. Control does not process votes, and VotingPlugin does not depend on it for startup, joins, routing, rewards, or shutdown. Maintainers and coding agents should read [AGENTS.md](AGENTS.md). The complete management, inspection, limits, and threat @@ -158,6 +158,13 @@ All errors have the stable form: | `POST` | `/api/v1/nodes/{nodeId}/inspections/{inspectionId}/result` | matching node | Complete that inspection attempt | | `GET`, `POST` | `/api/v1/snapshots` | admin or WebUI session; CSRF for POST | List summaries or save a named snapshot from a completed file read | | `GET` | `/api/v1/snapshots/{snapshotId}` | admin or WebUI session | Load one durable snapshot's full redacted file content | +| `POST` | `/api/v1/artifacts/votingplugin` | admin or WebUI session + CSRF | Stream and verify one bounded VotingPlugin JAR into private content-addressed storage | +| `GET`, `POST` | `/api/v1/deployments` | admin or WebUI session; CSRF for POST | List deployment history or stage a verified artifact on explicit `plugin.deploy.v1` nodes | +| `GET` | `/api/v1/deployments/{deploymentId}` | admin or WebUI session | Read durable per-node staging state | +| `POST` | `/api/v1/deployments/{deploymentId}/retry` | admin or WebUI session + CSRF | Retry only failed, currently eligible nodes as a new operation | +| `POST` | `/api/v1/nodes/{nodeId}/deployments` | matching node | Claim one session-pinned deployment task, or `204` | +| `GET` | `/api/v1/nodes/{nodeId}/deployments/{deploymentId}/artifact` | matching node + exact session/attempt headers | Download the claimed verified JAR during its lease | +| `POST` | `/api/v1/nodes/{nodeId}/deployments/{deploymentId}/result` | matching node | Complete the exact staging attempt | Routes are exact. Child suffixes do not inherit a handler, every known endpoint has an intentional method/structured 405, and all unknown endpoints return a structured 404. @@ -195,9 +202,16 @@ Control and VotingPlugin both enforce fixed quick-setup preset/option schemas; u than becoming arbitrary YAML writes. The WebUI settings catalog is a static versioned reference over these typed paths, not a generic setting API. -Read actions load only the primary server shown in the configuration header. Preview and apply still cover every server +Opening Settings or changing the selected server automatically reads that server's current configuration. A failed read +clears the editor and exposes an inline retry; successful applies invalidate cached values and read the confirmed state +again. Read actions load only the primary server shown in the configuration header. Preview and apply still cover every server explicitly included in configuration changes, so one slow secondary node does not delay opening the editor or guided form. +`plugin.deploy.v1` is additive and exact: older nodes remain connected but are excluded from JAR staging. The WebUI uploads +at most 64 MiB, Control validates the ZIP structure and root `plugin.yml`, and every node re-verifies the SHA-256 before +staging. Staging never reloads or restarts a server; success is reported as `RESTART_REQUIRED`. Interrupted Control attempts +become failed durable history and require an explicit retry, preventing a pre-restart lease from authorizing a download. + `data.inspect.v1` is a separate read-only lane for overview, vote-site health (including persisted unconfigured service observations), exact-player data, bounded VoteLog summary/search/correlation trace, non-creating service-site resolution, no-side-effect reward simulation, and redacted diagnostics. It accepts only allow-listed string filters and bounded result diff --git a/docs/control-management.md b/docs/control-management.md index 93e9abca..1f5b000c 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -45,6 +45,8 @@ Control accepts only the intersection with its own allow-list. | `config.vote-sites-sync.v1` | Reward-safe VoteSites merge from one backend to selected targets | | `config.transport-test.v1` | Typed, bounded proxy-to-backend communication check | | `config.proxy-method.v1` | Coordinated preview/apply and acknowledged runtime replacement for a supported network proxy method | +| `config.proxy-method.v2` | The same coordinated contract with the optional HTTP method represented explicitly | +| `plugin.deploy.v1` | Verify and stage a VotingPlugin JAR for the node's next process restart; never hot reload or restart it | | `data.inspect.v1` | Typed read-only data, health, simulation, and diagnostics requests | Do not infer support from plugin version strings. Check `acceptedCapabilities` for the exact capability. @@ -60,7 +62,8 @@ authenticated, CSRF-protected endpoint and node capability checks as an external | Overview dashboard | Builds six summary cards, Attention Required, quick actions, topology, logged-service activity, and recent operation activity from the node registry plus existing `overview`, `vote-site-health`, and `vote-log-summary` inspections | Read-only aggregation; failed, incomplete, or malformed sub-inspections produce a warning rather than a Healthy claim; disconnected registered nodes produce a warning; unknown Minecraft presence is not treated as offline; VoteLog counts are labeled as logged events; actions only open existing safe workflows rather than inventing auto-fixes | | Network Doctor | Runs `diagnostics` (which includes the overview fields), combines node health with Control's current topology, and displays checks for connector, configuration, Votifier, vote sites, rewards, logging, and proxy topology | Read-only; “healthy” is bounded reported state, not a synthetic vote | | Diagnostics download | Downloads the last Network Doctor result as local JSON | Redacted status bundle only; no raw configuration/logs/player records/infrastructure secrets | -| Activity | Loads the newest 50 live/recovered operation views, labels phases, lineage, reload/rollback, resumes eligible guided preview approvals, and offers retry only when `retryable` | Recovered history cannot be retried; approval is single-use and apply is CSRF-protected; proxy-method apply needs a new preview | +| Activity | Loads the newest 50 live/recovered configuration operation views, labels phases, lineage, reload/rollback, resumes eligible guided preview approvals, and offers retry only when `retryable` | Recovered configuration history cannot be retried; approval is single-use and apply is CSRF-protected; proxy-method apply needs a new preview | +| Plugin update | Uploads one bounded JAR, shows the deployment-capable subset, and stages it on those nodes | SHA-256 and JAR identity are verified; session/attempt leases authorize downloads; Control never automatically reloads or restarts nodes; private storage is capped at 32 artifacts / 512 MiB and evicts only artifacts not referenced by retained deployment history | | Fast file reads | Caches a successful file read for 30 seconds by node ID, node session, and file | Browser memory only; cleared on logout and successful relevant writes; session binding prevents reuse after reconnect | | Full-YAML drafts | Keeps unsaved editor contents during a registry refresh | A dirty draft is bound to its source node, session, and file; it cannot preview or apply after that session changes. The operator must explicitly confirm a current-file read/reload, which discards the retained draft and rebinds the editor. | | Proxy configuration | Opens `bungeeconfig.yml` only for the selected online proxy that negotiated `config.proxy-files.v1` | Fixed one-file capability, never proxy file browsing; redacted READ, PREVIEW, and one-time approved APPLY still apply | @@ -77,7 +80,11 @@ authenticated, CSRF-protected endpoint and node capability checks as an external The Setup tab replaces the former “Quick Setup” framing but retains existing typed presets, VoteSites sync, detected-plugin command suggestions, preview, approval, node backup, reload, and rollback. Setup profiles are convenience input only; live -values should be loaded before modifying an existing configuration. +values should be loaded before modifying an existing configuration. The normal Settings flow performs that read +automatically on entry and whenever the selected node or file changes. It clears the prior node's editable state before the +request, deduplicates navigation-triggered reads, caches successful reads briefly by node session and file, and +invalidates/re-reads after a successful apply. Failure leaves no stale editable value and exposes an inline Retry action. +None of these reads can trigger APPLY. ## Configuration operations @@ -438,6 +445,13 @@ node resources require the bearer credential bound to the path node ID. | `POST` | `/api/v1/operations/{operationId}/retry` | admin/browser + CSRF | Reissue safe failed work as a new operation | | `POST` | `/api/v1/nodes/{nodeId}/operations` | matching node | Claim one configuration task or `204` | | `POST` | `/api/v1/nodes/{nodeId}/operations/{operationId}/result` | matching node | Complete one claimed configuration task | +| `POST` | `/api/v1/artifacts/votingplugin` | admin/browser + CSRF | Stream, hash, inspect, and atomically retain one VotingPlugin JAR (64 MiB maximum) | +| `GET`, `POST` | `/api/v1/deployments` | admin/browser; CSRF for POST | List durable staging operations or target an exact verified artifact | +| `GET` | `/api/v1/deployments/{deploymentId}` | admin/browser | Read per-node staging state | +| `POST` | `/api/v1/deployments/{deploymentId}/retry` | admin/browser + CSRF | Create a new operation for currently eligible failed targets only | +| `POST` | `/api/v1/nodes/{nodeId}/deployments` | matching node | Claim one session-pinned deployment task | +| `GET` | `/api/v1/nodes/{nodeId}/deployments/{deploymentId}/artifact` | matching node + session/attempt headers | Stream the verified artifact during the exact live lease | +| `POST` | `/api/v1/nodes/{nodeId}/deployments/{deploymentId}/result` | matching node | Complete the exact attempt with staged/restart-required or a bounded failure | | `POST` | `/api/v1/inspections` | admin/browser + CSRF | Queue one typed read-only query | | `GET` | `/api/v1/inspections/{inspectionId}` | admin/browser | Read short-lived inspection status/result | | `POST` | `/api/v1/nodes/{nodeId}/inspections` | matching node | Claim one inspection or `204` | diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 64f171b9..0726aed5 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -1,12 +1,14 @@ package com.bencodez.votingplugin.control; import com.bencodez.votingplugin.control.auth.CredentialStore; +import com.bencodez.votingplugin.control.artifact.ArtifactStore; import com.bencodez.votingplugin.control.domain.InMemoryNodeRegistry; import com.bencodez.votingplugin.control.domain.ConfigurationAuditLog; import com.bencodez.votingplugin.control.domain.ConfigurationOperations; import com.bencodez.votingplugin.control.domain.ConfigurationOperationJournal; import com.bencodez.votingplugin.control.domain.ConfigurationSnapshots; import com.bencodez.votingplugin.control.domain.InspectionOperations; +import com.bencodez.votingplugin.control.domain.DeploymentOperations; import com.bencodez.votingplugin.control.http.ControlHttpServer; import com.bencodez.votingplugin.control.protocol.ControlIdentity; import com.bencodez.votingplugin.control.protocol.Protocol; @@ -162,9 +164,12 @@ static void runServer(Map environment) throws Exception { ConfigurationOperations operations = new ConfigurationOperations(registry, audit, clock, operationJournal); InspectionOperations inspections = new InspectionOperations(registry, audit, clock); ConfigurationSnapshots snapshots = new ConfigurationSnapshots(configuration.dataDirectory(), clock); + ArtifactStore artifacts = new ArtifactStore(configuration.dataDirectory().resolve("plugin-artifacts")); + DeploymentOperations deployments = new DeploymentOperations(registry, audit, + configuration.dataDirectory(), clock); ControlHttpServer server = new ControlHttpServer(configuration.address(), registry, identity, credentials, - operations, inspections, snapshots, configuration.secureCookies(), configuration.trustedProxyAddresses(), - configuration.launchId()); + operations, inspections, snapshots, artifacts, deployments, configuration.secureCookies(), + configuration.trustedProxyAddresses(), configuration.launchId()); ProcessHandle parent = parentProcess(configuration.parentPid()); CountDownLatch shutdown = new CountDownLatch(1); Runtime.getRuntime().addShutdownHook(new Thread(() -> { diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/DeploymentOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/DeploymentOperations.java new file mode 100644 index 00000000..4b38344e --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/DeploymentOperations.java @@ -0,0 +1,599 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.DurableFiles; +import com.bencodez.votingplugin.control.protocol.DeploymentRequest; +import com.bencodez.votingplugin.control.protocol.DeploymentResult; +import com.bencodez.votingplugin.control.protocol.DeploymentTask; +import com.bencodez.votingplugin.control.protocol.DeploymentTaskResult; +import com.bencodez.votingplugin.control.protocol.NodeStatus; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.channels.FileChannel; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** + * Coordinator for the narrow plugin deployment capability. It only stages a + * verified artifact on an explicitly selected node; it never writes to a node + * and never starts or restarts a server itself. + */ +public final class DeploymentOperations { + public static final String CAPABILITY = DeploymentRequest.CAPABILITY; + public static final Duration LEASE = Duration.ofMinutes(2); + public static final Duration COMPLETE_RETENTION = Duration.ofMinutes(30); + public static final int MAX_RETAINED = 100; + private static final int MAX_MESSAGE_CHARS = 500; + private static final Set FAILURE_CODES = Set.of( + "ARTIFACT_NOT_FOUND", "HASH_MISMATCH", "SIZE_MISMATCH", "INVALID_ARTIFACT", + "UNSUPPORTED", "DOWNLOAD_FAILED", "STAGING_FAILED", "WRITE_FAILED", "RESTART_FAILED", + "RESTART_TIMEOUT", "CAPABILITY_LOST", "CANCELLED", "TIMEOUT", "DEPLOYMENT_FAILED", + "CONTROL_RESTARTED", "INTERNAL_ERROR"); + private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules() + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + private final NodeRegistry registry; + private final ConfigurationAuditLog audit; + private final Clock clock; + private final Path journal; + private final LinkedHashMap deployments = new LinkedHashMap<>(); + + public DeploymentOperations(NodeRegistry registry, Clock clock) { + this(registry, null, null, clock, false); + } + + public DeploymentOperations(NodeRegistry registry, ConfigurationAuditLog audit, Clock clock) { + this(registry, audit, null, clock, false); + } + + public DeploymentOperations(NodeRegistry registry, Path dataDirectory, Clock clock) throws IOException { + this(registry, null, dataDirectory, clock); + } + + public DeploymentOperations(NodeRegistry registry, ConfigurationAuditLog audit, Path dataDirectory, + Clock clock) throws IOException { + this(registry, audit, dataDirectory, clock, true); + } + + public DeploymentOperations(NodeRegistry registry, ConfigurationAuditLog audit, Clock clock, + Path dataDirectory) throws IOException { + this(registry, audit, dataDirectory, clock, true); + } + + private DeploymentOperations(NodeRegistry registry, ConfigurationAuditLog audit, Path dataDirectory, + Clock clock, boolean checked) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.audit = audit; + this.clock = Objects.requireNonNull(clock, "clock"); + try { + this.journal = dataDirectory == null ? null : prepareJournal(dataDirectory); + if (journal != null) loadJournal(); + } catch (IOException failure) { + throw new IllegalStateException("Deployment journal could not be opened", failure); + } + } + + /** Creates a deployment, pinning each selected node's current session. */ + public synchronized DeploymentResult create(DeploymentRequest request) { + Objects.requireNonNull(request, "request"); + prune(); + LinkedHashMap prior = copyDeployments(); + try { + List targets = new ArrayList<>(); + List unavailable = new ArrayList<>(); + for (String nodeId : request.nodeIds()) { + NodeStatus node = registry.find(nodeId); + if (node == null || !node.online() || !node.acceptedCapabilities().contains(CAPABILITY)) { + unavailable.add(nodeId); + } else { + targets.add(new Target(nodeId, node.sessionId())); + } + } + if (!unavailable.isEmpty()) { + throw new ValidationException("NODE_UNAVAILABLE", + "Every deployment target must be online and accept plugin.deploy.v1", unavailable); + } + if (deployments.size() >= MAX_RETAINED) evictOldestCompleted(); + if (deployments.size() >= MAX_RETAINED) { + throw new ValidationException("OPERATION_LIMIT", "Too many retained deployments", List.of()); + } + StoredDeployment stored = new StoredDeployment(UUID.randomUUID(), request.artifactId(), request.sha256(), + request.size(), clock.instant(), targets); + deployments.put(stored.id, stored); + commit(stored.id, null, "DEPLOYMENT_CREATED", "QUEUED"); + return view(stored); + } catch (RuntimeException failure) { + restore(prior, failure); + throw failure; + } + } + + /** Returns the oldest unclaimed work for this exact node session. */ + public synchronized DeploymentTask claim(String nodeId, UUID sessionId) { + return registry.withSession(nodeId, sessionId, node -> claimCurrent(nodeId, node)); + } + + private DeploymentTask claimCurrent(String nodeId, NodeStatus node) { + prune(); + LinkedHashMap prior = copyDeployments(); + try { + if (!node.online() || !node.acceptedCapabilities().contains(CAPABILITY)) return null; + Instant now = clock.instant(); + for (StoredDeployment deployment : deployments.values()) { + Target target = deployment.targets.get(nodeId); + if (target == null || target.state.equals("SUCCEEDED") || target.state.equals("FAILED")) continue; + if (!target.pinnedSession.equals(node.sessionId())) { + failUnavailable(deployment, target, "Node reconnected before this artifact was staged"); + continue; + } + if (target.state.equals("IN_PROGRESS")) { + if (target.leasedAt != null && now.isBefore(target.leasedAt.plus(LEASE))) continue; + target.state = "QUEUED"; + target.leasedAt = null; + target.attemptId = null; + } + if (!target.pinnedSession.equals(node.sessionId())) continue; + target.state = "IN_PROGRESS"; + target.leasedAt = now; + target.attemptId = UUID.randomUUID(); + commit(deployment.id, nodeId, "DEPLOYMENT_CLAIMED", "IN_PROGRESS"); + return new DeploymentTask(deployment.id, deployment.artifactId, deployment.sha256, deployment.size, + target.attemptId); + } + return null; + } catch (RuntimeException failure) { + restore(prior, failure); + throw failure; + } + } + + /** Completes an attempt only while its session, attempt and two-minute lease remain current. */ + public synchronized DeploymentResult complete(UUID deploymentId, String nodeId, DeploymentTaskResult result) { + if (result == null) throw invalid("deployment result is required"); + return registry.withSession(nodeId, result.sessionId(), node -> completeCurrent(deploymentId, node, result)); + } + + /** + * Authorizes a node artifact download without exposing the artifact bytes + * or metadata to an unrelated session. The caller must present the exact + * attempt returned by {@link #claim(String, UUID)} while its lease remains + * active. + */ + public synchronized DeploymentTask authorizeArtifact(UUID deploymentId, String nodeId, UUID sessionId, + UUID attemptId) { + if (deploymentId == null || attemptId == null) throw invalid("deploymentId and attemptId are required"); + return registry.withSession(nodeId, sessionId, node -> { + if (!node.online() || !node.acceptedCapabilities().contains(CAPABILITY)) { + throw new ValidationException("NODE_UNAVAILABLE", "Node cannot download this deployment", List.of()); + } + StoredDeployment deployment = deployments.get(deploymentId); + if (deployment == null) { + throw new ValidationException("OPERATION_NOT_FOUND", "Deployment was not found", List.of()); + } + Target target = deployment.targets.get(nodeId); + if (target == null || !target.pinnedSession.equals(sessionId)) { + throw new ValidationException("SESSION_MISMATCH", "Deployment is not authorized for this session", List.of()); + } + if (!"IN_PROGRESS".equals(target.state) || target.leasedAt == null + || !clock.instant().isBefore(target.leasedAt.plus(LEASE))) { + throw new ValidationException("TASK_LEASE_EXPIRED", "Deployment lease expired", List.of()); + } + if (!attemptId.equals(target.attemptId)) { + throw new ValidationException("TASK_NOT_CLAIMED", "Deployment attempt does not match", List.of()); + } + return new DeploymentTask(deployment.id, deployment.artifactId, deployment.sha256, + deployment.size, target.attemptId); + }); + } + + private DeploymentResult completeCurrent(UUID deploymentId, NodeStatus node, DeploymentTaskResult result) { + prune(); + LinkedHashMap prior = copyDeployments(); + try { + StoredDeployment deployment = deployments.get(deploymentId); + if (deployment == null) throw new ValidationException("OPERATION_NOT_FOUND", "Deployment was not found", List.of()); + Target target = deployment.targets.get(node.nodeId()); + if (target == null) throw new ValidationException("NODE_NOT_TARGETED", "Node was not selected for deployment", List.of()); + if (!target.pinnedSession.equals(result.sessionId())) { + throw new ValidationException("SESSION_MISMATCH", "Deployment was claimed by another node session", List.of()); + } + if (!target.state.equals("IN_PROGRESS")) { + throw new ValidationException("TASK_NOT_CLAIMED", "Deployment was not claimed", List.of()); + } + if (target.leasedAt == null || !clock.instant().isBefore(target.leasedAt.plus(LEASE))) { + throw new ValidationException("TASK_LEASE_EXPIRED", "Deployment lease expired", List.of()); + } + if (!Objects.equals(target.attemptId, result.attemptId())) { + throw new ValidationException("TASK_NOT_CLAIMED", "Deployment attempt does not match", List.of()); + } + validateResult(result); + target.result = result; + target.state = result.success() ? "SUCCEEDED" : "FAILED"; + target.leasedAt = null; + target.attemptId = null; + commit(deployment.id, node.nodeId(), "DEPLOYMENT_COMPLETED", result.code()); + return view(deployment); + } catch (RuntimeException failure) { + restore(prior, failure); + throw failure; + } + } + + /** Creates a new operation for failed targets that are eligible now; successes are never copied. */ + public synchronized DeploymentResult retry(UUID deploymentId) { + prune(); + StoredDeployment original = deployments.get(deploymentId); + if (original == null) throw new ValidationException("OPERATION_NOT_FOUND", "Deployment was not found", List.of()); + List eligible = new ArrayList<>(); + for (Target target : original.targets.values()) { + if (!target.state.equals("FAILED")) continue; + NodeStatus node = registry.find(target.nodeId); + if (node != null && node.online() && node.acceptedCapabilities().contains(CAPABILITY)) { + eligible.add(target.nodeId); + } + } + if (eligible.isEmpty()) { + throw new ValidationException("NO_RETRYABLE_TARGETS", "No failed deployment target is currently eligible", List.of()); + } + return create(new DeploymentRequest(original.artifactId, original.sha256, original.size, eligible)); + } + + public synchronized DeploymentResult get(UUID deploymentId) { + prune(); + StoredDeployment deployment = deployments.get(deploymentId); + if (deployment == null) throw new ValidationException("OPERATION_NOT_FOUND", "Deployment was not found", List.of()); + return view(deployment); + } + + public synchronized List list(int offset, int limit) { + prune(); + if (offset < 0 || limit < 1 || limit > 100) throw invalid("offset must be >= 0 and limit must be between 1 and 100"); + List newestFirst = new ArrayList<>(deployments.values()); + Collections.reverse(newestFirst); + return newestFirst.stream().skip(offset).limit(limit).map(this::view).toList(); + } + + /** Artifact IDs still referenced by the retained durable journal. */ + public synchronized Set referencedArtifactIds() { + prune(); + return deployments.values().stream().map(deployment -> deployment.artifactId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + + private void validateResult(DeploymentTaskResult result) { + if (result.attemptId() == null) throw invalid("attemptId is required"); + if (result.code() == null || result.code().isBlank() || result.code().length() > 64 + || !result.code().matches("[A-Z][A-Z0-9_]{1,63}")) throw invalid("result code is invalid"); + if (result.message() != null && (result.message().length() > MAX_MESSAGE_CHARS + || result.message().chars().anyMatch(Character::isISOControl))) throw invalid("result message is invalid"); + if (result.success() && !"RESTART_REQUIRED".equals(result.code())) { + throw invalid("successful deployment results must use RESTART_REQUIRED"); + } + if (!result.success() && !FAILURE_CODES.contains(result.code())) { + throw invalid("result code is not supported"); + } + } + + private DeploymentResult view(StoredDeployment deployment) { + List nodes = deployment.targets.values().stream() + .map(target -> new DeploymentResult.NodeResult(target.nodeId, target.pinnedSession, target.state, + target.result, target.leasedAt, target.attemptId)).toList(); + return new DeploymentResult(deployment.id, deployment.artifactId, deployment.sha256, deployment.size, + state(deployment), deployment.createdAt, nodes); + } + + private static String state(StoredDeployment deployment) { + boolean queued = false, running = false, success = false, failure = false; + for (Target target : deployment.targets.values()) { + switch (target.state) { + case "QUEUED" -> queued = true; + case "IN_PROGRESS" -> running = true; + case "SUCCEEDED" -> success = true; + case "FAILED" -> failure = true; + default -> throw new IllegalStateException("unknown deployment target state"); + } + } + if (running) return "RUNNING"; + if (queued) return success || failure ? "PARTIAL" : "QUEUED"; + if (success && failure) return "PARTIALLY_FAILED"; + return success ? "SUCCEEDED" : "FAILED"; + } + + private void commit(UUID operationId, String nodeId, String action, String outcome) { + if (journal != null) persistJournal(); + if (audit != null) audit.append(action, operationId, nodeId, outcome); + } + + private LinkedHashMap copyDeployments() { + LinkedHashMap result = new LinkedHashMap<>(); + for (StoredDeployment deployment : deployments.values()) { + List targets = new ArrayList<>(); + for (Target original : deployment.targets.values()) { + Target target = new Target(original.nodeId, original.pinnedSession); + target.state = original.state; + target.leasedAt = original.leasedAt; + target.attemptId = original.attemptId; + target.result = original.result; + targets.add(target); + } + result.put(deployment.id, new StoredDeployment(deployment.id, deployment.artifactId, + deployment.sha256, deployment.size, deployment.createdAt, targets)); + } + return result; + } + + private void restore(LinkedHashMap prior, RuntimeException failure) { + deployments.clear(); + deployments.putAll(prior); + if (journal != null) { + try { + persistJournal(); + } catch (RuntimeException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + } + } + + private void prune() { + LinkedHashMap prior = copyDeployments(); + try { + for (StoredDeployment deployment : deployments.values()) { + for (Target target : deployment.targets.values()) { + if ("SUCCEEDED".equals(target.state) || "FAILED".equals(target.state)) continue; + NodeStatus node = registry.find(target.nodeId); + if (node != null && node.online() && (!target.pinnedSession.equals(node.sessionId()) + || !node.acceptedCapabilities().contains(CAPABILITY))) { + failUnavailable(deployment, target, + "Node session or deployment capability changed before staging completed"); + } + } + } + } catch (RuntimeException failure) { + restore(prior, failure); + throw failure; + } + Instant cutoff = clock.instant().minus(COMPLETE_RETENTION); + boolean removed = deployments.values().removeIf(deployment -> + isTerminal(deployment) && deployment.createdAt.isBefore(cutoff)); + evictCompleted(); + if (removed && journal != null) persistJournal(); + } + + private void failUnavailable(StoredDeployment deployment, Target target, String message) { + UUID attempt = target.attemptId == null ? UUID.randomUUID() : target.attemptId; + target.state = "FAILED"; + target.result = new DeploymentTaskResult(target.pinnedSession, false, "CAPABILITY_LOST", message, attempt); + target.leasedAt = null; + target.attemptId = null; + commit(deployment.id, target.nodeId, "DEPLOYMENT_CANCELLED", "CAPABILITY_LOST"); + } + + private void evictCompleted() { + while (deployments.size() > MAX_RETAINED) { + if (!evictOldestCompleted()) return; + } + } + + private boolean evictOldestCompleted() { + UUID candidate = deployments.entrySet().stream().filter(entry -> isTerminal(entry.getValue())) + .map(Map.Entry::getKey).findFirst().orElse(null); + if (candidate == null) return false; + deployments.remove(candidate); + return true; + } + + private static boolean isTerminal(StoredDeployment deployment) { + return deployment.targets.values().stream().allMatch(target -> target.state.equals("SUCCEEDED") || target.state.equals("FAILED")); + } + + private static ValidationException invalid(String message) { + return new ValidationException("VALIDATION_ERROR", "Request validation failed", List.of(message)); + } + + private static Path prepareJournal(Path dataDirectory) throws IOException { + Files.createDirectories(dataDirectory); + if (Files.isSymbolicLink(dataDirectory) || !Files.isDirectory(dataDirectory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Deployment data directory is not a real directory"); + } + Path file = dataDirectory.resolve("plugin-deployments.json"); + if (Files.exists(file, LinkOption.NOFOLLOW_LINKS) + && (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file))) { + throw new IOException("Deployment journal is not a regular file"); + } + return file; + } + + private void persistJournal() { + try { + List> entries = new ArrayList<>(); + for (StoredDeployment deployment : deployments.values()) { + Map value = new LinkedHashMap<>(); + value.put("id", deployment.id); + value.put("artifactId", deployment.artifactId); + value.put("sha256", deployment.sha256); + value.put("size", deployment.size); + value.put("createdAt", deployment.createdAt); + List> targets = new ArrayList<>(); + for (Target target : deployment.targets.values()) { + Map item = new LinkedHashMap<>(); + item.put("nodeId", target.nodeId); + item.put("sessionId", target.pinnedSession); + item.put("state", target.state); + item.put("leasedAt", target.leasedAt); + item.put("attemptId", target.attemptId); + item.put("result", target.result); + targets.add(item); + } + value.put("targets", targets); + entries.add(value); + } + byte[] bytes = JSON.writeValueAsBytes(entries); + if (bytes.length > 5 * 1024 * 1024) throw new IOException("Deployment journal exceeds its bound"); + Path temp = journal.resolveSibling(journal.getFileName() + ".tmp"); + if (Files.exists(temp, LinkOption.NOFOLLOW_LINKS)) { + if (Files.isSymbolicLink(temp)) throw new IOException("Deployment journal temporary file is a symlink"); + Files.delete(temp); + } + try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS)) { + channel.write(ByteBuffer.wrap(bytes)); + channel.force(true); + } + try { + Files.move(temp, journal, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { + Files.move(temp, journal, StandardCopyOption.REPLACE_EXISTING); + } + DurableFiles.forceDirectory(journal.getParent()); + } catch (IOException e) { + throw new IllegalStateException("Deployment journal could not be written", e); + } + } + + private void loadJournal() throws IOException { + if (!Files.exists(journal, LinkOption.NOFOLLOW_LINKS)) return; + byte[] bytes; + try (var channel = Files.newByteChannel(journal, Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + if (Files.size(journal) > 5 * 1024 * 1024) throw new IOException("Deployment journal exceeds its bound"); + ByteBuffer buffer = ByteBuffer.allocate((int) Files.size(journal)); + while (buffer.hasRemaining() && channel.read(buffer) >= 0) { } + if (buffer.hasRemaining()) throw new IOException("Deployment journal could not be read"); + bytes = buffer.array(); + } + if (bytes.length > 5 * 1024 * 1024) throw new IOException("Deployment journal exceeds its bound"); + String text = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString(); + JsonNode root; + try (JsonParser parser = JSON.getFactory().createParser(text)) { + root = JSON.readTree(parser); + if (parser.nextToken() != null) throw new IOException("Deployment journal has trailing data"); + } + if (root == null || !root.isArray() || root.size() > MAX_RETAINED) throw new IOException("Deployment journal is invalid"); + for (JsonNode item : root) { + StoredDeployment deployment = parseDeployment(item); + if (deployments.put(deployment.id, deployment) != null) throw new IOException("Duplicate deployment ID"); + } + boolean changed = false; + for (StoredDeployment deployment : deployments.values()) { + for (Target target : deployment.targets.values()) { + if (!"IN_PROGRESS".equals(target.state)) continue; + UUID interruptedAttempt = target.attemptId; + target.state = "FAILED"; + target.result = new DeploymentTaskResult(target.pinnedSession, false, "CONTROL_RESTARTED", + "Control restarted while this staging attempt was in progress", interruptedAttempt); + target.leasedAt = null; + target.attemptId = null; + changed = true; + } + } + if (changed) persistJournal(); + } + + private StoredDeployment parseDeployment(JsonNode item) throws IOException { + if (!item.isObject()) throw new IOException("Deployment journal entry is invalid"); + try { + requireExactFields(item, Set.of("id", "artifactId", "sha256", "size", "createdAt", "targets")); + UUID id = UUID.fromString(item.path("id").asText()); + String artifactId = item.path("artifactId").asText(); + String sha256 = item.path("sha256").asText(); + long size = item.path("size").asLong(-1); + Instant createdAt = Instant.parse(item.path("createdAt").asText()); + JsonNode targetsNode = item.path("targets"); + if (!targetsNode.isArray() || targetsNode.isEmpty() || targetsNode.size() > 100) throw new IOException("Invalid targets"); + List targets = new ArrayList<>(); + for (JsonNode node : targetsNode) { + requireExactFields(node, Set.of("nodeId", "sessionId", "state", "leasedAt", "attemptId", "result")); + Target target = new Target(node.path("nodeId").asText(), UUID.fromString(node.path("sessionId").asText())); + target.state = node.path("state").asText(); + if (!List.of("QUEUED", "IN_PROGRESS", "SUCCEEDED", "FAILED").contains(target.state)) throw new IOException("Invalid target state"); + target.leasedAt = node.path("leasedAt").isNull() ? null : Instant.parse(node.path("leasedAt").asText()); + target.attemptId = node.path("attemptId").isNull() ? null : UUID.fromString(node.path("attemptId").asText()); + if (node.has("result") && !node.path("result").isNull()) { + target.result = JSON.treeToValue(node.path("result"), DeploymentTaskResult.class); + } + boolean queued = "QUEUED".equals(target.state); + boolean inProgress = "IN_PROGRESS".equals(target.state); + boolean terminal = "SUCCEEDED".equals(target.state) || "FAILED".equals(target.state); + if (queued && (target.leasedAt != null || target.attemptId != null || target.result != null) + || inProgress && (target.leasedAt == null || target.attemptId == null || target.result != null) + || terminal && (target.leasedAt != null || target.attemptId != null || target.result == null)) { + throw new IOException("Invalid target state fields"); + } + if (terminal) { + validateResult(target.result); + if (!target.pinnedSession.equals(target.result.sessionId()) + || ("SUCCEEDED".equals(target.state) != target.result.success())) { + throw new IOException("Invalid target result"); + } + } + targets.add(target); + } + DeploymentRequest request = new DeploymentRequest(artifactId, sha256, size, + targets.stream().map(target -> target.nodeId).toList()); + return new StoredDeployment(id, request.artifactId(), request.sha256(), request.size(), createdAt, targets); + } catch (RuntimeException e) { + throw new IOException("Deployment journal entry is invalid", e); + } + } + + private static void requireExactFields(JsonNode value, Set expected) throws IOException { + if (!value.isObject()) throw new IOException("Deployment journal entry is invalid"); + Set actual = new java.util.HashSet<>(); + value.fieldNames().forEachRemaining(actual::add); + if (!actual.equals(expected)) throw new IOException("Deployment journal entry is invalid"); + } + + private static final class StoredDeployment { + private final UUID id; + private final String artifactId; + private final String sha256; + private final long size; + private final Instant createdAt; + private final LinkedHashMap targets = new LinkedHashMap<>(); + + private StoredDeployment(UUID id, String artifactId, String sha256, long size, Instant createdAt, List targets) { + this.id = id; + this.artifactId = artifactId; + this.sha256 = sha256; + this.size = size; + this.createdAt = createdAt; + for (Target target : targets) { + if (this.targets.put(target.nodeId, target) != null) throw new IllegalArgumentException("duplicate target"); + } + } + } + + private static final class Target { + private final String nodeId; + private final UUID pinnedSession; + private String state = "QUEUED"; + private Instant leasedAt; + private UUID attemptId; + private DeploymentTaskResult result; + + private Target(String nodeId, UUID pinnedSession) { + this.nodeId = nodeId; + this.pinnedSession = pinnedSession; + } + } +} 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 441e45f4..14f81783 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -30,7 +30,7 @@ public final class InMemoryNodeRegistry implements NodeRegistry { ConfigurationOperations.PROXY_FILE_CAPABILITY, ConfigurationOperations.QUICK_SETUP_CAPABILITY, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY, - ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, + ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, DeploymentOperations.CAPABILITY, "config.file-comments.v1", InspectionQuery.CAPABILITY); 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 f40cf578..5c11af4c 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -2,15 +2,22 @@ import com.bencodez.votingplugin.control.auth.CredentialStore; import com.bencodez.votingplugin.control.auth.WebSessionStore; +import com.bencodez.votingplugin.control.artifact.ArtifactStore; +import com.bencodez.votingplugin.control.artifact.ArtifactStore.ArtifactException; import com.bencodez.votingplugin.control.domain.NodeRegistry; import com.bencodez.votingplugin.control.domain.ConfigurationOperations; import com.bencodez.votingplugin.control.domain.ConfigurationSnapshots; import com.bencodez.votingplugin.control.domain.InspectionOperations; +import com.bencodez.votingplugin.control.domain.DeploymentOperations; import com.bencodez.votingplugin.control.domain.ValidationException; import com.bencodez.votingplugin.control.protocol.ConfigurationRequests; import com.bencodez.votingplugin.control.protocol.ConfigurationTask; import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; import com.bencodez.votingplugin.control.protocol.ControlIdentity; +import com.bencodez.votingplugin.control.protocol.DeploymentRequest; +import com.bencodez.votingplugin.control.protocol.DeploymentResult; +import com.bencodez.votingplugin.control.protocol.DeploymentTask; +import com.bencodez.votingplugin.control.protocol.DeploymentTaskResult; import com.bencodez.votingplugin.control.protocol.Heartbeat; import com.bencodez.votingplugin.control.protocol.InspectionRequests; import com.bencodez.votingplugin.control.protocol.InspectionTask; @@ -69,6 +76,8 @@ public final class ControlHttpServer implements AutoCloseable { private static final String OPERATIONS = "/api/v1/operations"; private static final String INSPECTIONS = "/api/v1/inspections"; private static final String SNAPSHOTS = "/api/v1/snapshots"; + private static final String ARTIFACTS = "/api/v1/artifacts/votingplugin"; + private static final String DEPLOYMENTS = "/api/v1/deployments"; private static final String AUTH_LOGIN = "/api/v1/auth/login"; private static final String AUTH_SESSION = "/api/v1/auth/session"; private static final String AUTH_LOGOUT = "/api/v1/auth/logout"; @@ -95,6 +104,9 @@ public final class ControlHttpServer implements AutoCloseable { private final ConfigurationOperations configurationOperations; private final InspectionOperations inspectionOperations; private final ConfigurationSnapshots configurationSnapshots; + private final ArtifactStore artifactStore; + private final DeploymentOperations deploymentOperations; + private final Object deploymentArtifactLifecycle = new Object(); private final ThreadPoolExecutor executor; private final ThreadPoolExecutor passwordExecutor; private final PasswordAdmission passwordAdmission = new PasswordAdmission(MAX_PASSWORD_ATTEMPTS_PER_CLIENT); @@ -152,7 +164,8 @@ public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, Contr InspectionOperations inspectionOperations, boolean secureCookies, Set trustedProxyAddresses, String launchId) throws IOException { this(address, registry, identity, credentials, configurationOperations, inspectionOperations, - temporarySnapshots(), Clock.systemUTC(), System::nanoTime, secureCookies, + temporarySnapshots(), temporaryArtifactStore(), new DeploymentOperations(registry, Clock.systemUTC()), + Clock.systemUTC(), System::nanoTime, secureCookies, trustedProxyAddresses, launchId); } @@ -161,16 +174,28 @@ public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, Contr InspectionOperations inspectionOperations, ConfigurationSnapshots configurationSnapshots, boolean secureCookies, Set trustedProxyAddresses, String launchId) throws IOException { this(address, registry, identity, credentials, configurationOperations, inspectionOperations, - configurationSnapshots, Clock.systemUTC(), System::nanoTime, secureCookies, + configurationSnapshots, temporaryArtifactStore(), new DeploymentOperations(registry, Clock.systemUTC()), + Clock.systemUTC(), System::nanoTime, secureCookies, trustedProxyAddresses, launchId); } + public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, ConfigurationOperations configurationOperations, + InspectionOperations inspectionOperations, ConfigurationSnapshots configurationSnapshots, + ArtifactStore artifactStore, DeploymentOperations deploymentOperations, + boolean secureCookies, Set trustedProxyAddresses, String launchId) throws IOException { + this(address, registry, identity, credentials, configurationOperations, inspectionOperations, + configurationSnapshots, artifactStore, deploymentOperations, Clock.systemUTC(), System::nanoTime, + secureCookies, trustedProxyAddresses, launchId); + } + ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations, Clock clock, java.util.function.LongSupplier nanoTime, boolean secureCookies, Set trustedProxyAddresses, String launchId) throws IOException { this(address, registry, identity, credentials, configurationOperations, - new InspectionOperations(registry, clock), temporarySnapshots(), clock, nanoTime, secureCookies, + new InspectionOperations(registry, clock), temporarySnapshots(), temporaryArtifactStore(), + new DeploymentOperations(registry, clock), clock, nanoTime, secureCookies, trustedProxyAddresses, launchId); } @@ -180,12 +205,14 @@ public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, Contr java.util.function.LongSupplier nanoTime, boolean secureCookies, Set trustedProxyAddresses, String launchId) throws IOException { this(address, registry, identity, credentials, configurationOperations, inspectionOperations, - temporarySnapshots(), clock, nanoTime, secureCookies, trustedProxyAddresses, launchId); + temporarySnapshots(), temporaryArtifactStore(), new DeploymentOperations(registry, clock), + clock, nanoTime, secureCookies, trustedProxyAddresses, launchId); } ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations, InspectionOperations inspectionOperations, ConfigurationSnapshots configurationSnapshots, + ArtifactStore artifactStore, DeploymentOperations deploymentOperations, Clock clock, java.util.function.LongSupplier nanoTime, boolean secureCookies, Set trustedProxyAddresses, String launchId) throws IOException { this.registry = Objects.requireNonNull(registry, "registry"); @@ -194,6 +221,8 @@ public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, Contr this.configurationOperations = Objects.requireNonNull(configurationOperations, "configurationOperations"); this.inspectionOperations = Objects.requireNonNull(inspectionOperations, "inspectionOperations"); this.configurationSnapshots = Objects.requireNonNull(configurationSnapshots, "configurationSnapshots"); + this.artifactStore = Objects.requireNonNull(artifactStore, "artifactStore"); + this.deploymentOperations = Objects.requireNonNull(deploymentOperations, "deploymentOperations"); this.secureCookies = secureCookies; this.trustedProxyAddresses = Set.copyOf(Objects.requireNonNull(trustedProxyAddresses, "trustedProxyAddresses")); this.launchId = launchId; @@ -243,6 +272,10 @@ private static ConfigurationSnapshots temporarySnapshots() throws IOException { Clock.systemUTC()); } + private static ArtifactStore temporaryArtifactStore() throws IOException { + return new ArtifactStore(Files.createTempDirectory("votingplugin-control-test-artifacts")); + } + @Override public void close() { server.stop(0); @@ -288,12 +321,14 @@ private void handle(HttpExchange exchange) throws IOException { } catch (JsonProcessingException | CharacterCodingException e) { error(exchange, 400, "MALFORMED_JSON", "Request body is not valid JSON", List.of()); } catch (RequestTooLargeException e) { - error(exchange, 413, "REQUEST_TOO_LARGE", "Request body exceeds " + MAX_REQUEST_BYTES + " bytes", + error(exchange, 413, "REQUEST_TOO_LARGE", "Request body exceeds " + e.maximum + " bytes", List.of()); } catch (SnapshotStoreException e) { System.getLogger(ControlHttpServer.class.getName()).log(System.Logger.Level.WARNING, "Snapshot store request failed: " + e.getCause().getClass().getSimpleName()); error(exchange, 503, "SNAPSHOT_STORE_UNAVAILABLE", "Configuration snapshot storage is unavailable", List.of()); + } catch (ArtifactException e) { + error(exchange, 400, "ARTIFACT_REJECTED", "VotingPlugin artifact was rejected", List.of()); } catch (IllegalArgumentException e) { error(exchange, 400, "VALIDATION_ERROR", "Request validation failed", List.of()); } catch (ResponseCompleteException ignored) { @@ -464,6 +499,53 @@ private void route(HttpExchange exchange) throws IOException { Map.of("created", result.created(), "node", result.node(), "identity", identity)); return; } + if (ARTIFACTS.equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + requireArtifactMediaType(exchange); + requireBodyWithin(exchange, ArtifactStore.MAX_UPLOAD_BYTES); + String filename = requiredHeader(exchange, "X-Filename"); + String claimedSha256 = singleHeader(exchange, "X-Artifact-SHA256"); + ArtifactStore.Artifact artifact; + synchronized (deploymentArtifactLifecycle) { + artifact = artifactStore.upload(exchange.getRequestBody(), filename, claimedSha256, + deploymentOperations.referencedArtifactIds()); + } + send(exchange, 201, Map.of("artifactId", artifact.artifactId(), "sha256", artifact.artifactId(), + "size", artifact.size(), "fileName", artifact.displayFilename())); + return; + } + if (DEPLOYMENTS.equals(path)) { + if ("GET".equals(exchange.getRequestMethod())) { + authenticateAdmin(exchange, false); + Map parameters = query(uri.getRawQuery()); + int offset = integer(parameters.getOrDefault("offset", "0"), "offset"); + int limit = integer(parameters.getOrDefault("limit", "50"), "limit"); + send(exchange, 200, Map.of("items", deploymentOperations.list(offset, limit), + "offset", offset, "limit", limit)); + return; + } + if ("POST".equals(exchange.getRequestMethod())) { + authenticateAdmin(exchange, true); + DeploymentRequest request = read(exchange, DeploymentRequest.class); + requireRequest(request); + DeploymentResult deployment; + synchronized (deploymentArtifactLifecycle) { + ArtifactStore.Artifact artifact = artifactStore.describe(request.artifactId()); + if (!artifact.artifactId().equals(request.sha256()) || artifact.size() != request.size()) { + throw new ValidationException("ARTIFACT_MISMATCH", + "Deployment metadata does not match the verified artifact", List.of()); + } + deployment = deploymentOperations.create(request); + } + send(exchange, 202, deployment); + return; + } + exchange.getResponseHeaders().set("Allow", "GET, POST"); + error(exchange, 405, "METHOD_NOT_ALLOWED", "Method is not allowed", + List.of("allowed=GET", "allowed=POST")); + throw new ResponseCompleteException(); + } if ((CONFIGURATION + "/read").equals(path)) { requireMethod(exchange, "POST"); authenticateAdmin(exchange, true); @@ -553,13 +635,31 @@ private void route(HttpExchange exchange) throws IOException { return; } } + if (path != null && path.startsWith(DEPLOYMENTS + "/")) { + String remainder = path.substring((DEPLOYMENTS + "/").length()); + String[] segments = remainder.split("/", -1); + UUID deploymentId = UUID.fromString(segments[0]); + if (segments.length == 1) { + requireMethod(exchange, "GET"); + authenticateAdmin(exchange, false); + send(exchange, 200, deploymentOperations.get(deploymentId)); + return; + } + if (segments.length == 2 && "retry".equals(segments[1])) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + send(exchange, 202, deploymentOperations.retry(deploymentId)); + return; + } + } String prefix = NODES + "/"; if (path != null && path.startsWith(prefix)) { String remainder = path.substring(prefix.length()); String[] segments = remainder.split("/", -1); if (segments.length == 2 && ("heartbeat".equals(segments[1]) || "presence".equals(segments[1]) - || "operations".equals(segments[1]) || "inspections".equals(segments[1]))) { + || "operations".equals(segments[1]) || "inspections".equals(segments[1]) + || "deployments".equals(segments[1]))) { String nodeId = decodePathSegment(segments[0]); if ("heartbeat".equals(segments[1])) { requireMethod(exchange, "PUT"); @@ -582,7 +682,7 @@ private void route(HttpExchange exchange) throws IOException { } else { send(exchange, 200, task); } - } else { + } else if ("inspections".equals(segments[1])) { requireMethod(exchange, "POST"); authenticateNode(exchange, nodeId); InspectionRequests.Claim claim = read(exchange, InspectionRequests.Claim.class); @@ -593,6 +693,13 @@ private void route(HttpExchange exchange) throws IOException { } else { send(exchange, 200, task); } + } else { + requireMethod(exchange, "POST"); + authenticateNode(exchange, nodeId); + DeploymentClaim claim = read(exchange, DeploymentClaim.class); + requireRequest(claim); + DeploymentTask task = deploymentOperations.claim(nodeId, claim.sessionId()); + if (task == null) noContent(exchange); else send(exchange, 200, task); } return; } @@ -612,6 +719,31 @@ private void route(HttpExchange exchange) throws IOException { send(exchange, 200, inspectionOperations.complete(UUID.fromString(segments[2]), nodeId, result)); return; } + if (segments.length == 4 && "deployments".equals(segments[1]) && "result".equals(segments[3])) { + String nodeId = decodePathSegment(segments[0]); + requireMethod(exchange, "POST"); + authenticateNode(exchange, nodeId); + DeploymentTaskResult result = read(exchange, DeploymentTaskResult.class); + requireRequest(result); + send(exchange, 200, deploymentOperations.complete(UUID.fromString(segments[2]), nodeId, result)); + return; + } + if (segments.length == 4 && "deployments".equals(segments[1]) && "artifact".equals(segments[3])) { + String nodeId = decodePathSegment(segments[0]); + requireMethod(exchange, "GET"); + authenticateNode(exchange, nodeId); + UUID deploymentId = UUID.fromString(segments[2]); + UUID sessionId = requiredUuidHeader(exchange, "X-Node-Session"); + UUID attemptId = requiredUuidHeader(exchange, "X-Deployment-Attempt"); + DeploymentTask task = deploymentOperations.authorizeArtifact(deploymentId, nodeId, sessionId, attemptId); + ArtifactStore.Artifact artifact = artifactStore.describe(task.artifactId()); + if (!artifact.artifactId().equals(task.sha256()) || artifact.size() != task.size()) { + throw new ValidationException("ARTIFACT_MISMATCH", + "Deployment artifact no longer matches the claimed task", List.of()); + } + sendArtifact(exchange, artifact, artifactStore.open(task.artifactId())); + return; + } } error(exchange, 404, "NOT_FOUND", "Endpoint not found", List.of()); } @@ -753,7 +885,7 @@ private T read(HttpExchange exchange, Class type) throws IOException { throw new IllegalArgumentException("Invalid Content-Length"); } if (length > MAX_REQUEST_BYTES) { - throw new RequestTooLargeException(); + throw new RequestTooLargeException(MAX_REQUEST_BYTES); } } byte[] bytes; @@ -764,7 +896,7 @@ private T read(HttpExchange exchange, Class type) throws IOException { while ((count = input.read(buffer)) != -1) { total += count; if (total > MAX_REQUEST_BYTES) { - throw new RequestTooLargeException(); + throw new RequestTooLargeException(MAX_REQUEST_BYTES); } output.write(buffer, 0, count); } @@ -776,6 +908,54 @@ private T read(HttpExchange exchange, Class type) throws IOException { return json.readValue(text, type); } + private static void requireArtifactMediaType(HttpExchange exchange) { + String type = singleHeader(exchange, "Content-Type"); + if (type == null || !"application/java-archive".equalsIgnoreCase(type.split(";", 2)[0].trim())) { + throw new ValidationException("UNSUPPORTED_MEDIA_TYPE", + "Content-Type must be application/java-archive", List.of()); + } + } + + private static void requireBodyWithin(HttpExchange exchange, long maximum) throws RequestTooLargeException { + String value = singleHeader(exchange, "Content-Length"); + if (value == null) return; + try { + long length = Long.parseLong(value); + if (length < 1 || length > maximum) throw new RequestTooLargeException(maximum); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException("Invalid Content-Length"); + } + } + + private static String requiredHeader(HttpExchange exchange, String name) { + String value = singleHeader(exchange, name); + if (value == null || value.isBlank()) { + throw new ValidationException("VALIDATION_ERROR", "Request validation failed", + List.of(name + " is required")); + } + return value; + } + + private static UUID requiredUuidHeader(HttpExchange exchange, String name) { + try { + return UUID.fromString(requiredHeader(exchange, name)); + } catch (IllegalArgumentException failure) { + throw new ValidationException("VALIDATION_ERROR", "Request validation failed", + List.of(name + " must be a UUID")); + } + } + + private void sendArtifact(HttpExchange exchange, ArtifactStore.Artifact artifact, InputStream input) throws IOException { + exchange.getResponseHeaders().set("Content-Type", "application/java-archive"); + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff"); + exchange.getResponseHeaders().set("X-Artifact-SHA256", artifact.artifactId()); + exchange.sendResponseHeaders(200, artifact.size()); + try (input; OutputStream output = exchange.getResponseBody()) { + input.transferTo(output); + } + } + private static void requireRequest(Object request) { if (request == null) { throw new ValidationException("VALIDATION_ERROR", "Request validation failed", @@ -1003,7 +1183,13 @@ private static String decodePathSegment(String value) { } @SuppressWarnings("serial") - private static final class RequestTooLargeException extends IOException { } + private static final class RequestTooLargeException extends IOException { + private final long maximum; + + private RequestTooLargeException(long maximum) { + this.maximum = maximum; + } + } @SuppressWarnings("serial") private static final class AuthenticationException extends RuntimeException { private final boolean rateLimited; @@ -1028,6 +1214,7 @@ private record PasswordRequest(String password) { } private record SetupRequest(String setupCode, String password) { } private record EnrollmentRequest(String nodeId) { } private record SnapshotRequest(String name, UUID operationId) { } + private record DeploymentClaim(UUID sessionId) { } record BackendPage(List items, int backendItemsReturned, boolean backendItemsTruncated, List backendItemsTruncatedNodeIds) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentRequest.java b/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentRequest.java new file mode 100644 index 00000000..e2326202 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentRequest.java @@ -0,0 +1,37 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** Admin request for a single, explicitly targeted plugin deployment. */ +public record DeploymentRequest(String artifactId, String sha256, long size, List nodeIds) { + public static final String CAPABILITY = "plugin.deploy.v1"; + public static final long MAX_ARTIFACT_BYTES = 64L * 1024 * 1024; + private static final Pattern ARTIFACT_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + + public DeploymentRequest { + if (artifactId == null || !ARTIFACT_ID.matcher(artifactId).matches()) { + throw new IllegalArgumentException("artifactId is invalid"); + } + if (sha256 == null || !sha256.matches("[0-9a-fA-F]{64}")) { + throw new IllegalArgumentException("sha256 must be a 64-character hexadecimal digest"); + } + sha256 = sha256.toLowerCase(java.util.Locale.ROOT); + if (size < 1 || size > MAX_ARTIFACT_BYTES) { + throw new IllegalArgumentException("size is outside the deployment limit"); + } + if (nodeIds == null || nodeIds.isEmpty() || nodeIds.size() > 100) { + throw new IllegalArgumentException("nodeIds must contain between 1 and 100 nodes"); + } + nodeIds = List.copyOf(nodeIds); + Set unique = new HashSet<>(); + for (String nodeId : nodeIds) { + if (nodeId == null || !NODE_ID.matcher(nodeId).matches() || !unique.add(nodeId)) { + throw new IllegalArgumentException("nodeIds must contain unique valid node IDs"); + } + } + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentResult.java b/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentResult.java new file mode 100644 index 00000000..bd18baa9 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentResult.java @@ -0,0 +1,17 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** Public, metadata-only view of a deployment and its per-node outcomes. */ +public record DeploymentResult(UUID deploymentId, String artifactId, String sha256, long size, String state, + Instant createdAt, List nodes) { + public DeploymentResult { + nodes = nodes == null ? List.of() : List.copyOf(nodes); + } + + public record NodeResult(String nodeId, UUID sessionId, String state, DeploymentTaskResult result, + Instant leasedAt, UUID attemptId) { + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentTask.java b/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentTask.java new file mode 100644 index 00000000..66f8924b --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentTask.java @@ -0,0 +1,12 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.UUID; + +/** Work item claimed by one currently registered node. */ +public record DeploymentTask(UUID deploymentId, String artifactId, String sha256, long size, UUID attemptId) { + public DeploymentTask { + if (deploymentId == null || attemptId == null || artifactId == null || sha256 == null) { + throw new IllegalArgumentException("deployment task metadata is required"); + } + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentTaskResult.java b/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentTaskResult.java new file mode 100644 index 00000000..79bbe707 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/DeploymentTaskResult.java @@ -0,0 +1,7 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.UUID; + +/** Bounded result reported by a node for one deployment attempt. */ +public record DeploymentTaskResult(UUID sessionId, boolean success, String code, String message, UUID attemptId) { +} diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index a06a0fec..b084e460 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -56,6 +56,10 @@ const refresh = document.querySelector('#refresh'); const previousPage = document.querySelector('#previous-page'); const nextPage = document.querySelector('#next-page'); const pageNumber = document.querySelector('#page-number'); +const deploymentJar = document.querySelector('#deployment-jar'); +const deployPlugin = document.querySelector('#deploy-plugin'); +const deploymentEligibility = document.querySelector('#deployment-eligibility'); +const deploymentStatus = document.querySelector('#deployment-status'); const sendAll = document.querySelector('#send-all'); const blockedServers = document.querySelector('#blocked-servers'); const readConfiguration = document.querySelector('#read-configuration'); @@ -276,6 +280,7 @@ let enrollmentMutationInFlight = false; let configurationOperationsInFlight = 0; let proxyMethodWorkflowInFlight = false; let voteSiteReadTimer = null; +let deploymentInFlight = false; const FILE_READ_CACHE_TTL_MS = 30_000; const MAX_FILE_READ_CACHE_ENTRIES = 12; const MAX_OPERATION_HISTORY = 50; @@ -303,6 +308,7 @@ let dashboardTopologySignature = ''; let dashboardConfigurationGeneration = 0; let dashboardLoading = false; let operationHistoryItems = []; +let deploymentHistoryItems = []; let observedServerConfigurationGeneration = null; let operationHistoryStatus = 'not-loaded'; let enrollmentStatus = 'not-loaded'; @@ -979,8 +985,8 @@ function rememberOperation(operation) { function renderOperationHistory() { operationHistory.replaceChildren(); - if (operationHistoryItems.length === 0) { - text(operationHistory, 'No retained configuration operations.'); + if (operationHistoryItems.length === 0 && deploymentHistoryItems.length === 0) { + text(operationHistory, 'No retained configuration or plugin deployment operations.'); renderMetrics(); return; } @@ -1055,6 +1061,36 @@ function renderOperationHistory() { item.append(heading, detail); operationHistory.append(item); }); + deploymentHistoryItems.forEach(deployment => { + const item = document.createElement('article'); + item.className = 'result-item'; + const heading = document.createElement('div'); + heading.className = 'section-title'; + const identity = document.createElement('div'); + identity.append(text(document.createElement('strong'), `Plugin deployment · ${deployment.state}`)); + identity.append(text(document.createElement('small'), `${deployment.deploymentId} · ${new Date(deployment.createdAt).toLocaleString()}`)); + heading.append(identity); + if ((deployment.nodes || []).some(node => node.state === 'FAILED')) { + const retry = text(document.createElement('button'), 'Retry failed targets'); + retry.type = 'button'; + retry.className = 'secondary compact'; + retry.addEventListener('click', async () => { + retry.disabled = true; + try { + const created = await authorized(`/api/v1/deployments/${deployment.deploymentId}/retry`, {method: 'POST'}); + const completed = await waitForDeployment(created, authenticationGeneration); + text(deploymentStatus, deploymentSummary(completed)); + await loadOperationHistory(); + } catch (error) { text(message, error.message); } + finally { retry.disabled = false; } + }); + heading.append(retry); + } + const detail = document.createElement('pre'); + text(detail, deploymentSummary(deployment)); + item.append(heading, detail); + operationHistory.append(item); + }); renderMetrics(); } @@ -1063,7 +1099,9 @@ async function loadOperationHistoryOnce() { const historyGeneration = authenticationGeneration; operationHistoryStatus = 'loading'; try { - const body = await authorized('/api/v1/operations'); + const [body, deploymentBody] = await Promise.all([ + authorized('/api/v1/operations'), authorized('/api/v1/deployments?offset=0&limit=50') + ]); if (!authenticated || historyGeneration !== authenticationGeneration) return; const retainedOperations = Array.isArray(body.items) ? body.items : []; const serverConfigurationGeneration = finiteCount(body.configurationGeneration); @@ -1079,6 +1117,8 @@ async function loadOperationHistoryOnce() { operationHistoryItems = retainedOperations.slice(0, MAX_OPERATION_HISTORY).map(operation => ({...operation, results: Object.fromEntries(Object.entries(operation.results || {}).map(([nodeId, result]) => [nodeId, result ? {...result, configuration: null} : result]))})); + deploymentHistoryItems = Array.isArray(deploymentBody.items) + ? deploymentBody.items.slice(0, MAX_OPERATION_HISTORY) : []; if (observedSuccessfulApply) invalidateConfigurationReads(); const pendingRestarts = new Map(); const restartSessions = body.voteLoggingRestartSessions; @@ -1103,6 +1143,7 @@ async function loadOperationHistoryOnce() { } catch (error) { if (!authenticated || historyGeneration !== authenticationGeneration) return; operationHistoryItems = []; + deploymentHistoryItems = []; voteLoggingRestartPending = new Map(); operationHistoryStatus = 'failed'; text(operationHistory, error.message || 'Operation history could not be loaded.'); @@ -1262,6 +1303,7 @@ function applyAuthenticatedSession(body) { dashboardInspectionStatus = emptyDashboardInspectionStatus(); dashboardTopologySignature = ''; operationHistoryItems = []; + deploymentHistoryItems = []; observedServerConfigurationGeneration = null; operationHistoryStatus = 'not-loaded'; enrollmentStatus = 'not-loaded'; @@ -1327,7 +1369,8 @@ function friendlyCapability(capability) { 'config.proxy-method.v2': 'Proxy method · HTTP', 'config.quick-setup.v1': 'Setup assistant', 'config.proxy-routing.v1': 'Proxy routing', - 'data.inspect.v1': 'Read-only data inspection' + 'data.inspect.v1': 'Read-only data inspection', + 'plugin.deploy.v1': 'Verified plugin staging' })[capability]; } @@ -2705,9 +2748,22 @@ function renderNodeViews() { renderVoteSitesSync(); renderTransportTest(); renderProxyMethod(); + renderDeploymentEligibility(); updateExtendedButtons(); } +function deploymentTargets() { + return allNodeItems.filter(node => node.online && node.acceptedCapabilities.includes('plugin.deploy.v1')); +} + +function renderDeploymentEligibility() { + const eligible = deploymentTargets(); + const connected = allNodeItems.filter(node => node.online); + text(deploymentEligibility, `${eligible.length}/${connected.length} connected nodes eligible`); + deploymentEligibility.className = `pill ${eligible.length ? 'online' : 'neutral'}`; + deployPlugin.disabled = !authenticated || deploymentInFlight || !deploymentJar.files?.length || eligible.length === 0; +} + function selectNodePage(offset) { pageOffset = Math.max(0, offset); visibleNodeItems = allNodeItems.slice(pageOffset, pageOffset + PAGE_SIZE); @@ -2984,6 +3040,39 @@ async function authorized(path, options = {}) { return body; } +async function deploymentFileSha256(file) { + if (!window.isSecureContext || !window.crypto?.subtle) { + return null; + } + const digest = await window.crypto.subtle.digest('SHA-256', await file.arrayBuffer()); + return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, '0')).join(''); +} + +function deploymentSummary(operation) { + const lines = [`Deployment ${operation.deploymentId} · ${operation.state}`]; + (operation.nodes || []).forEach(node => { + const result = node.result; + lines.push(result ? `${result.success ? '✓' : '✗'} ${node.nodeId}: ${configurationFailureLabel(result.code)} — ${result.message}` + : `… ${node.nodeId}: ${String(node.state).toLowerCase()}`); + }); + return lines.join('\n'); +} + +async function waitForDeployment(operation, generation) { + text(deploymentStatus, deploymentSummary(operation)); + const deadline = Date.now() + 180_000; + while (operation.state === 'RUNNING' || operation.state === 'QUEUED' || operation.state === 'PARTIAL') { + if (Date.now() >= deadline) { + throw new Error(`Deployment ${operation.deploymentId} is still pending. Its durable state remains available in Activity.`); + } + await new Promise(resolve => window.setTimeout(resolve, 1500)); + if (generation !== authenticationGeneration) throw new Error('Authentication changed while deployment was running.'); + operation = await authorized(`/api/v1/deployments/${operation.deploymentId}`); + text(deploymentStatus, deploymentSummary(operation)); + } + return operation; +} + function discardAuthenticationState(reason) { authenticationGeneration++; authenticated = false; @@ -3031,6 +3120,7 @@ function discardAuthenticationState(reason) { dashboardInspectionStatus = emptyDashboardInspectionStatus(); dashboardTopologySignature = ''; operationHistoryItems = []; + deploymentHistoryItems = []; observedServerConfigurationGeneration = null; operationHistoryStatus = 'not-loaded'; enrollmentStatus = 'not-loaded'; @@ -4992,6 +5082,53 @@ quickPreset.addEventListener('input', () => { void autoLoadTab('quick-setup'); }); serverPicker.addEventListener('change', () => selectPrimaryServer(serverPicker.value)); +deploymentJar.addEventListener('change', renderDeploymentEligibility); +deployPlugin.addEventListener('click', async () => { + const file = deploymentJar.files?.[0]; + const eligible = deploymentTargets(); + if (!file || !eligible.length || deploymentInFlight) return; + if (!file.name.toLowerCase().endsWith('.jar') || file.size < 1 || file.size > 64 * 1024 * 1024) { + text(deploymentStatus, 'Choose a non-empty VotingPlugin JAR no larger than 64 MiB.'); + return; + } + const ineligible = allNodeItems.filter(node => node.online + && !node.acceptedCapabilities.includes('plugin.deploy.v1')).map(node => node.displayName); + const confirmation = `Upload ${file.name} (${file.size.toLocaleString()} bytes) and stage it on ` + + `${eligible.length} deployment-capable node(s)? Servers will require a restart. Automatic restart is disabled.` + + (ineligible.length ? ` Older/incompatible nodes excluded: ${ineligible.join(', ')}.` : ''); + if (!window.confirm(confirmation)) return; + deploymentInFlight = true; + renderDeploymentEligibility(); + const generation = authenticationGeneration; + try { + text(deploymentStatus, 'Calculating SHA-256 locally…'); + const sha256 = await deploymentFileSha256(file); + text(deploymentStatus, sha256 ? `Uploading artifact ${sha256.slice(0, 12)} for server verification…` + : 'Uploading artifact for bounded server-side SHA-256 verification…'); + const uploadHeaders = {'Content-Type': 'application/java-archive', 'X-Filename': file.name}; + if (sha256) uploadHeaders['X-Artifact-SHA256'] = sha256; + const artifact = await authorized('/api/v1/artifacts/votingplugin', { + method: 'POST', headers: uploadHeaders, body: file + }); + if (sha256 && artifact.sha256 !== sha256 || artifact.size !== file.size) { + throw new Error('Control returned artifact metadata that does not match the selected JAR.'); + } + const verifiedSha256 = artifact.sha256; + const operation = await authorized('/api/v1/deployments', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ + artifactId: artifact.artifactId, sha256: verifiedSha256, size: file.size, + nodeIds: eligible.map(node => node.nodeId) + }) + }); + const completed = await waitForDeployment(operation, generation); + text(deploymentStatus, `${deploymentSummary(completed)}\nRestart each successfully staged server to activate this JAR.`); + } catch (error) { + text(deploymentStatus, error.message); + } finally { + deploymentInFlight = false; + renderDeploymentEligibility(); + } +}); tabButtons.forEach(button => button.addEventListener('click', () => { if (button.dataset.configShortcut) setConfigView(button.dataset.configShortcut); setActiveTab(button.dataset.tab, true); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 7c4a1e5a..28de2e96 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -185,6 +185,15 @@

Servers

Page 1 +
+
+

Verified update staging

Update VotingPlugin

Upload one VotingPlugin JAR, verify its SHA-256, then stage it on every connected deployment-capable node. Servers are never restarted automatically.

+ No eligible nodes +
+ +
+
Nodes running an older connector remain connected but require one manual update before web deployment is available.
+