From a17cd2521afdd8b80777f1a9781fddd9e74b38db Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sat, 1 Aug 2026 21:09:04 +0200 Subject: [PATCH 1/2] feat(velocity): drain players via transfer before shutdown A proxy pod that is rolled today kicks everyone on it. The preStop hook now calls a loopback drain endpoint instead: lobby players are moved to another proxy with a Minecraft transfer packet immediately, players inside a round stay until the round sends them back towards a lobby (that connect becomes the transfer), and at the deadline the rest is transferred regardless. New logins are denied while draining. Wait-only without GROUNDS_DRAIN_TRANSFER_HOST; endpoint is loopback so only the pod's own preStop hook can trigger it. --- .../kotlin/gg/grounds/GroundsPluginAgones.kt | 22 ++++ .../kotlin/gg/grounds/drain/DrainConfig.kt | 50 +++++++ .../gg/grounds/drain/DrainHttpServer.kt | 83 ++++++++++++ .../kotlin/gg/grounds/drain/DrainListener.kt | 33 +++++ .../kotlin/gg/grounds/drain/DrainManager.kt | 124 ++++++++++++++++++ .../gg/grounds/drain/DrainConfigTest.kt | 64 +++++++++ .../gg/grounds/drain/DrainDecisionTest.kt | 46 +++++++ 7 files changed, 422 insertions(+) create mode 100644 velocity/src/main/kotlin/gg/grounds/drain/DrainConfig.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/drain/DrainHttpServer.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/drain/DrainListener.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/drain/DrainManager.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/drain/DrainConfigTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/drain/DrainDecisionTest.kt diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt index 872cb57..1c8090e 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt @@ -9,6 +9,10 @@ import com.velocitypowered.api.proxy.ProxyServer import gg.grounds.command.AgonesCommand import gg.grounds.discovery.DiscoveryConfig import gg.grounds.discovery.DiscoveryService +import gg.grounds.drain.DrainConfig +import gg.grounds.drain.DrainHttpServer +import gg.grounds.drain.DrainListener +import gg.grounds.drain.DrainManager import gg.grounds.gameserver.GameServerStateManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -30,6 +34,7 @@ constructor(private val proxyServer: ProxyServer, private val logger: Logger) { private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var stateManager: GameServerStateManager private lateinit var discoveryService: DiscoveryService + private lateinit var drainHttpServer: DrainHttpServer @Subscribe fun onProxyInitialize(event: ProxyInitializeEvent) { @@ -56,11 +61,28 @@ constructor(private val proxyServer: ProxyServer, private val logger: Logger) { AgonesCommand(proxyServer, { serverName -> discoveryService.getServerRole(serverName) }), ) + val drainConfig = DrainConfig.fromEnv() + val drainManager = + DrainManager( + this, + proxyServer, + logger, + drainConfig, + { serverName -> discoveryService.getServerRole(serverName) }, + discoveryConfig.lobbyValue, + ) + proxyServer.eventManager.register(this, DrainListener(drainManager)) + drainHttpServer = + DrainHttpServer(drainManager, drainConfig.httpPort, logger).also { it.start() } + logger.info("Initialized Agones plugin (platform=velocity)") } @Subscribe fun onProxyShutdown(event: ProxyShutdownEvent) { + if (this::drainHttpServer.isInitialized) { + drainHttpServer.stop() + } if (this::discoveryService.isInitialized) { discoveryService.stop() } diff --git a/velocity/src/main/kotlin/gg/grounds/drain/DrainConfig.kt b/velocity/src/main/kotlin/gg/grounds/drain/DrainConfig.kt new file mode 100644 index 0000000..a67a395 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/drain/DrainConfig.kt @@ -0,0 +1,50 @@ +package gg.grounds.drain + +/** + * Drain configuration sourced from environment variables. All keys are optional; with none set the + * HTTP endpoint still comes up (loopback only) and a drain degrades to "wait for players to leave", + * which is still strictly better than the kick it replaces. + * + * Environment keys: + * - `GROUNDS_DRAIN_TRANSFER_HOST` — `host[:port]` the Minecraft transfer packet sends players to. + * This is the *public* name the client reconnects through (mc-router resolves it to whichever + * proxy is alive), not a backend address. Unset means players are never transferred, only waited + * for. + * - `GROUNDS_DRAIN_HTTP_PORT` — loopback port the preStop hook calls. `0` disables the endpoint. + */ +data class DrainConfig(val transferHost: String?, val transferPort: Int, val httpPort: Int) { + + companion object { + const val DEFAULT_TRANSFER_PORT = 25565 + const val DEFAULT_HTTP_PORT = 8085 + + fun fromEnv(env: Map = System.getenv()): DrainConfig { + val rawTarget = env["GROUNDS_DRAIN_TRANSFER_HOST"]?.trim()?.takeIf { it.isNotEmpty() } + // rsplit, because a host can contain no colon but a port always follows the last one. + val host = + rawTarget?.substringBeforeLast(':', rawTarget)?.trim()?.takeIf { it.isNotEmpty() } + val portText = rawTarget?.substringAfterLast(':', "")?.trim().orEmpty() + val port = + when { + portText.isEmpty() -> DEFAULT_TRANSFER_PORT + else -> + portText.toIntOrNull()?.takeIf { it in 1..65535 } + ?: throw IllegalArgumentException( + "GROUNDS_DRAIN_TRANSFER_HOST '$rawTarget' has a bad port" + ) + } + val httpPort = + env["GROUNDS_DRAIN_HTTP_PORT"] + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { + it.toIntOrNull()?.takeIf { parsed -> parsed in 0..65535 } + ?: throw IllegalArgumentException( + "GROUNDS_DRAIN_HTTP_PORT '$it' must be a port number or 0" + ) + } ?: DEFAULT_HTTP_PORT + + return DrainConfig(host, port, httpPort) + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/drain/DrainHttpServer.kt b/velocity/src/main/kotlin/gg/grounds/drain/DrainHttpServer.kt new file mode 100644 index 0000000..ce6f20f --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/drain/DrainHttpServer.kt @@ -0,0 +1,83 @@ +package gg.grounds.drain + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import java.net.InetAddress +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import org.slf4j.Logger + +/** + * The loopback endpoint the pod's preStop hook talks to. Loopback on purpose: the only caller is + * `sh` inside the same container, and a drain trigger reachable from the cluster network would be a + * kick-everyone button. + * - `GET/POST /drain/start?deadlineSeconds=N` — begin draining; idempotent. + * - `GET /drain/players` — plain-text player count; the hook polls this until it reads `0`. + * - `GET /drain/status` — the same, for humans: `{"draining":bool,"players":N}`. + */ +class DrainHttpServer( + private val drainManager: DrainManager, + private val port: Int, + private val logger: Logger, +) { + private var server: HttpServer? = null + + fun start() { + if (port == 0) { + logger.info("Drain HTTP endpoint disabled (GROUNDS_DRAIN_HTTP_PORT=0)") + return + } + val httpServer = + HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), port), 0) + httpServer.createContext("/drain/start") { exchange -> + val deadline = deadlineSeconds(exchange.requestURI.query) + val started = drainManager.start(deadline) + respond(exchange, 200, if (started) "started" else "already-draining") + } + httpServer.createContext("/drain/players") { exchange -> + respond(exchange, 200, drainManager.playersRemaining().toString()) + } + httpServer.createContext("/drain/status") { exchange -> + respond( + exchange, + 200, + """{"draining":${drainManager.isDraining},"players":${drainManager.playersRemaining()}}""", + ) + } + httpServer.start() + server = httpServer + logger.info("Drain HTTP endpoint listening (port={})", port) + } + + fun stop() { + server?.stop(0) + server = null + } + + private fun respond(exchange: HttpExchange, status: Int, body: String) { + val bytes = body.toByteArray(StandardCharsets.UTF_8) + exchange.use { + it.responseHeaders.set("Content-Type", "text/plain; charset=utf-8") + it.sendResponseHeaders(status, bytes.size.toLong()) + it.responseBody.write(bytes) + } + } + + companion object { + const val DEFAULT_DEADLINE_SECONDS = 600L + + /** + * Clamped rather than rejected: the caller is a shell one-liner, not a client we argue + * with. + */ + internal fun deadlineSeconds(query: String?): Long { + val raw = + query + ?.split('&') + ?.firstOrNull { it.startsWith("deadlineSeconds=") } + ?.substringAfter('=') + ?.toLongOrNull() ?: return DEFAULT_DEADLINE_SECONDS + return raw.coerceIn(10L, 86_400L) + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/drain/DrainListener.kt b/velocity/src/main/kotlin/gg/grounds/drain/DrainListener.kt new file mode 100644 index 0000000..a5787f4 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/drain/DrainListener.kt @@ -0,0 +1,33 @@ +package gg.grounds.drain + +import com.velocitypowered.api.event.ResultedEvent +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.LoginEvent +import com.velocitypowered.api.event.player.ServerPreConnectEvent + +class DrainListener(private val drainManager: DrainManager) { + + /** + * A draining proxy takes no new players. The deny message is what a client sees in the rare + * window where a connection still reaches this pod after it left the Service's endpoints — + * reconnecting through the public name lands them on a live proxy. + */ + @Subscribe + fun onLogin(event: LoginEvent) { + if (!drainManager.isDraining) return + event.result = ResultedEvent.ComponentResult.denied(DrainManager.RESTART_MESSAGE) + } + + /** + * The moment a round is over, its players head back towards a lobby — on a draining proxy that + * connect becomes the transfer to another proxy instead. Every path to a lobby runs through + * this event: an explicit connection request, a kick-redirect, a plugin's fireAndForget. + */ + @Subscribe + fun onServerPreConnect(event: ServerPreConnectEvent) { + val target = event.result.server.orElse(null) ?: return + if (drainManager.interceptConnect(event.player, target.serverInfo.name)) { + event.result = ServerPreConnectEvent.ServerResult.denied() + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/drain/DrainManager.kt b/velocity/src/main/kotlin/gg/grounds/drain/DrainManager.kt new file mode 100644 index 0000000..558b2ae --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/drain/DrainManager.kt @@ -0,0 +1,124 @@ +package gg.grounds.drain + +import com.velocitypowered.api.network.ProtocolVersion +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ProxyServer +import java.net.InetSocketAddress +import java.util.concurrent.TimeUnit +import net.kyori.adventure.text.Component +import org.slf4j.Logger + +/** + * Moves players off this proxy before it shuts down, instead of letting Velocity kick them. + * + * Started by the pod's preStop hook via [DrainHttpServer]. From that moment on: + * - new logins are denied (they bounce back through the public name to a live proxy), + * - players sitting in a lobby are sent a Minecraft transfer packet right away, + * - players inside a round stay untouched — Agones keeps their GameServer alive, and the moment the + * round sends them back towards a lobby the transfer happens *instead of* that connect, + * - at the deadline everyone still here is transferred regardless, because a transfer that ends a + * round early still beats the kick that is otherwise seconds away. + * + * "Inside a round" is decided by the server's `grounds/server-type` role: anything that is not the + * lobby role defers the transfer. A server that discovery has no role for cannot be a protected + * round. + */ +class DrainManager( + private val plugin: Any, + private val proxy: ProxyServer, + private val logger: Logger, + private val config: DrainConfig, + private val serverRole: (String) -> String?, + private val lobbyValue: String, +) { + @Volatile + var isDraining: Boolean = false + private set + + fun playersRemaining(): Int = proxy.allPlayers.size + + /** Starts the drain. Idempotent — a second call reports `false` and changes nothing. */ + @Synchronized + fun start(deadlineSeconds: Long): Boolean { + if (isDraining) return false + isDraining = true + logger.info( + "Drain started (players={}, deadline={}s, transferTarget={})", + proxy.allPlayers.size, + deadlineSeconds, + config.transferHost?.let { "$it:${config.transferPort}" } ?: "", + ) + + proxy.allPlayers.forEach { player -> + if (!shouldDefer(roleOf(player), lobbyValue)) { + transferOut(player, force = false) + } + } + + proxy.scheduler + .buildTask(plugin, Runnable { onDeadline() }) + .delay(deadlineSeconds, TimeUnit.SECONDS) + .schedule() + return true + } + + /** + * A player is heading to [targetServer] — when draining and the target is not a round, send + * them to another proxy instead. Returns true when the connect should be cancelled. + */ + fun interceptConnect(player: Player, targetServer: String): Boolean { + if (!isDraining) return false + if (shouldDefer(serverRole(targetServer), lobbyValue)) return false + return transferOut(player, force = false) + } + + private fun onDeadline() { + val remaining = proxy.allPlayers + if (remaining.isEmpty()) return + logger.warn("Drain deadline reached with {} players left; transferring all", remaining.size) + remaining.forEach { transferOut(it, force = true) } + } + + /** + * True when the transfer (or, under force, the disconnect) was issued. Without force, a player + * we cannot transfer — no target configured, client older than 1.20.5 — is left alone: they + * keep playing until the deadline, and the deadline path disconnects them with an honest + * message rather than the raw proxy-shutdown kick. + */ + private fun transferOut(player: Player, force: Boolean): Boolean { + val host = config.transferHost + val transferable = + host != null && player.protocolVersion >= ProtocolVersion.MINECRAFT_1_20_5 + if (transferable) { + logger.info( + "Draining player via transfer (player={}, target={}:{})", + player.username, + host, + config.transferPort, + ) + player.transferToHost(InetSocketAddress.createUnresolved(host!!, config.transferPort)) + return true + } + if (force) { + player.disconnect(RESTART_MESSAGE) + return true + } + return false + } + + private fun roleOf(player: Player): String? = + player.currentServer.map { it.serverInfo.name }.orElse(null)?.let(serverRole) + + companion object { + val RESTART_MESSAGE: Component = + Component.text("This proxy is restarting — please reconnect.") + + /** + * A transfer is deferred only for players on a server whose role is a real, non-lobby role: + * that is where a round can be running. No server or no role means nothing to protect. + */ + @JvmStatic + fun shouldDefer(role: String?, lobbyValue: String): Boolean = + role != null && role != lobbyValue + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/drain/DrainConfigTest.kt b/velocity/src/test/kotlin/gg/grounds/drain/DrainConfigTest.kt new file mode 100644 index 0000000..c1214fa --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/drain/DrainConfigTest.kt @@ -0,0 +1,64 @@ +package gg.grounds.drain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class DrainConfigTest { + + @Test + fun `empty env yields wait-only drain with the default endpoint port`() { + val cfg = DrainConfig.fromEnv(env = emptyMap()) + + assertNull(cfg.transferHost) + assertEquals(DrainConfig.DEFAULT_TRANSFER_PORT, cfg.transferPort) + assertEquals(DrainConfig.DEFAULT_HTTP_PORT, cfg.httpPort) + } + + @Test + fun `transfer host without port gets the minecraft default`() { + val cfg = + DrainConfig.fromEnv(env = mapOf("GROUNDS_DRAIN_TRANSFER_HOST" to "eu.geo.grnds.io")) + + assertEquals("eu.geo.grnds.io", cfg.transferHost) + assertEquals(25565, cfg.transferPort) + } + + @Test + fun `transfer host with port keeps it`() { + val cfg = + DrainConfig.fromEnv( + env = mapOf("GROUNDS_DRAIN_TRANSFER_HOST" to "eu.geo.grnds.io:25566") + ) + + assertEquals("eu.geo.grnds.io", cfg.transferHost) + assertEquals(25566, cfg.transferPort) + } + + @Test + fun `blank transfer host means wait-only`() { + val cfg = DrainConfig.fromEnv(env = mapOf("GROUNDS_DRAIN_TRANSFER_HOST" to " ")) + assertNull(cfg.transferHost) + } + + @Test + fun `bad transfer port fails loud instead of draining into nowhere`() { + assertThrows(IllegalArgumentException::class.java) { + DrainConfig.fromEnv(env = mapOf("GROUNDS_DRAIN_TRANSFER_HOST" to "host:notaport")) + } + } + + @Test + fun `http port zero disables the endpoint`() { + val cfg = DrainConfig.fromEnv(env = mapOf("GROUNDS_DRAIN_HTTP_PORT" to "0")) + assertEquals(0, cfg.httpPort) + } + + @Test + fun `bad http port fails loud`() { + assertThrows(IllegalArgumentException::class.java) { + DrainConfig.fromEnv(env = mapOf("GROUNDS_DRAIN_HTTP_PORT" to "eighty")) + } + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/drain/DrainDecisionTest.kt b/velocity/src/test/kotlin/gg/grounds/drain/DrainDecisionTest.kt new file mode 100644 index 0000000..b5fadc2 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/drain/DrainDecisionTest.kt @@ -0,0 +1,46 @@ +package gg.grounds.drain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DrainDecisionTest { + + @Test + fun `lobby players are transferred right away`() { + assertFalse(DrainManager.shouldDefer(role = "lobby", lobbyValue = "lobby")) + } + + @Test + fun `players inside a round are deferred`() { + assertTrue(DrainManager.shouldDefer(role = "game", lobbyValue = "lobby")) + assertTrue(DrainManager.shouldDefer(role = "match", lobbyValue = "lobby")) + } + + @Test + fun `no server or unknown role is nothing to protect`() { + assertFalse(DrainManager.shouldDefer(role = null, lobbyValue = "lobby")) + } + + @Test + fun `deadline defaults when the query is absent or unreadable`() { + assertEquals( + DrainHttpServer.DEFAULT_DEADLINE_SECONDS, + DrainHttpServer.deadlineSeconds(null), + ) + assertEquals(DrainHttpServer.DEFAULT_DEADLINE_SECONDS, DrainHttpServer.deadlineSeconds("")) + assertEquals( + DrainHttpServer.DEFAULT_DEADLINE_SECONDS, + DrainHttpServer.deadlineSeconds("deadlineSeconds=soon"), + ) + } + + @Test + fun `deadline reads and clamps the query parameter`() { + assertEquals(840L, DrainHttpServer.deadlineSeconds("deadlineSeconds=840")) + assertEquals(10L, DrainHttpServer.deadlineSeconds("deadlineSeconds=1")) + assertEquals(86_400L, DrainHttpServer.deadlineSeconds("deadlineSeconds=999999999")) + assertEquals(120L, DrainHttpServer.deadlineSeconds("foo=bar&deadlineSeconds=120")) + } +} From 948902df5a4fdb754d58469867c02fc8a9f369a8 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sat, 1 Aug 2026 21:43:13 +0200 Subject: [PATCH 2/2] feat(velocity): derive the drain transfer target from REGIONS GROUNDS_DRAIN_TRANSFER_HOST becomes an override: without it the target is this region's own entry in the REGIONS catalogue (what /region already transfers players with), selected by REGION. One shared values file then drains correctly in every region. --- .../kotlin/gg/grounds/drain/DrainConfig.kt | 31 ++++++++++++-- .../gg/grounds/drain/DrainConfigTest.kt | 41 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/velocity/src/main/kotlin/gg/grounds/drain/DrainConfig.kt b/velocity/src/main/kotlin/gg/grounds/drain/DrainConfig.kt index a67a395..1a5bfe7 100644 --- a/velocity/src/main/kotlin/gg/grounds/drain/DrainConfig.kt +++ b/velocity/src/main/kotlin/gg/grounds/drain/DrainConfig.kt @@ -8,8 +8,10 @@ package gg.grounds.drain * Environment keys: * - `GROUNDS_DRAIN_TRANSFER_HOST` — `host[:port]` the Minecraft transfer packet sends players to. * This is the *public* name the client reconnects through (mc-router resolves it to whichever - * proxy is alive), not a backend address. Unset means players are never transferred, only waited - * for. + * proxy is alive), not a backend address. Optional: when unset, the target is this region's own + * entry in `REGIONS` (the `code=host[:port]` catalogue `/region` already transfers players with, + * selected by `REGION`) — the same values file then works in every region. With neither set, + * players are never transferred, only waited for. * - `GROUNDS_DRAIN_HTTP_PORT` — loopback port the preStop hook calls. `0` disables the endpoint. */ data class DrainConfig(val transferHost: String?, val transferPort: Int, val httpPort: Int) { @@ -19,7 +21,9 @@ data class DrainConfig(val transferHost: String?, val transferPort: Int, val htt const val DEFAULT_HTTP_PORT = 8085 fun fromEnv(env: Map = System.getenv()): DrainConfig { - val rawTarget = env["GROUNDS_DRAIN_TRANSFER_HOST"]?.trim()?.takeIf { it.isNotEmpty() } + val rawTarget = + env["GROUNDS_DRAIN_TRANSFER_HOST"]?.trim()?.takeIf { it.isNotEmpty() } + ?: ownRegionTarget(env["REGIONS"], env["REGION"]) // rsplit, because a host can contain no colon but a port always follows the last one. val host = rawTarget?.substringBeforeLast(':', rawTarget)?.trim()?.takeIf { it.isNotEmpty() } @@ -30,7 +34,7 @@ data class DrainConfig(val transferHost: String?, val transferPort: Int, val htt else -> portText.toIntOrNull()?.takeIf { it in 1..65535 } ?: throw IllegalArgumentException( - "GROUNDS_DRAIN_TRANSFER_HOST '$rawTarget' has a bad port" + "drain transfer target '$rawTarget' has a bad port" ) } val httpPort = @@ -46,5 +50,24 @@ data class DrainConfig(val transferHost: String?, val transferPort: Int, val htt return DrainConfig(host, port, httpPort) } + + /** + * This region's `host[:port]` out of the `REGIONS` catalogue. Choosing the *own* region on + * purpose: a drained player should land where they already are, latency-wise — the + * replacement proxy in the same region — not wherever a geo name happens to steer them. + * Null (no catalogue, no region, region not listed) leaves the drain wait-only. + */ + internal fun ownRegionTarget(regions: String?, region: String?): String? { + if (regions.isNullOrBlank() || region.isNullOrBlank()) return null + val code = region.trim() + return regions + .split(',') + .map { it.trim() } + .firstNotNullOfOrNull { entry -> + val entryCode = entry.substringBefore('=', "").trim() + val target = entry.substringAfter('=', "").trim() + target.takeIf { entryCode.equals(code, ignoreCase = true) && it.isNotEmpty() } + } + } } } diff --git a/velocity/src/test/kotlin/gg/grounds/drain/DrainConfigTest.kt b/velocity/src/test/kotlin/gg/grounds/drain/DrainConfigTest.kt index c1214fa..5037d88 100644 --- a/velocity/src/test/kotlin/gg/grounds/drain/DrainConfigTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/drain/DrainConfigTest.kt @@ -49,6 +49,47 @@ class DrainConfigTest { } } + @Test + fun `falls back to the own region's REGIONS entry`() { + val cfg = + DrainConfig.fromEnv( + env = + mapOf( + "REGIONS" to + "nl-ams1=nl-ams1.stage.grnds.io,us-nyc1=nyc1.stage.grnds.io:25566", + "REGION" to "us-nyc1", + ) + ) + + assertEquals("nyc1.stage.grnds.io", cfg.transferHost) + assertEquals(25566, cfg.transferPort) + } + + @Test + fun `explicit transfer host wins over the REGIONS catalogue`() { + val cfg = + DrainConfig.fromEnv( + env = + mapOf( + "GROUNDS_DRAIN_TRANSFER_HOST" to "override.grnds.io", + "REGIONS" to "nl-ams1=nl-ams1.stage.grnds.io", + "REGION" to "nl-ams1", + ) + ) + + assertEquals("override.grnds.io", cfg.transferHost) + } + + @Test + fun `a region missing from the catalogue means wait-only`() { + val cfg = + DrainConfig.fromEnv( + env = mapOf("REGIONS" to "nl-ams1=nl-ams1.stage.grnds.io", "REGION" to "pl-waw1") + ) + + assertNull(cfg.transferHost) + } + @Test fun `http port zero disables the endpoint`() { val cfg = DrainConfig.fromEnv(env = mapOf("GROUNDS_DRAIN_HTTP_PORT" to "0"))