Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions velocity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
17 changes: 16 additions & 1 deletion velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 { "<none>" },
discoveryConfig.lobbyLabel.ifEmpty { "<none>" },
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(),
Expand Down
81 changes: 81 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryConfig.kt
Original file line number Diff line number Diff line change
@@ -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<String>,
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<String, String> = 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")
}
}
}
}
59 changes: 37 additions & 22 deletions velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -78,7 +79,7 @@ class DiscoveryService(
pollTask =
proxyServer.scheduler
.buildTask(plugin, this::updateRegisteredGameServers)
.repeat(2, TimeUnit.SECONDS)
.repeat(config.pollInterval.toSeconds(), TimeUnit.SECONDS)
.schedule()
}

Expand All @@ -94,23 +95,29 @@ class DiscoveryService(
private fun fetchRunningGameServers(): List<GameServer> {
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()
Expand All @@ -127,34 +134,36 @@ 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)
}

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={})",
Expand All @@ -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, String>): String? =
when {
config.lobbyLabel.isEmpty() -> config.lobbyValue
else -> labels[config.lobbyLabel]
}

private fun unregisterServersThatAreNoLongerRunning(
runningGameServers: List<GameServer>,
currentServers: Map<String, RegisteredServer>,
Expand All @@ -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")
}
}
Loading