feat(official-bots): resolve tournament bot token from Redis and account service
Build & Test (NowChessSystems) TeamCity build finished

On startup, TournamentBotGamePlayer now resolves the bot token via
a three-tier fallback: account service (fresh, validates against current
DB) → Redis cache (shared across pod instances) → TOURNAMENT_BOT_TOKEN
env var. Fetched tokens are written to Redis so sibling pods skip the
account service call.

joinTournament gains the same Redis fallback so join calls succeed even
when TOURNAMENT_BOT_TOKEN is not set in the environment.

Adds GET /api/account/official-bots/{name}/token (InternalOnly) to the
account service, backed by AccountService.getOfficialBotTokenByName.
AccountServiceClient gains a matching getBotToken method.

TournamentBotConfig.fromEnvWithToken accepts a pre-resolved token so the
env var is no longer required when a token can be sourced elsewhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janis Eccarius
2026-06-22 19:15:36 +02:00
parent a63d195cb3
commit 386ddc5c19
5 changed files with 65 additions and 11 deletions
@@ -193,6 +193,14 @@ class AccountResource:
val bots = accountService.getOfficialBotAccounts()
Response.ok(bots.map(toOfficialBotDto)).build()
@GET
@Path("/official-bots/{name}/token")
@InternalOnly
def getOfficialBotToken(@PathParam("name") name: String): Response =
accountService.getOfficialBotTokenByName(name) match
case None => Response.status(Response.Status.NOT_FOUND).build()
case Some(token) => Response.ok(RotatedTokenDto(token)).build()
@POST
@Path("/official-bots")
@RolesAllowed(Array("Admin"))
@@ -225,6 +225,9 @@ class AccountService:
def getOfficialBotAccounts(): List[OfficialBotAccount] =
officialBotAccountRepository.findAll()
def getOfficialBotTokenByName(name: String): Option[String] =
officialBotAccountRepository.findByName(name).map(_.token)
@Transactional
def deleteOfficialBotAccount(botId: UUID): Either[AccountError, Unit] =
officialBotAccountRepository.findById(botId) match
@@ -7,6 +7,7 @@ import org.eclipse.microprofile.rest.client.annotation.{RegisterClientHeaders, R
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class SyncOfficialBotsRequest(bots: List[String])
case class BotTokenResponse(token: String)
@Path("/api/account/official-bots")
@RegisterRestClient(configKey = "account-service")
@@ -18,3 +19,8 @@ trait AccountServiceClient:
@Path("/sync")
@Consumes(Array(MediaType.APPLICATION_JSON))
def syncBots(req: SyncOfficialBotsRequest): Unit
@GET
@Path("/{name}/token")
@Produces(Array(MediaType.APPLICATION_JSON))
def getBotToken(@PathParam("name") name: String): BotTokenResponse
@@ -16,13 +16,17 @@ object TournamentBotConfig:
private val mapper = new ObjectMapper()
def fromEnv(env: Map[String, String]): Option[TournamentBotConfig] =
fromEnvWithToken(env, None)
def fromEnvWithToken(env: Map[String, String], resolvedToken: Option[String]): Option[TournamentBotConfig] =
val token = env.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty).orElse(resolvedToken)
for
tournamentId <- env.get("TOURNAMENT_ID").filter(_.nonEmpty)
token <- env.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty)
botId <- jwtSubject(token)
tok <- token
botId <- jwtSubject(tok)
serverUrl = env.getOrElse("TOURNAMENT_SERVICE_URL", "http://localhost:8086")
difficulty = env.getOrElse("TOURNAMENT_BOT_DIFFICULTY", "medium")
yield TournamentBotConfig(serverUrl, tournamentId, token, botId, difficulty)
yield TournamentBotConfig(serverUrl, tournamentId, tok, botId, difficulty)
def jwtSubject(token: String): Option[String] =
Try {
@@ -3,13 +3,17 @@ package de.nowchess.bot.service
import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.{Bot, BotController}
import de.nowchess.bot.client.AccountServiceClient
import de.nowchess.bot.config.RedisConfig
import de.nowchess.io.fen.FenParser
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.runtime.Startup
import jakarta.annotation.{PostConstruct, PreDestroy}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import scala.jdk.CollectionConverters.*
@@ -26,14 +30,15 @@ class TournamentBotGamePlayer:
// scalafix:off DisableSyntax.var
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized
@Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
@Inject @RestClient var accountServiceClient: AccountServiceClient = uninitialized
// scalafix:on DisableSyntax.var
private val client: Client = ClientBuilder.newClient()
private val workers: ExecutorService = Executors.newCachedThreadPool()
private val activeGames = ConcurrentHashMap.newKeySet[String]()
private val config = TournamentBotConfig.fromEnv(System.getenv().asScala.toMap)
// scalafix:off DisableSyntax.var
@volatile private var running = true
// scalafix:on DisableSyntax.var
@@ -43,18 +48,44 @@ class TournamentBotGamePlayer:
@PostConstruct
def initialize(): Unit =
parkOnStartup()
config match
val env = System.getenv().asScala.toMap
val difficulty = env.getOrElse("TOURNAMENT_BOT_DIFFICULTY", "medium")
val token = resolveToken(difficulty)
parkOnStartup(token)
TournamentBotConfig.fromEnvWithToken(env, token) match
case None =>
log.info("Tournament bot disabled — set TOURNAMENT_ID and TOURNAMENT_BOT_TOKEN to enable")
log.info("Tournament bot disabled — set TOURNAMENT_ID to enable")
case Some(cfg) =>
log.infof("Tournament bot enabled — server=%s tournament=%s bot=%s", cfg.serverUrl, cfg.tournamentId, cfg.botId)
startAsync(cfg)
private def parkOnStartup(): Unit =
val token = System.getenv().asScala.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty)
private def resolveToken(difficulty: String): Option[String] =
val name = botName(difficulty)
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:$name"
Try(accountServiceClient.getBotToken(name).token)
.toOption
.filter(_.nonEmpty)
.map { token =>
redis.value(classOf[String]).set(redisKey, token)
log.infof("Fetched fresh bot token for %s from account service", name)
token
}
.orElse {
Option(redis.value(classOf[String]).get(redisKey)).filter(_.nonEmpty).map { token =>
log.infof("Using cached bot token for %s from Redis", name)
token
}
}
.orElse {
System.getenv().asScala.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty).map { token =>
log.infof("Using TOURNAMENT_BOT_TOKEN env var for %s", name)
token
}
}
private def parkOnStartup(token: Option[String]): Unit =
token match
case None => log.warn("TOURNAMENT_BOT_TOKEN not set — skipping park")
case None => log.warn("No bot token resolved — skipping park")
case Some(tok) =>
val localAccountUrl = System.getenv().asScala.getOrElse("ACCOUNT_SERVICE_URL", "http://localhost:8083")
BotController.listBots.foreach(diff => parkOn(localAccountUrl, diff, tok))
@@ -95,8 +126,10 @@ class TournamentBotGamePlayer:
botToken: Option[String],
difficulty: String,
): Either[String, String] =
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:${botName(difficulty)}"
val resolvedToken = botToken.filter(_.nonEmpty)
.orElse(System.getenv().asScala.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty))
.orElse(Option(redis.value(classOf[String]).get(redisKey)).filter(_.nonEmpty))
resolvedToken match
case None => Left("No bot token provided and TOURNAMENT_BOT_TOKEN not configured")
case Some(token) =>