diff --git a/.gitignore b/.gitignore index b63da45..973dab3 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,6 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +run/ \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..51024d5 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..dcc2d5f --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index eab14b2..b2febd7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,27 +4,43 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.kotlin) alias(libs.plugins.shadow) + alias(libs.plugins.sonatypeCentralPortalPublisher) + `maven-publish` } allprojects { - group = "app.simplecloud.controller" - version = "1.0-SNAPSHOT" + version = "0.0.27-EXPERIMENTAL" repositories { mavenCentral() + maven("https://buf.build/gen/maven") } } subprojects { apply(plugin = "org.jetbrains.kotlin.jvm") apply(plugin = "com.github.johnrengelman.shadow") + apply(plugin = "net.thebugmc.gradle.sonatype-central-portal-publisher") + apply(plugin = "maven-publish") dependencies { testImplementation(rootProject.libs.kotlinTest) implementation(rootProject.libs.kotlinJvm) } + publishing { + publications { + create("mavenJava") { + from(components["java"]) + } + } + } + + java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + } + kotlin { jvmToolchain(17) } @@ -41,4 +57,43 @@ subprojects { tasks.test { useJUnitPlatform() } + + centralPortal { + name = project.name + + username = project.findProperty("sonatypeUsername") as String + password = project.findProperty("sonatypePassword") as String + + pom { + name.set("SimpleCloud controller") + description.set("The heart of SimpleCloud v3") + url.set("https://github.com/theSimpleCloud/simplecloud-controller") + + developers { + developer { + id.set("fllipeis") + email.set("p.eistrach@gmail.com") + } + developer { + id.set("dayyeeet") + email.set("david@cappell.net") + } + } + licenses { + license { + name.set("Apache-2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + scm { + url.set("https://github.com/theSimpleCloud/simplecloud-controller.git") + connection.set("git:git@github.com:theSimpleCloud/simplecloud-controller.git") + } + } + } + + signing { + sign(publishing.publications) + useGpgCmd() + } } \ No newline at end of file diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/ControllerApi.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/ControllerApi.kt index ba58985..d1a9f91 100644 --- a/controller-api/src/main/kotlin/app/simplecloud/controller/api/ControllerApi.kt +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/ControllerApi.kt @@ -1,15 +1,41 @@ package app.simplecloud.controller.api -import app.simplecloud.controller.shared.group.Group -import java.util.concurrent.CompletableFuture +import app.simplecloud.controller.api.impl.ControllerApiImpl interface ControllerApi { /** - * NOTE: This may be moved to a separate api file. - * @param name the name of the group. - * @returns a [CompletableFuture] with the [Group]. + * @return the Controller [GroupApi] */ - fun getGroupByName(name: String): CompletableFuture + fun getGroups(): GroupApi + + /** + * @return the Controller [ServerApi] + */ + fun getServers(): ServerApi + + companion object { + + /** + * Creates a new [ControllerApi] instance + * @return the created [ControllerApi] + */ + @JvmStatic + fun create(): ControllerApi { + val authSecret = System.getenv("CONTROLLER_SECRET") + return create(authSecret) + } + + /** + * Creates a new [ControllerApi] instance + * @param authSecret the authentication key used by the Controller + * @return the created [ControllerApi] + */ + @JvmStatic + fun create(authSecret: String): ControllerApi { + return ControllerApiImpl(authSecret) + } + + } } \ No newline at end of file diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/ControllerApiSingleton.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/ControllerApiSingleton.kt deleted file mode 100644 index 9f141c6..0000000 --- a/controller-api/src/main/kotlin/app/simplecloud/controller/api/ControllerApiSingleton.kt +++ /dev/null @@ -1,17 +0,0 @@ -package app.simplecloud.controller.api - -import app.simplecloud.controller.api.impl.ControllerApiImpl - -class ControllerApiSingleton { - companion object { - @JvmStatic - lateinit var instance: ControllerApi - private set - - fun init() = init(ControllerApiImpl()) - - fun init(controllerApi: ControllerApi) { - instance = controllerApi - } - } -} \ No newline at end of file diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/GroupApi.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/GroupApi.kt new file mode 100644 index 0000000..8289c1f --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/GroupApi.kt @@ -0,0 +1,43 @@ +package app.simplecloud.controller.api + +import app.simplecloud.controller.shared.group.Group +import build.buf.gen.simplecloud.controller.v1.ServerType +import java.util.concurrent.CompletableFuture + +interface GroupApi { + + /** + * @param name the name of the group. + * @return a [CompletableFuture] with the [Group]. + */ + fun getGroupByName(name: String): CompletableFuture + + /** + * @param name the name of the group. + * @return the deleted [Group]. + */ + fun deleteGroup(name: String): CompletableFuture + + /** + * @param group the [Group] to create. + * @return the created [Group]. + */ + fun createGroup(group: Group): CompletableFuture + + /** + * @param group the [Group] to update. + * @return the updated [Group]. + */ + fun updateGroup(group: Group): CompletableFuture + /** + * @return a [CompletableFuture] with a list of all groups. + */ + fun getAllGroups(): CompletableFuture> + + /** + * @param type the [ServerType] of the group + * @return a [CompletableFuture] with a list of all groups matching this type. + */ + fun getGroupsByType(type: ServerType): CompletableFuture> + +} \ No newline at end of file diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/ServerApi.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/ServerApi.kt new file mode 100644 index 0000000..af0bb38 --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/ServerApi.kt @@ -0,0 +1,74 @@ +package app.simplecloud.controller.api + +import app.simplecloud.controller.shared.group.Group +import build.buf.gen.simplecloud.controller.v1.ServerType +import app.simplecloud.controller.shared.server.Server +import build.buf.gen.simplecloud.controller.v1.ServerState +import java.util.concurrent.CompletableFuture + +interface ServerApi { + + /** + * @return a [CompletableFuture] with a [List] of all [Server]s + */ + fun getAllServers(): CompletableFuture> + + /** + * @param id the id of the server. + * @return a [CompletableFuture] with the [Server]. + */ + fun getServerById(id: String): CompletableFuture + + /** + * @param groupName the name of the server group. + * @return a [CompletableFuture] with a [List] of [Server]s of that group. + */ + fun getServersByGroup(groupName: String): CompletableFuture> + + /** + * @param group The server group. + * @return a [CompletableFuture] with a [List] of [Server]s of that group. + */ + fun getServersByGroup(group: Group): CompletableFuture> + + /** + * @param type The servers type + * @return a [CompletableFuture] with a [List] of all [Server]s with this type + */ + fun getServersByType(type: ServerType): CompletableFuture> + + /** + * @param groupName the group name of the group the new server should be of. + * @return a [CompletableFuture] with a [Server] or null. + */ + fun startServer(groupName: String): CompletableFuture + + /** + * @param groupName the group name of the servers group. + * @param numericalId the numerical id of the server. + * @return a [CompletableFuture] with the stopped [Server]. + */ + fun stopServer(groupName: String, numericalId: Long): CompletableFuture + + /** + * @param id the id of the server. + * @return a [CompletableFuture] with the stopped [Server]. + */ + fun stopServer(id: String): CompletableFuture + + /** + * @param id the id of the server. + * @param state the new state of the server. + * @return a [CompletableFuture] with the updated [Server]. + */ + fun updateServerState(id: String, state: ServerState): CompletableFuture + + /** + * @param id the id of the server. + * @param key the server property key + * @param value the new property value + * @return a [CompletableFuture] with the updated [Server]. + */ + fun updateServerProperty(id: String, key: String, value: Any): CompletableFuture + +} \ No newline at end of file diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/ControllerApiImpl.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/ControllerApiImpl.kt index e7b7ff3..2550e78 100644 --- a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/ControllerApiImpl.kt +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/ControllerApiImpl.kt @@ -1,30 +1,34 @@ package app.simplecloud.controller.api.impl import app.simplecloud.controller.api.ControllerApi -import app.simplecloud.controller.shared.future.toCompletable -import app.simplecloud.controller.shared.group.Group -import app.simplecloud.controller.shared.proto.ControllerGroupServiceGrpc -import app.simplecloud.controller.shared.proto.GetGroupByNameRequest +import app.simplecloud.controller.api.GroupApi +import app.simplecloud.controller.api.ServerApi +import app.simplecloud.controller.shared.auth.AuthCallCredentials import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder -import java.util.concurrent.CompletableFuture -class ControllerApiImpl : ControllerApi { +class ControllerApiImpl( + authSecret: String +): ControllerApi { + + private val authCallCredentials = AuthCallCredentials(authSecret) private val managedChannel = createManagedChannelFromEnv() + private val groups: GroupApi = GroupApiImpl(managedChannel, authCallCredentials) + private val servers: ServerApi = ServerApiImpl(managedChannel, authCallCredentials) + + /** + * @return The controllers [GroupApi] + */ + override fun getGroups(): GroupApi { + return groups + } - protected val groupServiceStub: ControllerGroupServiceGrpc.ControllerGroupServiceFutureStub = - ControllerGroupServiceGrpc.newFutureStub(managedChannel) - - override fun getGroupByName(name: String): CompletableFuture { - return groupServiceStub.getGroupByName( - GetGroupByNameRequest.newBuilder() - .setName(name) - .build() - ).toCompletable() - .thenApply { - Group.fromDefinition(it.group) - } + /** + * @return The controllers [ServerApi] + */ + override fun getServers(): ServerApi { + return servers } private fun createManagedChannelFromEnv(): ManagedChannel { diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/GroupApiImpl.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/GroupApiImpl.kt new file mode 100644 index 0000000..25b4fc6 --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/GroupApiImpl.kt @@ -0,0 +1,73 @@ +package app.simplecloud.controller.api.impl + +import app.simplecloud.controller.api.GroupApi +import app.simplecloud.controller.shared.auth.AuthCallCredentials +import app.simplecloud.controller.shared.future.toCompletable +import app.simplecloud.controller.shared.group.Group +import build.buf.gen.simplecloud.controller.v1.ControllerGroupServiceGrpc +import build.buf.gen.simplecloud.controller.v1.GetGroupByNameRequest +import build.buf.gen.simplecloud.controller.v1.GetAllGroupsRequest +import build.buf.gen.simplecloud.controller.v1.GetGroupsByTypeRequest +import build.buf.gen.simplecloud.controller.v1.ServerType +import io.grpc.ManagedChannel +import java.util.concurrent.CompletableFuture + +class GroupApiImpl( + managedChannel: ManagedChannel, + authCallCredentials: AuthCallCredentials +) : GroupApi { + + private val groupServiceStub: ControllerGroupServiceGrpc.ControllerGroupServiceFutureStub = + ControllerGroupServiceGrpc.newFutureStub(managedChannel) + .withCallCredentials(authCallCredentials) + + override fun getGroupByName(name: String): CompletableFuture { + return groupServiceStub.getGroupByName( + GetGroupByNameRequest.newBuilder() + .setName(name) + .build() + ).toCompletable() + .thenApply { + Group.fromDefinition(it.group) + } + } + + override fun deleteGroup(name: String): CompletableFuture { + return groupServiceStub.deleteGroupByName( + GetGroupByNameRequest.newBuilder() + .setName(name) + .build() + ).toCompletable() + .thenApply { + Group.fromDefinition(it) + } + } + + override fun createGroup(group: Group): CompletableFuture { + return groupServiceStub.createGroup( + group.toDefinition() + ).toCompletable() + .thenApply { + Group.fromDefinition(it) + } + } + + override fun updateGroup(group: Group): CompletableFuture { + return groupServiceStub.updateGroup(group.toDefinition()).toCompletable().thenApply { + return@thenApply Group.fromDefinition(it) + } + } + + override fun getAllGroups(): CompletableFuture> { + return groupServiceStub.getAllGroups(GetAllGroupsRequest.newBuilder().build()).toCompletable().thenApply { + return@thenApply it.groupsList.map { group -> Group.fromDefinition(group) } + } + } + + override fun getGroupsByType(type: ServerType): CompletableFuture> { + return groupServiceStub.getGroupsByType(GetGroupsByTypeRequest.newBuilder().setType(type).build()).toCompletable().thenApply { + return@thenApply it.groupsList.map { group -> Group.fromDefinition(group) } + } + } + +} \ No newline at end of file diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/ServerApiImpl.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/ServerApiImpl.kt new file mode 100644 index 0000000..189429c --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/ServerApiImpl.kt @@ -0,0 +1,113 @@ +package app.simplecloud.controller.api.impl + +import app.simplecloud.controller.api.ServerApi +import app.simplecloud.controller.shared.auth.AuthCallCredentials +import app.simplecloud.controller.shared.future.toCompletable +import app.simplecloud.controller.shared.group.Group +import build.buf.gen.simplecloud.controller.v1.* +import app.simplecloud.controller.shared.server.Server +import io.grpc.ManagedChannel +import java.util.concurrent.CompletableFuture + +class ServerApiImpl( + managedChannel: ManagedChannel, + authCallCredentials: AuthCallCredentials +) : ServerApi { + + private val serverServiceStub: ControllerServerServiceGrpc.ControllerServerServiceFutureStub = + ControllerServerServiceGrpc.newFutureStub(managedChannel).withCallCredentials(authCallCredentials) + + override fun getAllServers(): CompletableFuture> { + return serverServiceStub.getAllServers(GetAllServersRequest.newBuilder().build()).toCompletable().thenApply { + Server.fromDefinition(it.serversList) + } + } + + override fun getServerById(id: String): CompletableFuture { + return serverServiceStub.getServerById( + ServerIdRequest.newBuilder() + .setId(id) + .build() + ).toCompletable().thenApply { + Server.fromDefinition(it) + } + } + + override fun getServersByGroup(groupName: String): CompletableFuture> { + return serverServiceStub.getServersByGroup( + GroupNameRequest.newBuilder() + .setName(groupName) + .build() + ).toCompletable().thenApply { + Server.fromDefinition(it.serversList) + } + } + + override fun getServersByGroup(group: Group): CompletableFuture> { + return getServersByGroup(group.name) + } + + override fun getServersByType(type: ServerType): CompletableFuture> { + return serverServiceStub.getServersByType( + ServerTypeRequest.newBuilder() + .setType(type) + .build() + ).toCompletable().thenApply { + Server.fromDefinition(it.serversList) + } + } + + override fun startServer(groupName: String): CompletableFuture { + return serverServiceStub.startServer( + GroupNameRequest.newBuilder() + .setName(groupName) + .build() + ).toCompletable().thenApply { + Server.fromDefinition(it) + } + } + + override fun stopServer(groupName: String, numericalId: Long): CompletableFuture { + return serverServiceStub.stopServerByNumerical( + StopServerByNumericalRequest.newBuilder() + .setGroup(groupName) + .setNumericalId(numericalId) + .build() + ).toCompletable().thenApply { + Server.fromDefinition(it) + } + } + + override fun stopServer(id: String): CompletableFuture { + return serverServiceStub.stopServer( + ServerIdRequest.newBuilder() + .setId(id) + .build() + ).toCompletable().thenApply { + Server.fromDefinition(it) + } + } + + override fun updateServerState(id: String, state: ServerState): CompletableFuture { + return serverServiceStub.updateServerState( + ServerUpdateStateRequest.newBuilder() + .setState(state) + .setId(id) + .build() + ).toCompletable().thenApply { + return@thenApply Server.fromDefinition(it) + } + } + + override fun updateServerProperty(id: String, key: String, value: Any): CompletableFuture { + return serverServiceStub.updateServerProperty( + ServerUpdatePropertyRequest.newBuilder() + .setKey(key) + .setValue(value.toString()) + .setId(id) + .build() + ).toCompletable().thenApply { + return@thenApply Server.fromDefinition(it) + } + } +} \ No newline at end of file diff --git a/controller-runtime/build.gradle.kts b/controller-runtime/build.gradle.kts index 32e3518..7b4bdb5 100644 --- a/controller-runtime/build.gradle.kts +++ b/controller-runtime/build.gradle.kts @@ -1,12 +1,97 @@ plugins { application + alias(libs.plugins.jooqCodegen) } dependencies { api(project(":controller-shared")) + api(rootProject.libs.kotlinCoroutines) + api(rootProject.libs.bundles.jooq) + api(rootProject.libs.sqliteJdbc) + jooqCodegen(rootProject.libs.jooqMetaExtensions) implementation(rootProject.libs.bundles.log4j) + implementation(rootProject.libs.clikt) + implementation(rootProject.libs.spotifyCompletableFutures) } application { mainClass.set("app.simplecloud.controller.runtime.launcher.LauncherKt") +} + +sourceSets { + main { + java { + srcDirs( + "build/generated/source/db/main/java", + ) + } + resources { + srcDirs( + "src/main/db" + ) + } + } +} + +tasks.named("compileKotlin") { + dependsOn(tasks.jooqCodegen) +} + +jooq { + configuration { + generator { + target { + directory = "build/generated/source/db/main/java" + packageName = "app.simplecloud.controller.shared.db" + } + database { + name = "org.jooq.meta.extensions.ddl.DDLDatabase" + properties { + // Specify the location of your SQL script. + // You may use ant-style file matching, e.g. /path/**/to/*.sql + // + // Where: + // - ** matches any directory subtree + // - * matches any number of characters in a directory / file name + // - ? matches a single character in a directory / file name + property { + key = "scripts" + value = "src/main/db/schema.sql" + } + + // The sort order of the scripts within a directory, where: + // + // - semantic: sorts versions, e.g. v-3.10.0 is after v-3.9.0 (default) + // - alphanumeric: sorts strings, e.g. v-3.10.0 is before v-3.9.0 + // - flyway: sorts files the same way as flyway does + // - none: doesn't sort directory contents after fetching them from the directory + property { + key = "sort" + value = "semantic" + } + + // The default schema for unqualified objects: + // + // - public: all unqualified objects are located in the PUBLIC (upper case) schema + // - none: all unqualified objects are located in the default schema (default) + // + // This configuration can be overridden with the schema mapping feature + property { + key = "unqualifiedSchema" + value = "none" + } + + // The default name case for unquoted objects: + // + // - as_is: unquoted object names are kept unquoted + // - upper: unquoted object names are turned into upper case (most databases) + // - lower: unquoted object names are turned into lower case (e.g. PostgreSQL) + property { + key = "defaultNameCase" + value = "lower" + } + } + } + } + } } \ No newline at end of file diff --git a/controller-runtime/src/main/db/schema.sql b/controller-runtime/src/main/db/schema.sql new file mode 100644 index 0000000..d779577 --- /dev/null +++ b/controller-runtime/src/main/db/schema.sql @@ -0,0 +1,28 @@ +/** + This file represents the sql schema of the v3 backend. + Execute jooqCodegen to create java classes for these files. + */ + +CREATE TABLE IF NOT EXISTS cloud_servers( + unique_id varchar NOT NULL PRIMARY KEY, + group_name varchar NOT NULL, + host_id varchar NOT NULL, + numerical_id int NOT NULL, + ip varchar NOT NULL, + port int NOT NULL, + minimum_memory int NOT NULL, + maximum_memory int NOT NULL, + max_players int NOT NULL, + player_count int NOT NULL, + state varchar NOT NULL, + type varchar NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS cloud_server_properties( + server_id varchar NOT NULL, + key varchar NOT NULL, + value varchar, + CONSTRAINT compound_key PRIMARY KEY (server_id, key) +); \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/ControllerRuntime.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/ControllerRuntime.kt index b76455e..c5abe3e 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/ControllerRuntime.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/ControllerRuntime.kt @@ -1,26 +1,70 @@ package app.simplecloud.controller.runtime +import app.simplecloud.controller.runtime.database.DatabaseFactory import app.simplecloud.controller.runtime.group.GroupRepository import app.simplecloud.controller.runtime.group.GroupService +import app.simplecloud.controller.runtime.host.ServerHostRepository +import app.simplecloud.controller.runtime.launcher.ControllerStartCommand +import app.simplecloud.controller.runtime.reconciler.Reconciler +import app.simplecloud.controller.runtime.server.ServerNumericalIdRepository +import app.simplecloud.controller.runtime.server.ServerRepository +import app.simplecloud.controller.runtime.server.ServerService +import app.simplecloud.controller.shared.auth.AuthCallCredentials +import app.simplecloud.controller.shared.auth.AuthSecretInterceptor +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder import io.grpc.Server import io.grpc.ServerBuilder +import kotlinx.coroutines.* import org.apache.logging.log4j.LogManager import kotlin.concurrent.thread -class ControllerRuntime { +class ControllerRuntime( + private val controllerStartCommand: ControllerStartCommand +) { private val logger = LogManager.getLogger(ControllerRuntime::class.java) + private val database = DatabaseFactory.createDatabase(controllerStartCommand.databaseUrl) + private val authCallCredentials = AuthCallCredentials(controllerStartCommand.authSecret) - private val groupRepository = GroupRepository() - - private val server = createGrpcServerFromEnv() + private val groupRepository = GroupRepository(controllerStartCommand.groupPath) + private val numericalIdRepository = ServerNumericalIdRepository() + private val serverRepository = ServerRepository(database, numericalIdRepository) + private val hostRepository = ServerHostRepository() + private val reconciler = Reconciler( + groupRepository, + serverRepository, + hostRepository, + numericalIdRepository, + createManagedChannel(), + authCallCredentials + ) + private val server = createGrpcServer() fun start() { - logger.info("Starting controller...") + setupDatabase() startGrpcServer() + startReconciler() + loadGroups() + loadServers() + } + + private fun setupDatabase() { + logger.info("Setting up database...") + database.setup() + } + + private fun loadServers() { + logger.info("Loading servers...") + val loadedServers = serverRepository.load() + if (loadedServers.isEmpty()) { + return + } + + logger.info("Loaded servers from cache: ${loadedServers.joinToString { "${it.group}-${it.numericalId}" }}") } - fun startGrpcServer() { + private fun startGrpcServer() { logger.info("Starting gRPC server...") thread { server.start() @@ -28,13 +72,52 @@ class ControllerRuntime { } } - private fun createGrpcServerFromEnv(): Server { - val port = System.getenv("GRPC_PORT")?.toInt() ?: 5816 - return ServerBuilder.forPort(port) + private fun startReconciler() { + logger.info("Starting Reconciler...") + startReconcilerJob() + } + + private fun loadGroups() { + logger.info("Loading groups...") + val loadedGroups = groupRepository.load() + if (loadedGroups.isEmpty()) { + logger.warn("No groups found.") + return + } + + logger.info("Loaded groups: ${loadedGroups.joinToString { it.name }}") + } + + private fun createGrpcServer(): Server { + return ServerBuilder.forPort(controllerStartCommand.grpcPort) + .addService(GroupService(groupRepository)) .addService( - GroupService(groupRepository), + ServerService( + numericalIdRepository, + serverRepository, + hostRepository, + groupRepository, + controllerStartCommand.forwardingSecret, + authCallCredentials + ) ) + .intercept(AuthSecretInterceptor(controllerStartCommand.authSecret)) .build() } + private fun createManagedChannel(): ManagedChannel { + return ManagedChannelBuilder.forAddress(controllerStartCommand.grpcHost, controllerStartCommand.grpcPort) + .usePlaintext() + .build() + } + + private fun startReconcilerJob(): Job { + return CoroutineScope(Dispatchers.Default).launch { + while (isActive) { + reconciler.reconcile() + delay(2000L) + } + } + } + } \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/LoadableRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/LoadableRepository.kt new file mode 100644 index 0000000..64de502 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/LoadableRepository.kt @@ -0,0 +1,5 @@ +package app.simplecloud.controller.runtime + +interface LoadableRepository : Repository { + fun load(): List +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/Repository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/Repository.kt new file mode 100644 index 0000000..1785fa2 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/Repository.kt @@ -0,0 +1,11 @@ +package app.simplecloud.controller.runtime + +import java.util.concurrent.CompletableFuture + + +interface Repository { + fun delete(element: E): CompletableFuture + fun save(element: E) + fun find(identifier: I): CompletableFuture + fun getAll(): CompletableFuture> +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/YamlDirectoryRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/YamlDirectoryRepository.kt new file mode 100644 index 0000000..34e627c --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/YamlDirectoryRepository.kt @@ -0,0 +1,142 @@ +package app.simplecloud.controller.runtime + +import kotlinx.coroutines.* +import org.apache.logging.log4j.LogManager +import org.spongepowered.configurate.ConfigurationOptions +import org.spongepowered.configurate.kotlin.objectMapperFactory +import org.spongepowered.configurate.loader.ParsingException +import org.spongepowered.configurate.yaml.NodeStyle +import org.spongepowered.configurate.yaml.YamlConfigurationLoader +import java.io.File +import java.nio.file.* +import java.util.concurrent.CompletableFuture + + +abstract class YamlDirectoryRepository( + private val directory: Path, + private val clazz: Class, +) : LoadableRepository { + + private val logger = LogManager.getLogger(this::class.java) + + private val watchService = FileSystems.getDefault().newWatchService() + private val loaders = mutableMapOf() + protected val entities = mutableMapOf() + + abstract fun getFileName(identifier: I): String + + override fun delete(element: E): CompletableFuture { + val file = entities.keys.find { entities[it] == element } ?: return CompletableFuture.completedFuture(false) + return CompletableFuture.completedFuture(deleteFile(file)) + } + + override fun getAll(): CompletableFuture> { + return CompletableFuture.completedFuture(entities.values.toList()) + } + + override fun load(): List { + if (!directory.toFile().exists()) { + directory.toFile().mkdirs() + } + + registerWatcher() + + return Files.list(directory) + .toList() + .filter { !it.toFile().isDirectory && it.toString().endsWith(".yml") } + .mapNotNull { load(it.toFile()) } + } + + private fun load(file: File): E? { + try { + val loader = getOrCreateLoader(file) + val node = loader.load(ConfigurationOptions.defaults()) + val entity = node.get(clazz) ?: return null + entities[file] = entity + return entity + } catch (ex: ParsingException) { + val existedBefore = entities.containsKey(file) + if (existedBefore) { + logger.error("Could not load file ${file.name}. Switching back to an older version.") + return null + } + + logger.error("Could not load file ${file.name}. Make sure it's correctly formatted!") + return null + } + } + + private fun deleteFile(file: File): Boolean { + val deletedSuccessfully = file.delete() + val removedSuccessfully = entities.remove(file) != null + return deletedSuccessfully && removedSuccessfully + } + + protected fun save(fileName: String, entity: E) { + val file = directory.resolve(fileName).toFile() + val loader = getOrCreateLoader(file) + val node = loader.createNode(ConfigurationOptions.defaults()) + node.set(clazz, entity) + loader.save(node) + entities[file] = entity + } + + private fun getOrCreateLoader(file: File): YamlConfigurationLoader { + return loaders.getOrPut(file) { + YamlConfigurationLoader.builder() + .path(file.toPath()) + .nodeStyle(NodeStyle.BLOCK) + .defaultOptions { options -> + options.serializers { builder -> + builder.registerAnnotatedObjects(objectMapperFactory()) + } + }.build() + } + } + + private fun registerWatcher(): Job { + directory.register( + watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY + ) + + return CoroutineScope(Dispatchers.Default).launch { + while (isActive) { + val key = watchService.take() + for (event in key.pollEvents()) { + val path = event.context() as? Path ?: continue + val resolvedPath = directory.resolve(path) + if (Files.isDirectory(resolvedPath) || !resolvedPath.toString().endsWith(".yml")) { + continue + } + val kind = event.kind() + logger.info("Detected change in $resolvedPath (${getChangeStatus(kind)})") + when (kind) { + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY + -> { + load(resolvedPath.toFile()) + } + + StandardWatchEventKinds.ENTRY_DELETE -> { + deleteFile(resolvedPath.toFile()) + } + } + } + key.reset() + } + } + } + + private fun getChangeStatus(kind: WatchEvent.Kind<*>): String { + return when (kind) { + StandardWatchEventKinds.ENTRY_CREATE -> "Created" + StandardWatchEventKinds.ENTRY_DELETE -> "Deleted" + StandardWatchEventKinds.ENTRY_MODIFY -> "Modified" + else -> "Unknown" + } + } + +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/database/Database.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/database/Database.kt new file mode 100644 index 0000000..99266b9 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/database/Database.kt @@ -0,0 +1,22 @@ +package app.simplecloud.controller.runtime.database + +import org.jooq.DSLContext + +class Database( + val context: DSLContext +) { + + fun setup() { + System.setProperty("org.jooq.no-logo", "true") + System.setProperty("org.jooq.no-tips", "true") + val setupInputStream = Database::class.java.getResourceAsStream("/schema.sql") + ?: throw IllegalArgumentException("Database schema not found.") + val setupCommands = setupInputStream.bufferedReader().use { it.readText() }.split(";") + setupCommands.forEach { + val trimmed = it.trim() + if (trimmed.isNotEmpty()) + context.execute(org.jooq.impl.DSL.sql(trimmed)) + } + } + +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/database/DatabaseFactory.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/database/DatabaseFactory.kt new file mode 100644 index 0000000..d7bc914 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/database/DatabaseFactory.kt @@ -0,0 +1,12 @@ +package app.simplecloud.controller.runtime.database + +import org.jooq.impl.DSL + +object DatabaseFactory { + + fun createDatabase(databaseUrl: String): Database { + val databaseContext = DSL.using(databaseUrl) + return Database(databaseContext) + } + +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupRepository.kt index c96acf9..73669fe 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupRepository.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupRepository.kt @@ -1,15 +1,26 @@ package app.simplecloud.controller.runtime.group +import app.simplecloud.controller.runtime.YamlDirectoryRepository import app.simplecloud.controller.shared.group.Group -import app.simplecloud.controller.shared.proto.GroupDefinition +import java.nio.file.Path +import java.util.concurrent.CompletableFuture -class GroupRepository { +class GroupRepository( + path: Path +) : YamlDirectoryRepository(path, Group::class.java) { + override fun getFileName(identifier: String): String { + return "$identifier.yml" + } - // TODO: Load groups from file - private val groups = listOf() + override fun find(identifier: String): CompletableFuture { + return CompletableFuture.completedFuture(entities.values.find { it.name == identifier }) + } - fun findGroupByName(name: String): GroupDefinition? { - return groups.firstOrNull { it.name == name }?.toDefinition() + override fun save(element: Group) { + save(getFileName(element.name), element) } + override fun getAll(): CompletableFuture> { + return CompletableFuture.completedFuture(entities.values.toList()) + } } \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupService.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupService.kt index d8cdef7..5fd259a 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupService.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupService.kt @@ -1,8 +1,8 @@ package app.simplecloud.controller.runtime.group -import app.simplecloud.controller.shared.proto.ControllerGroupServiceGrpc -import app.simplecloud.controller.shared.proto.GetGroupByNameRequest -import app.simplecloud.controller.shared.proto.GetGroupByNameResponse +import app.simplecloud.controller.shared.group.Group +import build.buf.gen.simplecloud.controller.v1.* +import io.grpc.Status import io.grpc.stub.StreamObserver class GroupService( @@ -13,13 +13,120 @@ class GroupService( request: GetGroupByNameRequest, responseObserver: StreamObserver ) { - val group = groupRepository.findGroupByName(request.name) - val response = GetGroupByNameResponse.newBuilder() - .setGroup(group) - .build() + groupRepository.find(request.name).thenApply { group -> + if (group == null) { + responseObserver.onError( + Status.NOT_FOUND + .withDescription("This group does not exist") + .asRuntimeException() + ) + return@thenApply + } - responseObserver.onNext(response) + val response = GetGroupByNameResponse.newBuilder() + .setGroup(group.toDefinition()) + .build() + + responseObserver.onNext(response) + responseObserver.onCompleted() + } + + } + + override fun getAllGroups(request: GetAllGroupsRequest, responseObserver: StreamObserver) { + groupRepository.getAll().thenApply { groups -> + val response = GetAllGroupsResponse.newBuilder() + .addAllGroups(groups.map { it.toDefinition() }) + .build() + responseObserver.onNext(response) + responseObserver.onCompleted() + } + + } + + override fun getGroupsByType( + request: GetGroupsByTypeRequest, + responseObserver: StreamObserver + ) { + val type = request.type + groupRepository.getAll().thenApply { groups -> + val response = GetGroupsByTypeResponse.newBuilder() + .addAllGroups(groups.filter { it.type == type }.map { it.toDefinition() }) + .build() + responseObserver.onNext(response) + responseObserver.onCompleted() + } + + } + + override fun updateGroup(request: GroupDefinition, responseObserver: StreamObserver) { + val group = Group.fromDefinition(request) + try { + groupRepository.save(group) + } catch (e: Exception) { + responseObserver.onError( + Status.INTERNAL + .withDescription("Error whilst updating group") + .withCause(e) + .asRuntimeException() + ) + return + } + + responseObserver.onNext(group.toDefinition()) + responseObserver.onCompleted() + } + + override fun createGroup(request: GroupDefinition, responseObserver: StreamObserver) { + val group = Group.fromDefinition(request) + try { + groupRepository.save(group) + } catch (e: Exception) { + responseObserver.onError( + Status.INTERNAL + .withDescription("Error whilst creating group") + .withCause(e) + .asRuntimeException() + ) + return + } + + responseObserver.onNext(group.toDefinition()) responseObserver.onCompleted() } + override fun deleteGroupByName(request: GetGroupByNameRequest, responseObserver: StreamObserver) { + groupRepository.find(request.name).thenApply { group -> + if (group == null) { + responseObserver.onError( + Status.NOT_FOUND + .withDescription("This group does not exist") + .asRuntimeException() + ) + return@thenApply + } + groupRepository.delete(group).thenApply thenDelete@ { successfullyDeleted -> + if(!successfullyDeleted) { + responseObserver.onError( + Status.INTERNAL + .withDescription("Could not delete group") + .asRuntimeException() + ) + + return@thenDelete + } + responseObserver.onNext(group.toDefinition()) + responseObserver.onCompleted() + }.exceptionally { + responseObserver.onError( + Status.INTERNAL + .withDescription("Could not delete group") + .withCause(it) + .asRuntimeException() + ) + } + } + + } + } \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostException.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostException.kt new file mode 100644 index 0000000..e4651e1 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostException.kt @@ -0,0 +1,3 @@ +package app.simplecloud.controller.runtime.host + +class ServerHostException(message: String) : Exception(message) \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostRepository.kt new file mode 100644 index 0000000..14ddb8d --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostRepository.kt @@ -0,0 +1,60 @@ +package app.simplecloud.controller.runtime.host + +import app.simplecloud.controller.runtime.Repository +import app.simplecloud.controller.runtime.server.ServerRepository +import app.simplecloud.controller.shared.host.ServerHost +import com.spotify.futures.CompletableFutures +import io.grpc.ConnectivityState +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap + +class ServerHostRepository : Repository { + + private val hosts: ConcurrentHashMap = ConcurrentHashMap() + + fun findServerHostById(id: String): ServerHost? { + return hosts.getOrDefault(hosts.keys.firstOrNull { it == id }, null) + } + + override fun save(element: ServerHost) { + hosts[element.id] = element + } + + override fun find(identifier: ServerRepository): CompletableFuture { + return mapHostsToServerHostWithServerCount(identifier).thenApply { + val serverHostWithServerCount = it.minByOrNull { it.serverCount } + serverHostWithServerCount?.serverHost + } + } + + fun areServerHostsAvailable(): CompletableFuture { + return CompletableFuture.supplyAsync { + hosts.any { + val channel = it.value.createChannel() + val state = channel.getState(true) + channel.shutdown() + state == ConnectivityState.IDLE || state == ConnectivityState.READY + } + } + } + + override fun delete(element: ServerHost): CompletableFuture { + return CompletableFuture.completedFuture(hosts.remove(element.id, element)) + } + + override fun getAll(): CompletableFuture> { + return CompletableFuture.completedFuture(hosts.values.toList()) + } + + private fun mapHostsToServerHostWithServerCount(identifier: ServerRepository): CompletableFuture> { + return CompletableFutures.allAsList( + hosts.values.map { serverHost -> + identifier.findServersByHostId(serverHost.id).thenApply { + ServerHostWithServerCount(serverHost, it.size) + } + } + ) + } + +} + diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostWithServerCount.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostWithServerCount.kt new file mode 100644 index 0000000..30006b9 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostWithServerCount.kt @@ -0,0 +1,8 @@ +package app.simplecloud.controller.runtime.host + +import app.simplecloud.controller.shared.host.ServerHost + +data class ServerHostWithServerCount( + val serverHost: ServerHost, + val serverCount: Int +) diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/ControllerStartCommand.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/ControllerStartCommand.kt new file mode 100644 index 0000000..85f5558 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/ControllerStartCommand.kt @@ -0,0 +1,50 @@ +package app.simplecloud.controller.runtime.launcher + +import app.simplecloud.controller.runtime.ControllerRuntime +import app.simplecloud.controller.shared.secret.AuthFileSecretFactory +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.defaultLazy +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.int +import com.github.ajalt.clikt.parameters.types.path +import java.nio.file.Path + +class ControllerStartCommand : CliktCommand() { + + private val defaultDatabaseUrl = "jdbc:sqlite:database.db" + + val groupPath: Path by option(help = "Path to the group files (default: groups)", envvar = "GROUPS_PATH") + .path() + .default(Path.of("groups")) + val databaseUrl: String by option(help = "Database URL (default: ${defaultDatabaseUrl})", envvar = "DATABASE_URL") + .default(defaultDatabaseUrl) + + val grpcHost: String by option(help = "Grpc host (default: localhost)", envvar = "GRPC_HOST").default("localhost") + val grpcPort: Int by option(help = "Grpc port (default: 5816)", envvar = "GRPC_PORT").int().default(5816) + + private val authSecretPath: Path by option( + help = "Path to auth secret file (default: .auth.secret)", + envvar = "AUTH_SECRET_PATH" + ) + .path() + .default(Path.of(".secrets", "auth.secret")) + + val authSecret: String by option(help = "Auth secret", envvar = "AUTH_SECRET_KEY") + .defaultLazy { AuthFileSecretFactory.loadOrCreate(authSecretPath) } + + private val forwardingSecretPath: Path by option( + help = "Path to the forwarding secret (default: .forwarding.secret)", + envvar = "FORWARDING_SECRET_PATH" + ) + .path() + .default(Path.of(".secrets", "forwarding.secret")) + + val forwardingSecret: String by option(help = "Forwarding secrewt", envvar = "FORWARDING_SECRET") + .defaultLazy { AuthFileSecretFactory.loadOrCreate(forwardingSecretPath) } + + override fun run() { + val controllerRuntime = ControllerRuntime(this) + controllerRuntime.start() + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/Launcher.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/Launcher.kt new file mode 100644 index 0000000..10c2f99 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/Launcher.kt @@ -0,0 +1,16 @@ +package app.simplecloud.controller.runtime.launcher + +import org.apache.logging.log4j.LogManager + + +fun main(args: Array) { + configureLog4j() + ControllerStartCommand().main(args) +} + +fun configureLog4j() { + val globalExceptionHandlerLogger = LogManager.getLogger("GlobalExceptionHandler") + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + globalExceptionHandlerLogger.error("Uncaught exception in thread ${thread.name}", throwable) + } +} diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/LauncherKt.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/LauncherKt.kt deleted file mode 100644 index 60744fe..0000000 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/LauncherKt.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.simplecloud.controller.runtime.launcher - -import app.simplecloud.controller.runtime.ControllerRuntime - -fun main() { - val controllerRuntime = ControllerRuntime() - controllerRuntime.start() -} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/GroupReconciler.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/GroupReconciler.kt new file mode 100644 index 0000000..26de957 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/GroupReconciler.kt @@ -0,0 +1,137 @@ +package app.simplecloud.controller.runtime.reconciler + +import app.simplecloud.controller.runtime.host.ServerHostRepository +import app.simplecloud.controller.runtime.server.ServerNumericalIdRepository +import app.simplecloud.controller.runtime.server.ServerRepository +import app.simplecloud.controller.shared.future.toCompletable +import app.simplecloud.controller.shared.group.Group +import app.simplecloud.controller.shared.server.Server +import build.buf.gen.simplecloud.controller.v1.ControllerServerServiceGrpc.ControllerServerServiceFutureStub +import build.buf.gen.simplecloud.controller.v1.GroupNameRequest +import build.buf.gen.simplecloud.controller.v1.ServerIdRequest +import build.buf.gen.simplecloud.controller.v1.ServerState +import org.apache.logging.log4j.LogManager +import java.time.LocalDateTime +import kotlin.math.min + +class GroupReconciler( + private val serverRepository: ServerRepository, + private val serverHostRepository: ServerHostRepository, + private val numericalIdRepository: ServerNumericalIdRepository, + private val serverStub: ControllerServerServiceFutureStub, + private val group: Group, +) { + + private val logger = LogManager.getLogger(GroupReconciler::class.java) + private val servers = this.serverRepository.findServersByGroup(this.group.name).get() + + private val availableServerCount = calculateAvailableServerCount() + + fun reconcile() { + cleanupServers() + cleanupNumericalIds() + startServers() + } + + private fun calculateAvailableServerCount(): Int { + return servers.count { server -> + hasAvailableState(server) && isOnlineCountBelowPlayerRatio(server) + } + } + + private fun isOnlineCountBelowPlayerRatio(server: Server): Boolean { + if (this.group.newServerPlayerRatio <= 0) { + return true + } + return (server.playerCount / server.maxPlayers) * 100 < this.group.newServerPlayerRatio + } + + private fun hasAvailableState(server: Server): Boolean { + return server.state == ServerState.AVAILABLE + || server.state == ServerState.STARTING + || server.state == ServerState.PREPARING + } + + private fun cleanupServers() { + val fullyStartedServers = this.servers.filter { it.state == ServerState.AVAILABLE } + val hasMoreServersThenNeeded = fullyStartedServers.size > this.group.minOnlineCount + if (!hasMoreServersThenNeeded) + return + + val serverCountToStop = fullyStartedServers.size - this.group.minOnlineCount + fullyStartedServers + .filter { !wasUpdatedRecently(it) } + .shuffled() + .take(serverCountToStop.toInt()) + .forEach { stopServer(it) } + } + + private fun stopServer(server: Server) { + logger.info("Stopping server ${server.uniqueId} of group ${server.group}") + serverStub.stopServer( + ServerIdRequest.newBuilder() + .setId(server.uniqueId) + .build() + ).toCompletable() + .thenApply { + logger.info("Stopped server ${server.uniqueId} of group ${server.group}") + }.exceptionally { + logger.error("Could not stop server ${server.uniqueId} of group ${server.group}: ${it.message}") + } + } + + private fun cleanupNumericalIds() { + val usedNumericalIds = this.servers.map { it.numericalId } + val numericalIds = this.numericalIdRepository.findNumericalIds(this.group.name) + + val unusedNumericalIds = numericalIds.filter { !usedNumericalIds.contains(it) } + + unusedNumericalIds.forEach { this.numericalIdRepository.removeNumericalId(this.group.name, it) } + + if (unusedNumericalIds.isNotEmpty()) { + logger.info("Removed unused numerical ids $unusedNumericalIds of group ${this.group.name}") + } + } + + private fun wasUpdatedRecently(server: Server): Boolean { + return server.updatedAt.isAfter(LocalDateTime.now().minusMinutes(INACTIVE_SERVER_TIME)) + } + + private fun startServers() { + serverHostRepository.areServerHostsAvailable().thenApply { + if (!it) return@thenApply + if (isNewServerNeeded()) + startServer() + } + } + + private fun startServer() { + logger.info("Starting new instance of group ${this.group.name}") + serverStub.startServer(GroupNameRequest.newBuilder().setName(this.group.name).build()).toCompletable() + .thenApply { + logger.info("Started new instance ${it.groupName}-${it.numericalId}/${it.uniqueId} of group ${this.group.name} on ${it.ip}:${it.port}") + }.exceptionally { + it.printStackTrace() + logger.error("Could not start a new instance of group ${this.group.name}: ${it.message}") + } + } + + private fun isNewServerNeeded(): Boolean { + return calculateServerCountToStart() > 0 + } + + private fun calculateServerCountToStart(): Int { + val currentServerCount = this.servers.size + val neededServerCount = this.group.minOnlineCount - this.availableServerCount + val maxNewServers = this.group.maxOnlineCount - currentServerCount + + if (neededServerCount > 0) + return min(neededServerCount, maxNewServers).toInt() + return 0 + } + + companion object { + private const val INACTIVE_SERVER_TIME = 5L + } + +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/Reconciler.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/Reconciler.kt new file mode 100644 index 0000000..93525d2 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/Reconciler.kt @@ -0,0 +1,37 @@ +package app.simplecloud.controller.runtime.reconciler + +import app.simplecloud.controller.runtime.group.GroupRepository +import app.simplecloud.controller.runtime.host.ServerHostRepository +import app.simplecloud.controller.runtime.server.ServerNumericalIdRepository +import app.simplecloud.controller.runtime.server.ServerRepository +import app.simplecloud.controller.shared.auth.AuthCallCredentials +import build.buf.gen.simplecloud.controller.v1.ControllerServerServiceGrpc +import io.grpc.ManagedChannel + +class Reconciler( + private val groupRepository: GroupRepository, + private val serverRepository: ServerRepository, + private val serverHostRepository: ServerHostRepository, + private val numericalIdRepository: ServerNumericalIdRepository, + managedChannel: ManagedChannel, + authCallCredentials: AuthCallCredentials, +) { + + private val serverStub = ControllerServerServiceGrpc.newFutureStub(managedChannel) + .withCallCredentials(authCallCredentials) + + fun reconcile() { + this.groupRepository.getAll().thenApply { + it.forEach { group -> + GroupReconciler( + serverRepository, + serverHostRepository, + numericalIdRepository, + serverStub, + group + ).reconcile() + } + } + } + +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerNumericalIdRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerNumericalIdRepository.kt new file mode 100644 index 0000000..d8962cd --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerNumericalIdRepository.kt @@ -0,0 +1,33 @@ +package app.simplecloud.controller.runtime.server + +import java.util.concurrent.ConcurrentHashMap + +class ServerNumericalIdRepository { + + private val numericalIds = ConcurrentHashMap>() + + @Synchronized + fun findNextNumericalId(group: String): Int { + val numericalIds = findNumericalIds(group) + var nextId = 1 + while (numericalIds.contains(nextId)) { + nextId++ + } + saveNumericalId(group, nextId) + return nextId + } + + fun saveNumericalId(group: String, id: Int) { + numericalIds.compute(group) { _, v -> v?.plus(id) ?: setOf(id) } + } + + @Synchronized + fun removeNumericalId(group: String, id: Int): Boolean { + return numericalIds.computeIfPresent(group) { _, v -> v.minus(id) } != null + } + + fun findNumericalIds(group: String): Set { + return numericalIds[group] ?: emptySet() + } + +} diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerRepository.kt new file mode 100644 index 0000000..7b3dea6 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerRepository.kt @@ -0,0 +1,232 @@ +package app.simplecloud.controller.runtime.server + +import app.simplecloud.controller.runtime.LoadableRepository +import app.simplecloud.controller.runtime.database.Database +import app.simplecloud.controller.shared.db.Tables.CLOUD_SERVERS +import app.simplecloud.controller.shared.db.Tables.CLOUD_SERVER_PROPERTIES +import app.simplecloud.controller.shared.db.tables.records.CloudServersRecord +import app.simplecloud.controller.shared.server.Server +import build.buf.gen.simplecloud.controller.v1.ServerState +import build.buf.gen.simplecloud.controller.v1.ServerType +import org.jooq.Result +import java.time.LocalDateTime +import java.util.concurrent.CompletableFuture + +class ServerRepository( + private val database: Database, + private val numericalIdRepository: ServerNumericalIdRepository +) : LoadableRepository { + + + override fun find(identifier: String): CompletableFuture { + return CompletableFuture.supplyAsync { + val query = database.context.select() + .from(CLOUD_SERVERS) + .where(CLOUD_SERVERS.UNIQUE_ID.eq(identifier)) + .fetchInto( + CLOUD_SERVERS + ) + return@supplyAsync toList(query).firstOrNull() + } + } + + + fun findServerByNumerical(group: String, id: Int): CompletableFuture { + return CompletableFuture.supplyAsync { + val query = database.context.select().from(CLOUD_SERVERS) + .where( + CLOUD_SERVERS.GROUP_NAME.eq(group) + .and(CLOUD_SERVERS.NUMERICAL_ID.eq(id)) + ) + .fetchInto(CLOUD_SERVERS) + return@supplyAsync toList(query).firstOrNull() + } + } + + override fun getAll(): CompletableFuture> { + return CompletableFuture.supplyAsync { + val query = database.context.select() + .from(CLOUD_SERVERS) + .fetchInto(CLOUD_SERVERS) + return@supplyAsync toList(query) + } + } + + fun findServersByHostId(id: String): CompletableFuture> { + return CompletableFuture.supplyAsync { + val query = database.context.select() + .from(CLOUD_SERVERS) + .where(CLOUD_SERVERS.HOST_ID.eq(id)) + .fetchInto( + CLOUD_SERVERS + ) + return@supplyAsync toList(query) + } + } + + fun findServersByGroup(group: String): CompletableFuture> { + return CompletableFuture.supplyAsync { + val query = database.context.select() + .from(CLOUD_SERVERS) + .where(CLOUD_SERVERS.GROUP_NAME.eq(group)) + .fetchInto( + CLOUD_SERVERS + ) + return@supplyAsync toList(query) + } + + } + + fun findServersByType(type: ServerType): CompletableFuture> { + return CompletableFuture.supplyAsync { + val query = database.context.select() + .from(CLOUD_SERVERS) + .where(CLOUD_SERVERS.TYPE.eq(type.toString())) + .fetchInto(CLOUD_SERVERS) + return@supplyAsync toList(query) + } + } + + private fun toList(query: Result): List { + val result = mutableListOf() + query.map { + val propertiesQuery = + database.context.select() + .from(CLOUD_SERVER_PROPERTIES) + .where(CLOUD_SERVER_PROPERTIES.SERVER_ID.eq(it.uniqueId)) + .fetchInto(CLOUD_SERVER_PROPERTIES) + result.add( + Server( + it.uniqueId, + ServerType.valueOf(it.type), + it.groupName, + it.hostId, + it.numericalId, + it.ip, + it.port.toLong(), + it.minimumMemory.toLong(), + it.maximumMemory.toLong(), + it.maxPlayers.toLong(), + it.playerCount.toLong(), + propertiesQuery.map { item -> + item.key to item.value + }.toMap().toMutableMap(), + ServerState.valueOf(it.state), + it.createdAt, + it.updatedAt + ) + ) + } + return result + } + + override fun delete(element: Server): CompletableFuture { + val canDelete = + database.context.deleteFrom(CLOUD_SERVER_PROPERTIES) + .where(CLOUD_SERVER_PROPERTIES.SERVER_ID.eq(element.uniqueId)) + .executeAsync().toCompletableFuture().thenApply { + return@thenApply true + }.exceptionally { + it.printStackTrace() + return@exceptionally false + }.get() + if (!canDelete) return CompletableFuture.completedFuture(false) + numericalIdRepository.removeNumericalId(element.group, element.numericalId) + return database.context.deleteFrom(CLOUD_SERVERS) + .where(CLOUD_SERVERS.UNIQUE_ID.eq(element.uniqueId)) + .executeAsync() + .toCompletableFuture().thenApply { + return@thenApply it > 0 + }.exceptionally { + it.printStackTrace() + return@exceptionally false + } + } + + @Synchronized + override fun save(element: Server) { + numericalIdRepository.saveNumericalId(element.group, element.numericalId) + + val currentTimestamp = LocalDateTime.now() + database.context.insertInto( + CLOUD_SERVERS, + + CLOUD_SERVERS.UNIQUE_ID, + CLOUD_SERVERS.TYPE, + CLOUD_SERVERS.GROUP_NAME, + CLOUD_SERVERS.HOST_ID, + CLOUD_SERVERS.NUMERICAL_ID, + CLOUD_SERVERS.IP, + CLOUD_SERVERS.PORT, + CLOUD_SERVERS.MINIMUM_MEMORY, + CLOUD_SERVERS.MAXIMUM_MEMORY, + CLOUD_SERVERS.MAX_PLAYERS, + CLOUD_SERVERS.PLAYER_COUNT, + CLOUD_SERVERS.STATE, + CLOUD_SERVERS.CREATED_AT, + CLOUD_SERVERS.UPDATED_AT + ) + .values( + element.uniqueId, + element.type.toString(), + element.group, + element.host, + element.numericalId, + element.ip, + element.port.toInt(), + element.minMemory.toInt(), + element.maxMemory.toInt(), + element.maxPlayers.toInt(), + element.playerCount.toInt(), + element.state.toString(), + currentTimestamp, + currentTimestamp + ) + .onDuplicateKeyUpdate() + .set(CLOUD_SERVERS.UNIQUE_ID, element.uniqueId) + .set(CLOUD_SERVERS.TYPE, element.type.toString()) + .set(CLOUD_SERVERS.GROUP_NAME, element.group) + .set(CLOUD_SERVERS.HOST_ID, element.host) + .set(CLOUD_SERVERS.NUMERICAL_ID, element.numericalId) + .set(CLOUD_SERVERS.IP, element.ip) + .set(CLOUD_SERVERS.PORT, element.port.toInt()) + .set(CLOUD_SERVERS.MINIMUM_MEMORY, element.minMemory.toInt()) + .set(CLOUD_SERVERS.MAXIMUM_MEMORY, element.maxMemory.toInt()) + .set(CLOUD_SERVERS.MAX_PLAYERS, element.maxPlayers.toInt()) + .set(CLOUD_SERVERS.PLAYER_COUNT, element.playerCount.toInt()) + .set(CLOUD_SERVERS.STATE, element.state.toString()) + .set(CLOUD_SERVERS.UPDATED_AT, currentTimestamp) + .executeAsync() + element.properties.forEach { + database.context.insertInto( + CLOUD_SERVER_PROPERTIES, + + CLOUD_SERVER_PROPERTIES.SERVER_ID, + CLOUD_SERVER_PROPERTIES.KEY, + CLOUD_SERVER_PROPERTIES.VALUE + ) + .values( + element.uniqueId, + it.key, + it.value + ) + .onDuplicateKeyUpdate() + .set(CLOUD_SERVER_PROPERTIES.SERVER_ID, element.uniqueId) + .set(CLOUD_SERVER_PROPERTIES.KEY, it.key) + .set(CLOUD_SERVER_PROPERTIES.VALUE, it.value) + .executeAsync() + } + } + + override fun load(): List { + val query = database.context.select() + .from(CLOUD_SERVERS) + .fetchInto(CLOUD_SERVERS) + val list = toList(query) + list.forEach { + numericalIdRepository.saveNumericalId(it.group, it.numericalId) + } + return list + } + +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerService.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerService.kt new file mode 100644 index 0000000..c0b6dfd --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerService.kt @@ -0,0 +1,349 @@ +package app.simplecloud.controller.runtime.server + +import app.simplecloud.controller.runtime.group.GroupRepository +import app.simplecloud.controller.runtime.host.ServerHostException +import app.simplecloud.controller.runtime.host.ServerHostRepository +import app.simplecloud.controller.shared.auth.AuthCallCredentials +import app.simplecloud.controller.shared.future.toCompletable +import app.simplecloud.controller.shared.group.Group +import app.simplecloud.controller.shared.host.ServerHost +import app.simplecloud.controller.shared.server.Server +import build.buf.gen.simplecloud.controller.v1.* +import io.grpc.Context +import io.grpc.Status +import io.grpc.stub.StreamObserver +import org.apache.logging.log4j.LogManager +import java.time.LocalDateTime +import java.util.* +import java.util.concurrent.CompletableFuture + +class ServerService( + private val numericalIdRepository: ServerNumericalIdRepository, + private val serverRepository: ServerRepository, + private val hostRepository: ServerHostRepository, + private val groupRepository: GroupRepository, + private val forwardingSecret: String, + private val authCallCredentials: AuthCallCredentials +) : ControllerServerServiceGrpc.ControllerServerServiceImplBase() { + + private val logger = LogManager.getLogger(ServerService::class.java) + + override fun attachServerHost(request: ServerHostDefinition, responseObserver: StreamObserver) { + val serverHost = ServerHost.fromDefinition(request) + try { + hostRepository.delete(serverHost) + hostRepository.save(serverHost) + }catch (e: Exception) { + responseObserver.onError( + Status.INTERNAL + .withDescription("Could not save serverhost") + .withCause(e) + .asRuntimeException() + ) + return + } + logger.info("Successfully registered ServerHost ${serverHost.id}.") + responseObserver.onNext(serverHost.toDefinition()) + responseObserver.onCompleted() + Context.current().fork().run { + val channel = serverHost.createChannel() + val stub = ServerHostServiceGrpc.newFutureStub(channel) + .withCallCredentials(authCallCredentials) + serverRepository.findServersByHostId(serverHost.id).thenApply { + it.forEach { server -> + logger.info("Reattaching Server ${server.uniqueId} of group ${server.group}...") + stub.reattachServer(server.toDefinition()).toCompletable().thenApply { + logger.info("Success!") + }.exceptionally { + logger.error("Server was found to be offline, unregistering...") + serverRepository.delete(server) + }.get() + } + channel.shutdown() + } + } + } + + override fun getAllServers( + request: GetAllServersRequest, + responseObserver: StreamObserver + ) { + serverRepository.getAll().thenApply { servers -> + responseObserver.onNext( + GetAllServersResponse.newBuilder() + .addAllServers(servers.map { it.toDefinition() }) + .build() + ) + responseObserver.onCompleted() + } + } + + override fun getServerByNumerical( + request: GetServerByNumericalRequest, + responseObserver: StreamObserver + ) { + serverRepository.findServerByNumerical(request.group, request.numericalId.toInt()).thenApply { server -> + if (server == null) { + responseObserver.onError( + Status.NOT_FOUND + .withDescription("No server was found matching this group and numerical id") + .asRuntimeException() + ) + return@thenApply + } + responseObserver.onNext(server.toDefinition()) + responseObserver.onCompleted() + } + } + + override fun stopServerByNumerical( + request: StopServerByNumericalRequest, + responseObserver: StreamObserver + ) { + + serverRepository.findServerByNumerical(request.group, request.numericalId.toInt()).thenApply { server -> + if (server == null) { + responseObserver.onError( + Status.NOT_FOUND + .withDescription("No server was found matching this group and numerical id") + .asRuntimeException() + ) + return@thenApply + } + stopServer(server.toDefinition()).thenApply { + responseObserver.onNext(it) + responseObserver.onCompleted() + }.exceptionally { + responseObserver.onError(it) + } + } + + } + + override fun updateServer(request: ServerUpdateRequest, responseObserver: StreamObserver) { + val deleted = request.deleted + val server = Server.fromDefinition(request.server) + if (!deleted) { + try { + serverRepository.save(server) + }catch (e: Exception) { + responseObserver.onError( + Status.INTERNAL + .withDescription("Could not update server") + .withCause(e) + .asRuntimeException() + ) + return + } + responseObserver.onNext(server.toDefinition()) + responseObserver.onCompleted() + } else { + logger.info("Deleting server ${server.uniqueId} of group ${request.server.groupName}...") + serverRepository.delete(server).thenApply thenDelete@ { + if(!it) { + responseObserver.onError( + Status.INTERNAL + .withDescription("Could not delete server") + .asRuntimeException() + ) + return@thenDelete + } + logger.info("Deleted server ${server.uniqueId} of group ${request.server.groupName}.") + responseObserver.onNext(server.toDefinition()) + responseObserver.onCompleted() + }.exceptionally { + responseObserver.onError( + Status.INTERNAL + .withDescription("Could not delete server") + .withCause(it) + .asRuntimeException() + ) + } + } + } + + override fun getServerById(request: ServerIdRequest, responseObserver: StreamObserver) { + serverRepository.find(request.id).thenApply { server -> + if (server == null) { + responseObserver.onError( + Status.NOT_FOUND + .withDescription("No server was found matching this unique id") + .asRuntimeException() + ) + return@thenApply + } + responseObserver.onNext(server.toDefinition()) + responseObserver.onCompleted() + } + + } + + override fun getServersByGroup( + request: GroupNameRequest, + responseObserver: StreamObserver + ) { + serverRepository.findServersByGroup(request.name).thenApply { servers -> + val response = GetServersByGroupResponse.newBuilder() + .addAllServers(servers.map { it.toDefinition() }) + .build() + responseObserver.onNext(response) + responseObserver.onCompleted() + } + } + + override fun getServersByType( + request: ServerTypeRequest, + responseObserver: StreamObserver + ) { + serverRepository.findServersByType(request.type).thenApply { servers -> + val response = GetServersByGroupResponse.newBuilder() + .addAllServers(servers.map { it.toDefinition() }) + .build() + responseObserver.onNext(response) + responseObserver.onCompleted() + } + } + + override fun startServer(request: GroupNameRequest, responseObserver: StreamObserver) { + hostRepository.find(serverRepository).thenApply { host -> + if (host == null) { + responseObserver.onError( + Status.NOT_FOUND + .withDescription("No server host found, could not start server") + .asRuntimeException() + ) + return@thenApply + } + groupRepository.find(request.name).thenApply { group -> + if (group == null) { + responseObserver.onError( + Status.NOT_FOUND + .withDescription("No group was found matching this name") + .asRuntimeException() + ) + } else { + startServer(host, group) + } + } + } + } + + private fun startServer(host: ServerHost, group: Group): CompletableFuture { + val numericalId = numericalIdRepository.findNextNumericalId(group.name) + val server = buildServer(group, numericalId, forwardingSecret) + serverRepository.save(server) + val channel = host.createChannel() + val stub = ServerHostServiceGrpc.newFutureStub(channel) + .withCallCredentials(authCallCredentials) + serverRepository.save(server) + return stub.startServer( + StartServerRequest.newBuilder() + .setGroup(group.toDefinition()) + .setServer(server.toDefinition()) + .build() + ).toCompletable().thenApply { + serverRepository.save(Server.fromDefinition(it)) + channel.shutdown() + return@thenApply it + }.exceptionally { + serverRepository.delete(server) + numericalIdRepository.removeNumericalId(group.name, server.numericalId) + channel.shutdown() + throw it + } + } + + private fun buildServer(group: Group, numericalId: Int, forwardingSecret: String): Server { + return Server.fromDefinition( + ServerDefinition.newBuilder() + .setNumericalId(numericalId) + .setType(group.type) + .setGroupName(group.name) + .setMinimumMemory(group.minMemory) + .setMaximumMemory(group.maxMemory) + .setState(ServerState.PREPARING) + .setMaxPlayers(group.maxPlayers) + .setCreatedAt(LocalDateTime.now().toString()) + .setUpdatedAt(LocalDateTime.now().toString()) + .setPlayerCount(0) + .setUniqueId(UUID.randomUUID().toString().replace("-", "")).putAllProperties( + mapOf( + *group.properties.entries.map { it.key to it.value }.toTypedArray(), + "forwarding-secret" to forwardingSecret, + ) + ).build() + ) + } + + override fun stopServer(request: ServerIdRequest, responseObserver: StreamObserver) { + serverRepository.find(request.id).thenApply { server -> + if (server == null) { + throw Status.NOT_FOUND + .withDescription("No server was found matching this id.") + .asRuntimeException() + } + stopServer(server.toDefinition()).thenApply { + responseObserver.onNext(it) + responseObserver.onCompleted() + }.exceptionally { + responseObserver.onError(it) + }.get() + }.exceptionally { + responseObserver.onError(it) + } + } + + private fun stopServer(server: ServerDefinition): CompletableFuture { + val host = hostRepository.findServerHostById(server.hostId) + ?: throw Status.NOT_FOUND + .withDescription("No server host was found matching this server.") + .asRuntimeException() + val channel = host.createChannel() + val stub = ServerHostServiceGrpc.newFutureStub(channel) + .withCallCredentials(authCallCredentials) + return stub.stopServer(server).toCompletable().thenApply { + serverRepository.delete(Server.fromDefinition(server)) + channel.shutdown() + return@thenApply it + } + } + + override fun updateServerProperty( + request: ServerUpdatePropertyRequest, + responseObserver: StreamObserver + ) { + serverRepository.find(request.id).thenApply { server -> + if (server == null) { + throw Status.NOT_FOUND + .withDescription("Server with id ${request.id} does not exist.") + .asRuntimeException() + } + server.properties[request.key] = request.value + serverRepository.save(server) + responseObserver.onNext(server.toDefinition()) + responseObserver.onCompleted() + }.exceptionally { + responseObserver.onError(it) + } + } + + override fun updateServerState( + request: ServerUpdateStateRequest, + responseObserver: StreamObserver + ) { + serverRepository.find(request.id).thenApply { server -> + if (server == null) { + throw Status.NOT_FOUND + .withDescription("Server with id ${request.id} does not exist.") + .asRuntimeException() + } + server.state = request.state + serverRepository.save(server) + responseObserver.onNext(server.toDefinition()) + responseObserver.onCompleted() + }.exceptionally { + responseObserver.onError(it) + } + } + +} \ No newline at end of file diff --git a/controller-runtime/src/main/resources/log4j2.xml b/controller-runtime/src/main/resources/log4j2.xml index e32762f..462153c 100644 --- a/controller-runtime/src/main/resources/log4j2.xml +++ b/controller-runtime/src/main/resources/log4j2.xml @@ -4,10 +4,23 @@ + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/controller-shared/build.gradle.kts b/controller-shared/build.gradle.kts index cf71efc..56a56b8 100644 --- a/controller-shared/build.gradle.kts +++ b/controller-shared/build.gradle.kts @@ -1,51 +1,4 @@ -import com.google.protobuf.gradle.* - -plugins { - alias(libs.plugins.protobuf) -} - dependencies { api(rootProject.libs.bundles.proto) + api(rootProject.libs.bundles.configurate) } - -sourceSets { - main { - kotlin { - srcDirs( - "build/generated/source/proto/main/grpckt", - "build/generated/source/proto/main/kotlin", - ) - } - java { - srcDirs( - "build/generated/source/proto/main/grpc", - "build/generated/source/proto/main/java", - ) - } - } -} - -protobuf { - protoc { - artifact = "com.google.protobuf:protoc:3.23.1" - } - plugins { - id("grpc") { - artifact = "io.grpc:protoc-gen-grpc-java:1.55.1" - } - id("grpckt") { - artifact = "io.grpc:protoc-gen-grpc-kotlin:1.3.0:jdk8@jar" - } - } - generateProtoTasks { - all().forEach { - it.plugins { - id("grpc") - id("grpckt") - } - it.builtins { - id("kotlin") - } - } - } -} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/MetadataKeys.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/MetadataKeys.kt new file mode 100644 index 0000000..bf9c277 --- /dev/null +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/MetadataKeys.kt @@ -0,0 +1,9 @@ +package app.simplecloud.controller.shared + +import io.grpc.Metadata + +object MetadataKeys { + + val AUTH_SECRET_KEY = Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER) + +} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthCallCredentials.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthCallCredentials.kt new file mode 100644 index 0000000..8ffa976 --- /dev/null +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthCallCredentials.kt @@ -0,0 +1,24 @@ +package app.simplecloud.controller.shared.auth + +import app.simplecloud.controller.shared.MetadataKeys +import io.grpc.CallCredentials +import io.grpc.Metadata +import java.util.concurrent.Executor + +class AuthCallCredentials( + private val secretKey: String +): CallCredentials() { + + override fun applyRequestMetadata( + requestInfo: RequestInfo, + appExecutor: Executor, + applier: MetadataApplier + ) { + appExecutor.execute { + val headers = Metadata() + headers.put(MetadataKeys.AUTH_SECRET_KEY, secretKey) + applier.apply(headers) + } + } + +} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthSecretInterceptor.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthSecretInterceptor.kt new file mode 100644 index 0000000..290c710 --- /dev/null +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthSecretInterceptor.kt @@ -0,0 +1,24 @@ +package app.simplecloud.controller.shared.auth + +import app.simplecloud.controller.shared.MetadataKeys +import io.grpc.* + +class AuthSecretInterceptor( + private val secretKey: String +) : ServerInterceptor { + + override fun interceptCall( + call: ServerCall, + headers: Metadata, + next: ServerCallHandler + ): ServerCall.Listener { + val secretKey = headers.get(MetadataKeys.AUTH_SECRET_KEY) + if (this.secretKey != secretKey) { + call.close(Status.UNAUTHENTICATED, headers) + return object : ServerCall.Listener() {} + } + + return Contexts.interceptCall(Context.current(), call, headers, next) + } + +} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/future/ListenableFutureAdapter.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/future/ListenableFutureAdapter.kt index f91c395..013f43d 100644 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/future/ListenableFutureAdapter.kt +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/future/ListenableFutureAdapter.kt @@ -30,6 +30,7 @@ class ListenableFutureAdapter( } }, ForkJoinPool.commonPool()) } + companion object { fun toCompletable(listenableFuture: ListenableFuture): CompletableFuture { val listenableFutureAdapter: ListenableFutureAdapter = ListenableFutureAdapter(listenableFuture) diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/Group.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/Group.kt index 2897e7f..014f507 100644 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/Group.kt +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/Group.kt @@ -1,14 +1,35 @@ package app.simplecloud.controller.shared.group -import app.simplecloud.controller.shared.proto.GroupDefinition +import build.buf.gen.simplecloud.controller.v1.GroupDefinition +import build.buf.gen.simplecloud.controller.v1.ServerType +import org.spongepowered.configurate.objectmapping.ConfigSerializable +@ConfigSerializable data class Group( - val name: String, + val name: String = "", + val type: ServerType = ServerType.OTHER, + val minMemory: Long = 0, + val maxMemory: Long = 0, + val startPort: Long = 0, + val minOnlineCount: Long = 0, + val maxOnlineCount: Long = 0, + val maxPlayers: Long = 0, + val newServerPlayerRatio: Long = -1, + val properties: Map = mapOf() ) { fun toDefinition(): GroupDefinition { return GroupDefinition.newBuilder() .setName(name) + .setType(type) + .setMinimumMemory(minMemory) + .setMaximumMemory(maxMemory) + .setStartPort(startPort) + .setMinimumOnlineCount(minOnlineCount) + .setMaximumOnlineCount(maxOnlineCount) + .setMaxPlayers(maxPlayers) + .setNewServerPlayerRatio(newServerPlayerRatio) + .putAllProperties(properties) .build() } @@ -16,7 +37,16 @@ data class Group( @JvmStatic fun fromDefinition(groupDefinition: GroupDefinition): Group { return Group( - groupDefinition.name + groupDefinition.name, + groupDefinition.type, + groupDefinition.minimumMemory, + groupDefinition.maximumMemory, + groupDefinition.startPort, + groupDefinition.minimumOnlineCount, + groupDefinition.maximumOnlineCount, + groupDefinition.maxPlayers, + groupDefinition.newServerPlayerRatio, + groupDefinition.propertiesMap ) } } diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/host/ServerHost.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/host/ServerHost.kt new file mode 100644 index 0000000..c712a22 --- /dev/null +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/host/ServerHost.kt @@ -0,0 +1,39 @@ +package app.simplecloud.controller.shared.host + +import build.buf.gen.simplecloud.controller.v1.ServerHostDefinition +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import org.spongepowered.configurate.objectmapping.ConfigSerializable + +@ConfigSerializable +data class ServerHost( + val id: String, + val host: String, + val port: Int +) { + + fun toDefinition(): ServerHostDefinition { + return ServerHostDefinition.newBuilder() + .setHost(host) + .setPort(port) + .setUniqueId(id) + .build() + } + + companion object { + @JvmStatic + fun fromDefinition(serverHostDefinition: ServerHostDefinition): ServerHost { + return ServerHost( + serverHostDefinition.uniqueId, + serverHostDefinition.host, + serverHostDefinition.port + ) + } + } + + + fun createChannel(): ManagedChannel { + return ManagedChannelBuilder.forAddress(host, port).usePlaintext().build() + } + +} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/AuthFileSecretFactory.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/AuthFileSecretFactory.kt new file mode 100644 index 0000000..bd0d540 --- /dev/null +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/AuthFileSecretFactory.kt @@ -0,0 +1,28 @@ +package app.simplecloud.controller.shared.secret + +import java.nio.file.Files +import java.nio.file.Path + +object AuthFileSecretFactory { + + fun loadOrCreate(path: Path): String { + if (!Files.exists(path)) { + return create(path) + } + + return Files.readString(path) + } + + + private fun create(path: Path): String { + val secret = SecretGenerator.generate() + + if (!Files.exists(path)) { + path.parent?.let { Files.createDirectories(it) } + Files.writeString(path, secret) + } + + return secret + } + +} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/SecretGenerator.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/SecretGenerator.kt new file mode 100644 index 0000000..8c18bd3 --- /dev/null +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/SecretGenerator.kt @@ -0,0 +1,15 @@ +package app.simplecloud.controller.shared.secret + +import java.security.SecureRandom +import java.util.* + +object SecretGenerator { + + fun generate(size: Int = 64): String { + val random = SecureRandom() + val bytes = ByteArray(size) + random.nextBytes(bytes) + return Base64.getEncoder().encodeToString(bytes) + } + +} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/server/Server.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/server/Server.kt new file mode 100644 index 0000000..94c5398 --- /dev/null +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/server/Server.kt @@ -0,0 +1,72 @@ +package app.simplecloud.controller.shared.server + +import build.buf.gen.simplecloud.controller.v1.ServerDefinition +import build.buf.gen.simplecloud.controller.v1.ServerState +import build.buf.gen.simplecloud.controller.v1.ServerType +import java.time.LocalDateTime + +data class Server( + val uniqueId: String, + val type: ServerType, + val group: String, + val host: String?, + val numericalId: Int, + val ip: String, + val port: Long, + val minMemory: Long, + val maxMemory: Long, + val maxPlayers: Long, + var playerCount: Long, + val properties: MutableMap, + var state: ServerState, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime +) { + fun toDefinition(): ServerDefinition { + return ServerDefinition.newBuilder() + .setUniqueId(uniqueId) + .setType(type) + .setGroupName(group) + .setHostId(host) + .setIp(ip) + .setPort(port) + .setState(state) + .setMinimumMemory(minMemory) + .setMaximumMemory(maxMemory) + .setPlayerCount(playerCount) + .setMaxPlayers(maxPlayers) + .putAllProperties(properties) + .setNumericalId(numericalId) + .setCreatedAt(createdAt.toString()) + .setUpdatedAt(updatedAt.toString()) + .build() + } + + companion object { + @JvmStatic + fun fromDefinition(serverDefinition: ServerDefinition): Server { + return Server( + serverDefinition.uniqueId, + serverDefinition.type, + serverDefinition.groupName, + serverDefinition.hostId, + serverDefinition.numericalId, + serverDefinition.ip, + serverDefinition.port, + serverDefinition.minimumMemory, + serverDefinition.maximumMemory, + serverDefinition.maxPlayers, + serverDefinition.playerCount, + serverDefinition.propertiesMap, + serverDefinition.state, + LocalDateTime.parse(serverDefinition.createdAt), + LocalDateTime.parse(serverDefinition.updatedAt) + ) + } + + @JvmStatic + fun fromDefinition(definitions: List): List { + return definitions.map { definition -> fromDefinition(definition) } + } + } +} \ No newline at end of file diff --git a/controller-shared/src/main/proto/controller_group_api.proto b/controller-shared/src/main/proto/controller_group_api.proto deleted file mode 100644 index 6e40d92..0000000 --- a/controller-shared/src/main/proto/controller_group_api.proto +++ /dev/null @@ -1,20 +0,0 @@ -syntax = "proto3"; - -package app.simplecloud.controller.shared.proto; - -option java_package = "app.simplecloud.controller.shared.proto"; -option java_multiple_files = true; - -import "controller_types.proto"; - -message GetGroupByNameRequest { - string name = 1; -} - -message GetGroupByNameResponse { - GroupDefinition group = 2; -} - -service ControllerGroupService { - rpc getGroupByName(GetGroupByNameRequest) returns (GetGroupByNameResponse); -} \ No newline at end of file diff --git a/controller-shared/src/main/proto/controller_types.proto b/controller-shared/src/main/proto/controller_types.proto deleted file mode 100644 index 4aa24b8..0000000 --- a/controller-shared/src/main/proto/controller_types.proto +++ /dev/null @@ -1,44 +0,0 @@ -syntax = "proto3"; - -package app.simplecloud.controller.shared.proto; - -option java_package = "app.simplecloud.controller.shared.proto"; -option java_multiple_files = true; - -message GroupDefinition { - string name = 1; - string template_name = 2; - uint64 minimum_memory = 3; - uint64 maximum_memory = 4; - uint64 start_port = 5; - uint64 minimum_online_count = 6; - uint64 maximum_online_count = 7; - map properties = 8; -} - -message ServerDefinition { - string unique_id = 1; - string groupName = 2; - uint32 numerical_id = 3; - string template_name = 4; - uint64 port = 5; - uint64 minimum_memory = 6; - uint64 maximum_memory = 7; - uint64 player_count = 8; - map properties = 9; - ServerState state = 10; - ServerAllocationState allocation_state = 11; -} - -enum ServerState { - UNKNOWN = 0; - STARTING = 1; - RUNNING = 2; - STOPPING = 3; - STOPPED = 4; -} - -enum ServerAllocationState { - ALLOCATED = 0; - UNALLOCATED = 1; -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3efaae8..c6e6e36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,49 @@ [versions] kotlin = "1.8.0" +kotlinCoroutines = "1.4.2" shadow = "8.1.1" log4j = "2.20.0" -protobuf = "3.23.1" -protobufPlugin = "0.8.19" -grpcVersion = "1.55.1" +protobuf = "3.25.2" +grpc = "1.61.0" +grpcKotlin = "1.4.1" +simpleCloudProtoSpecs = "1.4.1.1.20240504121939.09af2f3cc691" +jooq = "3.19.3" +configurate = "4.1.2" +sqliteJdbc = "3.44.1.0" +clikt = "4.3.0" +sonatypeCentralPortalPublisher = "1.2.3" +spotifyCompletableFutures = "0.3.6" [libraries] kotlinJvm = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlinTest = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlinCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } + log4jCore = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" } log4jApi = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j" } log4jSlf4j = { module = "org.apache.logging.log4j:log4j-slf4j-impl", version.ref = "log4j" } -protobuf = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" } -grpcStub = { module = "io.grpc:grpc-stub", version.ref = "grpcVersion" } -grpcProtobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpcVersion" } -grpcNettyShaded = { module = "io.grpc:grpc-netty-shaded", version.ref = "grpcVersion" } + +protobufKotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" } + +grpcStub = { module = "io.grpc:grpc-stub", version.ref = "grpc" } +grpcKotlinStub = { module = "io.grpc:grpc-kotlin-stub", version.ref = "grpcKotlin" } +grpcProtobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpc" } +grpcNettyShaded = { module = "io.grpc:grpc-netty-shaded", version.ref = "grpc" } + +simpleCloudProtoSpecs = { module = "build.buf.gen:simplecloud_proto-specs_grpc_kotlin", version.ref = "simpleCloudProtoSpecs" } + +qooq = { module = "org.jooq:jooq", version.ref = "jooq" } +qooqMeta = { module = "org.jooq:jooq-meta", version.ref = "jooq" } +jooqMetaExtensions = { module = "org.jooq:jooq-meta-extensions", version.ref = "jooq" } + +configurateYaml = { module = "org.spongepowered:configurate-yaml", version.ref = "configurate" } +configurateExtraKotlin = { module = "org.spongepowered:configurate-extra-kotlin", version.ref = "configurate" } + +sqliteJdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" } + +clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } + +spotifyCompletableFutures = { module = "com.spotify:completable-futures", version.ref = "spotifyCompletableFutures" } [bundles] log4j = [ @@ -24,13 +52,24 @@ log4j = [ "log4jSlf4j" ] proto = [ - "protobuf", + "protobufKotlin", "grpcStub", + "grpcKotlinStub", "grpcProtobuf", - "grpcNettyShaded" + "grpcNettyShaded", + "simpleCloudProtoSpecs" +] +jooq = [ + "qooq", + "qooqMeta" +] +configurate = [ + "configurateYaml", + "configurateExtraKotlin" ] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } shadow = { id = "com.github.johnrengelman.shadow", version.ref = "shadow" } -protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } \ No newline at end of file +jooqCodegen = { id = "org.jooq.jooq-codegen-gradle", version.ref = "jooq" } +sonatypeCentralPortalPublisher = { id = "net.thebugmc.gradle.sonatype-central-portal-publisher", version.ref = "sonatypeCentralPortalPublisher" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0832ac8..2f2ebf5 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Jan 18 09:50:39 CET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e7394f5 --- /dev/null +++ b/readme.md @@ -0,0 +1,27 @@ +# SimpleCloud v3 Controller + +Process that (automatically) manages minecraft server deployments (across multiple root-servers). +At least one [ServerHost](#serverhosts) is needed to actually start servers. +> Please visit [our documentation](https://docs.simplecloud.app/controller) to learn how exactly it works + +## Features + +- [x] Reconciler (auto-deploying for servers) +- [x] [API](#api-usage) using [gRPC](https://grpc.io/) +- [x] Server cache [SQL](https://en.wikipedia.org/wiki/SQL)-Database (any dialect) + +## ServerHosts + +ServerHosts are processes, that directly handle minecraft server deployments. Each root-server should have exactly one +ServerHost online. We provide a [default implementation](), +however, you can write your [own implementation](). You can have as many ServerHost instances as you like. + +## API usage + +> If you are searching for documentation, please visit our [official documentation](https://docs.simplecloud.app/api) + +The SimpleCloud v3 Controller provides API for both server groups and actual servers. +The group API is used for [CRUD-Operations](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) of server +groups, whereas the server API is used to manage running servers or starting new ones. + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 3b5cc01..e5c8b31 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,7 +14,7 @@ plugins { rootProject.name = "simplecloud-controller" include( - "controller-shared", - "controller-api", - "controller-runtime" + "controller-shared", + "controller-api", + "controller-runtime" ) \ No newline at end of file diff --git a/structure.png b/structure.png new file mode 100644 index 0000000..a530aba Binary files /dev/null and b/structure.png differ