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) + } +}