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
22 changes: 22 additions & 0 deletions .github/workflows/docker-gradle-build-push.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Docker Build

# Publishes ghcr.io/groundsgg/plugin-player (edge on main, semver on tag).
# The image carries the shaded JAR at /jar/plugin.jar so the platform bundle
# can pull the plugin into the velocity proxy via the plugin-velocity-jar chart.

on:
push:
branches:
- main
tags:
- "v*"
pull_request:

permissions:
packages: write
contents: write
actions: write

jobs:
reusable:
uses: groundsgg/.github/.github/workflows/docker-gradle-build-push.yml@main
49 changes: 49 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1
#
# Builds the plugin-player Velocity-plugin JAR and packages it for the
# platform-test environment. The output image carries the JAR at
# /jar/plugin.jar — the shape the `plugin-velocity-jar` Helm chart expects
# (oci://ghcr.io/groundsgg/charts/plugin-velocity-jar). Velocity proxy pods
# in a per-engineer vCluster fetch this image's JAR via the chart's
# init-container + httpd indirection.
#
# Pushed as `ghcr.io/groundsgg/plugin-player:edge` (main) / `:<semver>` (tag)
# by .github/workflows/docker-gradle-build-push.yml.

FROM eclipse-temurin:25-jdk AS build
WORKDIR /src

# GitHub Packages credentials for the gg.grounds.velocity convention plugin.
# The token comes from the `github_token` build secret (never a build-arg —
# that would leak it into the layer history).
ARG GITHUB_USER

# Copy gradle wrapper + root config first so dependency caches stay warm
# across source-only changes.
COPY gradle/ gradle/
COPY gradlew settings.gradle.kts build.gradle.kts ./

COPY common/ common/
COPY velocity/ velocity/

# `:velocity:build` produces the shaded plugin JAR. plugin-proxy-api stays
# compileOnly (never shaded): the ProxyServiceRegistry this plugin writes its
# session lookup into must be the class plugin-proxy loaded at runtime.
RUN --mount=type=secret,id=github_token,required=true \
/bin/sh -euc '\
: "${GITHUB_USER:?GITHUB_USER build arg is required}"; \
token="$(cat /run/secrets/github_token)"; \
./gradlew --no-daemon :velocity:build \
-Pgithub.user="${GITHUB_USER}" \
-Pgithub.token="${token}" \
'

RUN mkdir -p /out && \
cp "$(ls -S /src/velocity/build/libs/*.jar | head -n1)" /out/plugin.jar

FROM alpine:3
RUN mkdir -p /jar
COPY --from=build /out/plugin.jar /jar/plugin.jar
# No ENTRYPOINT — the plugin-velocity-jar chart's init-container `cp`s
# /jar/plugin.jar out, then the chart's main container (busybox httpd)
# serves the JAR. This image only carries data.
8 changes: 7 additions & 1 deletion common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ repositories {
}
}

dependencies { protobuf("gg.grounds:library-grpc-contracts-player:0.2.0") }
dependencies {
protobuf("gg.grounds:library-grpc-contracts-player:0.3.0")

testImplementation("org.junit.jupiter:junit-jupiter-api:5.13.4")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.13.4")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.13.4")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package gg.grounds.player.presence

import io.grpc.CallOptions
import io.grpc.Channel
import io.grpc.ClientCall
import io.grpc.ClientInterceptor
import io.grpc.ForwardingClientCall
import io.grpc.Metadata
import io.grpc.MethodDescriptor
import java.nio.file.Files
import java.nio.file.Path

/**
* Attaches the projected ServiceAccount JWT as `Authorization: Bearer ...` to every outgoing call.
*
* service-player runs with `grounds.auth.enabled=true` and rejects tokenless calls with
* UNAUTHENTICATED. The Grounds charts project a short-lived token (audience `grounds-services`)
* into the proxy pod and point [TOKEN_FILE_ENV] at it; kubelet rotates the file, so it is re-read
* per call rather than cached.
*
* With no token file present (local dev against a service running `grounds.auth.enabled=false`) the
* call goes out without the header.
*/
class GroundsTokenInterceptor(private val tokenLoader: () -> String? = ::loadTokenFromFile) :
ClientInterceptor {

override fun <ReqT : Any, RespT : Any> interceptCall(
method: MethodDescriptor<ReqT, RespT>,
callOptions: CallOptions,
next: Channel,
): ClientCall<ReqT, RespT> {
val delegate = next.newCall(method, callOptions)
return object : ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(delegate) {
override fun start(responseListener: Listener<RespT>, headers: Metadata) {
tokenLoader()?.let { headers.put(AUTHORIZATION, "Bearer $it") }
super.start(responseListener, headers)
}
}
}

companion object {
const val TOKEN_FILE_ENV = "GROUNDS_TOKEN_FILE"
const val DEFAULT_TOKEN_PATH = "/var/run/secrets/grounds/token"

private val AUTHORIZATION: Metadata.Key<String> =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER)

private fun loadTokenFromFile(): String? {
val path = Path.of(System.getenv(TOKEN_FILE_ENV) ?: DEFAULT_TOKEN_PATH)
return try {
if (Files.exists(path)) Files.readString(path).trim().ifEmpty { null } else null
} catch (_: Exception) {
null
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package gg.grounds.player.presence

import gg.grounds.grpc.player.GetPlayerSessionRequest
import gg.grounds.grpc.player.PlayerHeartbeatBatchReply
import gg.grounds.grpc.player.PlayerHeartbeatBatchRequest
import gg.grounds.grpc.player.PlayerLoginRequest
import gg.grounds.grpc.player.PlayerLogoutReply
import gg.grounds.grpc.player.PlayerLogoutRequest
import gg.grounds.grpc.player.PlayerPresenceServiceGrpc
import gg.grounds.grpc.player.PlayerSessionInfo
import gg.grounds.grpc.player.ResolvePlayerNameRequest
import gg.grounds.grpc.player.SuggestPlayerNamesRequest
import gg.grounds.grpc.player.UpdatePlayerServerRequest
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import io.grpc.Status
Expand All @@ -18,13 +23,17 @@ private constructor(
private val channel: ManagedChannel,
private val stub: PlayerPresenceServiceGrpc.PlayerPresenceServiceBlockingStub,
) : AutoCloseable {
fun tryLogin(playerId: UUID): PlayerLoginResult {
fun tryLogin(playerId: UUID, playerName: String = "", proxyId: String = ""): PlayerLoginResult {
return try {
val reply =
stub
.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.tryPlayerLogin(
PlayerLoginRequest.newBuilder().setPlayerId(playerId.toString()).build()
PlayerLoginRequest.newBuilder()
.setPlayerId(playerId.toString())
.setPlayerName(playerName)
.setProxyId(proxyId)
.build()
)
PlayerLoginResult.Success(reply)
} catch (e: StatusRuntimeException) {
Expand Down Expand Up @@ -67,6 +76,70 @@ private constructor(
}
}

/**
* The lookups below back cross-proxy features, and they run on the command path (`/msg`,
* tab-complete). A failure means "I don't know", never an exception into Velocity's event loop
* — the caller then falls back to what it can see locally.
*/
fun getSession(playerId: UUID): PlayerSessionInfo? {
return try {
val reply =
stub
.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.getPlayerSession(
GetPlayerSessionRequest.newBuilder()
.setPlayerId(playerId.toString())
.build()
)
if (reply.found) reply.session else null
} catch (e: RuntimeException) {
null
}
}

fun resolveName(playerName: String): PlayerSessionInfo? {
return try {
val reply =
stub
.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.resolvePlayerName(
ResolvePlayerNameRequest.newBuilder().setPlayerName(playerName).build()
)
if (reply.found) reply.session else null
} catch (e: RuntimeException) {
null
}
}

fun suggestNames(prefix: String, limit: Int): List<String> {
return try {
stub
.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.suggestPlayerNames(
SuggestPlayerNamesRequest.newBuilder().setPrefix(prefix).setLimit(limit).build()
)
.playerNamesList
} catch (e: RuntimeException) {
emptyList()
}
}

fun updateServer(playerId: UUID, serverName: String): Boolean {
return try {
stub
.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.updatePlayerServer(
UpdatePlayerServerRequest.newBuilder()
.setPlayerId(playerId.toString())
.setServerName(serverName)
.build()
)
.updated
} catch (e: RuntimeException) {
false
}
}

override fun close() {
channel.shutdown()
try {
Expand All @@ -84,6 +157,10 @@ private constructor(
fun create(target: String): GrpcPlayerPresenceClient {
val channelBuilder = ManagedChannelBuilder.forTarget(target)
channelBuilder.usePlaintext()
// service-player rejects tokenless calls with UNAUTHENTICATED, and every failure here
// is
// swallowed into "unknown" — so without this the whole presence chain fails silently.
channelBuilder.intercept(GroundsTokenInterceptor())
val channel = channelBuilder.build()
val stub = PlayerPresenceServiceGrpc.newBlockingStub(channel)
return GrpcPlayerPresenceClient(channel, stub)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package gg.grounds.player.presence

import io.grpc.CallOptions
import io.grpc.Channel
import io.grpc.ClientCall
import io.grpc.Metadata
import io.grpc.MethodDescriptor
import java.io.InputStream
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test

class GroundsTokenInterceptorTest {

@Test
fun attachesBearerTokenWhenTokenIsAvailable() {
val channel = CapturingChannel()

GroundsTokenInterceptor(tokenLoader = { "jwt-abc" })
.interceptCall(METHOD, CallOptions.DEFAULT, channel)
.start(NoopListener(), Metadata())

assertEquals("Bearer jwt-abc", channel.capturedHeaders?.get(AUTHORIZATION))
}

@Test
fun sendsNoAuthorizationHeaderWhenTokenIsMissing() {
val channel = CapturingChannel()

GroundsTokenInterceptor(tokenLoader = { null })
.interceptCall(METHOD, CallOptions.DEFAULT, channel)
.start(NoopListener(), Metadata())

assertFalse(channel.capturedHeaders?.containsKey(AUTHORIZATION) ?: true)
}

private class CapturingChannel : Channel() {
var capturedHeaders: Metadata? = null

override fun authority(): String = "test"

override fun <ReqT : Any, RespT : Any> newCall(
methodDescriptor: MethodDescriptor<ReqT, RespT>,
callOptions: CallOptions,
): ClientCall<ReqT, RespT> =
object : ClientCall<ReqT, RespT>() {
override fun start(responseListener: Listener<RespT>, headers: Metadata) {
capturedHeaders = headers
}

override fun request(numMessages: Int) = Unit

override fun cancel(message: String?, cause: Throwable?) = Unit

override fun halfClose() = Unit

override fun sendMessage(message: ReqT) = Unit
}
}

private class NoopListener : ClientCall.Listener<String>()

private companion object {
val AUTHORIZATION: Metadata.Key<String> =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER)

val MARSHALLER =
object : MethodDescriptor.Marshaller<String> {
override fun stream(value: String): InputStream = value.byteInputStream()

override fun parse(stream: InputStream): String = stream.reader().readText()
}

val METHOD: MethodDescriptor<String, String> =
MethodDescriptor.newBuilder(MARSHALLER, MARSHALLER)
.setType(MethodDescriptor.MethodType.UNARY)
.setFullMethodName("gg.grounds.player.Test/Call")
.build()
}
}
13 changes: 13 additions & 0 deletions velocity/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
plugins { id("gg.grounds.velocity-conventions") }

repositories {
maven {
url = uri("https://maven.pkg.github.com/groundsgg/*")
credentials {
username = providers.gradleProperty("github.user").get()
password = providers.gradleProperty("github.token").get()
}
}
}

dependencies {
implementation(project(":common"))
// plugin-proxy owns the ProxyServiceRegistry at runtime — compileOnly, never shaded, or the
// registry this plugin writes into would be a different class from the one chat/social read.
compileOnly("gg.grounds:plugin-proxy-api:0.1.0")
implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.0.4")
implementation("tools.jackson.module:jackson-module-kotlin:3.0.4")
implementation("io.grpc:grpc-netty-shaded:1.78.0")
Expand Down
Loading