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/json" />
|
||||||
<option value="$PROJECT_DIR$/modules/official-bots" />
|
<option value="$PROJECT_DIR$/modules/official-bots" />
|
||||||
<option value="$PROJECT_DIR$/modules/rule" />
|
<option value="$PROJECT_DIR$/modules/rule" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/security" />
|
||||||
<option value="$PROJECT_DIR$/modules/store" />
|
<option value="$PROJECT_DIR$/modules/store" />
|
||||||
<option value="$PROJECT_DIR$/modules/ws" />
|
<option value="$PROJECT_DIR$/modules/ws" />
|
||||||
</set>
|
</set>
|
||||||
|
|||||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
</profile>
|
</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="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
<parameters>
|
<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(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||||
implementation("io.quarkus:quarkus-rest")
|
implementation("io.quarkus:quarkus-rest")
|
||||||
implementation("io.quarkus:quarkus-rest-jackson")
|
implementation("io.quarkus:quarkus-rest-jackson")
|
||||||
@@ -60,6 +62,7 @@ dependencies {
|
|||||||
implementation("io.quarkus:quarkus-micrometer")
|
implementation("io.quarkus:quarkus-micrometer")
|
||||||
implementation("io.quarkus:quarkus-smallrye-openapi")
|
implementation("io.quarkus:quarkus-smallrye-openapi")
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
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(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ quarkus:
|
|||||||
rest-client:
|
rest-client:
|
||||||
core-service:
|
core-service:
|
||||||
url: http://localhost:8080
|
url: http://localhost:8080
|
||||||
bot-platform-service:
|
|
||||||
url: http://localhost:8087
|
|
||||||
smallrye-openapi:
|
smallrye-openapi:
|
||||||
info-title: NowChess Account Service
|
info-title: NowChess Account Service
|
||||||
path: /openapi
|
path: /openapi
|
||||||
@@ -24,13 +22,24 @@ quarkus:
|
|||||||
schema-management:
|
schema-management:
|
||||||
strategy: drop-and-create
|
strategy: drop-and-create
|
||||||
|
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
prefix: nowchess
|
||||||
|
internal:
|
||||||
|
secret: ${INTERNAL_SECRET}
|
||||||
|
|
||||||
"%deployed":
|
"%deployed":
|
||||||
quarkus:
|
quarkus:
|
||||||
rest-client:
|
rest-client:
|
||||||
core-service:
|
core-service:
|
||||||
url: ${CORE_SERVICE_URL}
|
url: ${CORE_SERVICE_URL}
|
||||||
bot-platform-service:
|
nowchess:
|
||||||
url: ${BOT_PLATFORM_SERVICE_URL}
|
redis:
|
||||||
|
host: ${REDIS_HOST:localhost}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
prefix: ${REDIS_PREFIX:nowchess}
|
||||||
datasource:
|
datasource:
|
||||||
db-kind: postgresql
|
db-kind: postgresql
|
||||||
username: ${DB_USER}
|
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
|
package de.nowchess.account.client
|
||||||
|
|
||||||
|
import de.nowchess.security.InternalSecretClientFilter
|
||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
import jakarta.ws.rs.core.MediaType
|
import jakarta.ws.rs.core.MediaType
|
||||||
|
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||||
|
|
||||||
case class CorePlayerInfo(id: String, displayName: String)
|
case class CorePlayerInfo(id: String, displayName: String)
|
||||||
@@ -16,6 +18,7 @@ case class CoreGameResponse(gameId: String)
|
|||||||
|
|
||||||
@Path("/api/board/game")
|
@Path("/api/board/game")
|
||||||
@RegisterRestClient(configKey = "core-service")
|
@RegisterRestClient(configKey = "core-service")
|
||||||
|
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||||
trait CoreGameClient:
|
trait CoreGameClient:
|
||||||
|
|
||||||
@POST
|
@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)
|
val userId = UUID.fromString(jwt.getSubject)
|
||||||
Response.ok(challengeService.listForUser(userId)).build()
|
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
|
@POST
|
||||||
@Path("/{id}/accept")
|
@Path("/{id}/accept")
|
||||||
def accept(@PathParam("id") id: UUID): Response =
|
def accept(@PathParam("id") id: UUID): Response =
|
||||||
|
|||||||
+18
-25
@@ -1,8 +1,8 @@
|
|||||||
package de.nowchess.account.resource
|
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.dto.{ErrorDto, OfficialChallengeResponse}
|
||||||
import de.nowchess.account.service.AccountService
|
import de.nowchess.account.service.{AccountService, BotEventPublisher}
|
||||||
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
|
||||||
@@ -23,19 +23,13 @@ import java.util.UUID
|
|||||||
class OfficialChallengeResource:
|
class OfficialChallengeResource:
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Inject
|
@Inject var accountService: AccountService = uninitialized
|
||||||
var accountService: AccountService = uninitialized
|
@Inject var jwt: JsonWebToken = uninitialized
|
||||||
|
@Inject var botEventPublisher: BotEventPublisher = uninitialized
|
||||||
@Inject
|
|
||||||
var jwt: JsonWebToken = uninitialized
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@RestClient
|
@RestClient
|
||||||
var coreGameClient: CoreGameClient = uninitialized
|
var coreGameClient: CoreGameClient = uninitialized
|
||||||
|
|
||||||
@Inject
|
|
||||||
@RestClient
|
|
||||||
var botPlatformClient: BotPlatformClient = uninitialized
|
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|
||||||
private val log = Logger.getLogger(classOf[OfficialChallengeResource])
|
private val log = Logger.getLogger(classOf[OfficialChallengeResource])
|
||||||
@@ -43,9 +37,9 @@ class OfficialChallengeResource:
|
|||||||
@POST
|
@POST
|
||||||
@Path("/{botName}")
|
@Path("/{botName}")
|
||||||
def challengeWithDifficulty(
|
def challengeWithDifficulty(
|
||||||
@PathParam("botName") botName: String,
|
@PathParam("botName") botName: String,
|
||||||
@QueryParam("difficulty") difficulty: Int,
|
@QueryParam("difficulty") difficulty: Int,
|
||||||
@QueryParam("color") color: String,
|
@QueryParam("color") color: String,
|
||||||
): Response =
|
): Response =
|
||||||
if difficulty < 1000 || difficulty > 2800 then
|
if difficulty < 1000 || difficulty > 2800 then
|
||||||
return Response
|
return Response
|
||||||
@@ -56,10 +50,12 @@ class OfficialChallengeResource:
|
|||||||
val playerColor = Option(color).map(_.toLowerCase).getOrElse("random") match
|
val playerColor = Option(color).map(_.toLowerCase).getOrElse("random") match
|
||||||
case "white" | "black" | "random" => Option(color).map(_.toLowerCase).getOrElse("random")
|
case "white" | "black" | "random" => Option(color).map(_.toLowerCase).getOrElse("random")
|
||||||
case other =>
|
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 userId = UUID.fromString(jwt.getSubject)
|
||||||
|
|
||||||
val botOpt = accountService.getOfficialBotAccounts().find(_.name == botName)
|
val botOpt = accountService.getOfficialBotAccounts().find(_.name == botName)
|
||||||
val userOpt = accountService.findById(userId)
|
val userOpt = accountService.findById(userId)
|
||||||
|
|
||||||
@@ -70,9 +66,9 @@ class OfficialChallengeResource:
|
|||||||
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto("User not found")).build()
|
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto("User not found")).build()
|
||||||
case (Some(bot), Some(user)) =>
|
case (Some(bot), Some(user)) =>
|
||||||
val userIsWhite = playerColor match
|
val userIsWhite = playerColor match
|
||||||
case "white" => true
|
case "white" => true
|
||||||
case "black" => false
|
case "black" => false
|
||||||
case _ => scala.util.Random.nextBoolean()
|
case _ => scala.util.Random.nextBoolean()
|
||||||
val (white, black, botColor) =
|
val (white, black, botColor) =
|
||||||
if userIsWhite then
|
if userIsWhite then
|
||||||
(CorePlayerInfo(user.id.toString, user.username), CorePlayerInfo(bot.id.toString, bot.name), "black")
|
(CorePlayerInfo(user.id.toString, user.username), CorePlayerInfo(bot.id.toString, bot.name), "black")
|
||||||
@@ -86,9 +82,6 @@ class OfficialChallengeResource:
|
|||||||
case Left(err) =>
|
case Left(err) =>
|
||||||
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(err)).build()
|
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(err)).build()
|
||||||
case Right(id) =>
|
case Right(id) =>
|
||||||
try botPlatformClient.assignBot(id, botName, difficulty, botColor, bot.id.toString)
|
try botEventPublisher.publishGameStart(bot.name, id, botColor, difficulty, bot.id.toString)
|
||||||
catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", id)
|
catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", id)
|
||||||
Response
|
Response.status(Response.Status.CREATED).entity(OfficialChallengeResponse(id, botName, difficulty)).build()
|
||||||
.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
|
package de.nowchess.account.service
|
||||||
|
|
||||||
import de.nowchess.account.client.{
|
import de.nowchess.account.client.{
|
||||||
BotPlatformClient,
|
|
||||||
CoreCreateGameRequest,
|
CoreCreateGameRequest,
|
||||||
CoreGameClient,
|
CoreGameClient,
|
||||||
CoreGameResponse,
|
CoreGameResponse,
|
||||||
@@ -26,6 +25,7 @@ import org.eclipse.microprofile.rest.client.inject.RestClient
|
|||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -47,8 +47,7 @@ class ChallengeService:
|
|||||||
var coreGameClient: CoreGameClient = uninitialized
|
var coreGameClient: CoreGameClient = uninitialized
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@RestClient
|
var botEventPublisher: BotEventPublisher = uninitialized
|
||||||
var botPlatformClient: BotPlatformClient = uninitialized
|
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -75,6 +74,8 @@ class ChallengeService:
|
|||||||
challenge.createdAt = Instant.now()
|
challenge.createdAt = Instant.now()
|
||||||
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
|
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
|
||||||
challengeRepository.persist(challenge)
|
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
|
challenge
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -89,6 +90,8 @@ class ChallengeService:
|
|||||||
challenge.gameId = gameId
|
challenge.gameId = gameId
|
||||||
challengeRepository.merge(challenge)
|
challengeRepository.merge(challenge)
|
||||||
notifyBotIfNeeded(challenge, gameId)
|
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
|
challenge
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -115,6 +118,16 @@ class ChallengeService:
|
|||||||
challengeRepository.merge(challenge)
|
challengeRepository.merge(challenge)
|
||||||
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 =
|
def listForUser(userId: UUID): ChallengeListDto =
|
||||||
val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto)
|
val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto)
|
||||||
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
|
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
|
||||||
@@ -125,8 +138,8 @@ class ChallengeService:
|
|||||||
List(challenge.challenger, challenge.destUser).foreach { user =>
|
List(challenge.challenger, challenge.destUser).foreach { user =>
|
||||||
user.getBotAccounts.headOption.foreach { bot =>
|
user.getBotAccounts.headOption.foreach { bot =>
|
||||||
val playingAs = if white.id == user.id.toString then "white" else "black"
|
val playingAs = if white.id == user.id.toString then "white" else "black"
|
||||||
try botPlatformClient.assignBot(gameId, bot.name, 1400, playingAs, bot.id.toString)
|
try botEventPublisher.publishGameStart(bot.id.toString, gameId, playingAs, 1400, bot.id.toString)
|
||||||
catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", gameId)
|
catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", gameId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
package de.nowchess.api.dto
|
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(
|
final case class PlayerInfo(
|
||||||
id: PlayerId,
|
id: PlayerId,
|
||||||
displayName: String,
|
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
|
package de.nowchess.botplatform.registry
|
||||||
|
|
||||||
|
import de.nowchess.botplatform.config.RedisConfig
|
||||||
import io.smallrye.mutiny.subscription.MultiEmitter
|
import io.smallrye.mutiny.subscription.MultiEmitter
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
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
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class BotRegistry:
|
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 =
|
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 =
|
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 =
|
def dispatch(botId: String, event: String): Unit =
|
||||||
Option(connections.get(botId)) match
|
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event)
|
||||||
case Some(emitter) =>
|
()
|
||||||
emitter.asInstanceOf[MultiEmitter[String]].emit(event)
|
|
||||||
true
|
|
||||||
case None => false
|
|
||||||
|
|
||||||
def registeredBots: List[String] =
|
def registeredBots: List[String] =
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|||||||
+24
-24
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.botplatform.resource
|
package de.nowchess.botplatform.resource
|
||||||
|
|
||||||
import de.nowchess.botplatform.registry.{BotGameInfo, BotRegistry}
|
import de.nowchess.botplatform.config.RedisConfig
|
||||||
import de.nowchess.botplatform.service.GameBotMonitor
|
import de.nowchess.botplatform.registry.BotRegistry
|
||||||
import io.smallrye.mutiny.Multi
|
import io.smallrye.mutiny.Multi
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
@@ -9,6 +9,8 @@ import jakarta.inject.Inject
|
|||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
import jakarta.ws.rs.core.{MediaType, Response}
|
import jakarta.ws.rs.core.{MediaType, Response}
|
||||||
import org.eclipse.microprofile.jwt.JsonWebToken
|
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import org.redisson.api.listener.MessageListener
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
@Path("/api/bot")
|
@Path("/api/bot")
|
||||||
@@ -17,14 +19,10 @@ import scala.compiletime.uninitialized
|
|||||||
class BotEventResource:
|
class BotEventResource:
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Inject
|
@Inject var registry: BotRegistry = uninitialized
|
||||||
var registry: BotRegistry = uninitialized
|
@Inject var jwt: JsonWebToken = uninitialized
|
||||||
|
@Inject var redisson: RedissonClient = uninitialized
|
||||||
@Inject
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
var jwt: JsonWebToken = uninitialized
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
var gameMonitor: GameBotMonitor = uninitialized
|
|
||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -34,7 +32,7 @@ class BotEventResource:
|
|||||||
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
|
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
|
||||||
val subject = Option(jwt.getSubject).getOrElse("")
|
val subject = Option(jwt.getSubject).getOrElse("")
|
||||||
if tokenType != "bot" || subject != botId then
|
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
|
else
|
||||||
Multi.createFrom().emitter[String] { emitter =>
|
Multi.createFrom().emitter[String] { emitter =>
|
||||||
registry.register(botId, emitter)
|
registry.register(botId, emitter)
|
||||||
@@ -46,22 +44,24 @@ class BotEventResource:
|
|||||||
@Produces(Array(MediaType.SERVER_SENT_EVENTS))
|
@Produces(Array(MediaType.SERVER_SENT_EVENTS))
|
||||||
def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
|
def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
|
||||||
Multi.createFrom().emitter[String] { emitter =>
|
Multi.createFrom().emitter[String] { emitter =>
|
||||||
registry.register(s"game-$gameId", emitter)
|
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
|
||||||
emitter.onTermination(() => registry.unregister(s"game-$gameId"))
|
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
|
@POST
|
||||||
@Path("/game/{gameId}/assign")
|
@Path("/game/{gameId}/move/{uci}")
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
def assignBot(
|
def makeMove(
|
||||||
@PathParam("gameId") gameId: String,
|
@PathParam("gameId") gameId: String,
|
||||||
@QueryParam("botId") botId: String,
|
@PathParam("uci") uci: String,
|
||||||
@QueryParam("difficulty") difficulty: Int,
|
|
||||||
@QueryParam("playingAs") playingAs: String,
|
|
||||||
@QueryParam("botAccountId") botAccountId: String,
|
|
||||||
): Response =
|
): Response =
|
||||||
val info = BotGameInfo(botId, difficulty, playingAs, botAccountId)
|
val playerId = Option(jwt.getSubject).getOrElse("")
|
||||||
gameMonitor.watchGame(gameId, info)
|
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
|
||||||
val event = s"""{"type":"gameStart","gameId":"$gameId","botId":"$botId"}"""
|
redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:c2s").publish(moveMsg)
|
||||||
registry.dispatch(botId, event)
|
|
||||||
Response.ok().build()
|
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:rule"))
|
||||||
implementation(project(":modules:io"))
|
implementation(project(":modules:io"))
|
||||||
implementation(project(":modules:official-bots"))
|
implementation(project(":modules:official-bots"))
|
||||||
|
implementation(project(":modules:security"))
|
||||||
|
|
||||||
|
|
||||||
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ nowchess:
|
|||||||
port: 6379
|
port: 6379
|
||||||
prefix: nowchess
|
prefix: nowchess
|
||||||
|
|
||||||
|
internal:
|
||||||
|
secret: ${INTERNAL_SECRET}
|
||||||
|
|
||||||
coordinator:
|
coordinator:
|
||||||
enabled: ${NOWCHESS_COORDINATOR_ENABLED:false}
|
enabled: ${NOWCHESS_COORDINATOR_ENABLED:false}
|
||||||
host: localhost
|
host: localhost
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ package de.nowchess.chess.client
|
|||||||
|
|
||||||
import de.nowchess.api.dto.{ImportFenRequest, ImportPgnRequest}
|
import de.nowchess.api.dto.{ImportFenRequest, ImportPgnRequest}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.security.InternalSecretClientFilter
|
||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
import jakarta.ws.rs.core.MediaType
|
import jakarta.ws.rs.core.MediaType
|
||||||
|
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||||
|
|
||||||
case class CombinedExportResponse(fen: String, pgn: String)
|
case class CombinedExportResponse(fen: String, pgn: String)
|
||||||
|
|
||||||
@Path("/io")
|
@Path("/io")
|
||||||
@RegisterRestClient(configKey = "io-service")
|
@RegisterRestClient(configKey = "io-service")
|
||||||
|
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||||
trait IoServiceClient:
|
trait IoServiceClient:
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package de.nowchess.chess.client
|
|||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.move.Move
|
import de.nowchess.api.move.Move
|
||||||
import de.nowchess.api.rules.PostMoveStatus
|
import de.nowchess.api.rules.PostMoveStatus
|
||||||
|
import de.nowchess.security.InternalSecretClientFilter
|
||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
import jakarta.ws.rs.core.MediaType
|
import jakarta.ws.rs.core.MediaType
|
||||||
|
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||||
|
|
||||||
case class RuleSquareRequest(context: GameContext, square: String)
|
case class RuleSquareRequest(context: GameContext, square: String)
|
||||||
@@ -12,6 +14,7 @@ case class RuleMoveRequest(context: GameContext, move: Move)
|
|||||||
|
|
||||||
@Path("/api/rules")
|
@Path("/api/rules")
|
||||||
@RegisterRestClient(configKey = "rule-service")
|
@RegisterRestClient(configKey = "rule-service")
|
||||||
|
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||||
trait RuleServiceClient:
|
trait RuleServiceClient:
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package de.nowchess.chess.client
|
package de.nowchess.chess.client
|
||||||
|
|
||||||
|
import de.nowchess.security.InternalSecretClientFilter
|
||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
import jakarta.ws.rs.core.MediaType
|
import jakarta.ws.rs.core.MediaType
|
||||||
|
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||||
|
|
||||||
@RegisterRestClient(configKey = "store-service")
|
@RegisterRestClient(configKey = "store-service")
|
||||||
|
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||||
@Path("/game")
|
@Path("/game")
|
||||||
trait StoreServiceClient:
|
trait StoreServiceClient:
|
||||||
@GET
|
@GET
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
|||||||
import de.nowchess.api.dto.GameStateEventDto
|
import de.nowchess.api.dto.GameStateEventDto
|
||||||
import de.nowchess.api.game.{CorrespondenceClockState, LiveClockState}
|
import de.nowchess.api.game.{CorrespondenceClockState, LiveClockState}
|
||||||
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
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.observer.{GameEvent, Observer}
|
||||||
import de.nowchess.chess.registry.GameRegistry
|
import de.nowchess.chess.registry.GameRegistry
|
||||||
import de.nowchess.chess.resource.GameDtoMapper
|
import de.nowchess.chess.resource.GameDtoMapper
|
||||||
@@ -54,6 +56,21 @@ class GameRedisPublisher(
|
|||||||
clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
|
clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
|
||||||
clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
|
clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
|
||||||
pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.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),
|
redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci),
|
||||||
pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
|
pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ case class GameWritebackEventDto(
|
|||||||
clockMoveDeadline: Option[Long],
|
clockMoveDeadline: Option[Long],
|
||||||
clockActiveColor: Option[String],
|
clockActiveColor: Option[String],
|
||||||
pendingDrawOffer: Option[String],
|
pendingDrawOffer: Option[String],
|
||||||
|
result: Option[String] = None,
|
||||||
|
terminationReason: Option[String] = None,
|
||||||
redoStack: List[String] = Nil,
|
redoStack: List[String] = Nil,
|
||||||
pendingTakebackRequest: Option[String] = None,
|
pendingTakebackRequest: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ object GameDtoMapper:
|
|||||||
case _ => base
|
case _ => base
|
||||||
|
|
||||||
def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
|
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] =
|
def toClockDto(entry: GameEntry): Option[ClockDto] =
|
||||||
val now = Instant.now()
|
val now = Instant.now()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
|
|||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
||||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||||
|
import de.nowchess.security.InternalOnly
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
@@ -79,9 +80,9 @@ class GameResource:
|
|||||||
val color = colorOf(entry)
|
val color = colorOf(entry)
|
||||||
if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
|
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("")
|
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")
|
throw ForbiddenException("Only bots can make moves")
|
||||||
|
|
||||||
// scalafix:on DisableSyntax.throw
|
// scalafix:on DisableSyntax.throw
|
||||||
@@ -153,6 +154,7 @@ class GameResource:
|
|||||||
// scalafix:off DisableSyntax.throw
|
// scalafix:off DisableSyntax.throw
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
|
@InternalOnly
|
||||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
def createGame(body: CreateGameRequestDto): Response =
|
def createGame(body: CreateGameRequestDto): Response =
|
||||||
@@ -189,7 +191,7 @@ class GameResource:
|
|||||||
@Path("/{gameId}/move/{uci}")
|
@Path("/{gameId}/move/{uci}")
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
|
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
|
||||||
assertIsBot()
|
assertIsNotBot()
|
||||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||||
assertGameNotOver(entry)
|
assertGameNotOver(entry)
|
||||||
assertIsCurrentPlayer(entry)
|
assertIsCurrentPlayer(entry)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ dependencies {
|
|||||||
implementation(project(":modules:api"))
|
implementation(project(":modules:api"))
|
||||||
implementation(project(":modules:json"))
|
implementation(project(":modules:json"))
|
||||||
implementation(project(":modules:rule"))
|
implementation(project(":modules:rule"))
|
||||||
|
implementation(project(":modules:security"))
|
||||||
|
|
||||||
// Jackson for JSON serialization/deserialization
|
// Jackson for JSON serialization/deserialization
|
||||||
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
|
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ quarkus:
|
|||||||
use-separate-server: false
|
use-separate-server: false
|
||||||
application:
|
application:
|
||||||
name: nowchess-io
|
name: nowchess-io
|
||||||
|
|
||||||
|
nowchess:
|
||||||
|
internal:
|
||||||
|
secret: ${INTERNAL_SECRET}
|
||||||
smallrye-openapi:
|
smallrye-openapi:
|
||||||
info-title: NowChess IO Service
|
info-title: NowChess IO Service
|
||||||
info-version: 1.0.0
|
info-version: 1.0.0
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.io.service.resource
|
package de.nowchess.io.service.resource
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.security.InternalOnly
|
||||||
import de.nowchess.io.fen.{FenExporter, FenParser}
|
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||||
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||||
import de.nowchess.io.service.dto.{CombinedExportResponse, ImportFenRequest, ImportPgnRequest, IoErrorDto}
|
import de.nowchess.io.service.dto.{CombinedExportResponse, ImportFenRequest, ImportPgnRequest, IoErrorDto}
|
||||||
@@ -15,6 +16,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag
|
|||||||
|
|
||||||
@Path("/io")
|
@Path("/io")
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
|
@InternalOnly
|
||||||
@Tag(name = "IO", description = "Chess notation import and export")
|
@Tag(name = "IO", description = "Chess notation import and export")
|
||||||
class IoResource:
|
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 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.Bot
|
|
||||||
import de.nowchess.bot.BotController
|
import de.nowchess.bot.BotController
|
||||||
import de.nowchess.bot.BotDifficulty
|
import de.nowchess.bot.BotDifficulty
|
||||||
import de.nowchess.bot.config.RedisConfig
|
import de.nowchess.bot.config.RedisConfig
|
||||||
@@ -12,6 +11,7 @@ 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.redisson.api.RedissonClient
|
import org.redisson.api.RedissonClient
|
||||||
|
import org.redisson.api.listener.MessageListener
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@@ -24,35 +24,72 @@ class OfficialBotService:
|
|||||||
@Inject var botController: BotController = uninitialized
|
@Inject var botController: BotController = uninitialized
|
||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
private val terminalStatuses =
|
||||||
|
Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
|
||||||
|
|
||||||
def onStart(@Observes event: StartupEvent): Unit =
|
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 =
|
private def handleBotEvent(botName: String, msg: String): Unit =
|
||||||
val queue = redisson.getBlockingQueue[String]("nowchess:bot:move-queue")
|
try
|
||||||
while true do
|
val node = objectMapper.readTree(msg)
|
||||||
try
|
if node.path("type").asText() == "gameStart" then
|
||||||
val json = queue.take()
|
val gameId = node.path("gameId").asText()
|
||||||
MoveRequestParser.parse(json, objectMapper).foreach(processRequest)
|
val playingAs = node.path("playingAs").asText()
|
||||||
catch case _: InterruptedException => Thread.currentThread().interrupt()
|
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 =
|
private def watchGame(botName: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
|
||||||
val difficulty = DifficultyMapper.fromElo(req.difficulty).getOrElse(BotDifficulty.Medium)
|
val topic = redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:s2c")
|
||||||
val botName = difficulty match
|
topic.addListener(
|
||||||
case BotDifficulty.Easy => "easy"
|
classOf[String],
|
||||||
case BotDifficulty.Medium => "medium"
|
new MessageListener[String]:
|
||||||
case BotDifficulty.Hard => "hard"
|
def onMessage(channel: CharSequence, msg: String): Unit =
|
||||||
case BotDifficulty.Expert => "expert"
|
handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg),
|
||||||
botController.getBot(botName).foreach(bot => parseAndMove(req, bot))
|
)
|
||||||
|
()
|
||||||
|
|
||||||
private def parseAndMove(req: MoveRequest, bot: Bot): Unit =
|
private def handleGameEvent(
|
||||||
FenParser.parseFen(req.fen).toOption.foreach { context =>
|
botName: String,
|
||||||
bot(context).foreach { move =>
|
gameId: String,
|
||||||
val uci = toUci(move)
|
playingAs: String,
|
||||||
val c2sTopic = s"${redisConfig.prefix}:game:${req.gameId}:c2s"
|
difficulty: Int,
|
||||||
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"${req.botAccountId}"}"""
|
botAccountId: String,
|
||||||
redisson.getTopic(c2sTopic).publish(moveMsg)
|
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:api"))
|
||||||
implementation(project(":modules:json"))
|
implementation(project(":modules:json"))
|
||||||
|
implementation(project(":modules:security"))
|
||||||
|
|
||||||
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||||
implementation("io.quarkus:quarkus-rest")
|
implementation("io.quarkus:quarkus-rest")
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ quarkus:
|
|||||||
use-separate-server: false
|
use-separate-server: false
|
||||||
application:
|
application:
|
||||||
name: rule-service
|
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.api.move.Move
|
||||||
import de.nowchess.rules.dto.*
|
import de.nowchess.rules.dto.*
|
||||||
import de.nowchess.api.rules.PostMoveStatus
|
import de.nowchess.api.rules.PostMoveStatus
|
||||||
|
import de.nowchess.security.InternalOnly
|
||||||
import de.nowchess.rules.sets.DefaultRules
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
@@ -12,6 +13,7 @@ import jakarta.ws.rs.core.MediaType
|
|||||||
|
|
||||||
@Path("/api/rules")
|
@Path("/api/rules")
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
|
@InternalOnly
|
||||||
class RuleSetResource:
|
class RuleSetResource:
|
||||||
private val rules = DefaultRules
|
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
|
import java.time.Instant
|
||||||
|
|
||||||
@Entity
|
@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:
|
class GameRecord extends PanacheEntityBase:
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Id
|
@Id
|
||||||
@@ -79,4 +85,11 @@ class GameRecord extends PanacheEntityBase:
|
|||||||
|
|
||||||
@Column
|
@Column
|
||||||
var pendingDrawOffer: String = uninitialized
|
var pendingDrawOffer: String = uninitialized
|
||||||
|
|
||||||
|
// Game result
|
||||||
|
@Column
|
||||||
|
var result: String = uninitialized
|
||||||
|
|
||||||
|
@Column
|
||||||
|
var terminationReason: String = uninitialized
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ case class GameWritebackEventDto(
|
|||||||
clockMoveDeadline: Option[Long],
|
clockMoveDeadline: Option[Long],
|
||||||
clockActiveColor: Option[String],
|
clockActiveColor: Option[String],
|
||||||
pendingDrawOffer: 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")
|
val topic = redisson.getTopic("game-writeback")
|
||||||
topic.addListener(
|
topic.addListener(
|
||||||
classOf[String],
|
classOf[String],
|
||||||
new MessageListener[String]:
|
(channel: CharSequence, json: String) => Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
|
||||||
def onMessage(channel: CharSequence, json: String): Unit =
|
.foreach(writebackService.writeBack)
|
||||||
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.inject.Inject
|
||||||
import jakarta.persistence.EntityManager
|
import jakarta.persistence.EntityManager
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class GameRecordRepository:
|
class GameRecordRepository:
|
||||||
@@ -21,3 +22,25 @@ class GameRecordRepository:
|
|||||||
|
|
||||||
def merge(record: GameRecord): Unit =
|
def merge(record: GameRecord): Unit =
|
||||||
em.merge(record)
|
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.inject.Inject
|
||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
import jakarta.ws.rs.core.{MediaType, Response}
|
import jakarta.ws.rs.core.{MediaType, Response}
|
||||||
|
import jakarta.ws.rs.DefaultValue
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
@Path("/game")
|
@Path("/game")
|
||||||
@@ -22,3 +23,23 @@ class StoreGameResource:
|
|||||||
repository
|
repository
|
||||||
.findByGameId(gameId)
|
.findByGameId(gameId)
|
||||||
.fold(Response.status(404).build())(r => Response.ok(r).build())
|
.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.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
|
||||||
record.clockActiveColor = event.clockActiveColor.orNull
|
record.clockActiveColor = event.clockActiveColor.orNull
|
||||||
record.pendingDrawOffer = event.pendingDrawOffer.orNull
|
record.pendingDrawOffer = event.pendingDrawOffer.orNull
|
||||||
|
record.result = event.result.orNull
|
||||||
|
record.terminationReason = event.terminationReason.orNull
|
||||||
record.createdAt = Instant.now()
|
record.createdAt = Instant.now()
|
||||||
record.updatedAt = Instant.now()
|
record.updatedAt = Instant.now()
|
||||||
repository.persist(record)
|
repository.persist(record)
|
||||||
@@ -64,6 +66,8 @@ class GameWritebackService:
|
|||||||
r.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
|
r.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
|
||||||
r.clockActiveColor = event.clockActiveColor.orNull
|
r.clockActiveColor = event.clockActiveColor.orNull
|
||||||
r.pendingDrawOffer = event.pendingDrawOffer.orNull
|
r.pendingDrawOffer = event.pendingDrawOffer.orNull
|
||||||
|
r.result = event.result.orNull
|
||||||
|
r.terminationReason = event.terminationReason.orNull
|
||||||
r.updatedAt = Instant.now()
|
r.updatedAt = Instant.now()
|
||||||
repository.merge(r)
|
repository.merge(r)
|
||||||
case _ => ()
|
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:json",
|
||||||
"modules:io",
|
"modules:io",
|
||||||
"modules:rule",
|
"modules:rule",
|
||||||
|
"modules:security",
|
||||||
"modules:bot-platform",
|
"modules:bot-platform",
|
||||||
"modules:official-bots",
|
"modules:official-bots",
|
||||||
"modules:account",
|
"modules:account",
|
||||||
|
|||||||
Reference in New Issue
Block a user