fix(official-bots): auto-register official bots with account service
Build & Test (NowChessSystems) TeamCity build failed

On startup, OfficialBotService now calls POST /api/account/official-bots/sync
(internal endpoint) to ensure all bots in BotController are registered in the
account DB. Without this, OfficialChallengeResource returns 404 for all
official bot challenges because no entries exist.

The sync is idempotent: existing bots are skipped. Startup failure in the
account service is logged but does not prevent the bot service from starting.

Closes NCS-70
https://knockoutwhist.youtrack.cloud/issue/NCS-70

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 11:02:50 +02:00
parent 085e34f062
commit e3503642cd
9 changed files with 104 additions and 1 deletions
@@ -49,3 +49,5 @@ case class RotatedTokenDto(token: String)
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String) case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String)
case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int) case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
case class SyncOfficialBotsRequest(bots: List[String])
@@ -89,6 +89,13 @@ class OfficialBotAccountRepository:
def findAll(): List[OfficialBotAccount] = def findAll(): List[OfficialBotAccount] =
em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList
def findByName(name: String): Option[OfficialBotAccount] =
em.createQuery("FROM OfficialBotAccount WHERE name = :name", classOf[OfficialBotAccount])
.setParameter("name", name)
.getResultList
.asScala
.headOption
def persist(bot: OfficialBotAccount): OfficialBotAccount = def persist(bot: OfficialBotAccount): OfficialBotAccount =
em.persist(bot) em.persist(bot)
bot bot
@@ -4,6 +4,7 @@ import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
import de.nowchess.account.dto.* import de.nowchess.account.dto.*
import de.nowchess.account.error.AccountError import de.nowchess.account.error.AccountError
import de.nowchess.account.service.AccountService import de.nowchess.account.service.AccountService
import de.nowchess.security.InternalOnly
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -179,6 +180,13 @@ class AccountResource:
createdAt = bot.createdAt.toString, createdAt = bot.createdAt.toString,
) )
@POST
@Path("/official-bots/sync")
@InternalOnly
def syncOfficialBots(req: SyncOfficialBotsRequest): Response =
accountService.syncOfficialBots(req.bots)
Response.noContent().build()
@GET @GET
@Path("/official-bots") @Path("/official-bots")
def getOfficialBots: Response = def getOfficialBots: Response =
@@ -206,6 +206,17 @@ class AccountService:
officialBotAccountRepository.persist(bot) officialBotAccountRepository.persist(bot)
Right(bot) Right(bot)
@Transactional
def syncOfficialBots(botNames: List[String]): Unit =
botNames.foreach { name =>
if officialBotAccountRepository.findByName(name).isEmpty then
val bot = new OfficialBotAccount()
bot.name = name
bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot)
log.infof("Auto-registered official bot: %s", name)
}
def getOfficialBotAccounts(): List[OfficialBotAccount] = def getOfficialBotAccounts(): List[OfficialBotAccount] =
officialBotAccountRepository.findAll() officialBotAccountRepository.findAll()
@@ -154,3 +154,34 @@ class AccountResourceTest:
.post("/api/account/refresh") .post("/api/account/refresh")
.`then`() .`then`()
.statusCode(401) .statusCode(401)
@Test
def syncOfficialBotsCreatesNewBots(): Unit =
givenRequest()
.body("""{"bots":["sync-easy","sync-hard"]}""")
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
RestAssured.`given`()
.when()
.get("/api/account/official-bots")
.`then`()
.statusCode(200)
.body("name", hasItems("sync-easy", "sync-hard"))
@Test
def syncOfficialBotsIsIdempotent(): Unit =
val body = """{"bots":["idempotent-bot"]}"""
givenRequest()
.body(body)
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
givenRequest()
.body(body)
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
+2
View File
@@ -77,6 +77,8 @@ dependencies {
implementation(project(":modules:api")) implementation(project(":modules:api"))
implementation(project(":modules:io")) implementation(project(":modules:io"))
implementation(project(":modules:rule")) implementation(project(":modules:rule"))
implementation(project(":modules:security"))
implementation("io.quarkus:quarkus-rest-client-jackson")
implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}") implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
implementation("io.quarkus:quarkus-redis-client") implementation("io.quarkus:quarkus-redis-client")
@@ -5,6 +5,9 @@ quarkus:
name: nowchess-official-bots name: nowchess-official-bots
redis: redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379} hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
rest-client:
account-service:
url: http://localhost:8083
smallrye-jwt: smallrye-jwt:
enabled: true enabled: true
log: log:
@@ -15,6 +18,8 @@ nowchess:
host: localhost host: localhost
port: 6379 port: 6379
prefix: nowchess prefix: nowchess
internal:
secret: 123abc
"%deployed": "%deployed":
quarkus: quarkus:
@@ -28,8 +33,13 @@ nowchess:
exporter: exporter:
otlp: otlp:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317} endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}
rest-client:
account-service:
url: ${ACCOUNT_SERVICE_URL}
nowchess: nowchess:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess} prefix: ${REDIS_PREFIX:nowchess}
internal:
secret: ${INTERNAL_SECRET}
@@ -0,0 +1,20 @@
package de.nowchess.bot.client
import de.nowchess.security.{InternalClientHeadersFactory, InternalSecretClientFilter}
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.{RegisterClientHeaders, RegisterProvider}
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class SyncOfficialBotsRequest(bots: List[String])
@Path("/api/account/official-bots")
@RegisterRestClient(configKey = "account-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
@RegisterClientHeaders(classOf[InternalClientHeadersFactory])
trait AccountServiceClient:
@POST
@Path("/sync")
@Consumes(Array(MediaType.APPLICATION_JSON))
def syncBots(req: SyncOfficialBotsRequest): Unit
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.BotController import de.nowchess.bot.BotController
import de.nowchess.bot.BotDifficulty import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.client.{AccountServiceClient, SyncOfficialBotsRequest}
import de.nowchess.bot.config.RedisConfig import de.nowchess.bot.config.RedisConfig
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.MeterRegistry
@@ -13,6 +14,8 @@ import jakarta.annotation.PostConstruct
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes import jakarta.enterprise.event.Observes
import jakarta.inject.Inject import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import java.util.function.Consumer import java.util.function.Consumer
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -20,12 +23,18 @@ import java.util.concurrent.TimeUnit
@ApplicationScoped @ApplicationScoped
class OfficialBotService: class OfficialBotService:
private val log = Logger.getLogger(classOf[OfficialBotService])
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var redis: RedisDataSource = uninitialized @Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized @Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized @Inject var botController: BotController = uninitialized
@Inject var meterRegistry: MeterRegistry = uninitialized @Inject var meterRegistry: MeterRegistry = uninitialized
@Inject
@RestClient
var accountServiceClient: AccountServiceClient = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
private val terminalStatuses = private val terminalStatuses =
@@ -39,7 +48,10 @@ class OfficialBotService:
} }
def onStart(@Observes event: StartupEvent): Unit = def onStart(@Observes event: StartupEvent): Unit =
BotController.listBots.foreach(subscribeToEventChannel) val bots = BotController.listBots
try accountServiceClient.syncBots(SyncOfficialBotsRequest(bots))
catch case ex: Exception => log.errorf(ex, "Failed to auto-register official bots with account service")
bots.foreach(subscribeToEventChannel)
private def subscribeToEventChannel(botName: String): Unit = private def subscribeToEventChannel(botName: String): Unit =
val handler: Consumer[String] = msg => handleBotEvent(botName, msg) val handler: Consumer[String] = msg => handleBotEvent(botName, msg)