diff --git a/Dockerfile b/Dockerfile index f6271e7..a61b23e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM gradle:8.0-jdk19 AS build +FROM gradle:8.12.0-jdk21 AS build COPY --chown=gradle:gradle . /home/gradle/src WORKDIR /home/gradle/src RUN gradle buildFatJar --no-daemon diff --git a/build.gradle.kts b/build.gradle.kts index 01c1fff..9d7e11a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,20 +1,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -val ktorVersion = "2.3.7" -val kotlinVersion = "1.9.10" -val logbackVersion = "1.4.12" -val koinKtorVersion = "3.5.0" - -version = "0.6.0" plugins { - id("io.ktor.plugin") version "2.3.4" - id("org.jetbrains.kotlin.plugin.serialization") version "1.9.20" - kotlin("jvm") version "1.9.20" -} - -repositories { - mavenCentral() - gradlePluginPortal() + alias(connectorLibs.plugins.kotlin.jvm) + alias(connectorLibs.plugins.kotlin.serialization) + alias(libs.plugins.ktor) } application { @@ -22,56 +11,39 @@ application { } group = "org.wagham" +version = "0.6.0" dependencies { implementation(project(":kabot-db-connector")) - implementation("org.jetbrains.kotlin:kotlin-reflect") - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") - implementation(group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version = "1.7.3") - implementation(group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-reactor", version = "1.7.3") - implementation(group = "com.github.ben-manes.caffeine", name = "caffeine", version = "3.1.8") - implementation(group = "org.apache.tika", name = "tika-core", version = "2.9.1") - - implementation(group = "io.ktor", name = "ktor-server-core-jvm", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-server-cors-jvm", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-server-content-negotiation-jvm", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-serialization-jackson-jvm", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-server-call-logging-jvm", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-server-cio-jvm", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-serialization-kotlinx-json", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-serialization-jackson", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-client-cio-jvm", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-client-core-jvm", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-client-content-negotiation-jvm", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-server-auth", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-server-auth-jwt", version = ktorVersion) - implementation(group = "io.ktor", name = "ktor-server-status-pages", version = ktorVersion) - - implementation(group = "io.insert-koin", name = "koin-ktor", version = koinKtorVersion) - implementation(group = "io.insert-koin", name = "koin-logger-slf4j", version = koinKtorVersion) - - implementation(group = "ch.qos.logback", name = "logback-classic", version = logbackVersion) - - implementation(group = "org.mindrot", name = "jbcrypt", version = "0.4") - - implementation(group="org.litote.kmongo", name="kmongo-coroutine", version = "4.7.0") - - testImplementation(group = "org.junit.jupiter", name = "junit-jupiter", version = "5.10.1") - testImplementation(group="io.kotest", name="kotest-assertions-core-jvm", version="5.5.3") - testImplementation(group="io.kotest", name="kotest-framework-engine-jvm", version="5.5.3") - testImplementation(group = "io.kotest.extensions", name = "kotest-extensions-spring", version = "1.1.3") + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + implementation(libs.bundles.ktor.server) + implementation(libs.bundles.ktor.serialization) + implementation(libs.bundles.ktor.client) + implementation(libs.bundles.koin) + implementation(connectorLibs.kotlinx.coroutines.core) + implementation(libs.caffeine) + implementation(libs.tika) + implementation(libs.kfswatch) + implementation(libs.logback) + implementation(libs.jbcrypt) + implementation(connectorLibs.kmongo) + implementation(libs.krontab) + + testImplementation(connectorLibs.bundles.kotest) + testImplementation(libs.jupyter) } tasks.withType { kotlinOptions { freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = "19" + jvmTarget = "21" } } java { - sourceCompatibility = JavaVersion.VERSION_19 - targetCompatibility = JavaVersion.VERSION_19 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } tasks.withType { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..d4a6e75 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,45 @@ +[versions] +ktor = "3.1.2" +logback = "1.4.14" +kfs = "1.3.0" +koin = "4.0.4" +caffeine = "3.1.0" +tike = "2.9.1" +jbcrypt = "0.4" +jupyter = "5.10.1" +krontab = "2.6.1" + +[libraries] +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8" } +caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } +tika = { module = "org.apache.tika:tika-core", version.ref = "tike" } +ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } +ktor-server-cors = { module = "io.ktor:ktor-server-cors-jvm", version.ref = "ktor" } +ktor-server-content-negotiations = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" } +ktor-server-logging = { module = "io.ktor:ktor-server-call-logging-jvm", version.ref = "ktor" } +ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } +ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" } +ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor" } +ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" } +ktor-serialization-kotlinx = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-serialization-jackson = { module = "io.ktor:ktor-serialization-jackson", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio-jvm", version.ref = "ktor" } +ktor-client-core = { module = "io.ktor:ktor-client-core-jvm", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation-jvm", version.ref = "ktor" } +kfswatch = { module = "io.github.irgaly.kfswatch:kfswatch", version.ref = "kfs" } +koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" } +koin-logger = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" } +logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +jbcrypt = { module = "org.mindrot:jbcrypt", version.ref = "jbcrypt" } +krontab = { module = "dev.inmo:krontab", version.ref = "krontab" } +jupyter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "jupyter" } + +[bundles] +ktor-server = ["ktor-server-core", "ktor-server-cors", "ktor-server-content-negotiations", "ktor-server-logging", "ktor-server-netty", "ktor-server-auth", "ktor-server-auth-jwt", "ktor-server-status-pages"] +ktor-serialization = ["ktor-serialization-kotlinx", "ktor-serialization-jackson"] +ktor-client = ["ktor-client-cio", "ktor-client-core", "ktor-client-content-negotiation"] +koin = ["koin-ktor", "koin-logger"] + +[plugins] +ktor = { id = "io.ktor.plugin", version.ref = "ktor" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a595206..e1adfb4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/kabot-db-connector b/kabot-db-connector index bd9bc28..ee17c71 160000 --- a/kabot-db-connector +++ b/kabot-db-connector @@ -1 +1 @@ -Subproject commit bd9bc28b57f1ce49655fc7c1d16e97eac76694a0 +Subproject commit ee17c718d1f583bbe73dda444bab81fb8ebf7c02 diff --git a/settings.gradle.kts b/settings.gradle.kts index 8e2151e..83c87ef 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,27 @@ rootProject.name = "kabot-api" +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + @Suppress("UnstableApiUsage") + repositories { + mavenLocal() + mavenCentral() + } + + versionCatalogs { + create("connectorLibs") { + from(files("./kabot-db-connector/libs.versions.toml")) + } + } + +} + include(":kabot-db-connector") \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/KabotApiApplication.kt b/src/main/kotlin/org/wagham/kabotapi/KabotApiApplication.kt index eed6877..59f75ef 100644 --- a/src/main/kotlin/org/wagham/kabotapi/KabotApiApplication.kt +++ b/src/main/kotlin/org/wagham/kabotapi/KabotApiApplication.kt @@ -6,7 +6,9 @@ import org.wagham.kabotapi.configuration.configureHTTP import org.wagham.kabotapi.configuration.configureKoin import org.wagham.kabotapi.configuration.configureRouting -fun main(args: Array) = io.ktor.server.cio.EngineMain.main(args) +fun main(args: Array) { + io.ktor.server.netty.EngineMain.main(args) +} @Suppress("unused") fun Application.module() { diff --git a/src/main/kotlin/org/wagham/kabotapi/components/InstanceConfigManager.kt b/src/main/kotlin/org/wagham/kabotapi/components/InstanceConfigManager.kt new file mode 100644 index 0000000..24fc769 --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/components/InstanceConfigManager.kt @@ -0,0 +1,76 @@ +package org.wagham.kabotapi.components + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import io.github.irgaly.kfswatch.KfsDirectoryWatcher +import io.github.irgaly.kfswatch.KfsEvent +import io.ktor.util.logging.* +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.wagham.kabotapi.model.foundry.FoundryOptions +import java.io.File + +class InstanceConfigManager( + private val baseFolder: String +) { + + companion object { + private const val OPTIONS_FILENAME = "options.json" + } + + private val logger = KtorSimpleLogger(this.javaClass.simpleName) + private val scope = CoroutineScope(Dispatchers.IO) + private val watcher = KfsDirectoryWatcher(scope) + private val instancesByUrl = mutableMapOf() + + fun startWatching() { + logger.info("Starting instance manager") + val dirsToWatch = File(baseFolder).walkTopDown().filter { + it.isFile && it.name == OPTIONS_FILENAME + }.map { + updateInstancesWith(it) + it.parent + }.toList() + scope.launch { + watcher.add(*dirsToWatch.toTypedArray()) + watcher.onEventFlow.collect { + try { + val file = File("${it.targetDirectory}/${it.path}") + if (file.isFile && file.name == OPTIONS_FILENAME) { + when (it.event) { + KfsEvent.Create, KfsEvent.Modify -> { + updateInstancesWith(file) + } + else -> { + logger.info("Deleted ${file.absolutePath}") + } + } + } + } catch (e: Exception) { + logger.error(e.message) + } + } + } + } + + fun getInfoByUrl(url: String): InstanceInfo? = instancesByUrl[url] + + private fun updateInstancesWith(optionsFile: File) { + val options = Json.decodeFromString(optionsFile.readText()) + val info = InstanceInfo( + id = options.dataPath.split("/").last(), + url = options.routePrefix, + name = options.masterName ?: "unknown", + ) + instancesByUrl[info.url] = info.also { + logger.info("Updating ${info.url} with $it") + } + } + + data class InstanceInfo( + val id: String, + val url: String, + val name: String + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/components/InstanceInactivityManager.kt b/src/main/kotlin/org/wagham/kabotapi/components/InstanceInactivityManager.kt new file mode 100644 index 0000000..0533b73 --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/components/InstanceInactivityManager.kt @@ -0,0 +1,93 @@ +package org.wagham.kabotapi.components + +import com.github.benmanes.caffeine.cache.Caffeine +import com.github.benmanes.caffeine.cache.RemovalCause +import dev.inmo.krontab.doInfinity +import io.ktor.util.logging.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.wagham.kabotapi.components.socket.CommandComponent +import org.wagham.kabotapi.components.socket.NginxLogsListener +import org.wagham.kabotapi.components.socket.Pm2ListCommand +import org.wagham.kabotapi.components.socket.Pm2StopCommand +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.toJavaDuration + +class InstanceInactivityManager( + private val instanceTtl: Duration, + private val commandComponent: CommandComponent, + private val nginxLogsListener: NginxLogsListener, + private val instanceConfigManager: InstanceConfigManager, + private val excludedInstances: Set +) { + + private val urlExtractingRegex = Regex(".* \"https://fnd\\.kaironbot\\.net/([^/]+).*") + private val managerScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val logger = KtorSimpleLogger(this.javaClass.simpleName) + private val instanceActivity = Caffeine.newBuilder() + .expireAfterWrite(instanceTtl.toJavaDuration()) + .evictionListener { target: String?, _: Long?, _: RemovalCause -> + if (target != null) { + managerScope.launch { + commandComponent.sendSocketCommand(Pm2StopCommand(target)) + } + } + }.build() + + fun startListening() { + logger.info("Starting inactivity manager") + listenForZombies() + listenForLogs() + } + + private fun listenForZombies() = managerScope.launch { + delay(instanceTtl) + doInfinity("0 0 * * * *") { + try { + commandComponent.sendSocketCommand(Pm2ListCommand()).filter { + it.name !in excludedInstances + }.forEach { + if ((System.currentTimeMillis() - it.pm2Env.uptime).milliseconds > 1.hours) { + val lastActivity = instanceActivity.getIfPresent(it.name) + if (lastActivity == null) { + commandComponent.sendSocketCommand(Pm2StopCommand(it.name)) + } + } + } + } catch (e: Exception) { + logger.error("Error while getting zombies", e) + } + } + } + + private fun listenForLogs() = managerScope.launch { + try { + nginxLogsListener.subscribe().collect { + try { + val info = getInstanceFromLog(it) + if (info != null) { + instanceActivity.put(info.id, System.currentTimeMillis()) + } + } catch (e: Exception) { + logger.error("Error parsing log $it", e) + } + } + } catch (e: Exception) { + logger.error("Log stream interrupted", e) + } + } + + + + private fun getInstanceFromLog(log: String): InstanceConfigManager.InstanceInfo? = + urlExtractingRegex.find(log)?.groupValues?.get(1)?.let { + instanceConfigManager.getInfoByUrl(it) + }?.takeIf { + it.id !in excludedInstances + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/components/socket/AbstractUdpListener.kt b/src/main/kotlin/org/wagham/kabotapi/components/socket/AbstractUdpListener.kt new file mode 100644 index 0000000..7f9d9d6 --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/components/socket/AbstractUdpListener.kt @@ -0,0 +1,45 @@ +package org.wagham.kabotapi.components.socket + +import java.net.DatagramPacket +import java.net.DatagramSocket +import kotlin.concurrent.thread +import io.ktor.util.logging.* + +abstract class AbstractUdpListener( + listenPort: Int, + protected val logger: Logger +) { + + private val receiveSocket = DatagramSocket(listenPort) + + protected abstract fun handlePacket(packet: String) + + fun startListening() { + logger.info("Starting ${this::class.simpleName}") + thread(start = true, isDaemon = true) { + receiveSocket.use { socket -> + val rcvBuffer = ByteArray(10240) + var buffer = "" + while(true) { + try { + val packet = DatagramPacket(rcvBuffer, rcvBuffer.size) + socket.receive(packet) + val idx = packet.data.indexOf('\n'.code.toByte()) + val received = if (idx == -1) null else buffer + String(packet.data, 0, idx) + buffer = + if (idx == -1) buffer + String(packet.data, 0, packet.length) + else String(packet.data, idx + 1, packet.length - idx - 1) + logger.info("Buffer: $buffer") + if (received != null) { + logger.info("Received: $received") + handlePacket(received) + } + + } catch (e: Exception) { + logger.error("Cannot receive packet", e) + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/components/socket/Command.kt b/src/main/kotlin/org/wagham/kabotapi/components/socket/Command.kt new file mode 100644 index 0000000..cdcaa56 --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/components/socket/Command.kt @@ -0,0 +1,61 @@ +package org.wagham.kabotapi.components.socket + +import kotlinx.serialization.json.Json +import org.wagham.kabotapi.model.pm2.Pm2ProcessInfo + +sealed class Command ( + val ts: Long, + private val command: String, + private val args: List, +) { + + fun toDatagramPayload(): ByteArray = buildString { + append(ts) + append(" $command") + args.forEach { + append(" $it") + } + }.toByteArray() + + abstract fun parseResponse(response: List): R +} + +class Pm2ListCommand : Command>(System.currentTimeMillis(), "list", emptyList()) { + + companion object { + private val json = Json { + ignoreUnknownKeys = true + } + } + + override fun parseResponse(response: List): List = response.map { + json.decodeFromString(it) + } + +} + +data class Pm2StartCommand( + val target: String, +) : Command(System.currentTimeMillis(), "start", listOf(target)) { + + override fun parseResponse(response: List): String { + check(response.size == 1 && response.first() == "ok") { + "Unexpected response $response" + } + return "ok" + } + +} + +data class Pm2StopCommand( + val target: String, +) : Command(System.currentTimeMillis(), "stop", listOf(target)) { + + override fun parseResponse(response: List): String { + check(response.size == 1 && response.first() == "ok") { + "Unexpected response $response" + } + return "ok" + } + +} \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/components/socket/CommandComponent.kt b/src/main/kotlin/org/wagham/kabotapi/components/socket/CommandComponent.kt new file mode 100644 index 0000000..9556dee --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/components/socket/CommandComponent.kt @@ -0,0 +1,81 @@ +package org.wagham.kabotapi.components.socket + +import io.ktor.util.logging.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import org.wagham.kabotapi.data.PeekableChannel +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import kotlin.time.Duration.Companion.milliseconds + +class CommandComponent( + private val sendPort: Int, + receivePort: Int, +): AbstractUdpListener(receivePort, KtorSimpleLogger("CommandComponent")) { + + private val packetChannel = PeekableChannel(capacity = 1000, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val address = InetAddress.getByName("127.0.0.1") + private val socketMutex = Mutex() + + override fun handlePacket(packet: String) { + val parts = packet.split('|') + packetChannel.trySend( + ParsedPacket( + parts[0].toLong(), + parts[1].toInt(), + parts[2].toInt(), + parts[3] + ) + ) + } + + private fun sendCommand(datagram: ByteArray) { + DatagramSocket().use { socket -> + val packet = DatagramPacket(datagram, datagram.size, address, sendPort) + socket.send(packet) + } + } + + suspend fun sendSocketCommand(command: Command): T = coroutineScope { + val responseDatagrams = mutableListOf() + socketMutex.withLock { + val responseJob = launch { + do { + val hasNext = runCatching { + withTimeout(500.milliseconds) { + val next = packetChannel.peek() + when { + next.ts < command.ts -> { + packetChannel.dropPeeked() + true + } + next.ts > command.ts -> false + else -> { + responseDatagrams.add(next.msg) + packetChannel.dropPeeked() + next.part < next.total + } + } + } + }.getOrDefault(false) + } while (hasNext) + } + sendCommand(command.toDatagramPayload()) + responseJob.join() + } + command.parseResponse(responseDatagrams) + } + + private data class ParsedPacket( + val ts: Long, + val part: Int, + val total: Int, + val msg: String, + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/components/socket/NginxLogsListener.kt b/src/main/kotlin/org/wagham/kabotapi/components/socket/NginxLogsListener.kt new file mode 100644 index 0000000..d0a6dec --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/components/socket/NginxLogsListener.kt @@ -0,0 +1,22 @@ +package org.wagham.kabotapi.components.socket + +import io.ktor.util.logging.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class NginxLogsListener( + listenPort: Int, +): AbstractUdpListener(listenPort, KtorSimpleLogger("NginxLogsListener")) { + + private val broadcastChannel = MutableSharedFlow(replay = 0, extraBufferCapacity = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + fun subscribe(): SharedFlow = broadcastChannel.asSharedFlow() + + override fun handlePacket(packet: String) { + if(broadcastChannel.subscriptionCount.value > 0) { + broadcastChannel.tryEmit(packet) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/configuration/KoinConfig.kt b/src/main/kotlin/org/wagham/kabotapi/configuration/KoinConfig.kt index 4851d53..daeba84 100644 --- a/src/main/kotlin/org/wagham/kabotapi/configuration/KoinConfig.kt +++ b/src/main/kotlin/org/wagham/kabotapi/configuration/KoinConfig.kt @@ -3,14 +3,21 @@ package org.wagham.kabotapi.configuration import io.ktor.server.application.* import io.ktor.server.config.* import org.koin.dsl.module +import org.koin.ktor.ext.inject import org.koin.ktor.plugin.Koin import org.koin.logger.slf4jLogger import org.wagham.kabotapi.components.DatabaseComponent import org.wagham.kabotapi.components.ExternalGateway +import org.wagham.kabotapi.components.InstanceConfigManager +import org.wagham.kabotapi.components.InstanceInactivityManager import org.wagham.kabotapi.components.JWTManager +import org.wagham.kabotapi.components.socket.CommandComponent +import org.wagham.kabotapi.components.socket.NginxLogsListener import org.wagham.kabotapi.entities.config.DiscordConfig +import org.wagham.kabotapi.entities.config.FoundryConfig import org.wagham.kabotapi.entities.config.JWTConfig import org.wagham.kabotapi.entities.config.MongoConfig +import org.wagham.kabotapi.entities.config.SocketConfig import org.wagham.kabotapi.logic.CharacterLogic import org.wagham.kabotapi.logic.DiscordLogic import org.wagham.kabotapi.logic.ItemLogic @@ -30,11 +37,26 @@ fun applicationModules( config: ApplicationConfig, dbConfig: MongoConfig, jwtConfig: JWTConfig, - discordConfig: DiscordConfig + discordConfig: DiscordConfig, + socketConfig: SocketConfig, + foundryConfig: FoundryConfig ) = module { single { JWTManager(jwtConfig) } single { ExternalGateway(config) } single { DatabaseComponent(dbConfig) } + single { CommandComponent(socketConfig.commandSendPort, socketConfig.commandReceivePort) } + single { NginxLogsListener(socketConfig.logsReceivePort) } + single { InstanceConfigManager(foundryConfig.instanceFolder) } + single { + InstanceInactivityManager( + instanceTtl = foundryConfig.instanceTtl, + commandComponent = get(), + nginxLogsListener = get(), + instanceConfigManager = get(), + excludedInstances = foundryConfig.excludedInstances, + ) + } + single { DiscordLogicImpl(get(), discordConfig)} single { CharacterLogicImpl(get(), get()) } single { LabelLogicImpl(get()) } @@ -54,9 +76,20 @@ fun Application.configureKoin() { val dbConfig = MongoConfig.fromConfig(environment.config) val jwtConfig = JWTConfig.fromConfig(environment.config) val discordConfig = DiscordConfig.fromConfig(environment.config) + val socketConfig = SocketConfig.fromConfig(environment.config) + val foundryConfig = FoundryConfig.fromConfig(environment.config) install(Koin) { slf4jLogger() - modules(applicationModules(environment.config, dbConfig, jwtConfig, discordConfig)) + modules(applicationModules(environment.config, dbConfig, jwtConfig, discordConfig, socketConfig, foundryConfig)) } + + val commandComponent: CommandComponent by inject() + commandComponent.startListening() + val nginxLogsListener: NginxLogsListener by inject() + nginxLogsListener.startListening() + val instanceConfigManager: InstanceConfigManager by inject() + instanceConfigManager.startWatching() + val instanceInactivityManager: InstanceInactivityManager by inject() + instanceInactivityManager.startListening() } \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/data/PeekableChannel.kt b/src/main/kotlin/org/wagham/kabotapi/data/PeekableChannel.kt new file mode 100644 index 0000000..2324fa1 --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/data/PeekableChannel.kt @@ -0,0 +1,39 @@ +package org.wagham.kabotapi.data + + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ChannelResult + +class PeekableChannel( + capacity: Int, + onBufferOverflow: BufferOverflow, +) { + + private val channel = Channel(capacity, onBufferOverflow) + private var peeked: T? = null + + fun dropPeeked() { + peeked = null + } + + suspend fun peek(): T { + if (peeked == null) { + peeked = channel.receive() + } + return checkNotNull(peeked) { "Peeked cannot be null" } + } + + suspend fun receive(): T = + if (peeked == null) { + channel.receive() + } else { + checkNotNull(peeked) { "Peeked cannot be null" }.also { + peeked = null + } + } + + fun trySend(element: T): ChannelResult = + channel.trySend(element) + +} \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/entities/config/FoundryConfig.kt b/src/main/kotlin/org/wagham/kabotapi/entities/config/FoundryConfig.kt new file mode 100644 index 0000000..da130c2 --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/entities/config/FoundryConfig.kt @@ -0,0 +1,23 @@ +package org.wagham.kabotapi.entities.config + +import io.ktor.server.config.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +data class FoundryConfig( + val instanceFolder: String, + val instanceTtl: Duration, + val excludedInstances: Set +) { + companion object { + fun fromConfig(config: ApplicationConfig) = FoundryConfig( + instanceFolder = config.property("foundry.instanceFolder").getString(), + instanceTtl = config.property("foundry.instanceTtlInMinutes").getString().toInt().minutes, + excludedInstances = config.property("foundry.excludedInstances").getString() + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .toSet() + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/entities/config/SocketConfig.kt b/src/main/kotlin/org/wagham/kabotapi/entities/config/SocketConfig.kt new file mode 100644 index 0000000..0c3635d --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/entities/config/SocketConfig.kt @@ -0,0 +1,17 @@ +package org.wagham.kabotapi.entities.config + +import io.ktor.server.config.* + +data class SocketConfig( + val commandSendPort: Int, + val commandReceivePort: Int, + val logsReceivePort: Int +) { + companion object { + fun fromConfig(config: ApplicationConfig) = SocketConfig( + commandSendPort = config.property("socket.commandSendPort").getString().toInt(), + commandReceivePort = config.property("socket.commandReceivePort").getString().toInt(), + logsReceivePort = config.property("socket.logsReceivePort").getString().toInt(), + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/model/foundry/FoundryOptions.kt b/src/main/kotlin/org/wagham/kabotapi/model/foundry/FoundryOptions.kt new file mode 100644 index 0000000..46e754a --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/model/foundry/FoundryOptions.kt @@ -0,0 +1,33 @@ +package org.wagham.kabotapi.model.foundry + +import kotlinx.serialization.Serializable + +@Serializable +data class FoundryOptions( + val port: Int, + val upnp: Boolean, + val fullscreen: Boolean, + val hostname: String, + val localHostname: String?, + val routePrefix: String, + val sslCert: String?, + val sslKey: String?, + val awsConfig: String?, + val dataPath: String, + val passwordSalt: String?, + val proxySSL: Boolean, + val proxyPort: Int, + val serviceConfig: String?, + val updateChannel: String, + val language: String, + val upnpLeaseDuration: String?, + val compressStatic: Boolean, + val world: String, + val compressSocket: Boolean, + val cssTheme: String, + val deleteNEDB: Boolean, + val hotReload: Boolean, + val protocol: String?, + val telemetry: Boolean, + val masterName: String? = null +) \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/model/pm2/Pm2ProcessInfo.kt b/src/main/kotlin/org/wagham/kabotapi/model/pm2/Pm2ProcessInfo.kt new file mode 100644 index 0000000..e9cb40d --- /dev/null +++ b/src/main/kotlin/org/wagham/kabotapi/model/pm2/Pm2ProcessInfo.kt @@ -0,0 +1,25 @@ +package org.wagham.kabotapi.model.pm2 + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Pm2ProcessInfo( + val pid: Int, + @SerialName("pm_id") val pm2Id: Int, + val name: String, + @SerialName("pm2_env") val pm2Env: Pm2Env, + val monit: Pm2Monit +) + +@Serializable +data class Pm2Env( + @SerialName("unstable_restarts") val unstableRestarts: Int, + @SerialName("pm_uptime") val uptime: Long +) + +@Serializable +data class Pm2Monit( + val memory: Long, + val cpu: Double, +) \ No newline at end of file diff --git a/src/main/kotlin/org/wagham/kabotapi/utils/RouteUtils.kt b/src/main/kotlin/org/wagham/kabotapi/utils/RouteUtils.kt index 1beba7d..a5aabd7 100644 --- a/src/main/kotlin/org/wagham/kabotapi/utils/RouteUtils.kt +++ b/src/main/kotlin/org/wagham/kabotapi/utils/RouteUtils.kt @@ -1,10 +1,8 @@ package org.wagham.kabotapi.utils -import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* import io.ktor.server.routing.* -import io.ktor.util.pipeline.* import org.wagham.db.enums.NyxRoles import org.wagham.kabotapi.components.toJWTClaims import org.wagham.kabotapi.configuration.AUTH_CTX @@ -18,7 +16,7 @@ fun Route.authenticatedGet( path: String, ctx: String = AUTH_CTX, roles: Set = emptySet(), - block: suspend PipelineContext.(JWTClaims) -> Unit + block: suspend RoutingContext.(JWTClaims) -> Unit ): Route = authenticate(ctx) { get(path) { val claims = call.principal()?.payload?.toJWTClaims() @@ -38,7 +36,7 @@ fun Route.authenticatedPost( path: String, ctx: String = AUTH_CTX, roles: Set = emptySet(), - block: suspend PipelineContext.(JWTClaims) -> Unit + block: suspend RoutingContext.(JWTClaims) -> Unit ): Route = authenticate(ctx) { post(path) { val claims = call.principal()?.payload?.toJWTClaims() @@ -58,7 +56,7 @@ fun Route.authenticatedDelete( path: String, ctx: String = AUTH_CTX, roles: Set = emptySet(), - block: suspend PipelineContext.(JWTClaims) -> Unit + block: suspend RoutingContext.(JWTClaims) -> Unit ): Route = authenticate(ctx) { delete(path) { val claims = call.principal()?.payload?.toJWTClaims() @@ -78,7 +76,7 @@ fun Route.authenticatedPut( path: String, ctx: String = AUTH_CTX, roles: Set = emptySet(), - block: suspend PipelineContext.(JWTClaims) -> Unit + block: suspend RoutingContext.(JWTClaims) -> Unit ): Route = authenticate(ctx) { put(path) { val claims = call.principal()?.payload?.toJWTClaims()