Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,17 @@ when the pieces are independently deployable or need materially different review
Protocol version `1` describes the registration/heartbeat resource protocol. Feature evolution normally uses a new
capability such as `data.inspect.v1`; do not bump the whole protocol for an optional additive feature.

`config.proxy-method.v1` persists a validated method on the proxy and reported backends. Backends reload only their proxy
communication handler. The proxy acknowledges the durable result before its existing guarded runtime replacement, so
teardown cannot lose the completion. A failed backend reload must restore its backup. Control availability or a method
switch must never delay or prevent normal proxy or backend startup.
`config.proxy-method.v1` persists the legacy plugin-messaging, Redis, MySQL, MQTT, or sockets method on the proxy and reported backends;
`config.proxy-method.v2` adds HTTP. Control must select the exact capability required by the requested method. Backends
reload only their proxy communication handler. The proxy acknowledges the durable result before its existing guarded
runtime replacement, so teardown cannot lose the completion. A failed backend reload must restore its backup. Control
availability or a method switch must never delay or prevent normal proxy or backend startup.

`config.quick-setup.v2` adds `VoteParty.Enabled` to the versioned quick-setup contract. Control must keep legacy
Vote Party payloads on `config.quick-setup.v1` and require v2 before sending or accepting the `enabled` field. The
VotingPlugin connector change may deploy first and remain unused until Control negotiates v2; deploying Control first
must leave v2 actions unavailable on older nodes. Merge the VotingPlugin capability implementation before relying on the
new Control behavior in production.

## Safe change checklist

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,10 @@ Configuration is split into independently negotiated capabilities. `config.proxy
manages only an enrolled proxy's `bungeeconfig.yml`; it is not general proxy file access. The proxy file is strict,
bounded, redacted YAML with safe preview/approval, atomic publication, and a local backup. General proxy-file settings
are saved without a proxy reload, so the operation reports that a restart is needed to activate them. `config.quick-setup.v1` supplies standalone,
proxy-backend, vote-site, easy-reward, common-settings, auto-create-vote-sites, vote-logging, vote-party, and typed
proxy-backend, vote-site, easy-reward, common-settings, vote-party, auto-create-vote-sites, vote-logging, and typed
reward-builder presets.
Legacy Vote Party editing remains available through v1; only its Enabled extension requires
`config.quick-setup.v2`, and v1 nodes are never sent that extended proposal.
The auto-create preset owns only `AutoCreateVoteSites`; the logging preset owns only enabled state, purge retention
(`-1` disables purging or `1`–`3650` days), and main-MySQL reuse, never connection credentials. Readable presets load their
installed values before editing.
Expand Down
9 changes: 7 additions & 2 deletions docs/control-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,16 @@ Control accepts only the intersection with its own allow-list.
| `config.files.v1` | Bounded reads/previews/applies for managed Bukkit YAML files |
| `config.file-comments.v1` | Preserves Control-managed comment metadata where supported |
| `config.quick-setup.v1` | Typed guided settings and reward/site presets |
| `config.quick-setup.v2` | Vote Party guided settings including revision-safe Enabled round trips |
| `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` | HTTP proxy-method selection and HTTP backend quick setup; only paired connectors implementing the HTTP contract advertise it |
Comment thread
BenCodez marked this conversation as resolved.
| `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.
Deploy the paired VotingPlugin connector change before enabling Control features that require a new capability. During a
mixed-version rollout, older nodes remain connected but are excluded from v2 HTTP previews and applies.

## WebUI feature map

Expand Down Expand Up @@ -126,8 +130,9 @@ general setting activation still requires a proxy restart.
4. Nodes stage and atomically replace managed YAML, reload VotingPlugin, and restore the local `.control-backup` if reload
fails. The result distinguishes reload and rollback from a successful save.

The proxy method preset validates and persists the requested `MYSQL`, `PLUGINMESSAGING`, `REDIS`, `MQTT`, or `SOCKETS`
method on the proxy and its reported backends. The proxy acknowledges its durable result before replacing its runtime, so
The proxy method preset validates and persists the requested `MYSQL`, `PLUGINMESSAGING`, `REDIS`, `MQTT`, `SOCKETS`, or
`HTTP` method on the proxy and its reported backends. `HTTP` requires every target to negotiate
`config.proxy-method.v2`; the other methods use `config.proxy-method.v1`. The proxy acknowledges its durable result before replacing its runtime, so
the operation result cannot be lost during teardown. Backends reload only their proxy communication handler. If a durable
write or backend handler reload fails, its local backup is restored and the operation reports the failed/rolled-back state
rather than a false network-wide success.
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-shade-plugin</artifactId><version>3.6.0</version>
<executions><execution><phase>package</phase><goals><goal>shade</goal></goals><configuration><shadedArtifactAttached>true</shadedArtifactAttached><shadedClassifierName>all</shadedClassifierName></configuration></execution></executions>
<executions><execution><phase>package</phase><goals><goal>shade</goal></goals><configuration><shadedArtifactAttached>true</shadedArtifactAttached><shadedClassifierName>all</shadedClassifierName><createDependencyReducedPom>false</createDependencyReducedPom></configuration></execution></executions>
</plugin>
</plugins>
</build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ public final class ConfigurationOperations implements AutoCloseable {
public static final String FILE_CAPABILITY = "config.files.v1";
public static final String PROXY_FILE_CAPABILITY = "config.proxy-files.v1";
public static final String QUICK_SETUP_CAPABILITY = "config.quick-setup.v1";
public static final String QUICK_SETUP_VOTE_PARTY_CAPABILITY = "config.quick-setup.v2";
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";
public static final String PROXY_METHOD_HTTP_CAPABILITY = "config.proxy-method.v2";
private static final int MAX_OPERATIONS = 1000;
private static final int MAX_LISTED_OPERATIONS = 100;
private static final int MAX_FILE_OPERATIONS = 16;
Expand Down Expand Up @@ -260,13 +262,23 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) {
else operation.claimSessions.put(nodeId, previousClaimSession);
throw e;
}
return new ConfigurationTask(operation.id, operation.type, operation.configuration,
return new ConfigurationTask(operation.id, operation.type, configurationForTask(operation),
operation.expectedRevisions.get(nodeId), attemptId);
}
}
return null;
}

private static ManagedConfiguration configurationForTask(StoredOperation operation) {
ManagedConfiguration configuration = operation.configuration;
if ("READ".equals(operation.type) && ManagedConfiguration.QUICK_SETUP.equals(configuration.domain())
&& "proxy-backend".equals(configuration.preset()) && configuration.options().containsKey("method")) {
return new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), null, null,
configuration.preset(), Map.of());
}
return configuration;
}

private boolean deferProxyMethodApply(StoredOperation operation, NodeStatus node) {
if (!"APPLY".equals(operation.type)
|| !ManagedConfiguration.QUICK_SETUP.equals(operation.configuration.domain())
Expand Down Expand Up @@ -318,8 +330,16 @@ private boolean deferProxyMethodApply(StoredOperation operation, NodeStatus node
"Proxy topology changed after approval; preview again", "TOPOLOGY_CHANGED");
return true;
}
String causes = backends.stream().filter(backendId -> operation.results.get(backendId) == null
|| !operation.results.get(backendId).success())
.map(backendId -> {
ConfigurationTaskResult failure = operation.results.get(backendId);
return failure == null ? backendId + " RESULT_UNAVAILABLE"
: backendId + " " + failure.code() + ": " + failure.message();
})
.collect(java.util.stream.Collectors.joining("; "));
automaticCancellation(operation, node.nodeId(), sessionId(node), "DEPENDENCY_FAILED",
"A backend failed the proxy method apply", "BACKEND_APPLY_FAILED");
truncateUtf8("Proxy not applied because " + causes, 500), "BACKEND_APPLY_FAILED");
return true;
}

Expand Down Expand Up @@ -471,6 +491,17 @@ private void validateProxyMethodTargets(ValidatedTargets targets, ManagedConfigu

private static void validateConfigurationTargets(ValidatedTargets targets,
ManagedConfiguration configuration) {
if (ManagedConfiguration.QUICK_SETUP.equals(configuration.domain())
&& "proxy-backend".equals(configuration.preset())) {
List<String> invalid = targets.nodeIds().stream()
.filter(nodeId -> !"BUKKIT".equalsIgnoreCase(targets.platforms().get(nodeId)))
.toList();
if (!invalid.isEmpty()) {
throw new ValidationException("INVALID_TARGET",
"Backend proxy settings require Bukkit nodes", invalid);
}
return;
}
if (!ManagedConfiguration.FILE.equals(configuration.domain())) return;
boolean proxyFile = "bungeeconfig.yml".equals(configuration.fileName());
List<String> invalid = targets.nodeIds().stream()
Expand Down Expand Up @@ -673,10 +704,17 @@ private static void validateResultConfiguration(StoredOperation operation, Confi
ManagedConfiguration expected = operation.configuration;
boolean mismatch = expected == null || !expected.domain().equals(actual.domain())
|| (ManagedConfiguration.FILE.equals(expected.domain()) && !expected.fileName().equals(actual.fileName()))
|| (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset()));
|| (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset()))
|| (!activeMethodRead(operation, expected) && !expected.capability().equals(actual.capability()));
if (mismatch) throw invalid("result configuration does not match the operation selector");
}

private static boolean activeMethodRead(StoredOperation operation, ManagedConfiguration expected) {
return "READ".equals(operation.type) && ManagedConfiguration.QUICK_SETUP.equals(expected.domain())
&& (ManagedConfiguration.PROXY_METHOD.equals(expected.preset())
|| "proxy-backend".equals(expected.preset()));
}

private String retainMessage(String message) {
int remaining = (int) Math.max(0, MAX_RETAINED_MESSAGE_BYTES - retainedMessageBytes);
String retained = truncateUtf8(message, remaining);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ public final class InMemoryNodeRegistry implements NodeRegistry {
public static final Set<String> SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot",
ConfigurationOperations.CAPABILITY, ConfigurationOperations.FILE_CAPABILITY,
ConfigurationOperations.PROXY_FILE_CAPABILITY,
ConfigurationOperations.QUICK_SETUP_CAPABILITY, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY,
ConfigurationOperations.QUICK_SETUP_CAPABILITY,
ConfigurationOperations.QUICK_SETUP_VOTE_PARTY_CAPABILITY,
ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY,
ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY,
ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY,
Comment thread
BenCodez marked this conversation as resolved.
Comment thread
BenCodez marked this conversation as resolved.
"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}");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,15 @@ public void validateProposal() {
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")
&& (options.size() != 1 || !List.of("PLUGINMESSAGING", "REDIS", "MQTT", "SOCKETS", "MYSQL", "HTTP")
.contains(options.get("method")))) {
throw new IllegalArgumentException("proxy method requires one supported method");
}
if (QUICK_SETUP.equals(domain) && "proxy-backend".equals(preset) && options.containsKey("method")
&& !List.of("PLUGINMESSAGING", "REDIS", "MQTT", "SOCKETS", "MYSQL", "HTTP")
.contains(options.get("method"))) {
throw new IllegalArgumentException("proxy backend requires a canonical supported method");
}
if (QUICK_SETUP.equals(domain) && REWARD_BUILDER.equals(preset)
&& (options.size() != 1 || !options.containsKey("proposal"))) {
throw new IllegalArgumentException("reward builder requires one typed proposal");
Expand All @@ -129,7 +134,11 @@ public String capability() {
case QUICK_SETUP -> VOTE_SITES_SYNC.equals(preset)
? "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";
? "HTTP".equals(options.get("method")) ? "config.proxy-method.v2" : "config.proxy-method.v1"
: "proxy-backend".equals(preset) && "HTTP".equals(options.get("method"))
? "config.proxy-method.v2"
: "vote-party".equals(preset) && options.containsKey("enabled") ? "config.quick-setup.v2"
: "config.quick-setup.v1";
Comment thread
BenCodez marked this conversation as resolved.
Comment thread
BenCodez marked this conversation as resolved.
default -> throw new IllegalStateException("unsupported configuration domain");
};
}
Expand Down
Loading
Loading