diff --git a/Cargo.toml b/Cargo.toml index 7e614e61..0581ca17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ ] [workspace.metadata] -protocol-version = 2 +protocol-version = 3 [profile.release] lto = true diff --git a/clients/jvm/build.gradle.kts b/clients/jvm/build.gradle.kts index 8cd1ad03..95d57a62 100644 --- a/clients/jvm/build.gradle.kts +++ b/clients/jvm/build.gradle.kts @@ -2,7 +2,7 @@ import com.google.protobuf.gradle.id plugins { id("java") - id("com.diffplug.spotless") version "7.0.0.BETA1" + id("com.diffplug.spotless") version "7.0.1" id("com.google.protobuf") version "0.9.4" } diff --git a/clients/jvm/common/src/main/java/io/atomic/cloud/common/cache/CachedObject.java b/clients/jvm/common/src/main/java/io/atomic/cloud/common/cache/CachedObject.java new file mode 100644 index 00000000..00423e71 --- /dev/null +++ b/clients/jvm/common/src/main/java/io/atomic/cloud/common/cache/CachedObject.java @@ -0,0 +1,25 @@ +package io.atomic.cloud.common.cache; + +import java.util.Optional; + +public class CachedObject { + + public static final long DEFAULT_EXPIRATION = 1000 * 30; + + private T value; + private long invalidateTime; + + public synchronized Optional getValue() { + if (this.value == null) return Optional.empty(); + if (System.currentTimeMillis() > invalidateTime) { + this.value = null; + return Optional.empty(); + } + return Optional.of(this.value); + } + + public synchronized void setValue(T value) { + this.value = value; + this.invalidateTime = System.currentTimeMillis() + DEFAULT_EXPIRATION; + } +} diff --git a/clients/jvm/common/src/main/java/io/atomic/cloud/common/connection/CloudConnection.java b/clients/jvm/common/src/main/java/io/atomic/cloud/common/connection/CloudConnection.java index 4a4574f2..9f57ad21 100644 --- a/clients/jvm/common/src/main/java/io/atomic/cloud/common/connection/CloudConnection.java +++ b/clients/jvm/common/src/main/java/io/atomic/cloud/common/connection/CloudConnection.java @@ -4,10 +4,8 @@ import com.google.protobuf.Empty; import com.google.protobuf.StringValue; import com.google.protobuf.UInt32Value; -import io.atomic.cloud.grpc.unit.ChannelManagement; -import io.atomic.cloud.grpc.unit.TransferManagement; -import io.atomic.cloud.grpc.unit.UnitServiceGrpc; -import io.atomic.cloud.grpc.unit.UserManagement; +import io.atomic.cloud.common.cache.CachedObject; +import io.atomic.cloud.grpc.unit.*; import io.grpc.CallCredentials; import io.grpc.ManagedChannelBuilder; import io.grpc.Metadata; @@ -16,6 +14,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import lombok.Getter; @@ -32,8 +31,9 @@ public class CloudConnection { private UnitServiceGrpc.UnitServiceStub client; // Cache values - private UInt32Value protocolVersion; - private StringValue controllerVersion; + private final CachedObject protocolVersion = new CachedObject<>(); + private final CachedObject controllerVersion = new CachedObject<>(); + private final CachedObject unitsInfo = new CachedObject<>(); public void connect() { var channel = ManagedChannelBuilder.forAddress(this.address.getHost(), this.address.getPort()); @@ -127,6 +127,27 @@ public void subscribeToChannel(String channel, StreamObserver getUnitsNow() { + var cached = this.unitsInfo.getValue(); + if (cached.isEmpty()) { + this.getUnits(); // Request value from controller + } + return cached; + } + + public CompletableFuture getUnits() { + var cached = this.unitsInfo.getValue(); + if (cached.isPresent()) { + return CompletableFuture.completedFuture(cached.get()); + } + var observer = new StreamObserverImpl(); + this.client.getUnits(Empty.getDefaultInstance(), observer); + return observer.future().thenApply((value) -> { + this.unitsInfo.setValue(value); + return value; + }); + } + public CompletableFuture sendReset() { var observer = new StreamObserverImpl(); this.client.reset(Empty.getDefaultInstance(), observer); @@ -134,29 +155,27 @@ public CompletableFuture sendReset() { } public synchronized CompletableFuture getProtocolVersion() { - if (this.controllerVersion != null) { - return CompletableFuture.completedFuture(this.protocolVersion); + var cached = this.protocolVersion.getValue(); + if (cached.isPresent()) { + return CompletableFuture.completedFuture(cached.get()); } var observer = new StreamObserverImpl(); this.client.getProtocolVersion(Empty.getDefaultInstance(), observer); return observer.future().thenApply((value) -> { - synchronized (this) { - this.protocolVersion = value; - } + this.protocolVersion.setValue(value); return value; }); } public synchronized CompletableFuture getControllerVersion() { - if (this.controllerVersion != null) { - return CompletableFuture.completedFuture(this.controllerVersion); + var cached = this.controllerVersion.getValue(); + if (cached.isPresent()) { + return CompletableFuture.completedFuture(cached.get()); } var observer = new StreamObserverImpl(); this.client.getControllerVersion(Empty.getDefaultInstance(), observer); return observer.future().thenApply((value) -> { - synchronized (this) { - this.controllerVersion = value; - } + this.controllerVersion.setValue(value); return value; }); } diff --git a/clients/jvm/gradle.properties b/clients/jvm/gradle.properties index 16f4cc0d..45d3919a 100644 --- a/clients/jvm/gradle.properties +++ b/clients/jvm/gradle.properties @@ -9,12 +9,12 @@ archive_basename=client client_version=0.3.0 ## Minecraft -minecraft_version=1.21.1 +minecraft_version=1.21.4 ## Java -lombok_version=1.18.34 -jetbrains_annotations_version=24.0.0 +lombok_version=1.18.36 +jetbrains_annotations_version=26.0.1 ## gRPC -grpc_version=1.65.0 -protobuf_version=4.27.2 \ No newline at end of file +grpc_version=1.69.0 +protobuf_version=4.29.2 \ No newline at end of file diff --git a/clients/jvm/gradle/wrapper/gradle-wrapper.jar b/clients/jvm/gradle/wrapper/gradle-wrapper.jar index e6441136..a4b76b95 100644 Binary files a/clients/jvm/gradle/wrapper/gradle-wrapper.jar and b/clients/jvm/gradle/wrapper/gradle-wrapper.jar differ diff --git a/clients/jvm/gradle/wrapper/gradle-wrapper.properties b/clients/jvm/gradle/wrapper/gradle-wrapper.properties index a4413138..cea7a793 100644 --- a/clients/jvm/gradle/wrapper/gradle-wrapper.properties +++ b/clients/jvm/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/clients/jvm/gradlew b/clients/jvm/gradlew old mode 100644 new mode 100755 index b740cf13..f3b75f3b --- a/clients/jvm/gradlew +++ b/clients/jvm/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/clients/jvm/gradlew.bat b/clients/jvm/gradlew.bat index 25da30db..9d21a218 100644 --- a/clients/jvm/gradlew.bat +++ b/clients/jvm/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## diff --git a/clients/jvm/paper/build.gradle.kts b/clients/jvm/paper/build.gradle.kts index fa0425dd..d4fd3ece 100644 --- a/clients/jvm/paper/build.gradle.kts +++ b/clients/jvm/paper/build.gradle.kts @@ -9,10 +9,10 @@ plugins { // Paper - id("io.papermc.paperweight.userdev") version "1.7.1" + id("io.papermc.paperweight.userdev") version "2.0.0-beta.12" // Shadow (Only for including the API files into the jar) - id("com.gradleup.shadow") version "8.3.0" + id("com.gradleup.shadow") version "9.0.0-beta4" } dependencies { diff --git a/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/CloudPluginBootstrap.java b/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/CloudPluginBootstrap.java index 3dff3dd5..c523f6e7 100644 --- a/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/CloudPluginBootstrap.java +++ b/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/CloudPluginBootstrap.java @@ -1,15 +1,15 @@ package io.atomic.cloud.paper; import io.atomic.cloud.paper.command.CloudCommand; +import io.atomic.cloud.paper.command.SendCommand; import io.papermc.paper.plugin.bootstrap.BootstrapContext; import io.papermc.paper.plugin.bootstrap.PluginBootstrap; import io.papermc.paper.plugin.bootstrap.PluginProviderContext; import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; import org.bukkit.plugin.java.JavaPlugin; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -@ApiStatus.Experimental +@SuppressWarnings("UnstableApiUsage") public class CloudPluginBootstrap implements PluginBootstrap { @Override @@ -28,6 +28,7 @@ private void registerCommands(@NotNull BootstrapContext context) { manager.registerEventHandler(LifecycleEvents.COMMANDS, event -> { final var commands = event.registrar(); CloudCommand.register(commands); + SendCommand.register(commands); }); } } diff --git a/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/command/CloudCommand.java b/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/command/CloudCommand.java index 859afa4a..e618f9aa 100644 --- a/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/command/CloudCommand.java +++ b/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/command/CloudCommand.java @@ -8,6 +8,7 @@ import net.kyori.adventure.text.format.NamedTextColor; import org.jetbrains.annotations.NotNull; +@SuppressWarnings("UnstableApiUsage") public class CloudCommand { public static void register(@NotNull Commands commands) { diff --git a/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/command/SendCommand.java b/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/command/SendCommand.java new file mode 100644 index 00000000..b54e3e30 --- /dev/null +++ b/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/command/SendCommand.java @@ -0,0 +1,61 @@ +package io.atomic.cloud.paper.command; + +import com.mojang.brigadier.Command; +import io.atomic.cloud.grpc.unit.TransferManagement; +import io.atomic.cloud.grpc.unit.UnitInformation; +import io.atomic.cloud.paper.CloudPlugin; +import io.atomic.cloud.paper.command.argument.UnitArgument; +import io.atomic.cloud.paper.permission.Permissions; +import io.papermc.paper.command.brigadier.Commands; +import io.papermc.paper.command.brigadier.argument.ArgumentTypes; +import io.papermc.paper.command.brigadier.argument.resolvers.selector.PlayerSelectorArgumentResolver; +import java.util.concurrent.CompletableFuture; +import org.jetbrains.annotations.NotNull; + +@SuppressWarnings("UnstableApiUsage") +public class SendCommand { + + public static void register(@NotNull Commands commands) { + commands.register(Commands.literal("send") + .requires(Permissions.SERVER_COMMAND::check) + .then(Commands.argument("user", ArgumentTypes.players()) + .then(Commands.argument("unit", UnitArgument.INSTANCE).executes(context -> { + var sender = context.getSource().getSender(); + var connection = CloudPlugin.INSTANCE.connection(); + + var users = context.getArgument("user", PlayerSelectorArgumentResolver.class) + .resolve(context.getSource()); + var unit = context.getArgument("unit", UnitInformation.SimpleUnitValue.class); + var userCount = users.size(); + + sender.sendRichMessage("Transferring " + userCount + + " users to unit " + unit.getName() + "..."); + CompletableFuture.allOf(users.stream() + .map((player) -> connection.transferUser( + TransferManagement.TransferUserRequest.newBuilder() + .setUserUuid(player.getUniqueId() + .toString()) + .setTarget( + TransferManagement.TransferTargetValue.newBuilder() + .setTargetType( + TransferManagement + .TransferTargetValue + .TargetType.UNIT) + .setTarget(unit.getUuid())) + .build())) + .toArray(CompletableFuture[]::new)) + .whenComplete((result, throwable) -> { + if (throwable != null) { + sender.sendRichMessage( + "Failed to transfer " + userCount + " users to unit " + + unit.getName() + ": " + throwable.getMessage()); + } else { + sender.sendRichMessage("Submitted " + userCount + + " transfer requests to controller"); + } + }); + return Command.SINGLE_SUCCESS; + }))) + .build()); + } +} diff --git a/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/command/argument/UnitArgument.java b/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/command/argument/UnitArgument.java new file mode 100644 index 00000000..6eecdf86 --- /dev/null +++ b/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/command/argument/UnitArgument.java @@ -0,0 +1,66 @@ +package io.atomic.cloud.paper.command.argument; + +import com.mojang.brigadier.arguments.ArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import io.atomic.cloud.grpc.unit.UnitInformation; +import io.atomic.cloud.paper.CloudPlugin; +import io.papermc.paper.command.brigadier.MessageComponentSerializer; +import io.papermc.paper.command.brigadier.argument.CustomArgumentType; +import java.util.Arrays; +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +@SuppressWarnings("UnstableApiUsage") +public class UnitArgument implements CustomArgumentType.Converted { + + public static final UnitArgument INSTANCE = new UnitArgument(); + private static final Collection EXAMPLES = Arrays.asList("lobby-1", "bedwars-2x1-1"); + + @Override + public UnitInformation.@NotNull SimpleUnitValue convert(@NotNull String value) throws CommandSyntaxException { + var cached = CloudPlugin.INSTANCE.connection().getUnitsNow(); + if (cached.isEmpty()) throw createException("Fetching available units..."); + var unit = cached.get().getUnitsList().stream() + .filter(item -> item.getName().equals(value)) + .findFirst(); + if (unit.isEmpty()) throw createException("\"" + value + "\" does not exist"); + return unit.get(); + } + + @Override + public @NotNull CompletableFuture listSuggestions( + @NotNull CommandContext context, @NotNull SuggestionsBuilder builder) { + return CloudPlugin.INSTANCE.connection().getUnits().thenCompose(response -> { + response.getUnitsList() + .forEach(unit -> builder.suggest( + unit.getName(), + MessageComponentSerializer.message() + .serialize(Component.text(unit.getUuid()).color(NamedTextColor.BLUE)))); + return builder.buildFuture(); + }); + } + + @Override + public @NotNull ArgumentType getNativeType() { + return StringArgumentType.word(); + } + + @Override + public @NotNull Collection getExamples() { + return EXAMPLES; + } + + @Contract("_ -> new") + private @NotNull CommandSyntaxException createException(@NotNull String message) { + return new CommandSyntaxException(new SimpleCommandExceptionType(() -> message), () -> message); + } +} diff --git a/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/permission/Permissions.java b/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/permission/Permissions.java index 13e48115..e35e56af 100644 --- a/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/permission/Permissions.java +++ b/clients/jvm/paper/src/main/java/io/atomic/cloud/paper/permission/Permissions.java @@ -9,7 +9,8 @@ @AllArgsConstructor @Getter public enum Permissions { - CLOUD_COMMAND("atomic.cloud.command.cloud"); + CLOUD_COMMAND("atomic.cloud.command.cloud"), + SERVER_COMMAND("atomic.cloud.command.server"); private final String permission; diff --git a/controller/src/application/event.rs b/controller/src/application/event.rs index e718f2d5..d5acd787 100644 --- a/controller/src/application/event.rs +++ b/controller/src/application/event.rs @@ -148,4 +148,4 @@ impl Hash for EventKey { }*/ } } -} \ No newline at end of file +} diff --git a/controller/src/network/unit.rs b/controller/src/network/unit.rs index 961eaf57..abaf239e 100644 --- a/controller/src/network/unit.rs +++ b/controller/src/network/unit.rs @@ -379,6 +379,30 @@ impl UnitService for UnitServiceImpl { Ok(Response::new(StdReceiverStream::new(receiver))) } + async fn get_units( + &self, + _request: Request<()>, + ) -> Result, Status> { + let units = self + .controller + .get_units() + .get_units() + .values() + .map(|unit| proto::unit_information::SimpleUnitValue { + name: unit.name.clone(), + uuid: unit.uuid.to_string(), + deployment: unit + .deployment + .as_ref() + .and_then(|d| d.deployment.upgrade().map(|d| d.name.clone())), + }) + .collect(); + + Ok(Response::new(proto::unit_information::UnitListResponse { + units, + })) + } + async fn reset(&self, request: Request<()>) -> Result, Status> { let requesting_unit = request .extensions() diff --git a/protocol/grpc/unit/unit.proto b/protocol/grpc/unit/unit.proto index 0f5c8cfb..554e154a 100644 --- a/protocol/grpc/unit/unit.proto +++ b/protocol/grpc/unit/unit.proto @@ -35,6 +35,9 @@ service UnitService { rpc UnsubscribeFromChannel(google.protobuf.StringValue) returns (google.protobuf.Empty); rpc SubscribeToChannel(google.protobuf.StringValue) returns (stream ChannelManagement.ChannelMessageValue); + // Unit Information + rpc GetUnits(google.protobuf.Empty) returns (UnitInformation.UnitListResponse); + // Housekeeping rpc Reset(google.protobuf.Empty) returns (google.protobuf.Empty); @@ -91,4 +94,17 @@ message ChannelManagement { string data = 3; uint64 timestamp = 4; } +} + +// Unit Information +message UnitInformation { + message UnitListResponse { + repeated SimpleUnitValue units = 1; + } + + message SimpleUnitValue { + string name = 1; + string uuid = 2; + optional string deployment = 3; + } } \ No newline at end of file