feat(security): add internal secret handling and Redis integration for bot events
This commit is contained in:
Generated
+1
@@ -19,6 +19,7 @@
|
||||
<option value="$PROJECT_DIR$/modules/json" />
|
||||
<option value="$PROJECT_DIR$/modules/official-bots" />
|
||||
<option value="$PROJECT_DIR$/modules/rule" />
|
||||
<option value="$PROJECT_DIR$/modules/security" />
|
||||
<option value="$PROJECT_DIR$/modules/store" />
|
||||
<option value="$PROJECT_DIR$/modules/ws" />
|
||||
</set>
|
||||
|
||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
</profile>
|
||||
<profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot-platform.integrationTest,NowChessSystems.modules.bot-platform.main,NowChessSystems.modules.bot-platform.native-test,NowChessSystems.modules.bot-platform.quarkus-generated-sources,NowChessSystems.modules.bot-platform.quarkus-test-generated-sources,NowChessSystems.modules.bot-platform.scoverage,NowChessSystems.modules.bot-platform.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.coordinator.integrationTest,NowChessSystems.modules.coordinator.main,NowChessSystems.modules.coordinator.native-test,NowChessSystems.modules.coordinator.quarkus-generated-sources,NowChessSystems.modules.coordinator.quarkus-test-generated-sources,NowChessSystems.modules.coordinator.scoverage,NowChessSystems.modules.coordinator.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.official-bots.integrationTest,NowChessSystems.modules.official-bots.main,NowChessSystems.modules.official-bots.native-test,NowChessSystems.modules.official-bots.quarkus-generated-sources,NowChessSystems.modules.official-bots.quarkus-test-generated-sources,NowChessSystems.modules.official-bots.scoverage,NowChessSystems.modules.official-bots.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.store.integrationTest,NowChessSystems.modules.store.main,NowChessSystems.modules.store.native-test,NowChessSystems.modules.store.quarkus-generated-sources,NowChessSystems.modules.store.quarkus-test-generated-sources,NowChessSystems.modules.store.scoverage,NowChessSystems.modules.store.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test,NowChessSystems.modules.ws.integrationTest,NowChessSystems.modules.ws.main,NowChessSystems.modules.ws.native-test,NowChessSystems.modules.ws.quarkus-generated-sources,NowChessSystems.modules.ws.quarkus-test-generated-sources,NowChessSystems.modules.ws.scoverage,NowChessSystems.modules.ws.test">
|
||||
<profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot-platform.integrationTest,NowChessSystems.modules.bot-platform.main,NowChessSystems.modules.bot-platform.native-test,NowChessSystems.modules.bot-platform.quarkus-generated-sources,NowChessSystems.modules.bot-platform.quarkus-test-generated-sources,NowChessSystems.modules.bot-platform.scoverage,NowChessSystems.modules.bot-platform.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.coordinator.integrationTest,NowChessSystems.modules.coordinator.main,NowChessSystems.modules.coordinator.native-test,NowChessSystems.modules.coordinator.quarkus-generated-sources,NowChessSystems.modules.coordinator.quarkus-test-generated-sources,NowChessSystems.modules.coordinator.scoverage,NowChessSystems.modules.coordinator.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.official-bots.integrationTest,NowChessSystems.modules.official-bots.main,NowChessSystems.modules.official-bots.native-test,NowChessSystems.modules.official-bots.quarkus-generated-sources,NowChessSystems.modules.official-bots.quarkus-test-generated-sources,NowChessSystems.modules.official-bots.scoverage,NowChessSystems.modules.official-bots.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.security.main,NowChessSystems.modules.security.scoverage,NowChessSystems.modules.security.test,NowChessSystems.modules.store.integrationTest,NowChessSystems.modules.store.main,NowChessSystems.modules.store.native-test,NowChessSystems.modules.store.quarkus-generated-sources,NowChessSystems.modules.store.quarkus-test-generated-sources,NowChessSystems.modules.store.scoverage,NowChessSystems.modules.store.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test,NowChessSystems.modules.ws.integrationTest,NowChessSystems.modules.ws.main,NowChessSystems.modules.ws.native-test,NowChessSystems.modules.ws.quarkus-generated-sources,NowChessSystems.modules.ws.quarkus-test-generated-sources,NowChessSystems.modules.ws.scoverage,NowChessSystems.modules.ws.test">
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
<parameters>
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# Plan: Add Coordinator Microservice
|
||||
|
||||
## Context
|
||||
NowChess scales `core` horizontally via shared Redis but lacks:
|
||||
- **Instance visibility**: no way to list running cores or their load
|
||||
- **Load balancing**: games land randomly on cores; no rebalancing
|
||||
- **Failover**: dead cores orphan subscriptions; bullet chess requires <1s recovery
|
||||
- **Auto-scaling**: manual ops to add/remove cores
|
||||
- **Cache management**: no eviction of stale games from core memory
|
||||
|
||||
Bullet chess games run on move timings of <3s. 30s failover = game lost on clock. Target: **<300ms failover**.
|
||||
|
||||
---
|
||||
|
||||
## Architecture: Sub-1s Failover
|
||||
|
||||
### Why Not Polling/TTL
|
||||
- TTL expiry: minimum 10-30s detection
|
||||
- HTTP polling 3x failure: 30s minimum
|
||||
- **gRPC streaming TCP drop: 50-200ms** — use this as primary
|
||||
|
||||
### Primary: gRPC Bidirectional Streaming
|
||||
- Core opens a **persistent bidirectional stream** (`CoreHeartbeatStream`) to coordinator on startup
|
||||
- Core sends heartbeat frames every **200ms**
|
||||
- Core crash = TCP RST/FIN → coordinator stream error in **~50-200ms**
|
||||
- Stream also carries metadata updates (subscription count changes) in real-time
|
||||
|
||||
### Fallback: Redis Heartbeat + K8s Watch
|
||||
- Redis heartbeat key `{prefix}:instances:{instanceId}` with **5s TTL**, refreshed every **2s**
|
||||
- K8s pod watch via Kubernetes Java client (event-driven; handles pod eviction/OOMKill)
|
||||
- Fallback covers: network partition (TCP stays up but core is zombie), coordinator restart gap
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Module: `modules/coordinator`
|
||||
**Language**: Scala 3.5.1, Quarkus REST + gRPC
|
||||
**Ports**: HTTP 8086, gRPC 9086
|
||||
**Dependencies**: Redisson, Kubernetes Java client, Quarkus gRPC
|
||||
**Persistence**: None (all state in Redis)
|
||||
|
||||
---
|
||||
|
||||
### 2. Instance Registry
|
||||
|
||||
**Redis schema**:
|
||||
```
|
||||
{prefix}:instances:{instanceId}
|
||||
- TTL: 5s (refreshed by core every 2s via background task)
|
||||
- Value: JSON
|
||||
{
|
||||
"instanceId": "core-abc123",
|
||||
"hostname": "core-pod-3",
|
||||
"httpPort": 8080,
|
||||
"grpcPort": 9080,
|
||||
"subscriptionCount": 147,
|
||||
"localCacheSize": 147,
|
||||
"lastHeartbeat": "2026-04-26T10:15:30.123Z"
|
||||
}
|
||||
|
||||
{prefix}:instance:{instanceId}:games
|
||||
- Type: Redis Set (no TTL — managed explicitly)
|
||||
- Members: all gameIds currently subscribed on this instance
|
||||
```
|
||||
|
||||
**Core changes** (new `InstanceHeartbeatService` bean in `modules/core`):
|
||||
- `@PostConstruct`: generate stable `instanceId` (hostname + random suffix); open gRPC stream to coordinator; publish Redis heartbeat; register in `{prefix}:instances:{instanceId}`
|
||||
- Every 200ms: send heartbeat frame on gRPC stream (carries `subscriptionCount`)
|
||||
- Every 2s: refresh Redis heartbeat bucket TTL
|
||||
- `subscribeGame(gameId)`: `SADD {prefix}:instance:{instanceId}:games gameId`
|
||||
- `unsubscribeGame(gameId)` / `evictGame(gameId)`: `SREM {prefix}:instance:{instanceId}:games gameId`
|
||||
- `@PreDestroy`: delete Redis key + games set; close gRPC stream (clean shutdown)
|
||||
|
||||
---
|
||||
|
||||
### 3. Health Monitoring (3 signals, primary fast)
|
||||
|
||||
| Signal | Mechanism | Detection time | Role |
|
||||
|--------|-----------|---------------|------|
|
||||
| **gRPC stream drop** | TCP RST/FIN on bidirectional stream | 50–200ms | Primary |
|
||||
| **Redis heartbeat expiry** | `{prefix}:instances:{instanceId}` TTL=5s | 5–7s | Fallback |
|
||||
| **K8s pod watch** | `CoreV1Api.listNamespacedPod` watch stream | ~instant (pod events) | Fallback |
|
||||
|
||||
**Dead decision**:
|
||||
- gRPC stream drops → **immediate failover** (no confirmation needed; games must recover fast)
|
||||
- Redis heartbeat expires (gRPC still up) → verify with single HTTP `/q/health` call → if fail: failover
|
||||
- K8s pod NotReady (gRPC still up) → failover
|
||||
|
||||
---
|
||||
|
||||
### 4. Failover Protocol (<300ms target)
|
||||
|
||||
```
|
||||
T+0ms Core JVM crashes / network drops
|
||||
T+50ms Coordinator: gRPC stream error received
|
||||
T+52ms SMEMBERS {prefix}:instance:{instanceId}:games → list of orphaned gameIds
|
||||
T+55ms Distribute gameIds across healthy cores (least-loaded first)
|
||||
T+60ms BatchResubscribeGames gRPC call(s) fire to healthy core(s)
|
||||
T+150ms Healthy cores resubscribed; Redis s2c topics live again
|
||||
T+200ms WebSocket clients reconnect; receive GameFullEventDto on CONNECTED
|
||||
```
|
||||
|
||||
**Failover steps** (coordinator `FailoverService`):
|
||||
1. On stream drop for `instanceId`:
|
||||
a. Mark instance DEAD in local map
|
||||
b. `SMEMBERS {prefix}:instance:{instanceId}:games`
|
||||
c. Group gameIds into batches per target core (round-robin by load)
|
||||
d. For each target core: call `BatchResubscribeGames(gameIds)`
|
||||
e. Each target core: calls `subscribeGame(gameId)` for each (loads from Redis if not in local cache)
|
||||
f. `DEL {prefix}:instance:{instanceId}:games` (cleanup)
|
||||
2. Log failover event with count of games migrated + latency
|
||||
|
||||
---
|
||||
|
||||
### 5. Load Rebalancing
|
||||
|
||||
**Thresholds** (both must be evaluated):
|
||||
1. **Absolute**: any core > 500 games → rebalance
|
||||
2. **Relative**: max load > mean × 1.2 AND max - min > 50 games → rebalance
|
||||
|
||||
**Algorithm** (runs every 30s, min 60s between actual rebalances):
|
||||
1. Read all `{prefix}:instances:*` keys → load map
|
||||
2. Identify overloaded cores (exceed either threshold)
|
||||
3. For each overloaded core: pick `excess = load - targetLoad` games
|
||||
4. Assign excess games to underloaded cores
|
||||
5. Call `UnsubscribeGames(gameIds)` on overloaded core
|
||||
6. Call `BatchResubscribeGames(gameIds)` on target core
|
||||
7. Overloaded core: `SREM` each game from its set
|
||||
8. Target core: `SADD` each game to its set on subscribe
|
||||
|
||||
---
|
||||
|
||||
### 6. Auto-Scaling
|
||||
|
||||
**Metric**: avg `subscriptionCount` across all cores
|
||||
|
||||
**Actions**:
|
||||
- avg > `scale-up-threshold` (80% of max): patch `nowchess-core` Argo Rollout `spec.replicas += 1`
|
||||
- avg < `scale-down-threshold` (30% of max) AND `replicas > min-replicas`: drain one core then scale down
|
||||
- Backoff: min 2-minute interval between scale events
|
||||
|
||||
**Argo Rollouts API**:
|
||||
- CRD: `argoproj.io/v1alpha1`, Kind: `Rollout`, resource: `rollouts`
|
||||
- Scale via Fabric8 `GenericKubernetesResource` patch on `spec.replicas`
|
||||
- No StatefulSet — Argo Rollout owns pod lifecycle (canary/blue-green strategies respected)
|
||||
- Pod watch filter: label selector `app=nowchess-core` (Rollout sets this; `rollouts-pod-template-hash` is Argo's equivalent of `pod-template-hash`)
|
||||
|
||||
**Drain before scale-down**:
|
||||
1. Pick least-loaded core
|
||||
2. Migrate all its games to other cores via `BatchResubscribeGames`
|
||||
3. Call `DrainInstance(instanceId)` on that core (sets it to reject new subscriptions)
|
||||
4. After drain confirmed: patch Rollout `spec.replicas -= 1`
|
||||
|
||||
---
|
||||
|
||||
### 7. Cache Eviction
|
||||
|
||||
**Trigger**: coordinator scans `{prefix}:game:entry:*` every 10 minutes
|
||||
**Policy**: if `now - lastUpdated > 45min` AND `gameId` in any instance's games set → call `EvictGame`
|
||||
**Effect**: core removes game from `localEngines` and `unsubscribeGame`, `SREM` from instance set
|
||||
|
||||
---
|
||||
|
||||
### 8. Proto: `coordinator_service.proto`
|
||||
|
||||
```proto
|
||||
syntax = "proto3";
|
||||
package de.nowchess.coordinator;
|
||||
|
||||
service CoordinatorService {
|
||||
// Core → Coordinator: bidirectional stream for liveness
|
||||
rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
|
||||
|
||||
// Coordinator → Core: batch resubscribe after failover or rebalance
|
||||
rpc BatchResubscribeGames(BatchResubscribeRequest) returns (BatchResubscribeResponse);
|
||||
|
||||
// Coordinator → Core: unsubscribe games (rebalance source)
|
||||
rpc UnsubscribeGames(UnsubscribeGamesRequest) returns (UnsubscribeGamesResponse);
|
||||
|
||||
// Coordinator → Core: evict idle games from local cache
|
||||
rpc EvictGames(EvictGamesRequest) returns (EvictGamesResponse);
|
||||
|
||||
// Coordinator → Core: drain instance before scale-down
|
||||
rpc DrainInstance(DrainInstanceRequest) returns (DrainInstanceResponse);
|
||||
}
|
||||
|
||||
message HeartbeatFrame {
|
||||
string instanceId = 1;
|
||||
string hostname = 2;
|
||||
int32 httpPort = 3;
|
||||
int32 grpcPort = 4;
|
||||
int32 subscriptionCount = 5;
|
||||
int32 localCacheSize = 6;
|
||||
int64 timestampMillis = 7;
|
||||
}
|
||||
|
||||
message CoordinatorCommand {
|
||||
// Future: coordinator can push commands back (e.g., "start draining")
|
||||
string type = 1;
|
||||
string payload = 2;
|
||||
}
|
||||
|
||||
message BatchResubscribeRequest {
|
||||
repeated string gameIds = 1;
|
||||
}
|
||||
|
||||
message BatchResubscribeResponse {
|
||||
int32 subscribedCount = 1;
|
||||
repeated string failedGameIds = 2;
|
||||
}
|
||||
|
||||
message UnsubscribeGamesRequest {
|
||||
repeated string gameIds = 1;
|
||||
}
|
||||
|
||||
message UnsubscribeGamesResponse {
|
||||
int32 unsubscribedCount = 1;
|
||||
}
|
||||
|
||||
message EvictGamesRequest {
|
||||
repeated string gameIds = 1;
|
||||
}
|
||||
|
||||
message EvictGamesResponse {
|
||||
int32 evictedCount = 1;
|
||||
}
|
||||
|
||||
message DrainInstanceRequest {}
|
||||
|
||||
message DrainInstanceResponse {
|
||||
int32 gamesMigrated = 0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. Coordinator REST API (internal)
|
||||
|
||||
- `GET /api/coordinator/instances` — all cores with load, health state
|
||||
- `GET /api/coordinator/metrics` — load distribution, rebalance history
|
||||
- `POST /api/coordinator/rebalance` — manual rebalance trigger
|
||||
- `POST /api/coordinator/failover/{instanceId}` — manual failover
|
||||
- `POST /api/coordinator/scale-up` / `scale-down` — manual scaling
|
||||
|
||||
---
|
||||
|
||||
### 10. Configuration
|
||||
|
||||
**`modules/coordinator/src/main/resources/application.yml`**:
|
||||
```yaml
|
||||
quarkus.application.name: nowchess-coordinator
|
||||
quarkus.http.port: 8086
|
||||
quarkus.grpc.server.port: 9086
|
||||
|
||||
nowchess.coordinator.max-games-per-core: 500
|
||||
nowchess.coordinator.max-deviation-percent: 20
|
||||
nowchess.coordinator.rebalance-interval: 30s
|
||||
nowchess.coordinator.rebalance-min-interval: 60s
|
||||
nowchess.coordinator.heartbeat-ttl: 5s
|
||||
nowchess.coordinator.stream-heartbeat-interval: 200ms
|
||||
nowchess.coordinator.cache-eviction-interval: 10m
|
||||
nowchess.coordinator.game-idle-threshold: 45m
|
||||
nowchess.coordinator.auto-scale-enabled: false
|
||||
nowchess.coordinator.scale-up-threshold: 0.8
|
||||
nowchess.coordinator.scale-down-threshold: 0.3
|
||||
nowchess.coordinator.scale-min-replicas: 2
|
||||
nowchess.coordinator.scale-max-replicas: 10
|
||||
nowchess.coordinator.k8s-namespace: default
|
||||
nowchess.coordinator.k8s-rollout-name: nowchess-core
|
||||
nowchess.coordinator.k8s-rollout-label-selector: app=nowchess-core
|
||||
|
||||
quarkus.kubernetes-client.trust-certs: true
|
||||
```
|
||||
|
||||
**Core `application.yml` additions**:
|
||||
```yaml
|
||||
nowchess.coordinator.host: localhost
|
||||
nowchess.coordinator.grpc-port: 9086
|
||||
nowchess.coordinator.stream-heartbeat-interval: 200ms
|
||||
nowchess.coordinator.redis-heartbeat-interval: 2s
|
||||
nowchess.coordinator.instance-id: ${HOSTNAME:local}-${quarkus.uuid}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 11. Files to Create / Modify
|
||||
|
||||
**New — `modules/coordinator/`**:
|
||||
```
|
||||
build.gradle.kts
|
||||
src/main/proto/coordinator_service.proto
|
||||
src/main/resources/application.yml
|
||||
src/main/scala/de/nowchess/coordinator/
|
||||
resource/CoordinatorResource.scala # REST endpoints
|
||||
service/InstanceRegistry.scala # Redis instance list + in-memory map
|
||||
service/HealthMonitor.scala # gRPC stream watcher + Redis TTL + k8s watch
|
||||
service/FailoverService.scala # dead core → BatchResubscribe
|
||||
service/LoadBalancer.scala # rebalance logic
|
||||
service/AutoScaler.scala # k8s StatefulSet scaling
|
||||
service/CacheEvictionManager.scala # idle game eviction
|
||||
grpc/CoordinatorGrpcServer.scala # CoordinatorService gRPC impl (for HeartbeatStream)
|
||||
```
|
||||
|
||||
**Modify — `modules/core/`**:
|
||||
- `build.gradle.kts` — add `coordinator_service.proto` stub, keep grpc dep
|
||||
- `src/main/proto/coordinator_service.proto` — copy (or symlink) proto for stub generation
|
||||
- `src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala` — `SADD`/`SREM` on subscribe/unsubscribe + implement `BatchResubscribeGames`, `UnsubscribeGames`, `EvictGames`, `DrainInstance` gRPC handlers
|
||||
- `src/main/scala/de/nowchess/chess/` — new `InstanceHeartbeatService.scala` (startup, gRPC stream, Redis TTL refresh)
|
||||
- `src/main/resources/application.yml` — coordinator connection config
|
||||
|
||||
**Modify — root**:
|
||||
- `settings.gradle.kts` — add `include("modules/coordinator")`
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. `./compile` — coordinator and core compile cleanly
|
||||
2. **Stream detection**: start core + coordinator; kill core JVM (`kill -9`); coordinator logs failover within 300ms
|
||||
3. **Game continuity**: active game on killed core; WebSocket client reconnects and receives game state
|
||||
4. **Rebalance**: create 600 games on core-1 (2-core setup); coordinator rebalances ~100 to core-2
|
||||
5. **Fallback**: disconnect gRPC stream manually but keep core alive; Redis TTL fallback triggers within 7s
|
||||
6. **Cache eviction**: create idle game; coordinator calls `EvictGames` after 45min idle
|
||||
7. **REST metrics**: `curl localhost:8086/api/coordinator/metrics` returns per-core load + health
|
||||
8. **Restart recovery**: restart coordinator; gRPC streams re-establish from cores; state rebuilt from Redis
|
||||
|
||||
---
|
||||
|
||||
## Dependencies (new)
|
||||
|
||||
- `io.fabric8:kubernetes-client:6.13.0` (Fabric8 k8s client — handles Argo `Rollout` CRD via `GenericKubernetesResource`; no Argo Java SDK needed)
|
||||
- Redisson — already in core, reuse via shared config
|
||||
- Quarkus gRPC — already in core, reuse
|
||||
@@ -45,6 +45,8 @@ dependencies {
|
||||
}
|
||||
}
|
||||
|
||||
implementation(project(":modules:security"))
|
||||
|
||||
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||
implementation("io.quarkus:quarkus-rest")
|
||||
implementation("io.quarkus:quarkus-rest-jackson")
|
||||
@@ -60,6 +62,7 @@ dependencies {
|
||||
implementation("io.quarkus:quarkus-micrometer")
|
||||
implementation("io.quarkus:quarkus-smallrye-openapi")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||
implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
|
||||
@@ -6,8 +6,6 @@ quarkus:
|
||||
rest-client:
|
||||
core-service:
|
||||
url: http://localhost:8080
|
||||
bot-platform-service:
|
||||
url: http://localhost:8087
|
||||
smallrye-openapi:
|
||||
info-title: NowChess Account Service
|
||||
path: /openapi
|
||||
@@ -24,13 +22,24 @@ quarkus:
|
||||
schema-management:
|
||||
strategy: drop-and-create
|
||||
|
||||
nowchess:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
prefix: nowchess
|
||||
internal:
|
||||
secret: ${INTERNAL_SECRET}
|
||||
|
||||
"%deployed":
|
||||
quarkus:
|
||||
rest-client:
|
||||
core-service:
|
||||
url: ${CORE_SERVICE_URL}
|
||||
bot-platform-service:
|
||||
url: ${BOT_PLATFORM_SERVICE_URL}
|
||||
nowchess:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
prefix: ${REDIS_PREFIX:nowchess}
|
||||
datasource:
|
||||
db-kind: postgresql
|
||||
username: ${DB_USER}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package de.nowchess.account.client
|
||||
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
@Path("/api/bot")
|
||||
@RegisterRestClient(configKey = "bot-platform-service")
|
||||
trait BotPlatformClient:
|
||||
|
||||
@POST
|
||||
@Path("/game/{gameId}/assign")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def assignBot(
|
||||
@PathParam("gameId") gameId: String,
|
||||
@QueryParam("botId") botId: String,
|
||||
@QueryParam("difficulty") difficulty: Int,
|
||||
@QueryParam("playingAs") playingAs: String,
|
||||
@QueryParam("botAccountId") botAccountId: String,
|
||||
): Unit
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.nowchess.account.client
|
||||
|
||||
import de.nowchess.security.InternalSecretClientFilter
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
case class CorePlayerInfo(id: String, displayName: String)
|
||||
@@ -16,6 +18,7 @@ case class CoreGameResponse(gameId: String)
|
||||
|
||||
@Path("/api/board/game")
|
||||
@RegisterRestClient(configKey = "core-service")
|
||||
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||
trait CoreGameClient:
|
||||
|
||||
@POST
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.nowchess.account.config
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@ApplicationScoped
|
||||
class RedisConfig:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
|
||||
var host: String = uninitialized
|
||||
|
||||
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
|
||||
var port: Int = uninitialized
|
||||
|
||||
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
|
||||
var prefix: String = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.nowchess.account.config
|
||||
|
||||
import jakarta.annotation.PreDestroy
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.enterprise.inject.Produces
|
||||
import jakarta.inject.Inject
|
||||
import org.redisson.Redisson
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.redisson.config.Config
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@ApplicationScoped
|
||||
class RedissonProducer:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
private var clientOpt: Option[RedissonClient] = None
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
@Produces
|
||||
@ApplicationScoped
|
||||
def produceRedissonClient(): RedissonClient =
|
||||
val config = new Config()
|
||||
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
|
||||
config.useSingleServer().setConnectionMinimumIdleSize(1)
|
||||
config.useSingleServer().setConnectTimeout(500)
|
||||
val client = Redisson.create(config)
|
||||
clientOpt = Some(client)
|
||||
client
|
||||
|
||||
@PreDestroy
|
||||
def shutdown(): Unit =
|
||||
clientOpt.foreach(_.shutdown())
|
||||
@@ -47,6 +47,14 @@ class ChallengeResource:
|
||||
val userId = UUID.fromString(jwt.getSubject)
|
||||
Response.ok(challengeService.listForUser(userId)).build()
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
def get(@PathParam("id") id: UUID): Response =
|
||||
val userId = UUID.fromString(jwt.getSubject)
|
||||
challengeService.findById(id, userId) match
|
||||
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
|
||||
case Left(error) => errorResponse(error)
|
||||
|
||||
@POST
|
||||
@Path("/{id}/accept")
|
||||
def accept(@PathParam("id") id: UUID): Response =
|
||||
|
||||
+18
-25
@@ -1,8 +1,8 @@
|
||||
package de.nowchess.account.resource
|
||||
|
||||
import de.nowchess.account.client.{BotPlatformClient, CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
|
||||
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
|
||||
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
|
||||
import de.nowchess.account.service.AccountService
|
||||
import de.nowchess.account.service.{AccountService, BotEventPublisher}
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
@@ -23,19 +23,13 @@ import java.util.UUID
|
||||
class OfficialChallengeResource:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
var accountService: AccountService = uninitialized
|
||||
|
||||
@Inject
|
||||
var jwt: JsonWebToken = uninitialized
|
||||
@Inject var accountService: AccountService = uninitialized
|
||||
@Inject var jwt: JsonWebToken = uninitialized
|
||||
@Inject var botEventPublisher: BotEventPublisher = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var coreGameClient: CoreGameClient = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var botPlatformClient: BotPlatformClient = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
private val log = Logger.getLogger(classOf[OfficialChallengeResource])
|
||||
@@ -43,9 +37,9 @@ class OfficialChallengeResource:
|
||||
@POST
|
||||
@Path("/{botName}")
|
||||
def challengeWithDifficulty(
|
||||
@PathParam("botName") botName: String,
|
||||
@QueryParam("difficulty") difficulty: Int,
|
||||
@QueryParam("color") color: String,
|
||||
@PathParam("botName") botName: String,
|
||||
@QueryParam("difficulty") difficulty: Int,
|
||||
@QueryParam("color") color: String,
|
||||
): Response =
|
||||
if difficulty < 1000 || difficulty > 2800 then
|
||||
return Response
|
||||
@@ -56,10 +50,12 @@ class OfficialChallengeResource:
|
||||
val playerColor = Option(color).map(_.toLowerCase).getOrElse("random") match
|
||||
case "white" | "black" | "random" => Option(color).map(_.toLowerCase).getOrElse("random")
|
||||
case other =>
|
||||
return Response.status(Response.Status.BAD_REQUEST).entity(ErrorDto(s"Invalid color: $other. Must be white, black or random")).build()
|
||||
return Response
|
||||
.status(Response.Status.BAD_REQUEST)
|
||||
.entity(ErrorDto(s"Invalid color: $other. Must be white, black or random"))
|
||||
.build()
|
||||
|
||||
val userId = UUID.fromString(jwt.getSubject)
|
||||
|
||||
val botOpt = accountService.getOfficialBotAccounts().find(_.name == botName)
|
||||
val userOpt = accountService.findById(userId)
|
||||
|
||||
@@ -70,9 +66,9 @@ class OfficialChallengeResource:
|
||||
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto("User not found")).build()
|
||||
case (Some(bot), Some(user)) =>
|
||||
val userIsWhite = playerColor match
|
||||
case "white" => true
|
||||
case "black" => false
|
||||
case _ => scala.util.Random.nextBoolean()
|
||||
case "white" => true
|
||||
case "black" => false
|
||||
case _ => scala.util.Random.nextBoolean()
|
||||
val (white, black, botColor) =
|
||||
if userIsWhite then
|
||||
(CorePlayerInfo(user.id.toString, user.username), CorePlayerInfo(bot.id.toString, bot.name), "black")
|
||||
@@ -86,9 +82,6 @@ class OfficialChallengeResource:
|
||||
case Left(err) =>
|
||||
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(err)).build()
|
||||
case Right(id) =>
|
||||
try botPlatformClient.assignBot(id, botName, difficulty, botColor, bot.id.toString)
|
||||
catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", id)
|
||||
Response
|
||||
.status(Response.Status.CREATED)
|
||||
.entity(OfficialChallengeResponse(id, botName, difficulty))
|
||||
.build()
|
||||
try botEventPublisher.publishGameStart(bot.name, id, botColor, difficulty, bot.id.toString)
|
||||
catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", id)
|
||||
Response.status(Response.Status.CREATED).entity(OfficialChallengeResponse(id, botName, difficulty)).build()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.nowchess.account.service
|
||||
|
||||
import de.nowchess.account.config.RedisConfig
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import org.redisson.api.RedissonClient
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@ApplicationScoped
|
||||
class BotEventPublisher:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var redisson: RedissonClient = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
|
||||
val event = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":$difficulty,"botAccountId":"$botAccountId"}"""
|
||||
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event)
|
||||
()
|
||||
|
||||
def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit =
|
||||
val event = s"""{"type":"challengeCreated","challengeId":"$challengeId","challengerName":"$challengerName"}"""
|
||||
redisson.getTopic(s"${redisConfig.prefix}:user:$destUserId:events").publish(event)
|
||||
()
|
||||
|
||||
def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit =
|
||||
val event = s"""{"type":"challengeAccepted","challengeId":"$challengeId","gameId":"$gameId"}"""
|
||||
redisson.getTopic(s"${redisConfig.prefix}:user:$challengerId:events").publish(event)
|
||||
()
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.nowchess.account.service
|
||||
|
||||
import de.nowchess.account.client.{
|
||||
BotPlatformClient,
|
||||
CoreCreateGameRequest,
|
||||
CoreGameClient,
|
||||
CoreGameResponse,
|
||||
@@ -26,6 +25,7 @@ import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.UUID
|
||||
@@ -47,8 +47,7 @@ class ChallengeService:
|
||||
var coreGameClient: CoreGameClient = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var botPlatformClient: BotPlatformClient = uninitialized
|
||||
var botEventPublisher: BotEventPublisher = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@Transactional
|
||||
@@ -75,6 +74,8 @@ class ChallengeService:
|
||||
challenge.createdAt = Instant.now()
|
||||
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
|
||||
challengeRepository.persist(challenge)
|
||||
try botEventPublisher.publishChallengeCreated(destUser.id.toString, challenge.id.toString, challenger.username)
|
||||
catch case ex: Exception => log.warnf(ex, "Failed to notify dest user for challenge %s", challenge.id)
|
||||
challenge
|
||||
|
||||
@Transactional
|
||||
@@ -89,6 +90,8 @@ class ChallengeService:
|
||||
challenge.gameId = gameId
|
||||
challengeRepository.merge(challenge)
|
||||
notifyBotIfNeeded(challenge, gameId)
|
||||
try botEventPublisher.publishChallengeAccepted(challenge.challenger.id.toString, challenge.id.toString, gameId)
|
||||
catch case ex: Exception => log.warnf(ex, "Failed to notify challenger for game %s", gameId)
|
||||
challenge
|
||||
|
||||
@Transactional
|
||||
@@ -115,6 +118,16 @@ class ChallengeService:
|
||||
challengeRepository.merge(challenge)
|
||||
challenge
|
||||
|
||||
def findById(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
|
||||
for
|
||||
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
|
||||
_ <- Either.cond(
|
||||
challenge.challenger.id == userId || challenge.destUser.id == userId,
|
||||
(),
|
||||
ChallengeError.NotAuthorized,
|
||||
)
|
||||
yield challenge
|
||||
|
||||
def listForUser(userId: UUID): ChallengeListDto =
|
||||
val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto)
|
||||
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
|
||||
@@ -125,8 +138,8 @@ class ChallengeService:
|
||||
List(challenge.challenger, challenge.destUser).foreach { user =>
|
||||
user.getBotAccounts.headOption.foreach { bot =>
|
||||
val playingAs = if white.id == user.id.toString then "white" else "black"
|
||||
try botPlatformClient.assignBot(gameId, bot.name, 1400, playingAs, bot.id.toString)
|
||||
catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", gameId)
|
||||
try botEventPublisher.publishGameStart(bot.id.toString, gameId, playingAs, 1400, bot.id.toString)
|
||||
catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", gameId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
package de.nowchess.api.dto
|
||||
|
||||
final case class PlayerInfoDto(id: String, displayName: String)
|
||||
import de.nowchess.api.player.PlayerType
|
||||
|
||||
final case class PlayerInfoDto(id: String, displayName: String, playerType: PlayerType)
|
||||
|
||||
@@ -23,4 +23,10 @@ object PlayerId:
|
||||
final case class PlayerInfo(
|
||||
id: PlayerId,
|
||||
displayName: String,
|
||||
playerType: PlayerType = PlayerType.Human,
|
||||
)
|
||||
|
||||
enum PlayerType:
|
||||
case Human
|
||||
case OfficialBot
|
||||
case Bot
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package de.nowchess.botplatform.registry
|
||||
|
||||
case class BotGameInfo(
|
||||
botId: String,
|
||||
difficulty: Int,
|
||||
playingAs: String,
|
||||
botAccountId: String,
|
||||
)
|
||||
+25
-10
@@ -1,29 +1,44 @@
|
||||
package de.nowchess.botplatform.registry
|
||||
|
||||
import de.nowchess.botplatform.config.RedisConfig
|
||||
import io.smallrye.mutiny.subscription.MultiEmitter
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.redisson.api.listener.MessageListener
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@ApplicationScoped
|
||||
class BotRegistry:
|
||||
|
||||
private val connections = ConcurrentHashMap[String, MultiEmitter[?]]()
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var redisson: RedissonClient = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val connections = ConcurrentHashMap[String, (MultiEmitter[?], Int)]()
|
||||
|
||||
def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
|
||||
connections.put(botId, emitter)
|
||||
val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events")
|
||||
val listenerId = topic.addListener(
|
||||
classOf[String],
|
||||
new MessageListener[String]:
|
||||
def onMessage(channel: CharSequence, msg: String): Unit =
|
||||
emitter.asInstanceOf[MultiEmitter[String]].emit(msg),
|
||||
)
|
||||
connections.put(botId, (emitter, listenerId))
|
||||
()
|
||||
|
||||
def unregister(botId: String): Unit =
|
||||
connections.remove(botId)
|
||||
()
|
||||
Option(connections.remove(botId)).foreach { (_, listenerId) =>
|
||||
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").removeListener(listenerId)
|
||||
}
|
||||
|
||||
def dispatch(botId: String, event: String): Boolean =
|
||||
Option(connections.get(botId)) match
|
||||
case Some(emitter) =>
|
||||
emitter.asInstanceOf[MultiEmitter[String]].emit(event)
|
||||
true
|
||||
case None => false
|
||||
def dispatch(botId: String, event: String): Unit =
|
||||
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event)
|
||||
()
|
||||
|
||||
def registeredBots: List[String] =
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
+24
-24
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.botplatform.resource
|
||||
|
||||
import de.nowchess.botplatform.registry.{BotGameInfo, BotRegistry}
|
||||
import de.nowchess.botplatform.service.GameBotMonitor
|
||||
import de.nowchess.botplatform.config.RedisConfig
|
||||
import de.nowchess.botplatform.registry.BotRegistry
|
||||
import io.smallrye.mutiny.Multi
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
@@ -9,6 +9,8 @@ import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.{MediaType, Response}
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.redisson.api.listener.MessageListener
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@Path("/api/bot")
|
||||
@@ -17,14 +19,10 @@ import scala.compiletime.uninitialized
|
||||
class BotEventResource:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
var registry: BotRegistry = uninitialized
|
||||
|
||||
@Inject
|
||||
var jwt: JsonWebToken = uninitialized
|
||||
|
||||
@Inject
|
||||
var gameMonitor: GameBotMonitor = uninitialized
|
||||
@Inject var registry: BotRegistry = uninitialized
|
||||
@Inject var jwt: JsonWebToken = uninitialized
|
||||
@Inject var redisson: RedissonClient = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
@GET
|
||||
@@ -34,7 +32,7 @@ class BotEventResource:
|
||||
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
|
||||
val subject = Option(jwt.getSubject).getOrElse("")
|
||||
if tokenType != "bot" || subject != botId then
|
||||
Multi.createFrom().failure(new jakarta.ws.rs.ForbiddenException("Not authorized for this bot"))
|
||||
Multi.createFrom().failure(new ForbiddenException("Not authorized for this bot"))
|
||||
else
|
||||
Multi.createFrom().emitter[String] { emitter =>
|
||||
registry.register(botId, emitter)
|
||||
@@ -46,22 +44,24 @@ class BotEventResource:
|
||||
@Produces(Array(MediaType.SERVER_SENT_EVENTS))
|
||||
def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
|
||||
Multi.createFrom().emitter[String] { emitter =>
|
||||
registry.register(s"game-$gameId", emitter)
|
||||
emitter.onTermination(() => registry.unregister(s"game-$gameId"))
|
||||
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
|
||||
val topic = redisson.getTopic(topicName)
|
||||
val listenerId = topic.addListener(
|
||||
classOf[String],
|
||||
new MessageListener[String]:
|
||||
def onMessage(channel: CharSequence, msg: String): Unit = emitter.emit(msg),
|
||||
)
|
||||
emitter.onTermination(() => topic.removeListener(listenerId))
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/game/{gameId}/assign")
|
||||
@Path("/game/{gameId}/move/{uci}")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def assignBot(
|
||||
@PathParam("gameId") gameId: String,
|
||||
@QueryParam("botId") botId: String,
|
||||
@QueryParam("difficulty") difficulty: Int,
|
||||
@QueryParam("playingAs") playingAs: String,
|
||||
@QueryParam("botAccountId") botAccountId: String,
|
||||
def makeMove(
|
||||
@PathParam("gameId") gameId: String,
|
||||
@PathParam("uci") uci: String,
|
||||
): Response =
|
||||
val info = BotGameInfo(botId, difficulty, playingAs, botAccountId)
|
||||
gameMonitor.watchGame(gameId, info)
|
||||
val event = s"""{"type":"gameStart","gameId":"$gameId","botId":"$botId"}"""
|
||||
registry.dispatch(botId, event)
|
||||
val playerId = Option(jwt.getSubject).getOrElse("")
|
||||
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
|
||||
redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:c2s").publish(moveMsg)
|
||||
Response.ok().build()
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
package de.nowchess.botplatform.service
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.botplatform.config.RedisConfig
|
||||
import de.nowchess.botplatform.registry.BotGameInfo
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import org.redisson.api.{RedissonClient, RBlockingQueue}
|
||||
import org.redisson.api.listener.MessageListener
|
||||
import scala.compiletime.uninitialized
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@ApplicationScoped
|
||||
class GameBotMonitor:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var redisson: RedissonClient = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val listeners = ConcurrentHashMap[String, Int]()
|
||||
|
||||
def watchGame(gameId: String, info: BotGameInfo): Unit =
|
||||
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
|
||||
val topic = redisson.getTopic(topicName)
|
||||
val listenerId = topic.addListener(
|
||||
classOf[String],
|
||||
new MessageListener[String]:
|
||||
def onMessage(channel: CharSequence, msg: String): Unit =
|
||||
handleS2cEvent(gameId, msg, info),
|
||||
)
|
||||
listeners.put(gameId, listenerId)
|
||||
|
||||
def unwatchGame(gameId: String): Unit =
|
||||
Option(listeners.remove(gameId)).foreach { listenerId =>
|
||||
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
|
||||
redisson.getTopic(topicName).removeListener(listenerId)
|
||||
}
|
||||
|
||||
private val terminalStatuses = Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
|
||||
|
||||
private def handleS2cEvent(gameId: String, msg: String, info: BotGameInfo): Unit =
|
||||
try
|
||||
val node = objectMapper.readTree(msg)
|
||||
val status = Option(node.path("state").path("status").asText()).getOrElse("")
|
||||
if terminalStatuses.contains(status) then
|
||||
unwatchGame(gameId)
|
||||
else
|
||||
val turn = Option(node.path("state").path("turn").asText()).getOrElse("")
|
||||
if turn == info.playingAs then
|
||||
val fen = node.path("state").path("fen").asText()
|
||||
val req = s"""{"gameId":"$gameId","fen":"${fen.replace("\"", "\\\"")}","turn":"$turn","playingAs":"${info.playingAs}","difficulty":${info.difficulty},"botAccountId":"${info.botAccountId}"}"""
|
||||
val queue: RBlockingQueue[String] = redisson.getBlockingQueue("nowchess:bot:move-queue")
|
||||
queue.put(req)
|
||||
catch case _: Exception => ()
|
||||
@@ -52,6 +52,7 @@ dependencies {
|
||||
implementation(project(":modules:rule"))
|
||||
implementation(project(":modules:io"))
|
||||
implementation(project(":modules:official-bots"))
|
||||
implementation(project(":modules:security"))
|
||||
|
||||
|
||||
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||
|
||||
@@ -23,6 +23,9 @@ nowchess:
|
||||
port: 6379
|
||||
prefix: nowchess
|
||||
|
||||
internal:
|
||||
secret: ${INTERNAL_SECRET}
|
||||
|
||||
coordinator:
|
||||
enabled: ${NOWCHESS_COORDINATOR_ENABLED:false}
|
||||
host: localhost
|
||||
|
||||
@@ -2,14 +2,17 @@ package de.nowchess.chess.client
|
||||
|
||||
import de.nowchess.api.dto.{ImportFenRequest, ImportPgnRequest}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.security.InternalSecretClientFilter
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
case class CombinedExportResponse(fen: String, pgn: String)
|
||||
|
||||
@Path("/io")
|
||||
@RegisterRestClient(configKey = "io-service")
|
||||
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||
trait IoServiceClient:
|
||||
|
||||
@POST
|
||||
|
||||
@@ -3,8 +3,10 @@ package de.nowchess.chess.client
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.Move
|
||||
import de.nowchess.api.rules.PostMoveStatus
|
||||
import de.nowchess.security.InternalSecretClientFilter
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
case class RuleSquareRequest(context: GameContext, square: String)
|
||||
@@ -12,6 +14,7 @@ case class RuleMoveRequest(context: GameContext, move: Move)
|
||||
|
||||
@Path("/api/rules")
|
||||
@RegisterRestClient(configKey = "rule-service")
|
||||
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||
trait RuleServiceClient:
|
||||
|
||||
@POST
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package de.nowchess.chess.client
|
||||
|
||||
import de.nowchess.security.InternalSecretClientFilter
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
@RegisterRestClient(configKey = "store-service")
|
||||
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||
@Path("/game")
|
||||
trait StoreServiceClient:
|
||||
@GET
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.api.dto.GameStateEventDto
|
||||
import de.nowchess.api.game.{CorrespondenceClockState, LiveClockState}
|
||||
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
||||
import de.nowchess.api.game.{DrawReason, GameResult, WinReason}
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.chess.observer.{GameEvent, Observer}
|
||||
import de.nowchess.chess.registry.GameRegistry
|
||||
import de.nowchess.chess.resource.GameDtoMapper
|
||||
@@ -54,6 +56,21 @@ class GameRedisPublisher(
|
||||
clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
|
||||
clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
|
||||
pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.label.toLowerCase),
|
||||
result = entry.engine.context.result.map {
|
||||
case GameResult.Win(Color.White, _) => "white"
|
||||
case GameResult.Win(Color.Black, _) => "black"
|
||||
case GameResult.Draw(_) => "draw"
|
||||
},
|
||||
terminationReason = entry.engine.context.result.map {
|
||||
case GameResult.Win(_, WinReason.Checkmate) => "checkmate"
|
||||
case GameResult.Win(_, WinReason.Resignation) => "resignation"
|
||||
case GameResult.Win(_, WinReason.TimeControl) => "timeout"
|
||||
case GameResult.Draw(DrawReason.Stalemate) => "stalemate"
|
||||
case GameResult.Draw(DrawReason.InsufficientMaterial) => "insufficient_material"
|
||||
case GameResult.Draw(DrawReason.FiftyMoveRule) => "fifty_move"
|
||||
case GameResult.Draw(DrawReason.ThreefoldRepetition) => "repetition"
|
||||
case GameResult.Draw(DrawReason.Agreement) => "agreement"
|
||||
},
|
||||
redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci),
|
||||
pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
|
||||
)
|
||||
|
||||
@@ -21,6 +21,8 @@ case class GameWritebackEventDto(
|
||||
clockMoveDeadline: Option[Long],
|
||||
clockActiveColor: Option[String],
|
||||
pendingDrawOffer: Option[String],
|
||||
result: Option[String] = None,
|
||||
terminationReason: Option[String] = None,
|
||||
redoStack: List[String] = Nil,
|
||||
pendingTakebackRequest: Option[String] = None,
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ object GameDtoMapper:
|
||||
case _ => base
|
||||
|
||||
def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
|
||||
PlayerInfoDto(info.id.value, info.displayName)
|
||||
PlayerInfoDto(info.id.value, info.displayName, info.playerType)
|
||||
|
||||
def toClockDto(entry: GameEntry): Option[ClockDto] =
|
||||
val now = Instant.now()
|
||||
|
||||
@@ -24,6 +24,7 @@ import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||
import de.nowchess.security.InternalOnly
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.*
|
||||
@@ -79,9 +80,9 @@ class GameResource:
|
||||
val color = colorOf(entry)
|
||||
if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
|
||||
|
||||
private def assertIsBot(): Unit =
|
||||
private def assertIsNotBot(): Unit =
|
||||
val botType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
|
||||
if !Set("bot", "official-bot").contains(botType) then
|
||||
if Set("bot", "official-bot").contains(botType) then
|
||||
throw ForbiddenException("Only bots can make moves")
|
||||
|
||||
// scalafix:on DisableSyntax.throw
|
||||
@@ -153,6 +154,7 @@ class GameResource:
|
||||
// scalafix:off DisableSyntax.throw
|
||||
|
||||
@POST
|
||||
@InternalOnly
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def createGame(body: CreateGameRequestDto): Response =
|
||||
@@ -189,7 +191,7 @@ class GameResource:
|
||||
@Path("/{gameId}/move/{uci}")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
|
||||
assertIsBot()
|
||||
assertIsNotBot()
|
||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||
assertGameNotOver(entry)
|
||||
assertIsCurrentPlayer(entry)
|
||||
|
||||
@@ -52,6 +52,7 @@ dependencies {
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:json"))
|
||||
implementation(project(":modules:rule"))
|
||||
implementation(project(":modules:security"))
|
||||
|
||||
// Jackson for JSON serialization/deserialization
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
|
||||
|
||||
@@ -6,6 +6,10 @@ quarkus:
|
||||
use-separate-server: false
|
||||
application:
|
||||
name: nowchess-io
|
||||
|
||||
nowchess:
|
||||
internal:
|
||||
secret: ${INTERNAL_SECRET}
|
||||
smallrye-openapi:
|
||||
info-title: NowChess IO Service
|
||||
info-version: 1.0.0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.nowchess.io.service.resource
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.security.InternalOnly
|
||||
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||
import de.nowchess.io.service.dto.{CombinedExportResponse, ImportFenRequest, ImportPgnRequest, IoErrorDto}
|
||||
@@ -15,6 +16,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag
|
||||
|
||||
@Path("/io")
|
||||
@ApplicationScoped
|
||||
@InternalOnly
|
||||
@Tag(name = "IO", description = "Chess notation import and export")
|
||||
class IoResource:
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package de.nowchess.bot.service
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
|
||||
case class MoveRequest(
|
||||
gameId: String,
|
||||
fen: String,
|
||||
turn: String,
|
||||
playingAs: String,
|
||||
difficulty: Int,
|
||||
botAccountId: String,
|
||||
)
|
||||
|
||||
object MoveRequestParser:
|
||||
def parse(json: String, mapper: ObjectMapper): Option[MoveRequest] =
|
||||
scala.util.Try {
|
||||
val node = mapper.readTree(json)
|
||||
MoveRequest(
|
||||
gameId = node.get("gameId").asText(),
|
||||
fen = node.get("fen").asText(),
|
||||
turn = node.get("turn").asText(),
|
||||
playingAs = node.get("playingAs").asText(),
|
||||
difficulty = node.get("difficulty").asInt(1400),
|
||||
botAccountId = node.get("botAccountId").asText(),
|
||||
)
|
||||
}.toOption
|
||||
+62
-25
@@ -2,7 +2,6 @@ package de.nowchess.bot.service
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.bot.Bot
|
||||
import de.nowchess.bot.BotController
|
||||
import de.nowchess.bot.BotDifficulty
|
||||
import de.nowchess.bot.config.RedisConfig
|
||||
@@ -12,6 +11,7 @@ import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.enterprise.event.Observes
|
||||
import jakarta.inject.Inject
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.redisson.api.listener.MessageListener
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@ApplicationScoped
|
||||
@@ -24,35 +24,72 @@ class OfficialBotService:
|
||||
@Inject var botController: BotController = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val terminalStatuses =
|
||||
Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
|
||||
|
||||
def onStart(@Observes event: StartupEvent): Unit =
|
||||
Thread.ofVirtual().start(() => runWorker())
|
||||
BotController.listBots.foreach(subscribeToEventChannel)
|
||||
|
||||
private def subscribeToEventChannel(botName: String): Unit =
|
||||
val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botName:events")
|
||||
topic.addListener(
|
||||
classOf[String],
|
||||
new MessageListener[String]:
|
||||
def onMessage(channel: CharSequence, msg: String): Unit =
|
||||
handleBotEvent(botName, msg),
|
||||
)
|
||||
()
|
||||
|
||||
private def runWorker(): Unit =
|
||||
val queue = redisson.getBlockingQueue[String]("nowchess:bot:move-queue")
|
||||
while true do
|
||||
try
|
||||
val json = queue.take()
|
||||
MoveRequestParser.parse(json, objectMapper).foreach(processRequest)
|
||||
catch case _: InterruptedException => Thread.currentThread().interrupt()
|
||||
private def handleBotEvent(botName: String, msg: String): Unit =
|
||||
try
|
||||
val node = objectMapper.readTree(msg)
|
||||
if node.path("type").asText() == "gameStart" then
|
||||
val gameId = node.path("gameId").asText()
|
||||
val playingAs = node.path("playingAs").asText()
|
||||
val difficulty = node.path("difficulty").asInt(1400)
|
||||
val botAccountId = node.path("botAccountId").asText()
|
||||
watchGame(botName, gameId, playingAs, difficulty, botAccountId)
|
||||
catch case _: Exception => ()
|
||||
|
||||
private def processRequest(req: MoveRequest): Unit =
|
||||
val difficulty = DifficultyMapper.fromElo(req.difficulty).getOrElse(BotDifficulty.Medium)
|
||||
val botName = difficulty match
|
||||
case BotDifficulty.Easy => "easy"
|
||||
case BotDifficulty.Medium => "medium"
|
||||
case BotDifficulty.Hard => "hard"
|
||||
case BotDifficulty.Expert => "expert"
|
||||
botController.getBot(botName).foreach(bot => parseAndMove(req, bot))
|
||||
private def watchGame(botName: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
|
||||
val topic = redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:s2c")
|
||||
topic.addListener(
|
||||
classOf[String],
|
||||
new MessageListener[String]:
|
||||
def onMessage(channel: CharSequence, msg: String): Unit =
|
||||
handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg),
|
||||
)
|
||||
()
|
||||
|
||||
private def parseAndMove(req: MoveRequest, bot: Bot): Unit =
|
||||
FenParser.parseFen(req.fen).toOption.foreach { context =>
|
||||
bot(context).foreach { move =>
|
||||
val uci = toUci(move)
|
||||
val c2sTopic = s"${redisConfig.prefix}:game:${req.gameId}:c2s"
|
||||
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"${req.botAccountId}"}"""
|
||||
redisson.getTopic(c2sTopic).publish(moveMsg)
|
||||
()
|
||||
private def handleGameEvent(
|
||||
botName: String,
|
||||
gameId: String,
|
||||
playingAs: String,
|
||||
difficulty: Int,
|
||||
botAccountId: String,
|
||||
msg: String,
|
||||
): Unit =
|
||||
try
|
||||
val node = objectMapper.readTree(msg)
|
||||
val status = node.path("state").path("status").asText("")
|
||||
if !terminalStatuses.contains(status) then
|
||||
val turn = node.path("state").path("turn").asText("")
|
||||
if turn == playingAs then
|
||||
val fen = node.path("state").path("fen").asText()
|
||||
computeAndSendMove(botName, gameId, fen, difficulty, botAccountId)
|
||||
catch case _: Exception => ()
|
||||
|
||||
private def computeAndSendMove(botName: String, gameId: String, fen: String, difficulty: Int, botAccountId: String): Unit =
|
||||
val level = DifficultyMapper.fromElo(difficulty).getOrElse(BotDifficulty.Medium)
|
||||
botController.getBot(botName).orElse(botController.getBot(level.toString.toLowerCase)).foreach { bot =>
|
||||
FenParser.parseFen(fen).toOption.foreach { context =>
|
||||
bot(context).foreach { move =>
|
||||
val uci = toUci(move)
|
||||
val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s"
|
||||
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$botAccountId"}"""
|
||||
redisson.getTopic(c2sTopic).publish(moveMsg)
|
||||
()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ dependencies {
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:json"))
|
||||
implementation(project(":modules:security"))
|
||||
|
||||
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||
implementation("io.quarkus:quarkus-rest")
|
||||
|
||||
@@ -6,3 +6,7 @@ quarkus:
|
||||
use-separate-server: false
|
||||
application:
|
||||
name: rule-service
|
||||
|
||||
nowchess:
|
||||
internal:
|
||||
secret: ${INTERNAL_SECRET}
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.Move
|
||||
import de.nowchess.rules.dto.*
|
||||
import de.nowchess.api.rules.PostMoveStatus
|
||||
import de.nowchess.security.InternalOnly
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.ws.rs.*
|
||||
@@ -12,6 +13,7 @@ import jakarta.ws.rs.core.MediaType
|
||||
|
||||
@Path("/api/rules")
|
||||
@ApplicationScoped
|
||||
@InternalOnly
|
||||
class RuleSetResource:
|
||||
private val rules = DefaultRules
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage") version "8.1"
|
||||
}
|
||||
|
||||
group = "de.nowchess"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
scala {
|
||||
scalaVersion = versions["SCALA3"]!!
|
||||
}
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||
}
|
||||
|
||||
val quarkusPlatformGroupId: String by project
|
||||
val quarkusPlatformArtifactId: String by project
|
||||
val quarkusPlatformVersion: String by project
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||
version { strictly(versions["SCALA3"]!!) }
|
||||
}
|
||||
implementation("org.scala-lang:scala3-library_3") {
|
||||
version { strictly(versions["SCALA3"]!!) }
|
||||
}
|
||||
|
||||
compileOnly(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||
compileOnly("io.quarkus:quarkus-rest")
|
||||
compileOnly("io.quarkus:quarkus-rest-client")
|
||||
compileOnly("io.quarkus:quarkus-grpc")
|
||||
compileOnly("io.quarkus:quarkus-arc")
|
||||
}
|
||||
|
||||
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
|
||||
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
|
||||
}
|
||||
configurations.scoverage {
|
||||
resolutionStrategy.eachDependency {
|
||||
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
|
||||
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
options.encoding = "UTF-8"
|
||||
options.compilerArgs.add("-parameters")
|
||||
}
|
||||
|
||||
tasks.withType<Jar>().configureEach {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.nowchess.security;
|
||||
|
||||
import jakarta.ws.rs.NameBinding;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@NameBinding
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
public @interface InternalOnly {}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.nowchess.security
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter}
|
||||
import jakarta.ws.rs.core.Response
|
||||
import jakarta.ws.rs.ext.Provider
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@Provider
|
||||
@InternalOnly
|
||||
@ApplicationScoped
|
||||
class InternalAuthFilter extends ContainerRequestFilter:
|
||||
|
||||
@ConfigProperty(name = "nowchess.internal.secret")
|
||||
// scalafix:off DisableSyntax.var
|
||||
var secret: String = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
override def filter(ctx: ContainerRequestContext): Unit =
|
||||
val header = ctx.getHeaderString("X-Internal-Secret")
|
||||
if header == null || header != secret then
|
||||
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build())
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package de.nowchess.security
|
||||
|
||||
import io.grpc.{Metadata, ServerCall, ServerCallHandler, ServerInterceptor, Status}
|
||||
import io.quarkus.grpc.GlobalInterceptor
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@GlobalInterceptor
|
||||
@ApplicationScoped
|
||||
class InternalGrpcAuthInterceptor extends ServerInterceptor:
|
||||
|
||||
private val secretKey = Metadata.Key.of("x-internal-secret", Metadata.ASCII_STRING_MARSHALLER)
|
||||
|
||||
@ConfigProperty(name = "nowchess.internal.secret")
|
||||
// scalafix:off DisableSyntax.var
|
||||
var secret: String = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
override def interceptCall[Req, Resp](
|
||||
call: ServerCall[Req, Resp],
|
||||
headers: Metadata,
|
||||
next: ServerCallHandler[Req, Resp],
|
||||
): ServerCall.Listener[Req] =
|
||||
val token = Option(headers.get(secretKey)).getOrElse("")
|
||||
if token != secret then
|
||||
call.close(Status.UNAUTHENTICATED.withDescription("Missing or invalid internal secret"), new Metadata())
|
||||
new ServerCall.Listener[Req] {}
|
||||
else
|
||||
next.startCall(call, headers)
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package de.nowchess.security
|
||||
|
||||
import io.grpc.{CallOptions, Channel, ClientCall, ClientInterceptor, ForwardingClientCall, Metadata, MethodDescriptor}
|
||||
import io.quarkus.grpc.GlobalInterceptor
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@GlobalInterceptor
|
||||
@ApplicationScoped
|
||||
class InternalGrpcSecretClientInterceptor extends ClientInterceptor:
|
||||
|
||||
private val secretKey = Metadata.Key.of("x-internal-secret", Metadata.ASCII_STRING_MARSHALLER)
|
||||
|
||||
@ConfigProperty(name = "nowchess.internal.secret")
|
||||
// scalafix:off DisableSyntax.var
|
||||
var secret: String = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
override def interceptCall[Req, Resp](
|
||||
method: MethodDescriptor[Req, Resp],
|
||||
callOptions: CallOptions,
|
||||
next: Channel,
|
||||
): ClientCall[Req, Resp] =
|
||||
new ForwardingClientCall.SimpleForwardingClientCall[Req, Resp](next.newCall(method, callOptions)):
|
||||
override def start(responseListener: ClientCall.Listener[Resp], headers: Metadata): Unit =
|
||||
headers.put(secretKey, secret)
|
||||
super.start(responseListener, headers)
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.nowchess.security
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.ws.rs.client.{ClientRequestContext, ClientRequestFilter}
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@ApplicationScoped
|
||||
class InternalSecretClientFilter extends ClientRequestFilter:
|
||||
|
||||
@ConfigProperty(name = "nowchess.internal.secret")
|
||||
// scalafix:off DisableSyntax.var
|
||||
var secret: String = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
override def filter(ctx: ClientRequestContext): Unit =
|
||||
ctx.getHeaders.putSingle("X-Internal-Secret", secret)
|
||||
@@ -6,7 +6,13 @@ import scala.compiletime.uninitialized
|
||||
import java.time.Instant
|
||||
|
||||
@Entity
|
||||
@Table(name = "game_records")
|
||||
@Table(
|
||||
name = "game_records",
|
||||
indexes = Array(
|
||||
new Index(name = "idx_game_records_white_id", columnList = "whiteId"),
|
||||
new Index(name = "idx_game_records_black_id", columnList = "blackId"),
|
||||
),
|
||||
)
|
||||
class GameRecord extends PanacheEntityBase:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Id
|
||||
@@ -79,4 +85,11 @@ class GameRecord extends PanacheEntityBase:
|
||||
|
||||
@Column
|
||||
var pendingDrawOffer: String = uninitialized
|
||||
|
||||
// Game result
|
||||
@Column
|
||||
var result: String = uninitialized
|
||||
|
||||
@Column
|
||||
var terminationReason: String = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@@ -21,4 +21,6 @@ case class GameWritebackEventDto(
|
||||
clockMoveDeadline: Option[Long],
|
||||
clockActiveColor: Option[String],
|
||||
pendingDrawOffer: Option[String],
|
||||
result: Option[String] = None,
|
||||
terminationReason: Option[String] = None,
|
||||
)
|
||||
|
||||
+2
-4
@@ -24,8 +24,6 @@ class GameWritebackStreamListener:
|
||||
val topic = redisson.getTopic("game-writeback")
|
||||
topic.addListener(
|
||||
classOf[String],
|
||||
new MessageListener[String]:
|
||||
def onMessage(channel: CharSequence, json: String): Unit =
|
||||
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
|
||||
.foreach(writebackService.writeBack),
|
||||
(channel: CharSequence, json: String) => Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
|
||||
.foreach(writebackService.writeBack)
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.persistence.EntityManager
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
@ApplicationScoped
|
||||
class GameRecordRepository:
|
||||
@@ -21,3 +22,25 @@ class GameRecordRepository:
|
||||
|
||||
def merge(record: GameRecord): Unit =
|
||||
em.merge(record)
|
||||
|
||||
def findByPlayerId(playerId: String, offset: Int, limit: Int): List[GameRecord] =
|
||||
em.createQuery(
|
||||
"SELECT g FROM GameRecord g WHERE g.whiteId = :id OR g.blackId = :id ORDER BY g.updatedAt DESC",
|
||||
classOf[GameRecord],
|
||||
).setParameter("id", playerId)
|
||||
.setFirstResult(offset)
|
||||
.setMaxResults(limit)
|
||||
.getResultList
|
||||
.asScala
|
||||
.toList
|
||||
|
||||
def findByPlayerIdRunning(playerId: String, offset: Int, limit: Int): List[GameRecord] =
|
||||
em.createQuery(
|
||||
"SELECT g FROM GameRecord g WHERE g.whiteId = :id OR g.blackId = :id AND g.result = null ORDER BY g.updatedAt DESC",
|
||||
classOf[GameRecord],
|
||||
).setParameter("id", playerId)
|
||||
.setFirstResult(offset)
|
||||
.setMaxResults(limit)
|
||||
.getResultList
|
||||
.asScala
|
||||
.toList
|
||||
|
||||
@@ -5,6 +5,7 @@ import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.{MediaType, Response}
|
||||
import jakarta.ws.rs.DefaultValue
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@Path("/game")
|
||||
@@ -22,3 +23,23 @@ class StoreGameResource:
|
||||
repository
|
||||
.findByGameId(gameId)
|
||||
.fold(Response.status(404).build())(r => Response.ok(r).build())
|
||||
|
||||
@GET
|
||||
@Path("/running/{playerId}")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def getRunning(
|
||||
@PathParam("playerId") playerId: String,
|
||||
@QueryParam("offset") @DefaultValue("0") offset: Int,
|
||||
@QueryParam("limit") @DefaultValue("20") limit: Int,
|
||||
): Response =
|
||||
Response.ok(repository.findByPlayerIdRunning(playerId, offset, limit)).build()
|
||||
|
||||
@GET
|
||||
@Path("/history/{playerId}")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def getHistory(
|
||||
@PathParam("playerId") playerId: String,
|
||||
@QueryParam("offset") @DefaultValue("0") offset: Int,
|
||||
@QueryParam("limit") @DefaultValue("20") limit: Int,
|
||||
): Response =
|
||||
Response.ok(repository.findByPlayerId(playerId, offset, limit)).build()
|
||||
|
||||
@@ -41,6 +41,8 @@ class GameWritebackService:
|
||||
record.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
|
||||
record.clockActiveColor = event.clockActiveColor.orNull
|
||||
record.pendingDrawOffer = event.pendingDrawOffer.orNull
|
||||
record.result = event.result.orNull
|
||||
record.terminationReason = event.terminationReason.orNull
|
||||
record.createdAt = Instant.now()
|
||||
record.updatedAt = Instant.now()
|
||||
repository.persist(record)
|
||||
@@ -64,6 +66,8 @@ class GameWritebackService:
|
||||
r.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
|
||||
r.clockActiveColor = event.clockActiveColor.orNull
|
||||
r.pendingDrawOffer = event.pendingDrawOffer.orNull
|
||||
r.result = event.result.orNull
|
||||
r.terminationReason = event.terminationReason.orNull
|
||||
r.updatedAt = Instant.now()
|
||||
repository.merge(r)
|
||||
case _ => ()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.nowchess.ws.resource
|
||||
|
||||
import de.nowchess.ws.config.RedisConfig
|
||||
import io.quarkus.websockets.next.*
|
||||
import io.smallrye.jwt.auth.principal.JWTParser
|
||||
import jakarta.inject.Inject
|
||||
import org.redisson.api.listener.MessageListener
|
||||
import org.redisson.api.RedissonClient
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.util.Try
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@WebSocket(path = "/api/user/ws")
|
||||
class UserWebSocketResource:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
var redisson: RedissonClient = uninitialized
|
||||
|
||||
@Inject
|
||||
var redisConfig: RedisConfig = uninitialized
|
||||
|
||||
@Inject
|
||||
var jwtParser: JWTParser = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val connections = new ConcurrentHashMap[String, (String, Int)]()
|
||||
|
||||
private def userTopic(userId: String): String =
|
||||
s"${redisConfig.prefix}:user:$userId:events"
|
||||
|
||||
@OnOpen
|
||||
def onOpen(connection: WebSocketConnection, handshake: HandshakeRequest): Unit =
|
||||
val userIdOpt = Option(handshake.header("Authorization"))
|
||||
.filter(_.nonEmpty)
|
||||
.flatMap(token => Try(jwtParser.parse(token)).toOption)
|
||||
.map(_.getSubject)
|
||||
|
||||
userIdOpt match
|
||||
case None => connection.close().subscribe().`with`(_ => (), _ => ())
|
||||
case Some(userId) =>
|
||||
val listenerId = redisson.getTopic(userTopic(userId)).addListener(
|
||||
classOf[String],
|
||||
new MessageListener[String]:
|
||||
def onMessage(channel: CharSequence, msg: String): Unit =
|
||||
connection.sendText(msg).subscribe().`with`(_ => (), _ => ()),
|
||||
)
|
||||
connections.put(connection.id(), (userId, listenerId))
|
||||
val connectedMsg = s"""{"type":"CONNECTED","userId":"$userId"}"""
|
||||
connection.sendText(connectedMsg).subscribe().`with`(_ => (), _ => ())
|
||||
|
||||
@OnClose
|
||||
def onClose(connection: WebSocketConnection): Unit =
|
||||
Option(connections.remove(connection.id())).foreach { (userId, listenerId) =>
|
||||
redisson.getTopic(userTopic(userId)).removeListener(listenerId)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ include(
|
||||
"modules:json",
|
||||
"modules:io",
|
||||
"modules:rule",
|
||||
"modules:security",
|
||||
"modules:bot-platform",
|
||||
"modules:official-bots",
|
||||
"modules:account",
|
||||
|
||||
Reference in New Issue
Block a user