From 386ddc5c19f8f893b16c6422aa5393b54c872e45 Mon Sep 17 00:00:00 2001 From: Janis Eccarius Date: Mon, 22 Jun 2026 19:15:36 +0200 Subject: [PATCH] feat(official-bots): resolve tournament bot token from Redis and account service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../account/resource/AccountResource.scala | 8 +++ .../account/service/AccountService.scala | 3 ++ .../bot/client/AccountServiceClient.scala | 6 +++ .../bot/service/TournamentBotConfig.scala | 10 ++-- .../bot/service/TournamentBotGamePlayer.scala | 49 ++++++++++++++++--- 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala index f525fcc..c0b4d2c 100644 --- a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala +++ b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala @@ -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")) diff --git a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala index fbfef4f..999677a 100644 --- a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala +++ b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala @@ -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 diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/client/AccountServiceClient.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/client/AccountServiceClient.scala index d95c8c2..40c5af5 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/client/AccountServiceClient.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/client/AccountServiceClient.scala @@ -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 diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotConfig.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotConfig.scala index 40ae675..cb40da1 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotConfig.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotConfig.scala @@ -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 { diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala index 509dff0..0f83627 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala @@ -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) =>