feat(official-bots): resolve tournament bot token from Redis and account service
Build & Test (NowChessSystems) TeamCity build finished
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:
@@ -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
|
||||
|
||||
+7
-3
@@ -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 {
|
||||
|
||||
+41
-8
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user