From 4d0db65f28f9bb26b95d8a912e277672d19cfb16 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sun, 26 Apr 2026 12:42:34 +0200 Subject: [PATCH] feat(velocity): make discovery config env-var driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hardcoded `companion object` constants in DiscoveryService with a `DiscoveryConfig` data class loaded from environment variables. All keys are optional and the defaults match the previous hardcoded behaviour, so existing prod deployments are unaffected. New env vars (all optional): - GROUNDS_AGONES_NAMESPACE (falls back to POD_NAMESPACE Downward API, then "games") - GROUNDS_AGONES_LABEL_SELECTOR (empty string = no k8s-side filter) - GROUNDS_AGONES_LOBBY_LABEL (empty string = treat every running GS as lobby — useful in per-dev / staging clusters) - GROUNDS_AGONES_LOBBY_VALUE - GROUNDS_AGONES_RUNNING_STATES (CSV) - GROUNDS_AGONES_POLL_INTERVAL (2s / 5m / 1h) - GROUNDS_AGONES_ADDRESS_TYPE (PodIP / ExternalIP / …) - GROUNDS_AGONES_PORT Motivation: the same plugin needs to run in three contexts — prod (games namespace, server-type filter), per-dev vClusters (Phase 3.1a in grounds-platform: own namespace, no role filtering), and the shared staging cluster (Phase 3.2). Cloud-native config via env vars + ConfigMap envFrom + Downward API is the Kubernetes-idiomatic shape and avoids forking the plugin per context. Plugin entrypoint logs the resolved config on startup so operators can see what is actually loaded. 14 unit tests in DiscoveryConfigTest cover defaults, override precedence, empty/whitespace-handling, malformed values, and the per-dev cluster preset. Adds JUnit 5 testImplementation to the velocity module (no tests existed before). --- README.md | 31 +++++ velocity/build.gradle.kts | 3 + .../kotlin/gg/grounds/GroundsPluginAgones.kt | 17 ++- .../gg/grounds/discovery/DiscoveryConfig.kt | 81 ++++++++++++ .../gg/grounds/discovery/DiscoveryService.kt | 59 +++++---- .../grounds/discovery/DiscoveryConfigTest.kt | 124 ++++++++++++++++++ 6 files changed, 292 insertions(+), 23 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryConfig.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/discovery/DiscoveryConfigTest.kt diff --git a/README.md b/README.md index 006e349..1d720fe 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,37 @@ discovered gameservers to the Velocity proxy. - `minestom` — Minestom library: same responsibility as `paper`, for Minestom-based gameservers +## Velocity discovery configuration + +The Velocity module reads its configuration from environment variables. Every +key is optional; the defaults preserve the historic prod behaviour, so existing +deployments do not need to set anything. + +| Env var | Default | Notes | +| -------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------- | +| `GROUNDS_AGONES_NAMESPACE` | `games` | Falls back to `POD_NAMESPACE` (Downward API) before the default | +| `GROUNDS_AGONES_LABEL_SELECTOR` | `grounds/server-type in (lobby,game,match)` | Empty string disables k8s-side label filtering | +| `GROUNDS_AGONES_LOBBY_LABEL` | `grounds/server-type` | Empty string treats every running GameServer as a lobby | +| `GROUNDS_AGONES_LOBBY_VALUE` | `lobby` | Value of `lobbyLabel` that marks a GameServer as a lobby | +| `GROUNDS_AGONES_RUNNING_STATES` | `Ready,Allocated,Reserved` | Comma-separated Agones states considered "running" | +| `GROUNDS_AGONES_POLL_INTERVAL` | `2s` | Accepts `Ns`, `Nm`, `Nh` | +| `GROUNDS_AGONES_ADDRESS_TYPE` | `PodIP` | Which entry of `status.addresses` to dial (`PodIP`, `ExternalIP`, …) | +| `GROUNDS_AGONES_PORT` | `25565` | TCP port on the GameServer | + +Typical Helm chart wiring uses a `ConfigMap` consumed via `envFrom`, plus +`POD_NAMESPACE` from the Downward API for clusters where the proxy should +watch its own namespace: + +```yaml +env: +- name: POD_NAMESPACE + valueFrom: + fieldRef: { fieldPath: metadata.namespace } +envFrom: +- configMapRef: + name: agones-discovery-config +``` + ## Build ```bash diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 23c4a3b..4eb0cc6 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -3,4 +3,7 @@ plugins { id("gg.grounds.velocity-conventions") } dependencies { implementation(project(":common")) implementation("io.kubernetes:client-java:26.0.0") + + testImplementation("org.junit.jupiter:junit-jupiter:5.11.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt index 125969d..872cb57 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt @@ -7,6 +7,7 @@ import com.velocitypowered.api.event.proxy.ProxyShutdownEvent import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.proxy.ProxyServer import gg.grounds.command.AgonesCommand +import gg.grounds.discovery.DiscoveryConfig import gg.grounds.discovery.DiscoveryService import gg.grounds.gameserver.GameServerStateManager import kotlinx.coroutines.CoroutineScope @@ -32,9 +33,23 @@ constructor(private val proxyServer: ProxyServer, private val logger: Logger) { @Subscribe fun onProxyInitialize(event: ProxyInitializeEvent) { + val discoveryConfig = DiscoveryConfig.fromEnv() + logger.info( + "Loaded Agones discovery config (namespace={}, labelSelector={}, lobbyLabel={}, lobbyValue={}, runningStates={}, pollInterval={}s, addressType={}, port={})", + discoveryConfig.namespace, + discoveryConfig.labelSelector.ifEmpty { "" }, + discoveryConfig.lobbyLabel.ifEmpty { "" }, + discoveryConfig.lobbyValue, + discoveryConfig.runningStates, + discoveryConfig.pollInterval.toSeconds(), + discoveryConfig.addressType, + discoveryConfig.port, + ) + stateManager = GameServerStateManager(this, proxyServer, logger, coroutineScope).also { it.start() } - discoveryService = DiscoveryService(this, proxyServer, logger).also { it.start() } + discoveryService = + DiscoveryService(this, proxyServer, logger, discoveryConfig).also { it.start() } proxyServer.commandManager.register( proxyServer.commandManager.metaBuilder("agones").build(), diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryConfig.kt b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryConfig.kt new file mode 100644 index 0000000..0134cbb --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryConfig.kt @@ -0,0 +1,81 @@ +package gg.grounds.discovery + +import java.time.Duration + +/** + * Discovery configuration sourced from environment variables. All keys are optional; the defaults + * preserve the historic prod behaviour, so existing deployments are unaffected. + * + * Environment keys: + * - `GROUNDS_AGONES_NAMESPACE` — Agones namespace to watch (falls back to `POD_NAMESPACE` set via + * the Downward API, then to `games`). + * - `GROUNDS_AGONES_LABEL_SELECTOR` — Kubernetes label selector applied to the GameServer list. + * Empty string disables filtering (useful in per-dev / staging clusters). + * - `GROUNDS_AGONES_LOBBY_LABEL` — Metadata label key whose value identifies a server's role. Empty + * string disables role-based filtering and treats every running GameServer as a lobby. + * - `GROUNDS_AGONES_LOBBY_VALUE` — Value of [lobbyLabel] that marks a GameServer as a lobby. + * - `GROUNDS_AGONES_RUNNING_STATES` — Comma-separated Agones states considered "running". + * - `GROUNDS_AGONES_POLL_INTERVAL` — How often to re-list GameServers (`2s`, `5m`, `1h`). + * - `GROUNDS_AGONES_ADDRESS_TYPE` — Which `status.addresses` entry to use (`PodIP`, `ExternalIP`, + * `InternalIP`, `Hostname`). + * - `GROUNDS_AGONES_PORT` — TCP port to dial on the discovered GameServer. + */ +data class DiscoveryConfig( + val namespace: String, + val labelSelector: String, + val lobbyLabel: String, + val lobbyValue: String, + val runningStates: Set, + val pollInterval: Duration, + val addressType: String, + val port: Int, +) { + companion object { + const val DEFAULT_NAMESPACE = "games" + const val DEFAULT_LABEL_SELECTOR = "grounds/server-type in (lobby,game,match)" + const val DEFAULT_LOBBY_LABEL = "grounds/server-type" + const val DEFAULT_LOBBY_VALUE = "lobby" + val DEFAULT_RUNNING_STATES = setOf("Ready", "Allocated", "Reserved") + val DEFAULT_POLL_INTERVAL: Duration = Duration.ofSeconds(2) + const val DEFAULT_ADDRESS_TYPE = "PodIP" + const val DEFAULT_PORT = 25565 + + fun fromEnv(env: Map = System.getenv()): DiscoveryConfig = + DiscoveryConfig( + namespace = + env["GROUNDS_AGONES_NAMESPACE"] ?: env["POD_NAMESPACE"] ?: DEFAULT_NAMESPACE, + labelSelector = env["GROUNDS_AGONES_LABEL_SELECTOR"] ?: DEFAULT_LABEL_SELECTOR, + lobbyLabel = env["GROUNDS_AGONES_LOBBY_LABEL"] ?: DEFAULT_LOBBY_LABEL, + lobbyValue = env["GROUNDS_AGONES_LOBBY_VALUE"] ?: DEFAULT_LOBBY_VALUE, + runningStates = + env["GROUNDS_AGONES_RUNNING_STATES"] + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.toSet() + ?.takeIf { it.isNotEmpty() } ?: DEFAULT_RUNNING_STATES, + pollInterval = + env["GROUNDS_AGONES_POLL_INTERVAL"]?.let(::parseDuration) + ?: DEFAULT_POLL_INTERVAL, + addressType = env["GROUNDS_AGONES_ADDRESS_TYPE"] ?: DEFAULT_ADDRESS_TYPE, + port = env["GROUNDS_AGONES_PORT"]?.toIntOrNull() ?: DEFAULT_PORT, + ) + + private val DURATION_PATTERN = Regex("""^(\d+)\s*(s|m|h)$""") + + private fun parseDuration(raw: String): Duration { + val match = + DURATION_PATTERN.matchEntire(raw.trim()) + ?: throw IllegalArgumentException( + "GROUNDS_AGONES_POLL_INTERVAL '$raw' must look like '2s', '5m', or '1h'" + ) + val n = match.groupValues[1].toLong() + return when (match.groupValues[2]) { + "s" -> Duration.ofSeconds(n) + "m" -> Duration.ofMinutes(n) + "h" -> Duration.ofHours(n) + else -> error("unreachable") + } + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt index ba0c9d6..9356106 100644 --- a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt +++ b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt @@ -17,6 +17,7 @@ class DiscoveryService( private val plugin: Any, private val proxyServer: ProxyServer, private val logger: Logger, + private val config: DiscoveryConfig = DiscoveryConfig.fromEnv(), ) { private val gson = Gson() private lateinit var customObjectsApi: CustomObjectsApi @@ -48,8 +49,8 @@ class DiscoveryService( } catch (error: Throwable) { logger.warn( "Failed to initialize Agones discovery client (namespace={}, labelSelector={})", - NAMESPACE, - LABEL_SELECTOR, + config.namespace, + config.labelSelector, error, ) null @@ -78,7 +79,7 @@ class DiscoveryService( pollTask = proxyServer.scheduler .buildTask(plugin, this::updateRegisteredGameServers) - .repeat(2, TimeUnit.SECONDS) + .repeat(config.pollInterval.toSeconds(), TimeUnit.SECONDS) .schedule() } @@ -94,23 +95,29 @@ class DiscoveryService( private fun fetchRunningGameServers(): List { if (!this::customObjectsApi.isInitialized) return emptyList() try { - val raw = - customObjectsApi - .listNamespacedCustomObject(GROUP, VERSION, NAMESPACE, PLURAL) - .labelSelector(LABEL_SELECTOR) - .execute() + val request = + customObjectsApi.listNamespacedCustomObject( + GROUP, + VERSION, + config.namespace, + PLURAL, + ) + if (config.labelSelector.isNotEmpty()) { + request.labelSelector(config.labelSelector) + } + val raw = request.execute() val list = gson.fromJson(gson.toJson(raw), GameServerList::class.java) return list.items.filter { gameServer -> val state = gameServer.status?.state - state != null && state in RUNNING_STATES + state != null && state in config.runningStates } } catch (error: Throwable) { logger.warn( "Failed to fetch running Agones GameServers (namespace={}, labelSelector={})", - NAMESPACE, - LABEL_SELECTOR, + config.namespace, + config.labelSelector, error, ) return emptyList() @@ -127,17 +134,17 @@ class DiscoveryService( if (serverName == null) { logger.error( "Failed to register Agones GameServer (namespace={}, reason=missing_server_name, labels={}, state={})", - NAMESPACE, + config.namespace, metadata?.labels, gameServer.status?.state, ) continue } - val serverType = metadata.labels[SERVER_TYPE_LABEL] ?: continue + val serverType = resolveServerType(metadata.labels) ?: continue serverRoles[serverName] = serverType - if (serverType == LOBBY_ROLE) { + if (serverType == config.lobbyValue) { lobbyServers.add(serverName) } else { lobbyServers.remove(serverName) @@ -145,16 +152,18 @@ class DiscoveryService( if (serverName in currentServers) continue - val address = gameServer.status?.addresses?.firstOrNull { it.type == "PodIP" }?.address + val address = + gameServer.status?.addresses?.firstOrNull { it.type == config.addressType }?.address if (address == null) { logger.error( - "Failed to register Agones GameServer (serverName={}, reason=missing_pod_ip)", + "Failed to register Agones GameServer (serverName={}, reason=missing_address, addressType={})", serverName, + config.addressType, ) continue } - val serverInfo = ServerInfo(serverName, InetSocketAddress(address, 25565)) + val serverInfo = ServerInfo(serverName, InetSocketAddress(address, config.port)) proxyServer.registerServer(serverInfo) logger.info( "Registered proxy server successfully (serverName={}, serverType={})", @@ -164,6 +173,17 @@ class DiscoveryService( } } + /** + * Returns the server's role label, or [DiscoveryConfig.lobbyValue] when role-based filtering is + * disabled (`GROUNDS_AGONES_LOBBY_LABEL=""`). When filtering is enabled and the label is + * missing, the GameServer is skipped (returns null). + */ + private fun resolveServerType(labels: Map): String? = + when { + config.lobbyLabel.isEmpty() -> config.lobbyValue + else -> labels[config.lobbyLabel] + } + private fun unregisterServersThatAreNoLongerRunning( runningGameServers: List, currentServers: Map, @@ -187,10 +207,5 @@ class DiscoveryService( private const val GROUP = "agones.dev" private const val VERSION = "v1" private const val PLURAL = "gameservers" - private const val NAMESPACE = "games" - private const val SERVER_TYPE_LABEL = "grounds/server-type" - private const val LOBBY_ROLE = "lobby" - private const val LABEL_SELECTOR = "$SERVER_TYPE_LABEL in (lobby,game,match)" - private val RUNNING_STATES = setOf("Ready", "Allocated", "Reserved") } } diff --git a/velocity/src/test/kotlin/gg/grounds/discovery/DiscoveryConfigTest.kt b/velocity/src/test/kotlin/gg/grounds/discovery/DiscoveryConfigTest.kt new file mode 100644 index 0000000..4de0bc1 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/discovery/DiscoveryConfigTest.kt @@ -0,0 +1,124 @@ +package gg.grounds.discovery + +import java.time.Duration +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class DiscoveryConfigTest { + + @Test + fun `empty env yields prod-compatible defaults`() { + val cfg = DiscoveryConfig.fromEnv(env = emptyMap()) + + assertEquals("games", cfg.namespace) + assertEquals("grounds/server-type in (lobby,game,match)", cfg.labelSelector) + assertEquals("grounds/server-type", cfg.lobbyLabel) + assertEquals("lobby", cfg.lobbyValue) + assertEquals(setOf("Ready", "Allocated", "Reserved"), cfg.runningStates) + assertEquals(Duration.ofSeconds(2), cfg.pollInterval) + assertEquals("PodIP", cfg.addressType) + assertEquals(25565, cfg.port) + } + + @Test + fun `GROUNDS_AGONES_NAMESPACE wins over POD_NAMESPACE and default`() { + val cfg = + DiscoveryConfig.fromEnv( + env = mapOf("GROUNDS_AGONES_NAMESPACE" to "explicit", "POD_NAMESPACE" to "downward") + ) + assertEquals("explicit", cfg.namespace) + } + + @Test + fun `POD_NAMESPACE wins over default when explicit override is absent`() { + val cfg = DiscoveryConfig.fromEnv(env = mapOf("POD_NAMESPACE" to "from-downward")) + assertEquals("from-downward", cfg.namespace) + } + + @Test + fun `empty label selector disables k8s-side filtering`() { + val cfg = DiscoveryConfig.fromEnv(env = mapOf("GROUNDS_AGONES_LABEL_SELECTOR" to "")) + assertEquals("", cfg.labelSelector) + } + + @Test + fun `empty lobby label signals role-based filter is off`() { + val cfg = DiscoveryConfig.fromEnv(env = mapOf("GROUNDS_AGONES_LOBBY_LABEL" to "")) + assertEquals("", cfg.lobbyLabel) + } + + @Test + fun `running states parses csv with whitespace`() { + val cfg = + DiscoveryConfig.fromEnv( + env = mapOf("GROUNDS_AGONES_RUNNING_STATES" to "Ready, Allocated ,Scheduled") + ) + assertEquals(setOf("Ready", "Allocated", "Scheduled"), cfg.runningStates) + } + + @Test + fun `running states empty string falls back to defaults`() { + val cfg = DiscoveryConfig.fromEnv(env = mapOf("GROUNDS_AGONES_RUNNING_STATES" to "")) + assertEquals(DiscoveryConfig.DEFAULT_RUNNING_STATES, cfg.runningStates) + } + + @Test + fun `poll interval accepts seconds`() { + val cfg = DiscoveryConfig.fromEnv(env = mapOf("GROUNDS_AGONES_POLL_INTERVAL" to "5s")) + assertEquals(Duration.ofSeconds(5), cfg.pollInterval) + } + + @Test + fun `poll interval accepts minutes and hours`() { + val m = DiscoveryConfig.fromEnv(env = mapOf("GROUNDS_AGONES_POLL_INTERVAL" to "5m")) + val h = DiscoveryConfig.fromEnv(env = mapOf("GROUNDS_AGONES_POLL_INTERVAL" to "1h")) + assertEquals(Duration.ofMinutes(5), m.pollInterval) + assertEquals(Duration.ofHours(1), h.pollInterval) + } + + @Test + fun `poll interval rejects malformed values`() { + assertThrows(IllegalArgumentException::class.java) { + DiscoveryConfig.fromEnv(env = mapOf("GROUNDS_AGONES_POLL_INTERVAL" to "two seconds")) + } + } + + @Test + fun `port falls back to default when value is non-numeric`() { + val cfg = DiscoveryConfig.fromEnv(env = mapOf("GROUNDS_AGONES_PORT" to "abc")) + assertEquals(25565, cfg.port) + } + + @Test + fun `port honours numeric value`() { + val cfg = DiscoveryConfig.fromEnv(env = mapOf("GROUNDS_AGONES_PORT" to "25577")) + assertEquals(25577, cfg.port) + } + + @Test + fun `address type override respected`() { + val cfg = + DiscoveryConfig.fromEnv(env = mapOf("GROUNDS_AGONES_ADDRESS_TYPE" to "ExternalIP")) + assertEquals("ExternalIP", cfg.addressType) + } + + @Test + fun `per-dev cluster preset — empty selectors and pod-namespace`() { + val cfg = + DiscoveryConfig.fromEnv( + env = + mapOf( + "POD_NAMESPACE" to "default", + "GROUNDS_AGONES_LABEL_SELECTOR" to "", + "GROUNDS_AGONES_LOBBY_LABEL" to "", + ) + ) + assertEquals("default", cfg.namespace) + assertEquals("", cfg.labelSelector) + assertEquals("", cfg.lobbyLabel) + // Defaults preserved for the rest + assertEquals(25565, cfg.port) + assertEquals(Duration.ofSeconds(2), cfg.pollInterval) + } +}