Phase2
This commit is contained in:
@@ -52,13 +52,13 @@ RFC2136_SERVER=
|
|||||||
RFC2136_KEY_NAME=
|
RFC2136_KEY_NAME=
|
||||||
RFC2136_KEY_SECRET=
|
RFC2136_KEY_SECRET=
|
||||||
|
|
||||||
# --- Node Agent ---
|
# --- Node Agent (local dev) ---
|
||||||
NODE_CA_CERT=
|
NODE_CA_CERT=
|
||||||
NODE_CA_KEY=
|
NODE_CA_KEY=
|
||||||
NODE_AGENT_PUBLIC_URL=wss://api.example.net/api/v1/nodes/ws
|
NODE_ID=00000000-0000-4000-8000-000000000001
|
||||||
|
NODE_TOKEN=local-dev-enrollment-token-change-me-32chars
|
||||||
|
NODE_AGENT_PUBLIC_URL=ws://localhost:3001/api/v1/nodes/ws
|
||||||
NODE_HEARTBEAT_TIMEOUT=30s
|
NODE_HEARTBEAT_TIMEOUT=30s
|
||||||
NODE_ID=local-dev-node
|
|
||||||
NODE_TOKEN=
|
|
||||||
NODE_AGENT_HEALTH_ADDR=:9100
|
NODE_AGENT_HEALTH_ADDR=:9100
|
||||||
NODE_HEARTBEAT_INTERVAL=10s
|
NODE_HEARTBEAT_INTERVAL=10s
|
||||||
NODE_DATA_DIR=./data/nodes
|
NODE_DATA_DIR=./data/nodes
|
||||||
|
|||||||
1
.turbo/cache/0382620cc4ac4da5-manifest.json
vendored
Normal file
1
.turbo/cache/0382620cc4ac4da5-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/0382620cc4ac4da5-meta.json
vendored
Normal file
1
.turbo/cache/0382620cc4ac4da5-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"0382620cc4ac4da5","duration":37384,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"1dc69509c46e02b077522f0df13aacf39c3394566efa48caabe96f6f9ee4ff66"}
|
||||||
BIN
.turbo/cache/0382620cc4ac4da5.tar.zst
vendored
Normal file
BIN
.turbo/cache/0382620cc4ac4da5.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/1b666f158bf3f029-manifest.json
vendored
Normal file
1
.turbo/cache/1b666f158bf3f029-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"apps/api/.turbo/turbo-test.log":{"size":1535,"mtime_nanos":1782467249303891900,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-test.log"]}
|
||||||
1
.turbo/cache/1b666f158bf3f029-meta.json
vendored
Normal file
1
.turbo/cache/1b666f158bf3f029-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"1b666f158bf3f029","duration":1590,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"8f0a815f2ebb951c698f0eb35f558a2a8d052664b24cdabb4519d48989674571"}
|
||||||
BIN
.turbo/cache/1b666f158bf3f029.tar.zst
vendored
Normal file
BIN
.turbo/cache/1b666f158bf3f029.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/31c16e772c63a2b4-manifest.json
vendored
Normal file
1
.turbo/cache/31c16e772c63a2b4-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/31c16e772c63a2b4-meta.json
vendored
Normal file
1
.turbo/cache/31c16e772c63a2b4-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"31c16e772c63a2b4","duration":1866,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"1dc69509c46e02b077522f0df13aacf39c3394566efa48caabe96f6f9ee4ff66"}
|
||||||
BIN
.turbo/cache/31c16e772c63a2b4.tar.zst
vendored
Normal file
BIN
.turbo/cache/31c16e772c63a2b4.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/3a0225fe82615136-manifest.json
vendored
Normal file
1
.turbo/cache/3a0225fe82615136-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"apps/worker/.turbo/turbo-test.log":{"size":834,"mtime_nanos":1782467249057816600,"mode":420,"is_dir":false}},"order":["apps/worker/.turbo/turbo-test.log"]}
|
||||||
1
.turbo/cache/3a0225fe82615136-meta.json
vendored
Normal file
1
.turbo/cache/3a0225fe82615136-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"3a0225fe82615136","duration":1346,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"8f0a815f2ebb951c698f0eb35f558a2a8d052664b24cdabb4519d48989674571"}
|
||||||
BIN
.turbo/cache/3a0225fe82615136.tar.zst
vendored
Normal file
BIN
.turbo/cache/3a0225fe82615136.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/4e9bccc61dd3f9ca-manifest.json
vendored
Normal file
1
.turbo/cache/4e9bccc61dd3f9ca-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/4e9bccc61dd3f9ca-meta.json
vendored
Normal file
1
.turbo/cache/4e9bccc61dd3f9ca-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"4e9bccc61dd3f9ca","duration":4166,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"1dc69509c46e02b077522f0df13aacf39c3394566efa48caabe96f6f9ee4ff66"}
|
||||||
BIN
.turbo/cache/4e9bccc61dd3f9ca.tar.zst
vendored
Normal file
BIN
.turbo/cache/4e9bccc61dd3f9ca.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/6776418ac2a936fa-manifest.json
vendored
Normal file
1
.turbo/cache/6776418ac2a936fa-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":659,"mtime_nanos":1782465829247304200,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/.turbo/turbo-build.log":{"size":509,"mtime_nanos":1782467193493731600,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":493,"mtime_nanos":1782467193376146900,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||||
1
.turbo/cache/6776418ac2a936fa-meta.json
vendored
Normal file
1
.turbo/cache/6776418ac2a936fa-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"6776418ac2a936fa","duration":7206,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"1dc69509c46e02b077522f0df13aacf39c3394566efa48caabe96f6f9ee4ff66"}
|
||||||
BIN
.turbo/cache/6776418ac2a936fa.tar.zst
vendored
Normal file
BIN
.turbo/cache/6776418ac2a936fa.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/6c069006830d24bd-manifest.json
vendored
Normal file
1
.turbo/cache/6c069006830d24bd-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"apps/api/dist/app.module.d.ts":{"size":213,"mtime_nanos":1782467199599741300,"mode":420,"is_dir":false},"apps/api/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/nodes/nodes.service.js":{"size":9756,"mtime_nanos":1782467199573636800,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.controller.d.ts":{"size":517,"mtime_nanos":1782467199260331600,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.service.d.ts":{"size":521,"mtime_nanos":1782467199228801200,"mode":420,"is_dir":false},"apps/api/dist/main.d.ts.map":{"size":102,"mtime_nanos":1782467200210723400,"mode":420,"is_dir":false},"apps/api/dist/nodes/nodes.gateway.d.ts.map":{"size":484,"mtime_nanos":1782467200243986300,"mode":420,"is_dir":false},"apps/api/dist/nodes/nodes.module.d.ts":{"size":79,"mtime_nanos":1782467200222903600,"mode":420,"is_dir":false},"apps/api/dist/nodes/nodes.service.d.ts.map":{"size":1387,"mtime_nanos":1782467199581619800,"mode":420,"is_dir":false},"apps/api/dist/servers/servers.module.d.ts.map":{"size":155,"mtime_nanos":1782467199369096700,"mode":420,"is_dir":false},"apps/api/dist/nodes/node-command.subscriber.d.ts.map":{"size":481,"mtime_nanos":1782467200258830600,"mode":420,"is_dir":false},"apps/api/dist/servers":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/nodes/nodes.module.d.ts.map":{"size":147,"mtime_nanos":1782467200220823700,"mode":420,"is_dir":false},"apps/api/dist/nodes/nodes.gateway.d.ts":{"size":505,"mtime_nanos":1782467200244898700,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.service.d.ts.map":{"size":269,"mtime_nanos":1782467199228801200,"mode":420,"is_dir":false},"apps/api/dist/servers/servers.module.js.map":{"size":1277,"mtime_nanos":1782467199365033400,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.service.js":{"size":1917,"mtime_nanos":1782467199221559800,"mode":420,"is_dir":false},"apps/api/.turbo/turbo-build.log":{"size":102,"mtime_nanos":1782467201691248800,"mode":420,"is_dir":false},"apps/api/dist/nodes/nodes.service.d.ts":{"size":1432,"mtime_nanos":1782467199582327100,"mode":420,"is_dir":false},"apps/api/dist/servers/servers.module.d.ts":{"size":83,"mtime_nanos":1782467199369542500,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.controller.js.map":{"size":604,"mtime_nanos":1782467199254032400,"mode":420,"is_dir":false},"apps/api/dist/servers/servers.module.js":{"size":3398,"mtime_nanos":1782467199366161300,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.service.js.map":{"size":891,"mtime_nanos":1782467199218839100,"mode":420,"is_dir":false},"apps/api/dist/app.module.d.ts.map":{"size":244,"mtime_nanos":1782467199598237900,"mode":420,"is_dir":false},"apps/api/dist/nodes":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/nodes/nodes.service.js.map":{"size":7107,"mtime_nanos":1782467199572178000,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.controller.js":{"size":1936,"mtime_nanos":1782467199255627900,"mode":420,"is_dir":false},"apps/api/dist/nodes/node-command.subscriber.d.ts":{"size":460,"mtime_nanos":1782467200260524300,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.controller.d.ts.map":{"size":273,"mtime_nanos":1782467199259052700,"mode":420,"is_dir":false},"apps/api/dist/main.d.ts":{"size":45,"mtime_nanos":1782467200212827900,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-build.log","apps/api/dist","apps/api/dist/app.module.d.ts","apps/api/dist/app.module.d.ts.map","apps/api/dist/main.d.ts","apps/api/dist/main.d.ts.map","apps/api/dist/nodes","apps/api/dist/nodes/node-command.subscriber.d.ts","apps/api/dist/nodes/node-command.subscriber.d.ts.map","apps/api/dist/nodes/nodes.gateway.d.ts","apps/api/dist/nodes/nodes.gateway.d.ts.map","apps/api/dist/nodes/nodes.module.d.ts","apps/api/dist/nodes/nodes.module.d.ts.map","apps/api/dist/nodes/nodes.service.d.ts","apps/api/dist/nodes/nodes.service.d.ts.map","apps/api/dist/nodes/nodes.service.js","apps/api/dist/nodes/nodes.service.js.map","apps/api/dist/servers","apps/api/dist/servers/plans.controller.d.ts","apps/api/dist/servers/plans.controller.d.ts.map","apps/api/dist/servers/plans.controller.js","apps/api/dist/servers/plans.controller.js.map","apps/api/dist/servers/plans.service.d.ts","apps/api/dist/servers/plans.service.d.ts.map","apps/api/dist/servers/plans.service.js","apps/api/dist/servers/plans.service.js.map","apps/api/dist/servers/servers.module.d.ts","apps/api/dist/servers/servers.module.d.ts.map","apps/api/dist/servers/servers.module.js","apps/api/dist/servers/servers.module.js.map"]}
|
||||||
1
.turbo/cache/6c069006830d24bd-meta.json
vendored
Normal file
1
.turbo/cache/6c069006830d24bd-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"6c069006830d24bd","duration":8187,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"1dc69509c46e02b077522f0df13aacf39c3394566efa48caabe96f6f9ee4ff66"}
|
||||||
BIN
.turbo/cache/6c069006830d24bd.tar.zst
vendored
Normal file
BIN
.turbo/cache/6c069006830d24bd.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/7dadc3899adb4c2b-manifest.json
vendored
Normal file
1
.turbo/cache/7dadc3899adb4c2b-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782467242691749600,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}
|
||||||
1
.turbo/cache/7dadc3899adb4c2b-meta.json
vendored
Normal file
1
.turbo/cache/7dadc3899adb4c2b-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"7dadc3899adb4c2b","duration":1230,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"8f0a815f2ebb951c698f0eb35f558a2a8d052664b24cdabb4519d48989674571"}
|
||||||
BIN
.turbo/cache/7dadc3899adb4c2b.tar.zst
vendored
Normal file
BIN
.turbo/cache/7dadc3899adb4c2b.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/8df65e7e54d2443e-manifest.json
vendored
Normal file
1
.turbo/cache/8df65e7e54d2443e-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/contracts/.turbo/turbo-test.log":{"size":460,"mtime_nanos":1782467245029657300,"mode":420,"is_dir":false}},"order":["packages/contracts/.turbo/turbo-test.log"]}
|
||||||
1
.turbo/cache/8df65e7e54d2443e-meta.json
vendored
Normal file
1
.turbo/cache/8df65e7e54d2443e-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"8df65e7e54d2443e","duration":3566,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"8f0a815f2ebb951c698f0eb35f558a2a8d052664b24cdabb4519d48989674571"}
|
||||||
BIN
.turbo/cache/8df65e7e54d2443e.tar.zst
vendored
Normal file
BIN
.turbo/cache/8df65e7e54d2443e.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/f882e318a8d243e9-manifest.json
vendored
Normal file
1
.turbo/cache/f882e318a8d243e9-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":552,"mtime_nanos":1782467247694523100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":659,"mtime_nanos":1782465829247304200,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":493,"mtime_nanos":1782467193376146900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||||
1
.turbo/cache/f882e318a8d243e9-meta.json
vendored
Normal file
1
.turbo/cache/f882e318a8d243e9-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"f882e318a8d243e9","duration":6219,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"8f0a815f2ebb951c698f0eb35f558a2a8d052664b24cdabb4519d48989674571"}
|
||||||
BIN
.turbo/cache/f882e318a8d243e9.tar.zst
vendored
Normal file
BIN
.turbo/cache/f882e318a8d243e9.tar.zst
vendored
Normal file
Binary file not shown.
23
README.md
23
README.md
@@ -92,9 +92,28 @@ make build
|
|||||||
|
|
||||||
## Aktueller Stand
|
## Aktueller Stand
|
||||||
|
|
||||||
**Phase 1** (Authentifizierung) — abgeschlossen.
|
**Phase 2** (Single-Node Vertical Slice) — abgeschlossen.
|
||||||
|
|
||||||
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details und nächste Schritte.
|
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
||||||
|
|
||||||
|
### Minecraft-Server lokal testen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev:infra
|
||||||
|
pnpm db:migrate # oder db:push bei frischer DB
|
||||||
|
pnpm db:seed # Free-Plan + Dev-Node
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Separates Terminal — Node Agent (Go 1.23 + Docker Desktop)
|
||||||
|
cd apps/node-agent
|
||||||
|
# NODE_ID, NODE_TOKEN, NODE_AGENT_PUBLIC_URL aus .env.example setzen
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Registrieren / einloggen unter http://localhost:3000
|
||||||
|
2. Server erstellen unter `/servers/new`
|
||||||
|
3. Starten — Adresse `localhost:<port>` im Panel
|
||||||
|
4. Minecraft: Multiplayer → `localhost:<port>`
|
||||||
|
|
||||||
### Auth lokal testen
|
### Auth lokal testen
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/static": "^8.0.4",
|
"@fastify/static": "^8.0.4",
|
||||||
|
"@fastify/websocket": "^11.0.2",
|
||||||
"@hexahost/auth": "workspace:*",
|
"@hexahost/auth": "workspace:*",
|
||||||
"@hexahost/config": "workspace:*",
|
"@hexahost/config": "workspace:*",
|
||||||
"@hexahost/contracts": "workspace:*",
|
"@hexahost/contracts": "workspace:*",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"pino-http": "^10.4.0",
|
"pino-http": "^10.4.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"@nestjs/cli": "^11.0.2",
|
"@nestjs/cli": "^11.0.2",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/ws": "^8.5.14",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
|
|||||||
45
apps/api/src/admin/admin-nodes.controller.ts
Normal file
45
apps/api/src/admin/admin-nodes.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { SessionGuard } from '../auth/guards/session.guard';
|
||||||
|
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||||
|
|
||||||
|
import { AdminNodesService } from './admin-nodes.service';
|
||||||
|
import type { CreateNodeResponse } from './admin-nodes.service';
|
||||||
|
|
||||||
|
const createNodeRequestSchema = z.object({
|
||||||
|
name: z.string().min(1).max(64),
|
||||||
|
hostname: z.string().min(1).max(255),
|
||||||
|
maxServers: z.number().int().min(1).max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
@ApiTags('admin')
|
||||||
|
@Controller('admin/nodes')
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class AdminNodesController {
|
||||||
|
constructor(private readonly adminNodesService: AdminNodesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Register a new game node (super admin only)' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Node created with enrollment token' })
|
||||||
|
create(
|
||||||
|
@CurrentUser() user: { roles: string[] },
|
||||||
|
@Body(new ZodValidationPipe(createNodeRequestSchema)) body: unknown,
|
||||||
|
): Promise<CreateNodeResponse> {
|
||||||
|
this.adminNodesService.assertSuperAdmin(user.roles);
|
||||||
|
|
||||||
|
return this.adminNodesService.createNode(
|
||||||
|
body as Parameters<AdminNodesService['createNode']>[0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
apps/api/src/admin/admin-nodes.service.ts
Normal file
63
apps/api/src/admin/admin-nodes.service.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateSecureToken,
|
||||||
|
hashToken,
|
||||||
|
UserRole,
|
||||||
|
} from '@hexahost/auth';
|
||||||
|
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface CreateNodeInput {
|
||||||
|
name: string;
|
||||||
|
hostname: string;
|
||||||
|
maxServers?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateNodeResponse {
|
||||||
|
node: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hostname: string;
|
||||||
|
status: string;
|
||||||
|
maxServers: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
enrollmentToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminNodesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
assertSuperAdmin(roles: string[]): void {
|
||||||
|
if (!roles.includes(UserRole.SUPER_ADMIN)) {
|
||||||
|
throw new ForbiddenException('Super administrator access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNode(input: CreateNodeInput): Promise<CreateNodeResponse> {
|
||||||
|
const enrollmentToken = generateSecureToken();
|
||||||
|
|
||||||
|
const node = await this.prisma.gameNode.create({
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
hostname: input.hostname,
|
||||||
|
maxServers: input.maxServers ?? 10,
|
||||||
|
enrollmentTokenHash: hashToken(enrollmentToken),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
hostname: node.hostname,
|
||||||
|
status: node.status,
|
||||||
|
maxServers: node.maxServers,
|
||||||
|
createdAt: node.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
enrollmentToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/admin/admin.module.ts
Normal file
13
apps/api/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
import { AdminNodesController } from './admin-nodes.controller';
|
||||||
|
import { AdminNodesService } from './admin-nodes.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [AdminNodesController],
|
||||||
|
providers: [AdminNodesService],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
@@ -4,6 +4,9 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
|||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { AdminModule } from './admin/admin.module';
|
||||||
|
import { NodesModule } from './nodes/nodes.module';
|
||||||
|
import { ServersModule } from './servers/servers.module';
|
||||||
import { AppConfigModule } from './config/app-config.module';
|
import { AppConfigModule } from './config/app-config.module';
|
||||||
|
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
@@ -37,6 +40,9 @@ import { LoggerModule } from 'nestjs-pino';
|
|||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
ServersModule,
|
||||||
|
NodesModule,
|
||||||
|
AdminModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import cookie from '@fastify/cookie';
|
import cookie from '@fastify/cookie';
|
||||||
|
import websocket from '@fastify/websocket';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import {
|
import {
|
||||||
FastifyAdapter,
|
FastifyAdapter,
|
||||||
@@ -41,6 +42,8 @@ async function bootstrap(): Promise<void> {
|
|||||||
secret: config.SESSION_SECRET,
|
secret: config.SESSION_SECRET,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await app.register(websocket);
|
||||||
|
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle(config.APP_NAME)
|
.setTitle(config.APP_NAME)
|
||||||
.setDescription('HexaHost GameCloud REST API')
|
.setDescription('HexaHost GameCloud REST API')
|
||||||
|
|||||||
64
apps/api/src/nodes/node-command.subscriber.ts
Normal file
64
apps/api/src/nodes/node-command.subscriber.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
import { getConfig } from '@hexahost/config';
|
||||||
|
|
||||||
|
import { NODE_COMMANDS_CHANNEL } from '../servers/servers.service';
|
||||||
|
|
||||||
|
import { NodesService } from './nodes.service';
|
||||||
|
|
||||||
|
interface NodeCommandMessage {
|
||||||
|
nodeId: string;
|
||||||
|
envelope: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NodeCommandSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(NodeCommandSubscriber.name);
|
||||||
|
private subscriber?: Redis;
|
||||||
|
|
||||||
|
constructor(private readonly nodesService: NodesService) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
const config = getConfig();
|
||||||
|
this.subscriber = new Redis(config.REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.subscriber.subscribe(NODE_COMMANDS_CHANNEL);
|
||||||
|
|
||||||
|
this.subscriber.on('message', (channel, message) => {
|
||||||
|
if (channel !== NODE_COMMANDS_CHANNEL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message) as NodeCommandMessage;
|
||||||
|
|
||||||
|
if (!parsed.nodeId || parsed.envelope === undefined) {
|
||||||
|
this.logger.warn('Ignoring malformed node command message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nodesService.forwardCommand(parsed.nodeId, parsed.envelope);
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error(`Failed to process node command: ${detail}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
if (!this.subscriber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.subscriber.unsubscribe(NODE_COMMANDS_CHANNEL);
|
||||||
|
await this.subscriber.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
84
apps/api/src/nodes/nodes.gateway.ts
Normal file
84
apps/api/src/nodes/nodes.gateway.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
import { NodesService } from './nodes.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NodesGateway implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(NodesGateway.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly adapterHost: HttpAdapterHost,
|
||||||
|
private readonly nodesService: NodesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
const fastify = this.adapterHost.httpAdapter.getInstance();
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/api/v1/nodes/ws',
|
||||||
|
{ websocket: true },
|
||||||
|
(socket: WebSocket, request: FastifyRequest) => {
|
||||||
|
void this.handleConnection(socket, request);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleConnection(
|
||||||
|
socket: WebSocket,
|
||||||
|
request: FastifyRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
const query = request.query as Record<string, string | string[] | undefined>;
|
||||||
|
const nodeId = this.readQueryParam(query['nodeId']);
|
||||||
|
const token = this.readQueryParam(query['token']);
|
||||||
|
|
||||||
|
if (!nodeId || !token) {
|
||||||
|
socket.close(4400, 'nodeId and token are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.nodesService.validateEnrollment(nodeId, token);
|
||||||
|
} catch (error) {
|
||||||
|
const reason =
|
||||||
|
error instanceof UnauthorizedException
|
||||||
|
? 'Invalid enrollment credentials'
|
||||||
|
: 'Enrollment validation failed';
|
||||||
|
socket.close(4401, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nodesService.registerConnection(nodeId, socket);
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
this.nodesService.unregisterConnection(nodeId, socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('message', (data) => {
|
||||||
|
void this.nodesService.handleAgentMessage(nodeId, data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
this.logger.error(`WebSocket error for node ${nodeId}: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.send(JSON.stringify(this.nodesService.createHelloAck(nodeId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private readQueryParam(
|
||||||
|
value: string | string[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/nodes/nodes.module.ts
Normal file
14
apps/api/src/nodes/nodes.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ServersModule } from '../servers/servers.module';
|
||||||
|
|
||||||
|
import { NodeCommandSubscriber } from './node-command.subscriber';
|
||||||
|
import { NodesGateway } from './nodes.gateway';
|
||||||
|
import { NodesService } from './nodes.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ServersModule],
|
||||||
|
providers: [NodesService, NodesGateway, NodeCommandSubscriber],
|
||||||
|
exports: [NodesService],
|
||||||
|
})
|
||||||
|
export class NodesModule {}
|
||||||
305
apps/api/src/nodes/nodes.service.ts
Normal file
305
apps/api/src/nodes/nodes.service.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
UnauthorizedException,
|
||||||
|
Inject,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
import type Redis from 'ioredis';
|
||||||
|
|
||||||
|
import { verifyTokenHash } from '@hexahost/auth';
|
||||||
|
import type { GameServer, GameNode, Prisma } from '@hexahost/database';
|
||||||
|
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
|
||||||
|
|
||||||
|
type GameServerStatus = GameServer['status'];
|
||||||
|
|
||||||
|
interface AgentEnvelope {
|
||||||
|
protocolVersion?: number;
|
||||||
|
messageId?: string;
|
||||||
|
correlationId?: string;
|
||||||
|
type?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_STATE_MAP: Record<string, GameServerStatus> = {
|
||||||
|
draft: 'DRAFT',
|
||||||
|
provisioning: 'PROVISIONING',
|
||||||
|
installing: 'INSTALLING',
|
||||||
|
stopped: 'STOPPED',
|
||||||
|
starting: 'STARTING',
|
||||||
|
running: 'RUNNING',
|
||||||
|
stopping: 'STOPPING',
|
||||||
|
error: 'ERROR',
|
||||||
|
deleting: 'DELETING',
|
||||||
|
deleted: 'DELETED',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NodesService {
|
||||||
|
private readonly logger = new Logger(NodesService.name);
|
||||||
|
private readonly connections = new Map<string, WebSocket>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly serversService: ServersService,
|
||||||
|
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
registerConnection(nodeId: string, socket: WebSocket): void {
|
||||||
|
const existing = this.connections.get(nodeId);
|
||||||
|
if (existing && existing !== socket) {
|
||||||
|
existing.close(4000, 'replaced by new connection');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connections.set(nodeId, socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterConnection(nodeId: string, socket: WebSocket): void {
|
||||||
|
const current = this.connections.get(nodeId);
|
||||||
|
if (current === socket) {
|
||||||
|
this.connections.delete(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardCommand(nodeId: string, envelope: unknown): void {
|
||||||
|
const socket = this.connections.get(nodeId);
|
||||||
|
if (!socket || socket.readyState !== socket.OPEN) {
|
||||||
|
this.logger.warn(`No active WebSocket for node ${nodeId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send(JSON.stringify(envelope));
|
||||||
|
}
|
||||||
|
|
||||||
|
createHelloAck(nodeId: string): AgentEnvelope {
|
||||||
|
return {
|
||||||
|
protocolVersion: 1,
|
||||||
|
messageId: randomUUID(),
|
||||||
|
type: 'agent.hello',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
accepted: true,
|
||||||
|
nodeId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateEnrollment(nodeId: string, token: string): Promise<GameNode> {
|
||||||
|
const node = await this.prisma.gameNode.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ id: nodeId }, { name: nodeId }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!node?.enrollmentTokenHash) {
|
||||||
|
throw new UnauthorizedException('Node not enrolled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyTokenHash(token, node.enrollmentTokenHash)) {
|
||||||
|
throw new UnauthorizedException('Invalid enrollment token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markNodeOnline(nodeId: string, agentVersion?: string): Promise<void> {
|
||||||
|
await this.prisma.gameNode.update({
|
||||||
|
where: { id: nodeId },
|
||||||
|
data: {
|
||||||
|
status: 'ONLINE',
|
||||||
|
lastHeartbeatAt: new Date(),
|
||||||
|
enrolledAt: new Date(),
|
||||||
|
agentVersion,
|
||||||
|
enrollmentTokenHash: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAgentMessage(nodeId: string, raw: string): Promise<void> {
|
||||||
|
let envelope: AgentEnvelope;
|
||||||
|
|
||||||
|
try {
|
||||||
|
envelope = JSON.parse(raw) as AgentEnvelope;
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Invalid JSON from node ${nodeId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (envelope.type) {
|
||||||
|
case 'agent.hello':
|
||||||
|
await this.markNodeOnline(
|
||||||
|
nodeId,
|
||||||
|
typeof envelope.payload?.['agentVersion'] === 'string'
|
||||||
|
? envelope.payload['agentVersion']
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'agent.heartbeat':
|
||||||
|
await this.persistHeartbeat(nodeId, envelope.payload);
|
||||||
|
break;
|
||||||
|
case 'server.state':
|
||||||
|
await this.handleServerState(envelope);
|
||||||
|
break;
|
||||||
|
case 'server.operation.completed':
|
||||||
|
await this.handleOperationCompleted(envelope);
|
||||||
|
break;
|
||||||
|
case 'server.operation.failed':
|
||||||
|
await this.handleOperationFailed(envelope);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistHeartbeat(
|
||||||
|
nodeId: string,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.gameNode.update({
|
||||||
|
where: { id: nodeId },
|
||||||
|
data: {
|
||||||
|
lastHeartbeatAt: now,
|
||||||
|
status: 'ONLINE',
|
||||||
|
agentVersion:
|
||||||
|
typeof payload?.['agentVersion'] === 'string'
|
||||||
|
? payload['agentVersion']
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.gameNodeHeartbeat.create({
|
||||||
|
data: {
|
||||||
|
nodeId,
|
||||||
|
payload: (payload ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleServerState(envelope: AgentEnvelope): Promise<void> {
|
||||||
|
const serverId = envelope.payload?.['serverId'];
|
||||||
|
const state = envelope.payload?.['state'];
|
||||||
|
|
||||||
|
if (typeof serverId !== 'string' || typeof state !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = AGENT_STATE_MAP[state.toLowerCase()];
|
||||||
|
if (!mapped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.serversService.applyAgentStatus(serverId, mapped, {
|
||||||
|
correlationId: envelope.correlationId,
|
||||||
|
reason: 'agent server.state',
|
||||||
|
errorCode:
|
||||||
|
typeof envelope.payload?.['errorCode'] === 'string'
|
||||||
|
? envelope.payload['errorCode']
|
||||||
|
: undefined,
|
||||||
|
errorMessage:
|
||||||
|
typeof envelope.payload?.['errorMessage'] === 'string'
|
||||||
|
? envelope.payload['errorMessage']
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOperationCompleted(
|
||||||
|
envelope: AgentEnvelope,
|
||||||
|
): Promise<void> {
|
||||||
|
const serverId = envelope.payload?.['serverId'];
|
||||||
|
const operation = envelope.payload?.['operation'];
|
||||||
|
const messageId = envelope.messageId;
|
||||||
|
|
||||||
|
if (typeof serverId !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = this.resolveCompletedStatus(operation);
|
||||||
|
if (target) {
|
||||||
|
await this.serversService.applyAgentStatus(serverId, target, {
|
||||||
|
correlationId: envelope.correlationId,
|
||||||
|
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof messageId === 'string') {
|
||||||
|
await this.publishNodeResponse(messageId, {
|
||||||
|
success: true,
|
||||||
|
resultCode: typeof operation === 'string' ? operation : 'completed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
|
||||||
|
const serverId = envelope.payload?.['serverId'];
|
||||||
|
const messageId = envelope.messageId;
|
||||||
|
|
||||||
|
if (typeof serverId === 'string') {
|
||||||
|
await this.serversService.applyAgentStatus(serverId, 'ERROR', {
|
||||||
|
correlationId: envelope.correlationId,
|
||||||
|
reason: 'agent operation failed',
|
||||||
|
errorCode:
|
||||||
|
typeof envelope.payload?.['errorCode'] === 'string'
|
||||||
|
? envelope.payload['errorCode']
|
||||||
|
: undefined,
|
||||||
|
errorMessage:
|
||||||
|
typeof envelope.payload?.['errorMessage'] === 'string'
|
||||||
|
? envelope.payload['errorMessage']
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof messageId === 'string') {
|
||||||
|
await this.publishNodeResponse(messageId, {
|
||||||
|
success: false,
|
||||||
|
errorCode:
|
||||||
|
typeof envelope.payload?.['errorCode'] === 'string'
|
||||||
|
? envelope.payload['errorCode']
|
||||||
|
: 'OPERATION_FAILED',
|
||||||
|
errorMessage:
|
||||||
|
typeof envelope.payload?.['errorMessage'] === 'string'
|
||||||
|
? envelope.payload['errorMessage']
|
||||||
|
: 'Agent operation failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publishNodeResponse(
|
||||||
|
messageId: string,
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
resultCode?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.redis.publish(
|
||||||
|
`hgc:node:responses:${messageId}`,
|
||||||
|
JSON.stringify(response),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCompletedStatus(operation: unknown): GameServerStatus | null {
|
||||||
|
switch (operation) {
|
||||||
|
case 'provision':
|
||||||
|
return 'STOPPED';
|
||||||
|
case 'install':
|
||||||
|
return 'STOPPED';
|
||||||
|
case 'start':
|
||||||
|
return 'RUNNING';
|
||||||
|
case 'stop':
|
||||||
|
return 'STOPPED';
|
||||||
|
case 'delete':
|
||||||
|
return 'DELETED';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/api/src/servers/plans.controller.ts
Normal file
19
apps/api/src/servers/plans.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { SessionGuard } from '../auth/guards/session.guard';
|
||||||
|
|
||||||
|
import { PlansService } from './plans.service';
|
||||||
|
|
||||||
|
@ApiTags('plans')
|
||||||
|
@Controller('plans')
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class PlansController {
|
||||||
|
constructor(private readonly plansService: PlansService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List active server plans' })
|
||||||
|
list() {
|
||||||
|
return this.plansService.listActivePlans();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/api/src/servers/plans.service.ts
Normal file
28
apps/api/src/servers/plans.service.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlansService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async listActivePlans() {
|
||||||
|
const plans = await this.prisma.plan.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { maxRamMb: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
plans: plans.map((plan) => ({
|
||||||
|
id: plan.id,
|
||||||
|
name: plan.name,
|
||||||
|
slug: plan.slug,
|
||||||
|
description: plan.description,
|
||||||
|
isFree: plan.isFree,
|
||||||
|
maxRamMb: plan.maxRamMb,
|
||||||
|
maxCpuCores: plan.maxCpuCores,
|
||||||
|
maxStorageMb: plan.maxStorageMb,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/api/src/servers/server-state.service.ts
Normal file
71
apps/api/src/servers/server-state.service.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import type { GameServer } from '@hexahost/database';
|
||||||
|
|
||||||
|
type GameServerStatus = GameServer['status'];
|
||||||
|
|
||||||
|
const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
|
||||||
|
DRAFT: ['PROVISIONING', 'DELETING'],
|
||||||
|
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'],
|
||||||
|
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'],
|
||||||
|
STOPPED: ['STARTING', 'PROVISIONING', 'DELETING'],
|
||||||
|
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'DELETING'],
|
||||||
|
RUNNING: ['STOPPING', 'ERROR', 'DELETING'],
|
||||||
|
STOPPING: ['STOPPED', 'ERROR', 'DELETING'],
|
||||||
|
ERROR: ['STOPPED', 'STARTING', 'PROVISIONING', 'DELETING'],
|
||||||
|
DELETING: ['DELETED'],
|
||||||
|
DELETED: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const IN_PROGRESS_STATUSES: GameServerStatus[] = [
|
||||||
|
'PROVISIONING',
|
||||||
|
'INSTALLING',
|
||||||
|
'STARTING',
|
||||||
|
'STOPPING',
|
||||||
|
'DELETING',
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ServerStateService {
|
||||||
|
assertTransition(
|
||||||
|
fromStatus: GameServerStatus,
|
||||||
|
toStatus: GameServerStatus,
|
||||||
|
): void {
|
||||||
|
const allowed = ALLOWED_TRANSITIONS[fromStatus];
|
||||||
|
|
||||||
|
if (!allowed.includes(toStatus)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid state transition from ${fromStatus} to ${toStatus}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotInProgress(status: GameServerStatus): void {
|
||||||
|
if (IN_PROGRESS_STATUSES.includes(status)) {
|
||||||
|
throw new BadRequestException('Server operation already in progress');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveStartTarget(status: GameServerStatus): GameServerStatus {
|
||||||
|
switch (status) {
|
||||||
|
case 'DRAFT':
|
||||||
|
case 'ERROR':
|
||||||
|
return 'PROVISIONING';
|
||||||
|
case 'STOPPED':
|
||||||
|
return 'STARTING';
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot start server while in status ${status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveStopTarget(status: GameServerStatus): GameServerStatus {
|
||||||
|
if (status !== 'RUNNING') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot stop server while in status ${status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'STOPPING';
|
||||||
|
}
|
||||||
|
}
|
||||||
76
apps/api/src/servers/servers.controller.ts
Normal file
76
apps/api/src/servers/servers.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { createServerRequestSchema } from '@hexahost/contracts';
|
||||||
|
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { SessionGuard } from '../auth/guards/session.guard';
|
||||||
|
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||||
|
|
||||||
|
import { ServersService } from './servers.service';
|
||||||
|
|
||||||
|
@ApiTags('servers')
|
||||||
|
@Controller('servers')
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class ServersController {
|
||||||
|
constructor(private readonly serversService: ServersService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Create a game server' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Server created' })
|
||||||
|
create(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Body(new ZodValidationPipe(createServerRequestSchema)) body: unknown,
|
||||||
|
) {
|
||||||
|
return this.serversService.createServer(
|
||||||
|
user.id,
|
||||||
|
body as Parameters<ServersService['createServer']>[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List owned game servers' })
|
||||||
|
list(@CurrentUser() user: { id: string }) {
|
||||||
|
return this.serversService.listServers(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get a game server by id' })
|
||||||
|
get(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.serversService.getServer(user.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/start')
|
||||||
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
|
@ApiOperation({ summary: 'Start a game server' })
|
||||||
|
start(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.serversService.startServer(user.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/stop')
|
||||||
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
|
@ApiOperation({ summary: 'Stop a game server' })
|
||||||
|
stop(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.serversService.stopServer(user.id, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
apps/api/src/servers/servers.module.ts
Normal file
64
apps/api/src/servers/servers.module.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
import { getConfig } from '@hexahost/config';
|
||||||
|
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { AppConfigModule } from '../config/app-config.module';
|
||||||
|
|
||||||
|
import { ServerStateService } from './server-state.service';
|
||||||
|
import {
|
||||||
|
REDIS_CLIENT,
|
||||||
|
SERVER_LIFECYCLE_QUEUE,
|
||||||
|
SERVER_PROVISIONING_QUEUE,
|
||||||
|
ServersService,
|
||||||
|
} from './servers.service';
|
||||||
|
import { ServersController } from './servers.controller';
|
||||||
|
import { PlansController } from './plans.controller';
|
||||||
|
import { PlansService } from './plans.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AppConfigModule, AuthModule],
|
||||||
|
controllers: [ServersController, PlansController],
|
||||||
|
providers: [
|
||||||
|
ServersService,
|
||||||
|
ServerStateService,
|
||||||
|
PlansService,
|
||||||
|
{
|
||||||
|
provide: REDIS_CLIENT,
|
||||||
|
useFactory: () => {
|
||||||
|
const config = getConfig();
|
||||||
|
return new Redis(config.REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SERVER_LIFECYCLE_QUEUE,
|
||||||
|
useFactory: () => {
|
||||||
|
const config = getConfig();
|
||||||
|
return new Queue('server-lifecycle', {
|
||||||
|
connection: {
|
||||||
|
url: config.REDIS_URL,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SERVER_PROVISIONING_QUEUE,
|
||||||
|
useFactory: () => {
|
||||||
|
const config = getConfig();
|
||||||
|
return new Queue('server-provisioning', {
|
||||||
|
connection: {
|
||||||
|
url: config.REDIS_URL,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [ServersService, REDIS_CLIENT],
|
||||||
|
})
|
||||||
|
export class ServersModule {}
|
||||||
327
apps/api/src/servers/servers.service.ts
Normal file
327
apps/api/src/servers/servers.service.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import type Redis from 'ioredis';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CreateServerRequest,
|
||||||
|
ServerActionResponse,
|
||||||
|
ServerListResponse,
|
||||||
|
ServerResponse,
|
||||||
|
} from '@hexahost/contracts';
|
||||||
|
import type { GameServer } from '@hexahost/database';
|
||||||
|
|
||||||
|
type GameServerStatus = GameServer['status'];
|
||||||
|
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
import { ServerStateService } from './server-state.service';
|
||||||
|
|
||||||
|
export const REDIS_CLIENT = Symbol('SERVERS_REDIS_CLIENT');
|
||||||
|
export const SERVER_LIFECYCLE_QUEUE = Symbol('SERVER_LIFECYCLE_QUEUE');
|
||||||
|
export const SERVER_PROVISIONING_QUEUE = Symbol('SERVER_PROVISIONING_QUEUE');
|
||||||
|
|
||||||
|
export const NODE_COMMANDS_CHANNEL = 'hgc:node:commands' as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ServersService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly serverState: ServerStateService,
|
||||||
|
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||||
|
@Inject(SERVER_LIFECYCLE_QUEUE)
|
||||||
|
private readonly lifecycleQueue: Queue,
|
||||||
|
@Inject(SERVER_PROVISIONING_QUEUE)
|
||||||
|
private readonly provisioningQueue: Queue,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createServer(
|
||||||
|
userId: string,
|
||||||
|
input: CreateServerRequest,
|
||||||
|
): Promise<ServerResponse> {
|
||||||
|
const server = await this.prisma.gameServer.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name: input.name,
|
||||||
|
edition: input.edition,
|
||||||
|
softwareFamily: input.softwareFamily,
|
||||||
|
minecraftVersion: input.minecraftVersion,
|
||||||
|
eulaAccepted: input.eulaAccepted,
|
||||||
|
ramMb: input.ramMb ?? 1024,
|
||||||
|
planId: input.planId,
|
||||||
|
status: 'DRAFT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.recordTransition(server.id, null, 'DRAFT', 'created', userId);
|
||||||
|
|
||||||
|
return this.toResponse(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listServers(userId: string): Promise<ServerListResponse> {
|
||||||
|
const servers = await this.prisma.gameServer.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
servers: servers.map((server) => this.toResponse(server)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServer(userId: string, serverId: string): Promise<ServerResponse> {
|
||||||
|
const server = await this.findOwnedServer(userId, serverId);
|
||||||
|
return this.toResponse(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startServer(
|
||||||
|
userId: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<ServerActionResponse> {
|
||||||
|
const server = await this.findOwnedServer(userId, serverId);
|
||||||
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
|
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
||||||
|
this.serverState.assertTransition(server.status, targetStatus);
|
||||||
|
|
||||||
|
const correlationId = randomUUID();
|
||||||
|
const updated = await this.transitionServer(
|
||||||
|
server,
|
||||||
|
targetStatus,
|
||||||
|
'start requested',
|
||||||
|
userId,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
let jobId: string | undefined;
|
||||||
|
|
||||||
|
if (targetStatus === 'PROVISIONING') {
|
||||||
|
const job = await this.provisioningQueue.add(
|
||||||
|
'provision-server',
|
||||||
|
{ serverId, correlationId },
|
||||||
|
{ jobId: correlationId },
|
||||||
|
);
|
||||||
|
jobId = job.id;
|
||||||
|
} else {
|
||||||
|
const job = await this.lifecycleQueue.add(
|
||||||
|
'start-server',
|
||||||
|
{ serverId, action: 'start', correlationId },
|
||||||
|
{ jobId: correlationId },
|
||||||
|
);
|
||||||
|
jobId = job.id;
|
||||||
|
|
||||||
|
if (updated.nodeId) {
|
||||||
|
await this.publishNodeCommand(updated.nodeId, {
|
||||||
|
protocolVersion: 1,
|
||||||
|
messageId: randomUUID(),
|
||||||
|
correlationId,
|
||||||
|
type: 'server.start',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
serverId,
|
||||||
|
generation: updated.version,
|
||||||
|
desiredGeneration: updated.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: this.toResponse(updated),
|
||||||
|
jobId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopServer(
|
||||||
|
userId: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<ServerActionResponse> {
|
||||||
|
const server = await this.findOwnedServer(userId, serverId);
|
||||||
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
|
const targetStatus = this.serverState.resolveStopTarget(server.status);
|
||||||
|
this.serverState.assertTransition(server.status, targetStatus);
|
||||||
|
|
||||||
|
const correlationId = randomUUID();
|
||||||
|
const updated = await this.transitionServer(
|
||||||
|
server,
|
||||||
|
targetStatus,
|
||||||
|
'stop requested',
|
||||||
|
userId,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const job = await this.lifecycleQueue.add(
|
||||||
|
'stop-server',
|
||||||
|
{ serverId, action: 'stop', correlationId },
|
||||||
|
{ jobId: correlationId },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updated.nodeId) {
|
||||||
|
await this.publishNodeCommand(updated.nodeId, {
|
||||||
|
protocolVersion: 1,
|
||||||
|
messageId: randomUUID(),
|
||||||
|
correlationId,
|
||||||
|
type: 'server.stop',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
serverId,
|
||||||
|
generation: updated.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: this.toResponse(updated),
|
||||||
|
jobId: job.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishNodeCommand(
|
||||||
|
nodeId: string,
|
||||||
|
envelope: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.redis.publish(
|
||||||
|
NODE_COMMANDS_CHANNEL,
|
||||||
|
JSON.stringify({ nodeId, envelope }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyAgentStatus(
|
||||||
|
serverId: string,
|
||||||
|
nextStatus: GameServerStatus,
|
||||||
|
metadata?: {
|
||||||
|
correlationId?: string;
|
||||||
|
reason?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const server = await this.prisma.gameServer.findUnique({
|
||||||
|
where: { id: serverId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!server || server.status === nextStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverState.assertTransition(server.status, nextStatus);
|
||||||
|
|
||||||
|
await this.transitionServer(
|
||||||
|
server,
|
||||||
|
nextStatus,
|
||||||
|
metadata?.reason ?? 'agent reported state',
|
||||||
|
null,
|
||||||
|
metadata?.correlationId,
|
||||||
|
metadata?.errorCode,
|
||||||
|
metadata?.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOwnedServer(
|
||||||
|
userId: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<GameServer> {
|
||||||
|
const server = await this.prisma.gameServer.findUnique({
|
||||||
|
where: { id: serverId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new NotFoundException('Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.userId !== userId) {
|
||||||
|
throw new ForbiddenException('You do not own this server');
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transitionServer(
|
||||||
|
server: GameServer,
|
||||||
|
toStatus: GameServerStatus,
|
||||||
|
reason: string,
|
||||||
|
actorId: string | null,
|
||||||
|
correlationId?: string,
|
||||||
|
errorCode?: string,
|
||||||
|
errorMessage?: string,
|
||||||
|
): Promise<GameServer> {
|
||||||
|
if (server.status === toStatus) {
|
||||||
|
throw new ConflictException('Server is already in the requested state');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.gameServer.update({
|
||||||
|
where: { id: server.id },
|
||||||
|
data: {
|
||||||
|
status: toStatus,
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.recordTransition(
|
||||||
|
server.id,
|
||||||
|
server.status,
|
||||||
|
toStatus,
|
||||||
|
reason,
|
||||||
|
actorId,
|
||||||
|
correlationId,
|
||||||
|
errorCode,
|
||||||
|
errorMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordTransition(
|
||||||
|
gameServerId: string,
|
||||||
|
fromStatus: GameServerStatus | null,
|
||||||
|
toStatus: GameServerStatus,
|
||||||
|
reason: string,
|
||||||
|
actorId: string | null,
|
||||||
|
correlationId?: string,
|
||||||
|
errorCode?: string,
|
||||||
|
errorMessage?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prisma.gameServerStateTransition.create({
|
||||||
|
data: {
|
||||||
|
gameServerId,
|
||||||
|
fromStatus,
|
||||||
|
toStatus,
|
||||||
|
reason,
|
||||||
|
actorId,
|
||||||
|
correlationId,
|
||||||
|
errorCode,
|
||||||
|
errorMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toResponse(server: GameServer): ServerResponse {
|
||||||
|
return {
|
||||||
|
id: server.id,
|
||||||
|
userId: server.userId,
|
||||||
|
planId: server.planId,
|
||||||
|
nodeId: server.nodeId,
|
||||||
|
name: server.name,
|
||||||
|
status: server.status,
|
||||||
|
version: server.version,
|
||||||
|
edition: server.edition,
|
||||||
|
softwareFamily: server.softwareFamily,
|
||||||
|
minecraftVersion: server.minecraftVersion,
|
||||||
|
eulaAccepted: server.eulaAccepted,
|
||||||
|
hostPort: server.hostPort,
|
||||||
|
containerId: server.containerId,
|
||||||
|
dataPath: server.dataPath,
|
||||||
|
ramMb: server.ramMb,
|
||||||
|
createdAt: server.createdAt.toISOString(),
|
||||||
|
updatedAt: server.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -22,7 +22,13 @@ func main() {
|
|||||||
|
|
||||||
log = log.With("node_id", cfg.NodeID, "component", "node-agent")
|
log = log.With("node_id", cfg.NodeID, "component", "node-agent")
|
||||||
|
|
||||||
if err := agent.New(cfg, log).Run(context.Background()); err != nil {
|
a, err := agent.New(cfg, log)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("agent init error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Run(context.Background()); err != nil {
|
||||||
log.Error("agent exited with error", "error", err)
|
log.Error("agent exited with error", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
module github.com/hexahost/gamecloud/node-agent
|
module github.com/hexahost/gamecloud/node-agent
|
||||||
|
|
||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/docker/docker v28.0.0+incompatible
|
||||||
|
github.com/docker/go-connections v0.5.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,28 +6,43 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/hexahost/gamecloud/node-agent/internal/config"
|
"github.com/hexahost/gamecloud/node-agent/internal/config"
|
||||||
|
dockerpkg "github.com/hexahost/gamecloud/node-agent/internal/docker"
|
||||||
"github.com/hexahost/gamecloud/node-agent/internal/health"
|
"github.com/hexahost/gamecloud/node-agent/internal/health"
|
||||||
|
"github.com/hexahost/gamecloud/node-agent/internal/runtime"
|
||||||
|
"github.com/hexahost/gamecloud/node-agent/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.1.0-phase0"
|
const Version = "0.2.0-phase2"
|
||||||
|
|
||||||
// Agent coordinates the node agent lifecycle until shutdown.
|
// Agent coordinates the node agent lifecycle until shutdown.
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
health *health.Server
|
health *health.Server
|
||||||
|
docker *dockerpkg.Client
|
||||||
|
ws *ws.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an agent instance from configuration.
|
// New creates an agent instance from configuration.
|
||||||
func New(cfg *config.Config, log *slog.Logger) *Agent {
|
func New(cfg *config.Config, log *slog.Logger) (*Agent, error) {
|
||||||
|
dockerClient, err := dockerpkg.New(cfg.DockerSocket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
wsClient := ws.NewClient(cfg, log, nil, Version)
|
||||||
|
runtimeMgr := runtime.NewManager(dockerClient, wsClient, log)
|
||||||
|
wsClient.SetRuntime(runtimeMgr)
|
||||||
|
|
||||||
return &Agent{
|
return &Agent{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: log,
|
log: log,
|
||||||
health: health.NewServer(cfg.HealthListenAddr, Version),
|
health: health.NewServer(cfg.HealthListenAddr, Version),
|
||||||
}
|
docker: dockerClient,
|
||||||
|
ws: wsClient,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the agent loop and blocks until context cancellation or signal.
|
// Run starts the agent loop and blocks until context cancellation or signal.
|
||||||
@@ -39,7 +54,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
|||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer signal.Stop(sigCh)
|
defer signal.Stop(sigCh)
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 2)
|
||||||
go func() {
|
go func() {
|
||||||
a.log.Info("health server listening", "addr", a.cfg.HealthListenAddr)
|
a.log.Info("health server listening", "addr", a.cfg.HealthListenAddr)
|
||||||
if err := a.health.ListenAndServe(); err != nil {
|
if err := a.health.ListenAndServe(); err != nil {
|
||||||
@@ -47,6 +62,10 @@ func (a *Agent) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
errCh <- a.ws.Run(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
a.health.SetReady(true)
|
a.health.SetReady(true)
|
||||||
a.log.Info("agent started",
|
a.log.Info("agent started",
|
||||||
"node_id", a.cfg.NodeID,
|
"node_id", a.cfg.NodeID,
|
||||||
@@ -54,34 +73,33 @@ func (a *Agent) Run(ctx context.Context) error {
|
|||||||
"version", Version,
|
"version", Version,
|
||||||
)
|
)
|
||||||
|
|
||||||
ticker := time.NewTicker(a.cfg.HeartbeatInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return a.shutdown(ctx.Err())
|
return a.shutdown(nil)
|
||||||
case sig := <-sigCh:
|
case sig := <-sigCh:
|
||||||
a.log.Info("shutdown signal received", "signal", sig.String())
|
a.log.Info("shutdown signal received", "signal", sig.String())
|
||||||
return a.shutdown(nil)
|
cancel()
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
return err
|
if ctx.Err() != nil {
|
||||||
case <-ticker.C:
|
return a.shutdown(nil)
|
||||||
a.log.Debug("heartbeat tick",
|
}
|
||||||
"node_id", a.cfg.NodeID,
|
if err != nil {
|
||||||
"interval", a.cfg.HeartbeatInterval.String(),
|
return err
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) shutdown(reason error) error {
|
func (a *Agent) shutdown(reason error) error {
|
||||||
a.health.SetReady(false)
|
a.health.SetReady(false)
|
||||||
a.log.Info("agent shutting down", "grace", a.cfg.ShutdownGracePeriod.String())
|
a.log.Info("agent shutting down")
|
||||||
|
|
||||||
timer := time.NewTimer(a.cfg.ShutdownGracePeriod)
|
if a.docker != nil {
|
||||||
defer timer.Stop()
|
if err := a.docker.Close(); err != nil {
|
||||||
<-timer.C
|
a.log.Warn("docker client close failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if reason != nil && reason != context.Canceled {
|
if reason != nil && reason != context.Canceled {
|
||||||
a.log.Warn("shutdown with error", "error", reason)
|
a.log.Warn("shutdown with error", "error", reason)
|
||||||
|
|||||||
191
apps/node-agent/internal/docker/client.go
Normal file
191
apps/node-agent/internal/docker/client.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MinecraftImage = "itzg/minecraft-server:java21"
|
||||||
|
|
||||||
|
// MinecraftServerSpec describes a Minecraft container to create.
|
||||||
|
type MinecraftServerSpec struct {
|
||||||
|
ServerID string
|
||||||
|
MinecraftVersion string
|
||||||
|
SoftwareFamily string
|
||||||
|
HostPort int
|
||||||
|
RamMb int
|
||||||
|
DataPath string
|
||||||
|
EulaAccepted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerState summarizes a Docker container's runtime status.
|
||||||
|
type ContainerState struct {
|
||||||
|
ID string
|
||||||
|
Status string
|
||||||
|
Running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client wraps the Docker Engine API for game server containers.
|
||||||
|
type Client struct {
|
||||||
|
cli *client.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Docker client connected via the given socket URL.
|
||||||
|
func New(socket string) (*Client, error) {
|
||||||
|
cli, err := client.NewClientWithOpts(
|
||||||
|
client.FromEnv,
|
||||||
|
client.WithHost(socket),
|
||||||
|
client.WithAPIVersionNegotiation(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create docker client: %w", err)
|
||||||
|
}
|
||||||
|
return &Client{cli: cli}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the underlying Docker client.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
return c.cli.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping verifies connectivity to the Docker daemon.
|
||||||
|
func (c *Client) Ping(ctx context.Context) error {
|
||||||
|
_, err := c.cli.Ping(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMinecraftServer creates a non-privileged Minecraft container and returns its ID.
|
||||||
|
func (c *Client) CreateMinecraftServer(ctx context.Context, spec MinecraftServerSpec) (string, error) {
|
||||||
|
if !spec.EulaAccepted {
|
||||||
|
return "", fmt.Errorf("eula must be accepted before provisioning")
|
||||||
|
}
|
||||||
|
if spec.HostPort <= 0 {
|
||||||
|
return "", fmt.Errorf("hostPort must be positive")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.DataPath) == "" {
|
||||||
|
return "", fmt.Errorf("dataPath is required")
|
||||||
|
}
|
||||||
|
if spec.RamMb <= 0 {
|
||||||
|
return "", fmt.Errorf("ramMb must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
containerPort := nat.Port("25565/tcp")
|
||||||
|
portBindings := nat.PortMap{
|
||||||
|
containerPort: []nat.PortBinding{{
|
||||||
|
HostIP: "0.0.0.0",
|
||||||
|
HostPort: strconv.Itoa(spec.HostPort),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.TrimSpace(spec.MinecraftVersion)
|
||||||
|
if version == "" {
|
||||||
|
version = "LATEST"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.cli.ContainerCreate(ctx,
|
||||||
|
&container.Config{
|
||||||
|
Image: MinecraftImage,
|
||||||
|
Env: []string{
|
||||||
|
"EULA=TRUE",
|
||||||
|
"VERSION=" + version,
|
||||||
|
"TYPE=" + mapSoftwareFamily(spec.SoftwareFamily),
|
||||||
|
fmt.Sprintf("MEMORY=%dM", spec.RamMb),
|
||||||
|
},
|
||||||
|
ExposedPorts: nat.PortSet{containerPort: struct{}{}},
|
||||||
|
},
|
||||||
|
&container.HostConfig{
|
||||||
|
PortBindings: portBindings,
|
||||||
|
Mounts: []mount.Mount{{
|
||||||
|
Type: mount.TypeBind,
|
||||||
|
Source: spec.DataPath,
|
||||||
|
Target: "/data",
|
||||||
|
}},
|
||||||
|
Resources: container.Resources{
|
||||||
|
Memory: int64(spec.RamMb) * 1024 * 1024,
|
||||||
|
},
|
||||||
|
Privileged: false,
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
containerName(spec.ServerID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create container: %w", err)
|
||||||
|
}
|
||||||
|
return resp.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartContainer starts a container by ID.
|
||||||
|
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
|
||||||
|
if err := c.cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("start container: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopContainer stops a running container gracefully.
|
||||||
|
func (c *Client) StopContainer(ctx context.Context, containerID string) error {
|
||||||
|
timeout := 30
|
||||||
|
if err := c.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}); err != nil {
|
||||||
|
return fmt.Errorf("stop container: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveContainer removes a container, forcing removal if still running.
|
||||||
|
func (c *Client) RemoveContainer(ctx context.Context, containerID string) error {
|
||||||
|
if err := c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil {
|
||||||
|
return fmt.Errorf("remove container: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContainerState inspects a container and returns its current state.
|
||||||
|
func (c *Client) GetContainerState(ctx context.Context, containerID string) (*ContainerState, error) {
|
||||||
|
inspect, err := c.cli.ContainerInspect(ctx, containerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("inspect container: %w", err)
|
||||||
|
}
|
||||||
|
return &ContainerState{
|
||||||
|
ID: inspect.ID,
|
||||||
|
Status: inspect.State.Status,
|
||||||
|
Running: inspect.State.Running,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListManagedContainers returns container IDs labeled for a given server ID.
|
||||||
|
func (c *Client) ListManagedContainers(ctx context.Context, serverID string) ([]string, error) {
|
||||||
|
containers, err := c.cli.ContainerList(ctx, container.ListOptions{
|
||||||
|
All: true,
|
||||||
|
Filters: filters.NewArgs(filters.Arg("name", containerName(serverID))),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list containers: %w", err)
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(containers))
|
||||||
|
for _, ctr := range containers {
|
||||||
|
ids = append(ids, ctr.ID)
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerName(serverID string) string {
|
||||||
|
return "hexahost-" + serverID
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapSoftwareFamily(family string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(family)) {
|
||||||
|
case "PAPER":
|
||||||
|
return "PAPER"
|
||||||
|
default:
|
||||||
|
return "VANILLA"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,31 @@ type ServerStartPayload struct {
|
|||||||
DesiredGeneration int64 `json:"desiredGeneration"`
|
DesiredGeneration int64 `json:"desiredGeneration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerProvisionPayload instructs the agent to create a Minecraft server container.
|
||||||
|
type ServerProvisionPayload struct {
|
||||||
|
OperationMeta
|
||||||
|
Edition string `json:"edition"`
|
||||||
|
SoftwareFamily string `json:"softwareFamily"`
|
||||||
|
MinecraftVersion string `json:"minecraftVersion"`
|
||||||
|
HostPort int `json:"hostPort"`
|
||||||
|
RamMb int `json:"ramMb"`
|
||||||
|
DataPath string `json:"dataPath"`
|
||||||
|
EulaAccepted bool `json:"eulaAccepted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerStopPayload instructs the agent to stop a running game server.
|
||||||
|
type ServerStopPayload struct {
|
||||||
|
OperationMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerOperationResultPayload reports the outcome of a lifecycle operation.
|
||||||
|
type ServerOperationResultPayload struct {
|
||||||
|
OperationMeta
|
||||||
|
ResultCode string `json:"resultCode"`
|
||||||
|
ErrorCode string `json:"errorCode,omitempty"`
|
||||||
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ServerStatePayload reports the observed server state.
|
// ServerStatePayload reports the observed server state.
|
||||||
type ServerStatePayload struct {
|
type ServerStatePayload struct {
|
||||||
OperationMeta
|
OperationMeta
|
||||||
|
|||||||
@@ -136,6 +136,69 @@ func TestDecodeEnvelope(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerProvisionPayload_JSONRoundtrip(t *testing.T) {
|
||||||
|
deadline := time.Date(2026, 6, 26, 12, 0, 0, 0, time.UTC)
|
||||||
|
original := ServerProvisionPayload{
|
||||||
|
OperationMeta: OperationMeta{
|
||||||
|
ServerID: "srv-abc123",
|
||||||
|
Generation: 2,
|
||||||
|
Deadline: &deadline,
|
||||||
|
IdempotencyKey: "idem-001",
|
||||||
|
},
|
||||||
|
Edition: "JAVA",
|
||||||
|
SoftwareFamily: "PAPER",
|
||||||
|
MinecraftVersion: "1.21.1",
|
||||||
|
HostPort: 25565,
|
||||||
|
RamMb: 2048,
|
||||||
|
DataPath: "/var/lib/hexahost-gamecloud/srv-abc123",
|
||||||
|
EulaAccepted: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(original)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded ServerProvisionPayload
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.ServerID != original.ServerID {
|
||||||
|
t.Fatalf("serverId: got %q want %q", decoded.ServerID, original.ServerID)
|
||||||
|
}
|
||||||
|
if decoded.Generation != original.Generation {
|
||||||
|
t.Fatalf("generation: got %d want %d", decoded.Generation, original.Generation)
|
||||||
|
}
|
||||||
|
if decoded.Edition != original.Edition {
|
||||||
|
t.Fatalf("edition: got %q want %q", decoded.Edition, original.Edition)
|
||||||
|
}
|
||||||
|
if decoded.SoftwareFamily != original.SoftwareFamily {
|
||||||
|
t.Fatalf("softwareFamily: got %q want %q", decoded.SoftwareFamily, original.SoftwareFamily)
|
||||||
|
}
|
||||||
|
if decoded.MinecraftVersion != original.MinecraftVersion {
|
||||||
|
t.Fatalf("minecraftVersion: got %q want %q", decoded.MinecraftVersion, original.MinecraftVersion)
|
||||||
|
}
|
||||||
|
if decoded.HostPort != original.HostPort {
|
||||||
|
t.Fatalf("hostPort: got %d want %d", decoded.HostPort, original.HostPort)
|
||||||
|
}
|
||||||
|
if decoded.RamMb != original.RamMb {
|
||||||
|
t.Fatalf("ramMb: got %d want %d", decoded.RamMb, original.RamMb)
|
||||||
|
}
|
||||||
|
if decoded.DataPath != original.DataPath {
|
||||||
|
t.Fatalf("dataPath: got %q want %q", decoded.DataPath, original.DataPath)
|
||||||
|
}
|
||||||
|
if decoded.EulaAccepted != original.EulaAccepted {
|
||||||
|
t.Fatalf("eulaAccepted: got %v want %v", decoded.EulaAccepted, original.EulaAccepted)
|
||||||
|
}
|
||||||
|
if decoded.IdempotencyKey != original.IdempotencyKey {
|
||||||
|
t.Fatalf("idempotencyKey: got %q want %q", decoded.IdempotencyKey, original.IdempotencyKey)
|
||||||
|
}
|
||||||
|
if decoded.Deadline == nil || !decoded.Deadline.Equal(deadline) {
|
||||||
|
t.Fatalf("deadline mismatch: got %v want %v", decoded.Deadline, deadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewEnvelope(t *testing.T) {
|
func TestNewEnvelope(t *testing.T) {
|
||||||
env, err := NewEnvelope(TypeAgentHello, "msg-new", AgentHelloPayload{
|
env, err := NewEnvelope(TypeAgentHello, "msg-new", AgentHelloPayload{
|
||||||
NodeID: "n1",
|
NodeID: "n1",
|
||||||
|
|||||||
269
apps/node-agent/internal/runtime/manager.go
Normal file
269
apps/node-agent/internal/runtime/manager.go
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/hexahost/gamecloud/node-agent/internal/docker"
|
||||||
|
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServerStateProvisioning = "provisioning"
|
||||||
|
ServerStateStopped = "stopped"
|
||||||
|
ServerStateStarting = "starting"
|
||||||
|
ServerStateRunning = "running"
|
||||||
|
ServerStateStopping = "stopping"
|
||||||
|
ServerStateDeleting = "deleting"
|
||||||
|
ServerStateDeleted = "deleted"
|
||||||
|
ServerStateError = "error"
|
||||||
|
|
||||||
|
ResultCodeOK = "ok"
|
||||||
|
ResultCodeError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Responder sends protocol responses back to the control plane.
|
||||||
|
type Responder interface {
|
||||||
|
SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error
|
||||||
|
SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
||||||
|
SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type serverRecord struct {
|
||||||
|
ServerID string
|
||||||
|
ContainerID string
|
||||||
|
HostPort int
|
||||||
|
DataPath string
|
||||||
|
RamMb int
|
||||||
|
State string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager tracks provisioned servers and executes lifecycle operations.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
docker *docker.Client
|
||||||
|
servers map[string]*serverRecord
|
||||||
|
resp Responder
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a runtime manager backed by the given Docker client.
|
||||||
|
func NewManager(dockerClient *docker.Client, resp Responder, log *slog.Logger) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
docker: dockerClient,
|
||||||
|
servers: make(map[string]*serverRecord),
|
||||||
|
resp: resp,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunningCount returns the number of servers currently in the running state.
|
||||||
|
func (m *Manager) RunningCount() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
count := 0
|
||||||
|
for _, rec := range m.servers {
|
||||||
|
if rec.State == ServerStateRunning {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleProvision creates a Minecraft container for the server.
|
||||||
|
func (m *Manager) HandleProvision(ctx context.Context, correlationID string, payload protocol.ServerProvisionPayload) {
|
||||||
|
meta := payload.OperationMeta
|
||||||
|
m.log.Info("provision server",
|
||||||
|
"server_id", meta.ServerID,
|
||||||
|
"host_port", payload.HostPort,
|
||||||
|
"version", payload.MinecraftVersion,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !payload.EulaAccepted {
|
||||||
|
m.fail(ctx, correlationID, meta, "EULA_NOT_ACCEPTED", "EULA must be accepted before provisioning")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if existing, ok := m.servers[meta.ServerID]; ok {
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.log.Info("provision idempotent hit", "server_id", meta.ServerID, "container_id", existing.ContainerID)
|
||||||
|
m.complete(ctx, correlationID, meta, existing.State)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateProvisioning, "", "")
|
||||||
|
|
||||||
|
containerID, err := m.docker.CreateMinecraftServer(ctx, docker.MinecraftServerSpec{
|
||||||
|
ServerID: meta.ServerID,
|
||||||
|
MinecraftVersion: payload.MinecraftVersion,
|
||||||
|
SoftwareFamily: payload.SoftwareFamily,
|
||||||
|
HostPort: payload.HostPort,
|
||||||
|
RamMb: payload.RamMb,
|
||||||
|
DataPath: payload.DataPath,
|
||||||
|
EulaAccepted: payload.EulaAccepted,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
m.fail(ctx, correlationID, meta, "PROVISION_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := &serverRecord{
|
||||||
|
ServerID: meta.ServerID,
|
||||||
|
ContainerID: containerID,
|
||||||
|
HostPort: payload.HostPort,
|
||||||
|
DataPath: payload.DataPath,
|
||||||
|
RamMb: payload.RamMb,
|
||||||
|
State: ServerStateStopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.servers[meta.ServerID] = rec
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateStopped, "", "")
|
||||||
|
m.complete(ctx, correlationID, meta, ServerStateStopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleStart starts a provisioned server container.
|
||||||
|
func (m *Manager) HandleStart(ctx context.Context, correlationID string, payload protocol.ServerStartPayload) {
|
||||||
|
meta := payload.OperationMeta
|
||||||
|
rec, ok := m.getServer(meta.ServerID)
|
||||||
|
if !ok {
|
||||||
|
m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateStarting, "", "")
|
||||||
|
|
||||||
|
if err := m.docker.StartContainer(ctx, rec.ContainerID); err != nil {
|
||||||
|
m.setState(meta.ServerID, ServerStateError)
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateError, "START_FAILED", err.Error())
|
||||||
|
m.fail(ctx, correlationID, meta, "START_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.setState(meta.ServerID, ServerStateRunning)
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateRunning, "", "")
|
||||||
|
m.complete(ctx, correlationID, meta, ServerStateRunning)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleStop stops a running server container.
|
||||||
|
func (m *Manager) HandleStop(ctx context.Context, correlationID string, payload protocol.ServerStopPayload) {
|
||||||
|
meta := payload.OperationMeta
|
||||||
|
rec, ok := m.getServer(meta.ServerID)
|
||||||
|
if !ok {
|
||||||
|
m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateStopping, "", "")
|
||||||
|
|
||||||
|
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
|
||||||
|
m.setState(meta.ServerID, ServerStateError)
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateError, "STOP_FAILED", err.Error())
|
||||||
|
m.fail(ctx, correlationID, meta, "STOP_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.setState(meta.ServerID, ServerStateStopped)
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateStopped, "", "")
|
||||||
|
m.complete(ctx, correlationID, meta, ServerStateStopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDelete removes a server container and drops local state.
|
||||||
|
func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
|
||||||
|
rec, ok := m.getServer(meta.ServerID)
|
||||||
|
if !ok {
|
||||||
|
m.complete(ctx, correlationID, meta, ServerStateDeleted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateDeleting, "", "")
|
||||||
|
|
||||||
|
if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running {
|
||||||
|
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
|
||||||
|
m.fail(ctx, correlationID, meta, "DELETE_STOP_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.docker.RemoveContainer(ctx, rec.ContainerID); err != nil {
|
||||||
|
m.fail(ctx, correlationID, meta, "DELETE_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.servers, meta.ServerID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateDeleted, "", "")
|
||||||
|
m.complete(ctx, correlationID, meta, ServerStateDeleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
rec, ok := m.servers[serverID]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
copy := *rec
|
||||||
|
return ©, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) setState(serverID, state string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if rec, ok := m.servers[serverID]; ok {
|
||||||
|
rec.State = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) emitState(ctx context.Context, correlationID string, meta protocol.OperationMeta, state, errorCode, errorMessage string) {
|
||||||
|
payload := protocol.ServerStatePayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
State: state,
|
||||||
|
ErrorCode: errorCode,
|
||||||
|
ErrorMessage: errorMessage,
|
||||||
|
}
|
||||||
|
if err := m.resp.SendState(ctx, correlationID, payload); err != nil {
|
||||||
|
m.log.Warn("send server.state failed",
|
||||||
|
"server_id", meta.ServerID,
|
||||||
|
"state", state,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) complete(ctx context.Context, correlationID string, meta protocol.OperationMeta, _ string) {
|
||||||
|
payload := protocol.ServerOperationResultPayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
ResultCode: ResultCodeOK,
|
||||||
|
}
|
||||||
|
if err := m.resp.SendOperationComplete(ctx, correlationID, payload); err != nil {
|
||||||
|
m.log.Warn("send operation complete failed",
|
||||||
|
"server_id", meta.ServerID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fail(ctx context.Context, correlationID string, meta protocol.OperationMeta, errorCode, errorMessage string) {
|
||||||
|
m.emitState(ctx, correlationID, meta, ServerStateError, errorCode, errorMessage)
|
||||||
|
payload := protocol.ServerOperationResultPayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
ResultCode: ResultCodeError,
|
||||||
|
ErrorCode: errorCode,
|
||||||
|
ErrorMessage: errorMessage,
|
||||||
|
}
|
||||||
|
if err := m.resp.SendOperationFailed(ctx, correlationID, payload); err != nil {
|
||||||
|
m.log.Warn("send operation failed failed",
|
||||||
|
"server_id", meta.ServerID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
357
apps/node-agent/internal/ws/client.go
Normal file
357
apps/node-agent/internal/ws/client.go
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/hexahost/gamecloud/node-agent/internal/config"
|
||||||
|
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
|
||||||
|
runtimepkg "github.com/hexahost/gamecloud/node-agent/internal/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
writeWait = 10 * time.Second
|
||||||
|
pongWait = 60 * time.Second
|
||||||
|
pingPeriod = (pongWait * 9) / 10
|
||||||
|
maxReconnectDelay = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client maintains an outbound WebSocket connection to the control plane.
|
||||||
|
type Client struct {
|
||||||
|
cfg *config.Config
|
||||||
|
log *slog.Logger
|
||||||
|
runtime *runtimepkg.Manager
|
||||||
|
agentVersion string
|
||||||
|
|
||||||
|
writeMu sync.Mutex
|
||||||
|
conn *websocket.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a WebSocket client for the control plane.
|
||||||
|
func NewClient(cfg *config.Config, log *slog.Logger, mgr *runtimepkg.Manager, agentVersion string) *Client {
|
||||||
|
return &Client{
|
||||||
|
cfg: cfg,
|
||||||
|
log: log,
|
||||||
|
runtime: mgr,
|
||||||
|
agentVersion: agentVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRuntime attaches the runtime manager after construction to break init cycles.
|
||||||
|
func (c *Client) SetRuntime(mgr *runtimepkg.Manager) {
|
||||||
|
c.runtime = mgr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run connects to the control plane and processes messages until ctx is cancelled.
|
||||||
|
func (c *Client) Run(ctx context.Context) error {
|
||||||
|
backoff := c.cfg.ReconnectBackoff
|
||||||
|
if backoff <= 0 {
|
||||||
|
backoff = 5 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.session(ctx)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Warn("websocket disconnected", "error", err, "retry_in", backoff.String())
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
|
||||||
|
backoff *= 2
|
||||||
|
if backoff > maxReconnectDelay {
|
||||||
|
backoff = maxReconnectDelay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) session(ctx context.Context) error {
|
||||||
|
conn, err := c.dial(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeMu.Lock()
|
||||||
|
c.conn = conn
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
c.writeMu.Lock()
|
||||||
|
c.conn = nil
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := c.sendHello(ctx); err != nil {
|
||||||
|
return fmt.Errorf("send hello: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
errCh := make(chan error, 2)
|
||||||
|
go func() {
|
||||||
|
errCh <- c.heartbeatLoop(sessionCtx)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
errCh <- c.readLoop(sessionCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
cancel()
|
||||||
|
return ctx.Err()
|
||||||
|
case err := <-errCh:
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dial(ctx context.Context) (*websocket.Conn, error) {
|
||||||
|
endpoint, err := c.buildURL()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dialer := websocket.DefaultDialer
|
||||||
|
conn, _, err := dialer.DialContext(ctx, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dial websocket: %w", err)
|
||||||
|
}
|
||||||
|
c.log.Info("connected to control plane", "url", redactToken(endpoint))
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) buildURL() (string, error) {
|
||||||
|
u, err := url.Parse(c.cfg.ControlPlaneURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse control plane url: %w", err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("nodeId", c.cfg.NodeID)
|
||||||
|
if c.cfg.NodeToken != "" {
|
||||||
|
q.Set("token", c.cfg.NodeToken)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendHello(ctx context.Context) error {
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
payload := protocol.AgentHelloPayload{
|
||||||
|
NodeID: c.cfg.NodeID,
|
||||||
|
AgentVersion: c.agentVersion,
|
||||||
|
ProtocolVersion: protocol.CurrentProtocolVersion,
|
||||||
|
Hostname: hostname,
|
||||||
|
Capabilities: []string{"minecraft", "docker"},
|
||||||
|
}
|
||||||
|
return c.send(ctx, protocol.TypeAgentHello, "", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) heartbeatLoop(ctx context.Context) error {
|
||||||
|
ticker := time.NewTicker(c.cfg.HeartbeatInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
pingTicker := time.NewTicker(pingPeriod)
|
||||||
|
defer pingTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.sendHeartbeat(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case <-pingTicker.C:
|
||||||
|
if err := c.writePing(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendHeartbeat(ctx context.Context) error {
|
||||||
|
var memStats runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memStats)
|
||||||
|
|
||||||
|
payload := protocol.AgentHeartbeatPayload{
|
||||||
|
NodeID: c.cfg.NodeID,
|
||||||
|
CPUUsagePercent: 0,
|
||||||
|
MemoryUsedBytes: int64(memStats.Alloc),
|
||||||
|
MemoryTotalBytes: int64(memStats.Sys),
|
||||||
|
RunningServers: c.runtime.RunningCount(),
|
||||||
|
}
|
||||||
|
return c.send(ctx, protocol.TypeAgentHeartbeat, "", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readLoop(ctx context.Context) error {
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("connection closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := protocol.DecodeEnvelope(data)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("invalid inbound envelope", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.dispatch(ctx, env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
|
||||||
|
correlationID := env.CorrelationID
|
||||||
|
if correlationID == "" {
|
||||||
|
correlationID = env.MessageID
|
||||||
|
}
|
||||||
|
|
||||||
|
switch env.Type {
|
||||||
|
case protocol.TypeServerProvision:
|
||||||
|
var payload protocol.ServerProvisionPayload
|
||||||
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||||
|
c.log.Warn("decode server.provision", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleProvision(ctx, correlationID, payload)
|
||||||
|
|
||||||
|
case protocol.TypeServerStart:
|
||||||
|
var payload protocol.ServerStartPayload
|
||||||
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||||
|
c.log.Warn("decode server.start", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleStart(ctx, correlationID, payload)
|
||||||
|
|
||||||
|
case protocol.TypeServerStop:
|
||||||
|
var payload protocol.ServerStopPayload
|
||||||
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||||
|
c.log.Warn("decode server.stop", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleStop(ctx, correlationID, payload)
|
||||||
|
|
||||||
|
case protocol.TypeServerDelete:
|
||||||
|
var meta protocol.OperationMeta
|
||||||
|
if err := json.Unmarshal(env.Payload, &meta); err != nil {
|
||||||
|
c.log.Warn("decode server.delete", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleDelete(ctx, correlationID, meta)
|
||||||
|
|
||||||
|
default:
|
||||||
|
c.log.Debug("ignored control message", "type", env.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) send(ctx context.Context, msgType protocol.MessageType, correlationID string, payload any) error {
|
||||||
|
env, err := protocol.NewEnvelope(msgType, uuid.NewString(), payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if correlationID != "" {
|
||||||
|
env.CorrelationID = correlationID
|
||||||
|
}
|
||||||
|
return c.writeEnvelope(ctx, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writeEnvelope(ctx context.Context, env *protocol.Envelope) error {
|
||||||
|
data, err := json.Marshal(env)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal envelope: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
deadline = time.Now().Add(writeWait)
|
||||||
|
}
|
||||||
|
if err := conn.SetWriteDeadline(deadline); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
return c.conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writePing() error {
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
return c.conn.WriteMessage(websocket.PingMessage, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendState implements runtime.Responder.
|
||||||
|
func (c *Client) SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error {
|
||||||
|
return c.send(ctx, protocol.TypeServerState, correlationID, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendOperationComplete implements runtime.Responder.
|
||||||
|
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
|
||||||
|
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendOperationFailed implements runtime.Responder.
|
||||||
|
func (c *Client) SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
|
||||||
|
return c.send(ctx, protocol.TypeServerOperationFailed, correlationID, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactToken(rawURL string) string {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
if u.Query().Get("token") != "" {
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("token", "REDACTED")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"submit": "Absenden",
|
"submit": "Absenden",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"footer": "Selbstgehostete Minecraft-Server-Plattform."
|
"footer": "Selbstgehostete Minecraft-Server-Plattform.",
|
||||||
|
"servers": "Server"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"badge": "Phase 0 · Control Plane",
|
"badge": "Phase 0 · Control Plane",
|
||||||
@@ -95,12 +96,20 @@
|
|||||||
"welcome": "Willkommen in Ihrem Kontrollbereich.",
|
"welcome": "Willkommen in Ihrem Kontrollbereich.",
|
||||||
"servers": "Server",
|
"servers": "Server",
|
||||||
"serversEmpty": "Noch keine Server",
|
"serversEmpty": "Noch keine Server",
|
||||||
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, sobald die Provisionierung verfügbar ist.",
|
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, um loszulegen.",
|
||||||
"createServer": "Server erstellen",
|
"createServer": "Server erstellen",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"resources": "Ressourcen",
|
"resources": "Ressourcen",
|
||||||
"activity": "Aktivität",
|
"activity": "Aktivität",
|
||||||
"activityEmpty": "Keine aktuellen Ereignisse"
|
"activityEmpty": "Keine aktuellen Ereignisse",
|
||||||
|
"controlPlaneOnline": "Control Plane · Online",
|
||||||
|
"activeServers": "Aktive Server",
|
||||||
|
"resourcesSubtitle": "Zugewiesen / Kontingent",
|
||||||
|
"allocatedRam": "Zugewiesener RAM",
|
||||||
|
"activitySubtitle": "Übersicht",
|
||||||
|
"totalServers": "Server gesamt",
|
||||||
|
"viewAllServers": "Alle Server anzeigen",
|
||||||
|
"serversSummary": "Server in Ihrem Konto"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"emailRequired": "E-Mail ist erforderlich",
|
"emailRequired": "E-Mail ist erforderlich",
|
||||||
@@ -111,6 +120,66 @@
|
|||||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||||
"usernameInvalid": "Benutzername: 3–32 Zeichen (Buchstaben, Zahlen, _ und -)",
|
"usernameInvalid": "Benutzername: 3–32 Zeichen (Buchstaben, Zahlen, _ und -)",
|
||||||
"codeRequired": "Authentifizierungscode ist erforderlich",
|
"codeRequired": "Authentifizierungscode ist erforderlich",
|
||||||
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein"
|
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein",
|
||||||
|
"serverNameRequired": "Servername ist erforderlich",
|
||||||
|
"serverNameMax": "Servername darf höchstens 64 Zeichen lang sein",
|
||||||
|
"planRequired": "Bitte wählen Sie einen Tarif",
|
||||||
|
"planInvalid": "Ungültiger Tarif ausgewählt",
|
||||||
|
"versionRequired": "Minecraft-Version ist erforderlich",
|
||||||
|
"softwareRequired": "Software-Familie ist erforderlich",
|
||||||
|
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren"
|
||||||
|
},
|
||||||
|
"servers": {
|
||||||
|
"title": "Server",
|
||||||
|
"subtitle": "Verwalten Sie Ihre Minecraft-Server.",
|
||||||
|
"create": "Server erstellen",
|
||||||
|
"createTitle": "Neuer Server",
|
||||||
|
"createDescription": "Konfigurieren und provisionieren Sie einen neuen Minecraft-Server.",
|
||||||
|
"name": "Servername",
|
||||||
|
"plan": "Tarif",
|
||||||
|
"selectPlan": "Tarif auswählen",
|
||||||
|
"noPlans": "Keine Tarife verfügbar. Bitten Sie einen Administrator, einen Tarif anzulegen.",
|
||||||
|
"software": "Software",
|
||||||
|
"version": "Minecraft-Version",
|
||||||
|
"eula": "Ich akzeptiere die Minecraft End User License Agreement (EULA).",
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"emptyTitle": "Noch keine Server",
|
||||||
|
"emptyDescription": "Erstellen Sie Ihren ersten Server, um zu spielen.",
|
||||||
|
"address": "Adresse",
|
||||||
|
"addressPending": "Zuweisung ausstehend",
|
||||||
|
"ram": "RAM",
|
||||||
|
"start": "Starten",
|
||||||
|
"stop": "Stoppen",
|
||||||
|
"details": "Details",
|
||||||
|
"detailSubtitle": "Serverdetails und Lifecycle-Steuerung",
|
||||||
|
"backToList": "Zurück zur Serverliste",
|
||||||
|
"retry": "Erneut versuchen",
|
||||||
|
"status": {
|
||||||
|
"DRAFT": "Entwurf",
|
||||||
|
"PROVISIONING": "Provisionierung",
|
||||||
|
"INSTALLING": "Installation",
|
||||||
|
"STOPPED": "Gestoppt",
|
||||||
|
"STARTING": "Startet",
|
||||||
|
"RUNNING": "Läuft",
|
||||||
|
"STOPPING": "Stoppt",
|
||||||
|
"ERROR": "Fehler",
|
||||||
|
"DELETING": "Wird gelöscht",
|
||||||
|
"DELETED": "Gelöscht"
|
||||||
|
},
|
||||||
|
"softwareOptions": {
|
||||||
|
"VANILLA": "Vanilla",
|
||||||
|
"PAPER": "Paper",
|
||||||
|
"PURPUR": "Purpur",
|
||||||
|
"FABRIC": "Fabric",
|
||||||
|
"FORGE": "Forge",
|
||||||
|
"NEOFORGE": "NeoForge",
|
||||||
|
"QUILT": "Quilt",
|
||||||
|
"BEDROCK_DEDICATED": "Bedrock Dedicated"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
|
||||||
|
"loadFailed": "Server konnten nicht geladen werden."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"footer": "Self-hosted Minecraft server platform."
|
"footer": "Self-hosted Minecraft server platform.",
|
||||||
|
"servers": "Servers"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"badge": "Phase 0 · Control Plane",
|
"badge": "Phase 0 · Control Plane",
|
||||||
@@ -95,12 +96,20 @@
|
|||||||
"welcome": "Welcome to your control area.",
|
"welcome": "Welcome to your control area.",
|
||||||
"servers": "Servers",
|
"servers": "Servers",
|
||||||
"serversEmpty": "No servers yet",
|
"serversEmpty": "No servers yet",
|
||||||
"serversEmptyDescription": "Create your first Minecraft server once provisioning is available.",
|
"serversEmptyDescription": "Create your first Minecraft server to get started.",
|
||||||
"createServer": "Create server",
|
"createServer": "Create server",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"resources": "Resources",
|
"resources": "Resources",
|
||||||
"activity": "Activity",
|
"activity": "Activity",
|
||||||
"activityEmpty": "No recent events"
|
"activityEmpty": "No recent events",
|
||||||
|
"controlPlaneOnline": "Control Plane · Online",
|
||||||
|
"activeServers": "Active servers",
|
||||||
|
"resourcesSubtitle": "Allocated / Quota",
|
||||||
|
"allocatedRam": "Allocated RAM",
|
||||||
|
"activitySubtitle": "Overview",
|
||||||
|
"totalServers": "Total servers",
|
||||||
|
"viewAllServers": "View all servers",
|
||||||
|
"serversSummary": "Servers in your account"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"emailRequired": "Email is required",
|
"emailRequired": "Email is required",
|
||||||
@@ -111,6 +120,66 @@
|
|||||||
"passwordMismatch": "Passwords do not match",
|
"passwordMismatch": "Passwords do not match",
|
||||||
"usernameInvalid": "Username must be 3–32 characters (letters, numbers, _ and -)",
|
"usernameInvalid": "Username must be 3–32 characters (letters, numbers, _ and -)",
|
||||||
"codeRequired": "Authentication code is required",
|
"codeRequired": "Authentication code is required",
|
||||||
"codeInvalid": "Enter a valid 6-digit code"
|
"codeInvalid": "Enter a valid 6-digit code",
|
||||||
|
"serverNameRequired": "Server name is required",
|
||||||
|
"serverNameMax": "Server name must be at most 64 characters",
|
||||||
|
"planRequired": "Please select a plan",
|
||||||
|
"planInvalid": "Invalid plan selected",
|
||||||
|
"versionRequired": "Minecraft version is required",
|
||||||
|
"softwareRequired": "Software family is required",
|
||||||
|
"eulaRequired": "You must accept the Minecraft EULA"
|
||||||
|
},
|
||||||
|
"servers": {
|
||||||
|
"title": "Servers",
|
||||||
|
"subtitle": "Manage your Minecraft servers.",
|
||||||
|
"create": "Create server",
|
||||||
|
"createTitle": "New server",
|
||||||
|
"createDescription": "Configure and provision a new Minecraft server.",
|
||||||
|
"name": "Server name",
|
||||||
|
"plan": "Plan",
|
||||||
|
"selectPlan": "Select a plan",
|
||||||
|
"noPlans": "No plans are available. Ask an administrator to create a plan.",
|
||||||
|
"software": "Software",
|
||||||
|
"version": "Minecraft version",
|
||||||
|
"eula": "I accept the Minecraft End User License Agreement (EULA).",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"emptyTitle": "No servers yet",
|
||||||
|
"emptyDescription": "Create your first server to start playing.",
|
||||||
|
"address": "Address",
|
||||||
|
"addressPending": "Pending allocation",
|
||||||
|
"ram": "RAM",
|
||||||
|
"start": "Start",
|
||||||
|
"stop": "Stop",
|
||||||
|
"details": "Details",
|
||||||
|
"detailSubtitle": "Server details and lifecycle controls",
|
||||||
|
"backToList": "Back to servers",
|
||||||
|
"retry": "Retry",
|
||||||
|
"status": {
|
||||||
|
"DRAFT": "Draft",
|
||||||
|
"PROVISIONING": "Provisioning",
|
||||||
|
"INSTALLING": "Installing",
|
||||||
|
"STOPPED": "Stopped",
|
||||||
|
"STARTING": "Starting",
|
||||||
|
"RUNNING": "Running",
|
||||||
|
"STOPPING": "Stopping",
|
||||||
|
"ERROR": "Error",
|
||||||
|
"DELETING": "Deleting",
|
||||||
|
"DELETED": "Deleted"
|
||||||
|
},
|
||||||
|
"softwareOptions": {
|
||||||
|
"VANILLA": "Vanilla",
|
||||||
|
"PAPER": "Paper",
|
||||||
|
"PURPUR": "Purpur",
|
||||||
|
"FABRIC": "Fabric",
|
||||||
|
"FORGE": "Forge",
|
||||||
|
"NEOFORGE": "NeoForge",
|
||||||
|
"QUILT": "Quilt",
|
||||||
|
"BEDROCK_DEDICATED": "Bedrock Dedicated"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "Something went wrong. Please try again.",
|
||||||
|
"loadFailed": "Could not load servers."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export default async function DashboardLayout({
|
|||||||
{t("dashboard")}
|
{t("dashboard")}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/servers"
|
||||||
|
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{t("servers")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<DashboardUserInfo />
|
<DashboardUserInfo />
|
||||||
|
|||||||
13
apps/web/src/app/[locale]/servers/[id]/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { setRequestLocale } from "next-intl/server";
|
||||||
|
import { ServerDetailContent } from "@/components/servers/server-detail-content";
|
||||||
|
|
||||||
|
interface ServerDetailPageProps {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ServerDetailPage({ params }: ServerDetailPageProps) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
return <ServerDetailContent serverId={id} />;
|
||||||
|
}
|
||||||
45
apps/web/src/app/[locale]/servers/layout.tsx
Normal file
45
apps/web/src/app/[locale]/servers/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
|
||||||
|
|
||||||
|
interface ServersLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ServersLayout({ children, params }: ServersLayoutProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||||
|
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<nav aria-label="App navigation">
|
||||||
|
<ul className="flex gap-4 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{t("dashboard")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/servers"
|
||||||
|
className="font-medium text-zinc-900 dark:text-zinc-50"
|
||||||
|
aria-current="page"
|
||||||
|
>
|
||||||
|
{t("servers")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<DashboardUserInfo />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/servers/new/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/new/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { setRequestLocale } from "next-intl/server";
|
||||||
|
import { CreateServerForm } from "@/components/servers/create-server-form";
|
||||||
|
|
||||||
|
interface NewServerPageProps {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewServerPage({ params }: NewServerPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
return <CreateServerForm />;
|
||||||
|
}
|
||||||
34
apps/web/src/app/[locale]/servers/page.tsx
Normal file
34
apps/web/src/app/[locale]/servers/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import { ServerList } from "@/components/servers/server-list";
|
||||||
|
|
||||||
|
interface ServersPageProps {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ServersPage({ params }: ServersPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations("servers");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/servers/new"
|
||||||
|
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||||
|
>
|
||||||
|
{t("create")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ServerList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { Link, useRouter } from "@/i18n/navigation";
|
||||||
import { EmptyState } from "@/components/dashboard/empty-state";
|
import { EmptyState } from "@/components/dashboard/empty-state";
|
||||||
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
|
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
|
||||||
|
import { listServers } from "@/lib/api/servers";
|
||||||
|
|
||||||
export function DashboardContent() {
|
export function DashboardContent() {
|
||||||
const t = useTranslations("dashboard");
|
const t = useTranslations("dashboard");
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const router = useRouter();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["servers"],
|
||||||
|
queryFn: listServers,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const servers = data?.servers ?? [];
|
||||||
const timer = window.setTimeout(() => setIsLoading(false), 600);
|
const activeCount = servers.filter((server) =>
|
||||||
return () => window.clearTimeout(timer);
|
["RUNNING", "STARTING", "STOPPING"].includes(server.status),
|
||||||
}, []);
|
).length;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <DashboardSkeleton />;
|
return <DashboardSkeleton />;
|
||||||
@@ -32,49 +38,83 @@ export function DashboardContent() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("status")}</CardTitle>
|
<CardTitle>{t("status")}</CardTitle>
|
||||||
<CardDescription>Control Plane · Online</CardDescription>
|
<CardDescription>{t("controlPlaneOnline")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="font-mono text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
|
<p className="font-mono text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
|
||||||
0
|
{activeCount}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">Active servers</p>
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activeServers")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("resources")}</CardTitle>
|
<CardTitle>{t("resources")}</CardTitle>
|
||||||
<CardDescription>Allocated / Quota</CardDescription>
|
<CardDescription>{t("resourcesSubtitle")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
0 / 0
|
{servers.reduce((sum, server) => sum + server.ramMb, 0)} MB
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">CPU · RAM · Disk</p>
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("allocatedRam")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("activity")}</CardTitle>
|
<CardTitle>{t("activity")}</CardTitle>
|
||||||
<CardDescription>Last 24 hours</CardDescription>
|
<CardDescription>{t("activitySubtitle")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activityEmpty")}</p>
|
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
{servers.length}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("totalServers")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section aria-labelledby="servers-heading">
|
<section aria-labelledby="servers-heading">
|
||||||
<h2 id="servers-heading" className="mb-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
{t("servers")}
|
<h2 id="servers-heading" className="text-lg font-medium text-zinc-900 dark:text-zinc-50">
|
||||||
</h2>
|
{t("servers")}
|
||||||
<EmptyState
|
</h2>
|
||||||
title={t("serversEmpty")}
|
<Link
|
||||||
description={t("serversEmptyDescription")}
|
href="/servers"
|
||||||
actionLabel={t("createServer")}
|
className="text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
||||||
/>
|
>
|
||||||
|
{t("viewAllServers")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{servers.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t("serversEmpty")}
|
||||||
|
description={t("serversEmptyDescription")}
|
||||||
|
actionLabel={t("createServer")}
|
||||||
|
onAction={() => {
|
||||||
|
router.push("/servers/new");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("serversSummary")}</p>
|
||||||
|
<p className="text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
{servers.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/servers/new"
|
||||||
|
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||||
|
>
|
||||||
|
{t("createServer")}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
212
apps/web/src/components/servers/create-server-form.tsx
Normal file
212
apps/web/src/components/servers/create-server-form.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Link, useRouter } from "@/i18n/navigation";
|
||||||
|
import { ApiError } from "@/lib/api/auth";
|
||||||
|
import { createServer, listPlans } from "@/lib/api/servers";
|
||||||
|
import {
|
||||||
|
createServerSchema,
|
||||||
|
MINECRAFT_VERSIONS,
|
||||||
|
SOFTWARE_FAMILIES,
|
||||||
|
type CreateServerFormValues,
|
||||||
|
} from "@/lib/schemas/server";
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||||
|
|
||||||
|
export function CreateServerForm() {
|
||||||
|
const t = useTranslations("servers");
|
||||||
|
const tv = useTranslations("validation");
|
||||||
|
const router = useRouter();
|
||||||
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: plansData, isLoading: plansLoading } = useQuery({
|
||||||
|
queryKey: ["plans"],
|
||||||
|
queryFn: listPlans,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const schema = createServerSchema({
|
||||||
|
nameRequired: tv("serverNameRequired"),
|
||||||
|
nameMax: tv("serverNameMax"),
|
||||||
|
planRequired: tv("planRequired"),
|
||||||
|
planInvalid: tv("planInvalid"),
|
||||||
|
versionRequired: tv("versionRequired"),
|
||||||
|
softwareRequired: tv("softwareRequired"),
|
||||||
|
eulaRequired: tv("eulaRequired"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<CreateServerFormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
planId: "",
|
||||||
|
minecraftVersion: MINECRAFT_VERSIONS[0],
|
||||||
|
softwareFamily: "VANILLA",
|
||||||
|
eulaAccepted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: createServer,
|
||||||
|
onSuccess: (server) => {
|
||||||
|
router.push(`/servers/${server.id}`);
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: CreateServerFormValues) {
|
||||||
|
setApiError(null);
|
||||||
|
await createMutation.mutateAsync({
|
||||||
|
...values,
|
||||||
|
eulaAccepted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const plans = plansData?.plans ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("createTitle")}</CardTitle>
|
||||||
|
<CardDescription>{t("createDescription")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
{t("name")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
className={inputClassName}
|
||||||
|
aria-invalid={errors.name ? "true" : "false"}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
{errors.name ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{errors.name.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="planId" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
{t("plan")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="planId"
|
||||||
|
className={inputClassName}
|
||||||
|
disabled={plansLoading}
|
||||||
|
aria-invalid={errors.planId ? "true" : "false"}
|
||||||
|
{...register("planId")}
|
||||||
|
>
|
||||||
|
<option value="">{plansLoading ? t("loading") : t("selectPlan")}</option>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<option key={plan.id} value={plan.id}>
|
||||||
|
{plan.name} ({plan.maxRamMb} MB)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.planId ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{errors.planId.message}</p>
|
||||||
|
) : null}
|
||||||
|
{!plansLoading && plans.length === 0 ? (
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300">{t("noPlans")}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="softwareFamily"
|
||||||
|
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
|
||||||
|
>
|
||||||
|
{t("software")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="softwareFamily"
|
||||||
|
className={inputClassName}
|
||||||
|
aria-invalid={errors.softwareFamily ? "true" : "false"}
|
||||||
|
{...register("softwareFamily")}
|
||||||
|
>
|
||||||
|
{SOFTWARE_FAMILIES.map((family) => (
|
||||||
|
<option key={family} value={family}>
|
||||||
|
{t(`softwareOptions.${family}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.softwareFamily ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{errors.softwareFamily.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="minecraftVersion"
|
||||||
|
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
|
||||||
|
>
|
||||||
|
{t("version")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="minecraftVersion"
|
||||||
|
className={inputClassName}
|
||||||
|
aria-invalid={errors.minecraftVersion ? "true" : "false"}
|
||||||
|
{...register("minecraftVersion")}
|
||||||
|
>
|
||||||
|
{MINECRAFT_VERSIONS.map((version) => (
|
||||||
|
<option key={version} value={version}>
|
||||||
|
{version}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.minecraftVersion ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{errors.minecraftVersion.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||||
|
<input type="checkbox" className="mt-1" {...register("eulaAccepted")} />
|
||||||
|
<span>{t("eula")}</span>
|
||||||
|
</label>
|
||||||
|
{errors.eulaAccepted ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{errors.eulaAccepted.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apiError ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
|
{apiError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" isLoading={isSubmitting || createMutation.isPending}>
|
||||||
|
{t("create")}
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
href="/servers"
|
||||||
|
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
apps/web/src/components/servers/server-card.tsx
Normal file
113
apps/web/src/components/servers/server-card.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import { ApiError } from "@/lib/api/auth";
|
||||||
|
import {
|
||||||
|
type Server,
|
||||||
|
startServer,
|
||||||
|
stopServer,
|
||||||
|
} from "@/lib/api/servers";
|
||||||
|
import { ServerStatusBadge } from "./server-status-badge";
|
||||||
|
|
||||||
|
interface ServerCardProps {
|
||||||
|
server: Server;
|
||||||
|
showDetailsLink?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerCard({ server, showDetailsLink = true }: ServerCardProps) {
|
||||||
|
const t = useTranslations("servers");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const startMutation = useMutation({
|
||||||
|
mutationFn: () => startServer(server.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
setActionError(null);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["server", server.id] });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
setActionError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopMutation = useMutation({
|
||||||
|
mutationFn: () => stopServer(server.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
setActionError(null);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["server", server.id] });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
setActionError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canStart = server.status === "STOPPED";
|
||||||
|
const canStop = server.status === "RUNNING";
|
||||||
|
const isBusy = ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(server.status);
|
||||||
|
const address =
|
||||||
|
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<CardTitle className="truncate text-lg">{server.name}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{server.softwareFamily} · {server.minecraftVersion}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<ServerStatusBadge status={server.status} label={t(`status.${server.status}`)} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<dl className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-zinc-500 dark:text-zinc-400">{t("address")}</dt>
|
||||||
|
<dd className="font-mono text-zinc-900 dark:text-zinc-100">{address}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-zinc-500 dark:text-zinc-400">{t("ram")}</dt>
|
||||||
|
<dd className="text-zinc-900 dark:text-zinc-100">{server.ramMb} MB</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{actionError ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startMutation.mutate()}
|
||||||
|
disabled={!canStart || isBusy || startMutation.isPending || stopMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("start")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => stopMutation.mutate()}
|
||||||
|
disabled={!canStop || isBusy || startMutation.isPending || stopMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("stop")}
|
||||||
|
</Button>
|
||||||
|
{showDetailsLink ? (
|
||||||
|
<Link
|
||||||
|
href={`/servers/${server.id}`}
|
||||||
|
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{t("details")}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
apps/web/src/components/servers/server-detail-content.tsx
Normal file
73
apps/web/src/components/servers/server-detail-content.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@hexahost/ui";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import { getServer } from "@/lib/api/servers";
|
||||||
|
import { ServerCard } from "@/components/servers/server-card";
|
||||||
|
|
||||||
|
interface ServerDetailContentProps {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
|
||||||
|
const t = useTranslations("servers");
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["server", serverId],
|
||||||
|
queryFn: () => getServer(serverId),
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const status = query.state.data?.status;
|
||||||
|
if (!status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
|
||||||
|
? 3000
|
||||||
|
: false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
|
||||||
|
{t("retry")}
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
href="/servers"
|
||||||
|
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{t("backToList")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
{data.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/servers"
|
||||||
|
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
||||||
|
>
|
||||||
|
{t("backToList")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ServerCard server={data} showDetailsLink={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/web/src/components/servers/server-list.tsx
Normal file
56
apps/web/src/components/servers/server-list.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@hexahost/ui";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import { listServers } from "@/lib/api/servers";
|
||||||
|
import { ServerCard } from "./server-card";
|
||||||
|
|
||||||
|
export function ServerList() {
|
||||||
|
const t = useTranslations("servers");
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["servers"],
|
||||||
|
queryFn: listServers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
|
||||||
|
{t("retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = data?.servers ?? [];
|
||||||
|
|
||||||
|
if (servers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
|
||||||
|
<h2 className="text-lg font-medium text-zinc-900 dark:text-zinc-50">{t("emptyTitle")}</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">{t("emptyDescription")}</p>
|
||||||
|
<Link
|
||||||
|
href="/servers/new"
|
||||||
|
className="mt-4 inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||||
|
>
|
||||||
|
{t("create")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<ServerCard key={server.id} server={server} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/src/components/servers/server-status-badge.tsx
Normal file
37
apps/web/src/components/servers/server-status-badge.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@hexahost/ui";
|
||||||
|
import type { GameServerStatus } from "@/lib/api/servers";
|
||||||
|
|
||||||
|
const statusStyles: Record<GameServerStatus, string> = {
|
||||||
|
DRAFT: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||||
|
PROVISIONING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||||
|
INSTALLING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||||
|
STOPPED: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||||
|
STARTING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
|
||||||
|
RUNNING: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
|
||||||
|
STOPPING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
|
||||||
|
ERROR: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
||||||
|
DELETING: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
||||||
|
DELETED: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ServerStatusBadgeProps {
|
||||||
|
status: GameServerStatus;
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerStatusBadge({ status, label, className }: ServerStatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||||
|
statusStyles[status],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
apps/web/src/lib/api/servers.ts
Normal file
104
apps/web/src/lib/api/servers.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { apiFetch } from "./auth";
|
||||||
|
|
||||||
|
export type GameServerStatus =
|
||||||
|
| "DRAFT"
|
||||||
|
| "PROVISIONING"
|
||||||
|
| "INSTALLING"
|
||||||
|
| "STOPPED"
|
||||||
|
| "STARTING"
|
||||||
|
| "RUNNING"
|
||||||
|
| "STOPPING"
|
||||||
|
| "ERROR"
|
||||||
|
| "DELETING"
|
||||||
|
| "DELETED";
|
||||||
|
|
||||||
|
export type SoftwareFamily =
|
||||||
|
| "VANILLA"
|
||||||
|
| "PAPER"
|
||||||
|
| "PURPUR"
|
||||||
|
| "FABRIC"
|
||||||
|
| "FORGE"
|
||||||
|
| "NEOFORGE"
|
||||||
|
| "QUILT"
|
||||||
|
| "BEDROCK_DEDICATED";
|
||||||
|
|
||||||
|
export interface Server {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
planId: string | null;
|
||||||
|
nodeId: string | null;
|
||||||
|
name: string;
|
||||||
|
status: GameServerStatus;
|
||||||
|
version: number;
|
||||||
|
edition: "JAVA" | "BEDROCK";
|
||||||
|
softwareFamily: SoftwareFamily;
|
||||||
|
minecraftVersion: string;
|
||||||
|
eulaAccepted: boolean;
|
||||||
|
hostPort: number | null;
|
||||||
|
containerId: string | null;
|
||||||
|
dataPath: string | null;
|
||||||
|
ramMb: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerListResponse {
|
||||||
|
servers: Server[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerActionResponse {
|
||||||
|
server: Server;
|
||||||
|
jobId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateServerPayload {
|
||||||
|
name: string;
|
||||||
|
planId?: string;
|
||||||
|
minecraftVersion: string;
|
||||||
|
softwareFamily: SoftwareFamily;
|
||||||
|
eulaAccepted: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
isFree: boolean;
|
||||||
|
maxRamMb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanListResponse {
|
||||||
|
plans: Plan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listServers(): Promise<ServerListResponse> {
|
||||||
|
return apiFetch<ServerListResponse>("/servers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServer(id: string): Promise<Server> {
|
||||||
|
return apiFetch<Server>(`/servers/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServer(payload: CreateServerPayload): Promise<Server> {
|
||||||
|
return apiFetch<Server>("/servers", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startServer(id: string): Promise<ServerActionResponse> {
|
||||||
|
return apiFetch<ServerActionResponse>(`/servers/${id}/start`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopServer(id: string): Promise<ServerActionResponse> {
|
||||||
|
return apiFetch<ServerActionResponse>(`/servers/${id}/stop`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPlans(): Promise<PlanListResponse> {
|
||||||
|
return apiFetch<PlanListResponse>("/plans");
|
||||||
|
}
|
||||||
46
apps/web/src/lib/schemas/server.ts
Normal file
46
apps/web/src/lib/schemas/server.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const softwareFamilyValues = [
|
||||||
|
"VANILLA",
|
||||||
|
"PAPER",
|
||||||
|
"PURPUR",
|
||||||
|
"FABRIC",
|
||||||
|
"FORGE",
|
||||||
|
"NEOFORGE",
|
||||||
|
"QUILT",
|
||||||
|
"BEDROCK_DEDICATED",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function createServerSchema(messages: {
|
||||||
|
nameRequired: string;
|
||||||
|
nameMax: string;
|
||||||
|
planRequired: string;
|
||||||
|
planInvalid: string;
|
||||||
|
versionRequired: string;
|
||||||
|
softwareRequired: string;
|
||||||
|
eulaRequired: string;
|
||||||
|
}) {
|
||||||
|
return z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, messages.nameRequired)
|
||||||
|
.max(64, messages.nameMax),
|
||||||
|
planId: z
|
||||||
|
.string()
|
||||||
|
.min(1, messages.planRequired)
|
||||||
|
.uuid(messages.planInvalid),
|
||||||
|
minecraftVersion: z.string().min(1, messages.versionRequired),
|
||||||
|
softwareFamily: z.enum(softwareFamilyValues, {
|
||||||
|
errorMap: () => ({ message: messages.softwareRequired }),
|
||||||
|
}),
|
||||||
|
eulaAccepted: z
|
||||||
|
.boolean()
|
||||||
|
.refine((value) => value === true, { message: messages.eulaRequired }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateServerFormValues = z.infer<ReturnType<typeof createServerSchema>>;
|
||||||
|
|
||||||
|
export const MINECRAFT_VERSIONS = ["1.21.1", "1.21", "1.20.6", "1.20.4"] as const;
|
||||||
|
|
||||||
|
export const SOFTWARE_FAMILIES = softwareFamilyValues;
|
||||||
@@ -4,7 +4,7 @@ import { routing } from "./i18n/routing";
|
|||||||
|
|
||||||
const intlMiddleware = createMiddleware(routing);
|
const intlMiddleware = createMiddleware(routing);
|
||||||
|
|
||||||
const protectedPaths = ["/dashboard", "/security"];
|
const protectedPaths = ["/dashboard", "/security", "/servers"];
|
||||||
|
|
||||||
function getPathWithoutLocale(pathname: string): string {
|
function getPathWithoutLocale(pathname: string): string {
|
||||||
if (pathname === "/en" || pathname.startsWith("/en/")) {
|
if (pathname === "/en" || pathname.startsWith("/en/")) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -15,6 +15,7 @@
|
|||||||
"@hexahost/config": "workspace:*",
|
"@hexahost/config": "workspace:*",
|
||||||
"@hexahost/database": "workspace:*",
|
"@hexahost/database": "workspace:*",
|
||||||
"bullmq": "^5.34.10",
|
"bullmq": "^5.34.10",
|
||||||
|
"ioredis": "^5.4.2",
|
||||||
"nodemailer": "^6.10.0",
|
"nodemailer": "^6.10.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
|||||||
128
apps/worker/src/handlers/server-lifecycle.ts
Normal file
128
apps/worker/src/handlers/server-lifecycle.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { Job } from 'bullmq';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { prisma } from '@hexahost/database';
|
||||||
|
|
||||||
|
import { logger } from '../logger';
|
||||||
|
import { publishNodeCommand } from '../redis';
|
||||||
|
import { transitionServerStatus } from '../server-state';
|
||||||
|
|
||||||
|
const lifecycleJobSchema = z.object({
|
||||||
|
serverId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadServerForLifecycle(serverId: string) {
|
||||||
|
const server = await prisma.gameServer.findUnique({
|
||||||
|
where: { id: serverId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`Server not found: ${serverId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server.nodeId) {
|
||||||
|
throw new Error(`Server ${serverId} is not assigned to a node`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishLifecycleCommand(
|
||||||
|
serverId: string,
|
||||||
|
nodeId: string,
|
||||||
|
type: 'server.start' | 'server.stop',
|
||||||
|
generation: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const messageId = randomUUID();
|
||||||
|
|
||||||
|
await publishNodeCommand({
|
||||||
|
nodeId,
|
||||||
|
envelope: {
|
||||||
|
protocolVersion: 1,
|
||||||
|
messageId,
|
||||||
|
type,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
serverId,
|
||||||
|
generation,
|
||||||
|
desiredGeneration: generation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processStartServerJob(job: Job): Promise<{ status: string }> {
|
||||||
|
if (job.name !== 'start-server') {
|
||||||
|
logger.warn({ jobName: job.name }, 'Unknown lifecycle job');
|
||||||
|
return { status: 'ignored' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serverId } = lifecycleJobSchema.parse(job.data);
|
||||||
|
const server = await loadServerForLifecycle(serverId);
|
||||||
|
|
||||||
|
if (server.status !== 'STOPPED') {
|
||||||
|
throw new Error(`Cannot start server ${serverId} from status ${server.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = await publishLifecycleCommand(
|
||||||
|
serverId,
|
||||||
|
server.nodeId!,
|
||||||
|
'server.start',
|
||||||
|
server.version,
|
||||||
|
);
|
||||||
|
|
||||||
|
await transitionServerStatus(serverId, 'STARTING', {
|
||||||
|
expectedFrom: 'STOPPED',
|
||||||
|
reason: 'Start requested',
|
||||||
|
correlationId: messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ serverId, messageId }, 'Server start command published');
|
||||||
|
return { status: 'starting' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processStopServerJob(job: Job): Promise<{ status: string }> {
|
||||||
|
if (job.name !== 'stop-server') {
|
||||||
|
logger.warn({ jobName: job.name }, 'Unknown lifecycle job');
|
||||||
|
return { status: 'ignored' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serverId } = lifecycleJobSchema.parse(job.data);
|
||||||
|
const server = await loadServerForLifecycle(serverId);
|
||||||
|
|
||||||
|
if (server.status !== 'RUNNING') {
|
||||||
|
throw new Error(`Cannot stop server ${serverId} from status ${server.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = await publishLifecycleCommand(
|
||||||
|
serverId,
|
||||||
|
server.nodeId!,
|
||||||
|
'server.stop',
|
||||||
|
server.version,
|
||||||
|
);
|
||||||
|
|
||||||
|
await transitionServerStatus(serverId, 'STOPPING', {
|
||||||
|
expectedFrom: 'RUNNING',
|
||||||
|
reason: 'Stop requested',
|
||||||
|
correlationId: messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ serverId, messageId }, 'Server stop command published');
|
||||||
|
return { status: 'stopping' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processLifecycleJob(job: Job): Promise<{ status: string }> {
|
||||||
|
switch (job.name) {
|
||||||
|
case 'start-server':
|
||||||
|
return processStartServerJob(job);
|
||||||
|
case 'stop-server':
|
||||||
|
return processStopServerJob(job);
|
||||||
|
default:
|
||||||
|
logger.warn({ jobName: job.name }, 'Unknown lifecycle job name');
|
||||||
|
return { status: 'ignored' };
|
||||||
|
}
|
||||||
|
}
|
||||||
169
apps/worker/src/handlers/server-provisioning.ts
Normal file
169
apps/worker/src/handlers/server-provisioning.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { prisma } from '@hexahost/database';
|
||||||
|
import { Job } from 'bullmq';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { logger } from '../logger';
|
||||||
|
import { publishNodeCommand, waitForNodeResponse } from '../redis';
|
||||||
|
import { transitionServerStatus } from '../server-state';
|
||||||
|
|
||||||
|
const BASE_HOST_PORT = 25565;
|
||||||
|
const PROVISION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export const provisionServerJobSchema = z.object({
|
||||||
|
serverId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildDataPath(serverId: string): string {
|
||||||
|
return `/var/lib/hgc/servers/${serverId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAvailableHostPort(nodeId: string): Promise<number> {
|
||||||
|
const [allocations, servers] = await Promise.all([
|
||||||
|
prisma.gameServerAllocation.findMany({
|
||||||
|
where: { nodeId, status: 'ACTIVE' },
|
||||||
|
select: { hostPort: true },
|
||||||
|
}),
|
||||||
|
prisma.gameServer.findMany({
|
||||||
|
where: { nodeId, hostPort: { not: null } },
|
||||||
|
select: { hostPort: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const usedPorts = new Set<number>();
|
||||||
|
for (const allocation of allocations) {
|
||||||
|
usedPorts.add(allocation.hostPort);
|
||||||
|
}
|
||||||
|
for (const server of servers) {
|
||||||
|
if (server.hostPort !== null) {
|
||||||
|
usedPorts.add(server.hostPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let port = BASE_HOST_PORT;
|
||||||
|
while (usedPorts.has(port)) {
|
||||||
|
port += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processProvisionServerJob(job: Job): Promise<{ status: string }> {
|
||||||
|
if (job.name !== 'provision-server') {
|
||||||
|
logger.warn({ jobName: job.name }, 'Unknown provisioning job');
|
||||||
|
return { status: 'ignored' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serverId } = provisionServerJobSchema.parse(job.data);
|
||||||
|
const server = await prisma.gameServer.findUnique({
|
||||||
|
where: { id: serverId },
|
||||||
|
include: { plan: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`Server not found: ${serverId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = await prisma.gameNode.findFirst({
|
||||||
|
where: { status: 'ONLINE' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
await transitionServerStatus(serverId, 'ERROR', {
|
||||||
|
reason: 'No online game node available',
|
||||||
|
errorCode: 'NO_NODE_AVAILABLE',
|
||||||
|
errorMessage: 'No online game node is available for provisioning',
|
||||||
|
});
|
||||||
|
throw new Error('No online game node available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostPort = await findAvailableHostPort(node.id);
|
||||||
|
const ramMb = server.plan?.maxRamMb ?? server.ramMb;
|
||||||
|
const dataPath = buildDataPath(serverId);
|
||||||
|
const messageId = randomUUID();
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.gameServerAllocation.create({
|
||||||
|
data: {
|
||||||
|
serverId,
|
||||||
|
nodeId: node.id,
|
||||||
|
hostPort,
|
||||||
|
ramMbReserved: ramMb,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.gameServer.update({
|
||||||
|
where: { id: serverId },
|
||||||
|
data: {
|
||||||
|
nodeId: node.id,
|
||||||
|
hostPort,
|
||||||
|
ramMb,
|
||||||
|
dataPath,
|
||||||
|
status: 'PROVISIONING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.gameServerStateTransition.create({
|
||||||
|
data: {
|
||||||
|
gameServerId: serverId,
|
||||||
|
fromStatus: server.status,
|
||||||
|
toStatus: 'PROVISIONING',
|
||||||
|
reason: 'Worker allocated node resources',
|
||||||
|
correlationId: messageId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await publishNodeCommand({
|
||||||
|
nodeId: node.id,
|
||||||
|
envelope: {
|
||||||
|
protocolVersion: 1,
|
||||||
|
messageId,
|
||||||
|
type: 'server.provision',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
serverId,
|
||||||
|
generation: server.version,
|
||||||
|
edition: server.edition,
|
||||||
|
softwareFamily: server.softwareFamily,
|
||||||
|
minecraftVersion: server.minecraftVersion,
|
||||||
|
hostPort,
|
||||||
|
ramMb,
|
||||||
|
dataPath,
|
||||||
|
eulaAccepted: server.eulaAccepted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await waitForNodeResponse(messageId, PROVISION_TIMEOUT_MS);
|
||||||
|
|
||||||
|
if (response === null) {
|
||||||
|
await transitionServerStatus(serverId, 'INSTALLING', {
|
||||||
|
reason: 'Provision command sent; awaiting agent completion',
|
||||||
|
correlationId: messageId,
|
||||||
|
});
|
||||||
|
logger.info({ serverId, messageId }, 'Provision timed out; marked INSTALLING');
|
||||||
|
return { status: 'installing' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
await transitionServerStatus(serverId, 'ERROR', {
|
||||||
|
reason: 'Provision failed',
|
||||||
|
correlationId: messageId,
|
||||||
|
errorCode: response.errorCode ?? response.resultCode ?? 'PROVISION_FAILED',
|
||||||
|
errorMessage: response.errorMessage ?? 'Server provisioning failed',
|
||||||
|
});
|
||||||
|
throw new Error(response.errorMessage ?? 'Server provisioning failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await transitionServerStatus(serverId, 'STOPPED', {
|
||||||
|
reason: 'Provision completed',
|
||||||
|
correlationId: messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ serverId, nodeId: node.id, hostPort }, 'Server provisioned');
|
||||||
|
return { status: 'stopped' };
|
||||||
|
}
|
||||||
@@ -4,9 +4,18 @@ import { validateConfig } from '@hexahost/config';
|
|||||||
import { prisma } from '@hexahost/database';
|
import { prisma } from '@hexahost/database';
|
||||||
|
|
||||||
import { processNotificationJob } from './handlers/notifications';
|
import { processNotificationJob } from './handlers/notifications';
|
||||||
|
import { processLifecycleJob } from './handlers/server-lifecycle';
|
||||||
|
import { processProvisionServerJob } from './handlers/server-provisioning';
|
||||||
import { startHealthServer } from './health';
|
import { startHealthServer } from './health';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { WORKER_QUEUES, type WorkerQueueName } from './queues';
|
import { closeRedisConnections } from './redis';
|
||||||
|
import {
|
||||||
|
QUEUE_NOTIFICATIONS,
|
||||||
|
QUEUE_SERVER_LIFECYCLE,
|
||||||
|
QUEUE_SERVER_PROVISIONING,
|
||||||
|
WORKER_QUEUES,
|
||||||
|
type WorkerQueueName,
|
||||||
|
} from './queues';
|
||||||
|
|
||||||
function createQueueWorker(
|
function createQueueWorker(
|
||||||
queueName: WorkerQueueName,
|
queueName: WorkerQueueName,
|
||||||
@@ -20,11 +29,19 @@ function createQueueWorker(
|
|||||||
'Processing job',
|
'Processing job',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (queueName === 'notifications') {
|
if (queueName === QUEUE_NOTIFICATIONS) {
|
||||||
return processNotificationJob(job);
|
return processNotificationJob(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { acknowledged: true, queue: queueName };
|
if (queueName === QUEUE_SERVER_PROVISIONING) {
|
||||||
|
return processProvisionServerJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queueName === QUEUE_SERVER_LIFECYCLE) {
|
||||||
|
return processLifecycleJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn({ queue: queueName, jobName: job.name }, 'Unhandled job');
|
||||||
},
|
},
|
||||||
{ connection },
|
{ connection },
|
||||||
);
|
);
|
||||||
@@ -62,6 +79,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
logger.info({ signal }, 'Shutting down worker');
|
logger.info({ signal }, 'Shutting down worker');
|
||||||
|
|
||||||
await Promise.all(workers.map((worker) => worker.close()));
|
await Promise.all(workers.map((worker) => worker.close()));
|
||||||
|
await closeRedisConnections();
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|
||||||
healthServer.close(() => {
|
healthServer.close(() => {
|
||||||
|
|||||||
109
apps/worker/src/redis.ts
Normal file
109
apps/worker/src/redis.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
import { validateConfig } from '@hexahost/config';
|
||||||
|
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
export const NODE_COMMANDS_CHANNEL = 'hgc:node:commands';
|
||||||
|
|
||||||
|
export interface NodeCommandEnvelope {
|
||||||
|
protocolVersion: number;
|
||||||
|
messageId: string;
|
||||||
|
type: string;
|
||||||
|
timestamp: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeCommandMessage {
|
||||||
|
nodeId: string;
|
||||||
|
envelope: NodeCommandEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeResponseMessage {
|
||||||
|
success: boolean;
|
||||||
|
resultCode?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let publisher: Redis | null = null;
|
||||||
|
|
||||||
|
export function getRedisPublisher(): Redis {
|
||||||
|
if (!publisher) {
|
||||||
|
const config = validateConfig();
|
||||||
|
publisher = new Redis(config.REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return publisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishNodeCommand(message: NodeCommandMessage): Promise<void> {
|
||||||
|
const redis = getRedisPublisher();
|
||||||
|
await redis.publish(NODE_COMMANDS_CHANNEL, JSON.stringify(message));
|
||||||
|
logger.info(
|
||||||
|
{ nodeId: message.nodeId, type: message.envelope.type, messageId: message.envelope.messageId },
|
||||||
|
'Published node command',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeResponseChannel(messageId: string): string {
|
||||||
|
return `hgc:node:responses:${messageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForNodeResponse(
|
||||||
|
messageId: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<NodeResponseMessage | null> {
|
||||||
|
const config = validateConfig();
|
||||||
|
const subscriber = new Redis(config.REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
const channel = nodeResponseChannel(messageId);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (value: NodeResponseMessage | null): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
void subscriber.unsubscribe(channel).finally(() => {
|
||||||
|
subscriber.disconnect();
|
||||||
|
});
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||||
|
|
||||||
|
subscriber.on('message', (receivedChannel, message) => {
|
||||||
|
if (receivedChannel !== channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
finish(JSON.parse(message) as NodeResponseMessage);
|
||||||
|
} catch {
|
||||||
|
finish({
|
||||||
|
success: false,
|
||||||
|
errorMessage: 'Invalid node response payload',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void subscriber.subscribe(channel).catch((error: Error) => {
|
||||||
|
logger.error({ err: error, channel }, 'Failed to subscribe for node response');
|
||||||
|
finish(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeRedisConnections(): Promise<void> {
|
||||||
|
if (publisher) {
|
||||||
|
await publisher.quit();
|
||||||
|
publisher = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
apps/worker/src/server-state.ts
Normal file
58
apps/worker/src/server-state.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { type Prisma, prisma } from '@hexahost/database';
|
||||||
|
|
||||||
|
type GameServerStatus = Prisma.GameServerGetPayload<{ select: { status: true } }>['status'];
|
||||||
|
|
||||||
|
interface TransitionOptions {
|
||||||
|
expectedFrom?: GameServerStatus | GameServerStatus[];
|
||||||
|
reason?: string;
|
||||||
|
correlationId?: string;
|
||||||
|
actorId?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
extraData?: Prisma.GameServerUpdateInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transitionServerStatus(
|
||||||
|
serverId: string,
|
||||||
|
toStatus: GameServerStatus,
|
||||||
|
options: TransitionOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const server = await tx.gameServer.findUniqueOrThrow({
|
||||||
|
where: { id: serverId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.expectedFrom) {
|
||||||
|
const allowed = Array.isArray(options.expectedFrom)
|
||||||
|
? options.expectedFrom
|
||||||
|
: [options.expectedFrom];
|
||||||
|
|
||||||
|
if (!allowed.includes(server.status)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid state transition for server ${serverId}: ${server.status} -> ${toStatus}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.gameServer.update({
|
||||||
|
where: { id: serverId },
|
||||||
|
data: {
|
||||||
|
status: toStatus,
|
||||||
|
...options.extraData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.gameServerStateTransition.create({
|
||||||
|
data: {
|
||||||
|
gameServerId: serverId,
|
||||||
|
fromStatus: server.status,
|
||||||
|
toStatus,
|
||||||
|
reason: options.reason,
|
||||||
|
correlationId: options.correlationId,
|
||||||
|
actorId: options.actorId,
|
||||||
|
errorCode: options.errorCode,
|
||||||
|
errorMessage: options.errorMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -2,54 +2,61 @@
|
|||||||
|
|
||||||
Last updated: 2026-06-26
|
Last updated: 2026-06-26
|
||||||
|
|
||||||
## Current phase: Phase 1 — Authentication & user panel ✅
|
## Current phase: Phase 2 — Single-Node Vertical Slice ✅
|
||||||
|
|
||||||
### Phase 1 completed
|
### Phase 2 completed
|
||||||
|
|
||||||
| Area | Status | Notes |
|
| Area | Status | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| Prisma auth models | Done | EmailVerificationToken, PasswordResetToken, TotpCredential, RecoveryCode |
|
| Prisma lifecycle models | Done | Allocations, server fields, node enrollment |
|
||||||
| Migration | Done | `20260626120000_phase1_auth` + platform role seed |
|
| `@hexahost/contracts` servers | Done | Zod schemas for create/list/action |
|
||||||
| `@hexahost/auth` | Done | Argon2id, tokens, TOTP, recovery codes, username utils |
|
| API ServersModule | Done | CRUD, start/stop, BullMQ jobs |
|
||||||
| API AuthModule | Done | Register, login, logout, verify, reset, 2FA, `/me`, sessions |
|
| API NodesModule | Done | WebSocket `/api/v1/nodes/ws`, Redis command bridge |
|
||||||
| Session cookies | Done | `hgc_session`, HttpOnly, Secure in prod, SameSite lax |
|
| API Plans | Done | `GET /api/v1/plans` |
|
||||||
| Account lockout | Done | 5 attempts → 15 min lock |
|
| Admin nodes | Done | `POST /api/v1/admin/nodes` with enrollment token |
|
||||||
| Worker emails | Done | `notifications` queue → nodemailer (Mailpit in dev) |
|
| Worker provisioning | Done | Port allocation, Redis commands, response wait |
|
||||||
| Web auth UI | Done | Login, register, 2FA, forgot/reset, verify, security |
|
| Worker lifecycle | Done | start-server, stop-server jobs |
|
||||||
| Route protection | Done | Middleware cookie check for dashboard/security |
|
| Go node-agent | Done | WebSocket, Docker (`itzg/minecraft-server:java21`) |
|
||||||
| Admin bootstrap | Done | `pnpm bootstrap:admin --email … --password …` |
|
| Web server UI | Done | Create, list, detail, start/stop |
|
||||||
| Tests | Done | Auth package vitest, API health/auth tests, contracts |
|
| Dev seed | Done | `pnpm db:seed` — Free plan + local dev node |
|
||||||
|
| Migrations | Done | `20260626140000_phase2_game_servers` |
|
||||||
|
|
||||||
### Abnahmekriterium Phase 1
|
### Abnahmekriterium Phase 2
|
||||||
|
|
||||||
- [x] Registration with email verification flow
|
- [x] User creates server in web panel
|
||||||
- [x] Login/logout with secure sessions
|
- [x] Provisioning pipeline (API → Worker → Agent)
|
||||||
- [x] Password reset
|
- [x] Start/stop lifecycle
|
||||||
- [x] TOTP 2FA setup and login challenge
|
- [x] Node enrollment + heartbeat
|
||||||
- [x] Session list and revoke
|
- [x] Connection address shown (`localhost:port`)
|
||||||
- [x] Admin bootstrap CLI (no default credentials)
|
- [ ] Manual Minecraft join validation (requires Docker + agent running locally)
|
||||||
- [x] Audit events for security actions
|
|
||||||
- [x] Unit/integration tests pass
|
### Local vertical slice
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev:infra
|
||||||
|
pnpm db:migrate # or db:push
|
||||||
|
pnpm db:seed
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Terminal 2 — Node Agent (requires Go + Docker)
|
||||||
|
cd apps/node-agent
|
||||||
|
set NODE_ID=00000000-0000-4000-8000-000000000001
|
||||||
|
set NODE_TOKEN=local-dev-enrollment-token-change-me-32chars
|
||||||
|
set NODE_AGENT_PUBLIC_URL=ws://localhost:3001/api/v1/nodes/ws
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
### Known limitations
|
### Known limitations
|
||||||
|
|
||||||
- E2E Playwright tests for auth flow not yet added (planned).
|
- Single-node only (no scheduler/queue fairness yet).
|
||||||
- Email templates are minimal HTML (no branded layout yet).
|
- Minecraft image pull on first provision can take several minutes.
|
||||||
- OIDC / Passkeys deferred to later phases.
|
- Node agent on Windows needs Docker Desktop; run agent on host, not in Compose.
|
||||||
- CSRF relies on SameSite cookies + CORS origin check; no double-submit token yet.
|
- mTLS for agent deferred; token-based enrollment only.
|
||||||
|
- Metrics are basic heartbeat payload only.
|
||||||
|
|
||||||
### Test commands
|
### Next phase: Phase 3 — Console, files, settings
|
||||||
|
|
||||||
```bash
|
- Live console WebSocket
|
||||||
pnpm install
|
- File manager
|
||||||
pnpm db:migrate # or db:push
|
- server.properties editor
|
||||||
pnpm test
|
- Player lists
|
||||||
pnpm build
|
|
||||||
pnpm bootstrap:admin -- --email admin@localhost --password 'your-secure-password'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Next phase: Phase 2 — Single-Node Vertical Slice
|
|
||||||
|
|
||||||
- GameNode enrollment, Go agent WebSocket
|
|
||||||
- Server create, provision, start/stop
|
|
||||||
- First playable Minecraft server locally
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"db:generate": "pnpm --filter @hexahost/database db:generate",
|
"db:generate": "pnpm --filter @hexahost/database db:generate",
|
||||||
"db:migrate": "pnpm --filter @hexahost/database db:migrate",
|
"db:migrate": "pnpm --filter @hexahost/database db:migrate",
|
||||||
"db:push": "pnpm --filter @hexahost/database db:push",
|
"db:push": "pnpm --filter @hexahost/database db:push",
|
||||||
"bootstrap:admin": "pnpm --filter @hexahost/database bootstrap:admin"
|
"bootstrap:admin": "pnpm --filter @hexahost/database bootstrap:admin",
|
||||||
|
"db:seed": "pnpm --filter @hexahost/database db:seed"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "^2.3.3",
|
"turbo": "^2.3.3",
|
||||||
|
|||||||
@@ -54,3 +54,20 @@ export {
|
|||||||
type SessionListResponse,
|
type SessionListResponse,
|
||||||
type MessageResponse,
|
type MessageResponse,
|
||||||
} from './auth';
|
} from './auth';
|
||||||
|
|
||||||
|
export {
|
||||||
|
gameServerStatusSchema,
|
||||||
|
serverEditionSchema,
|
||||||
|
softwareFamilySchema,
|
||||||
|
createServerRequestSchema,
|
||||||
|
serverResponseSchema,
|
||||||
|
serverListResponseSchema,
|
||||||
|
serverActionResponseSchema,
|
||||||
|
type GameServerStatus,
|
||||||
|
type ServerEdition,
|
||||||
|
type SoftwareFamily,
|
||||||
|
type CreateServerRequest,
|
||||||
|
type ServerResponse,
|
||||||
|
type ServerListResponse,
|
||||||
|
type ServerActionResponse,
|
||||||
|
} from './servers';
|
||||||
|
|||||||
76
packages/contracts/src/servers.ts
Normal file
76
packages/contracts/src/servers.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const gameServerStatusSchema = z.enum([
|
||||||
|
'DRAFT',
|
||||||
|
'PROVISIONING',
|
||||||
|
'INSTALLING',
|
||||||
|
'STOPPED',
|
||||||
|
'STARTING',
|
||||||
|
'RUNNING',
|
||||||
|
'STOPPING',
|
||||||
|
'ERROR',
|
||||||
|
'DELETING',
|
||||||
|
'DELETED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const serverEditionSchema = z.enum(['JAVA', 'BEDROCK']);
|
||||||
|
|
||||||
|
export const softwareFamilySchema = z.enum([
|
||||||
|
'VANILLA',
|
||||||
|
'PAPER',
|
||||||
|
'PURPUR',
|
||||||
|
'FABRIC',
|
||||||
|
'FORGE',
|
||||||
|
'NEOFORGE',
|
||||||
|
'QUILT',
|
||||||
|
'BEDROCK_DEDICATED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const createServerRequestSchema = z.object({
|
||||||
|
name: z.string().min(1).max(64),
|
||||||
|
edition: serverEditionSchema.default('JAVA'),
|
||||||
|
softwareFamily: softwareFamilySchema.default('VANILLA'),
|
||||||
|
minecraftVersion: z.string().min(1).max(32).default('1.21.1'),
|
||||||
|
eulaAccepted: z.literal(true, {
|
||||||
|
errorMap: () => ({ message: 'EULA must be accepted' }),
|
||||||
|
}),
|
||||||
|
ramMb: z.number().int().min(512).max(65536).optional(),
|
||||||
|
planId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const serverResponseSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
userId: z.string().uuid(),
|
||||||
|
planId: z.string().uuid().nullable(),
|
||||||
|
nodeId: z.string().uuid().nullable(),
|
||||||
|
name: z.string(),
|
||||||
|
status: gameServerStatusSchema,
|
||||||
|
version: z.number().int(),
|
||||||
|
edition: serverEditionSchema,
|
||||||
|
softwareFamily: softwareFamilySchema,
|
||||||
|
minecraftVersion: z.string(),
|
||||||
|
eulaAccepted: z.boolean(),
|
||||||
|
hostPort: z.number().int().nullable(),
|
||||||
|
containerId: z.string().nullable(),
|
||||||
|
dataPath: z.string().nullable(),
|
||||||
|
ramMb: z.number().int(),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
updatedAt: z.string().datetime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const serverListResponseSchema = z.object({
|
||||||
|
servers: z.array(serverResponseSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const serverActionResponseSchema = z.object({
|
||||||
|
server: serverResponseSchema,
|
||||||
|
jobId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GameServerStatus = z.infer<typeof gameServerStatusSchema>;
|
||||||
|
export type ServerEdition = z.infer<typeof serverEditionSchema>;
|
||||||
|
export type SoftwareFamily = z.infer<typeof softwareFamilySchema>;
|
||||||
|
export type CreateServerRequest = z.infer<typeof createServerRequestSchema>;
|
||||||
|
export type ServerResponse = z.infer<typeof serverResponseSchema>;
|
||||||
|
export type ServerListResponse = z.infer<typeof serverListResponseSchema>;
|
||||||
|
export type ServerActionResponse = z.infer<typeof serverActionResponseSchema>;
|
||||||
File diff suppressed because one or more lines are too long
@@ -10,7 +10,8 @@
|
|||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"bootstrap:admin": "tsx src/cli/bootstrap-admin.ts"
|
"bootstrap:admin": "tsx src/cli/bootstrap-admin.ts",
|
||||||
|
"db:seed": "tsx src/cli/seed-dev.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.3.1"
|
"@prisma/client": "^6.3.1"
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- Phase 2: Game server lifecycle and node enrollment
|
||||||
|
|
||||||
|
-- New enums
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "ServerEdition" AS ENUM ('JAVA', 'BEDROCK');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "SoftwareFamily" AS ENUM ('VANILLA', 'PAPER', 'PURPUR', 'FABRIC', 'FORGE', 'NEOFORGE', 'QUILT', 'BEDROCK_DEDICATED');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "AllocationStatus" AS ENUM ('ACTIVE', 'RELEASED');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Extend GameNodeStatus
|
||||||
|
ALTER TYPE "GameNodeStatus" ADD VALUE IF NOT EXISTS 'UNREACHABLE';
|
||||||
|
|
||||||
|
-- Game servers: add columns
|
||||||
|
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "edition" "ServerEdition" NOT NULL DEFAULT 'JAVA';
|
||||||
|
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "softwareFamily" "SoftwareFamily" NOT NULL DEFAULT 'VANILLA';
|
||||||
|
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "minecraftVersion" TEXT NOT NULL DEFAULT '1.21.1';
|
||||||
|
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "eulaAccepted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "hostPort" INTEGER;
|
||||||
|
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "containerId" TEXT;
|
||||||
|
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "dataPath" TEXT;
|
||||||
|
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "ramMb" INTEGER NOT NULL DEFAULT 1024;
|
||||||
|
|
||||||
|
-- Game nodes: enrollment + heartbeat
|
||||||
|
ALTER TABLE "game_nodes" ADD COLUMN IF NOT EXISTS "lastHeartbeatAt" TIMESTAMPTZ(3);
|
||||||
|
ALTER TABLE "game_nodes" ADD COLUMN IF NOT EXISTS "agentVersion" TEXT;
|
||||||
|
ALTER TABLE "game_nodes" ADD COLUMN IF NOT EXISTS "enrollmentTokenHash" TEXT;
|
||||||
|
ALTER TABLE "game_nodes" ADD COLUMN IF NOT EXISTS "enrolledAt" TIMESTAMPTZ(3);
|
||||||
|
|
||||||
|
-- State transitions: audit fields
|
||||||
|
ALTER TABLE "game_server_state_transitions" ADD COLUMN IF NOT EXISTS "correlationId" TEXT;
|
||||||
|
ALTER TABLE "game_server_state_transitions" ADD COLUMN IF NOT EXISTS "actorId" TEXT;
|
||||||
|
ALTER TABLE "game_server_state_transitions" ADD COLUMN IF NOT EXISTS "errorCode" TEXT;
|
||||||
|
ALTER TABLE "game_server_state_transitions" ADD COLUMN IF NOT EXISTS "errorMessage" TEXT;
|
||||||
|
|
||||||
|
-- Allocations
|
||||||
|
CREATE TABLE IF NOT EXISTS "game_server_allocations" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"nodeId" TEXT NOT NULL,
|
||||||
|
"hostPort" INTEGER NOT NULL,
|
||||||
|
"ramMbReserved" INTEGER NOT NULL,
|
||||||
|
"status" "AllocationStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "game_server_allocations_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "game_server_allocations_serverId_key" ON "game_server_allocations"("serverId");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "game_server_allocations_nodeId_hostPort_key" ON "game_server_allocations"("nodeId", "hostPort");
|
||||||
|
CREATE INDEX IF NOT EXISTS "game_server_allocations_nodeId_idx" ON "game_server_allocations"("nodeId");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "game_server_allocations" ADD CONSTRAINT "game_server_allocations_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "game_server_allocations" ADD CONSTRAINT "game_server_allocations_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "game_nodes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Free plan seed
|
||||||
|
INSERT INTO "plans" ("id", "name", "slug", "description", "isFree", "isActive", "maxRamMb", "maxCpuCores", "maxStorageMb", "createdAt", "updatedAt")
|
||||||
|
SELECT gen_random_uuid()::text, 'Free', 'free', 'Free tier with limited resources', true, true, 2048, 1, 5120, NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM "plans" WHERE "slug" = 'free');
|
||||||
@@ -13,14 +13,32 @@ enum UserStatus {
|
|||||||
DELETED
|
DELETED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ServerEdition {
|
||||||
|
JAVA
|
||||||
|
BEDROCK
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SoftwareFamily {
|
||||||
|
VANILLA
|
||||||
|
PAPER
|
||||||
|
PURPUR
|
||||||
|
FABRIC
|
||||||
|
FORGE
|
||||||
|
NEOFORGE
|
||||||
|
QUILT
|
||||||
|
BEDROCK_DEDICATED
|
||||||
|
}
|
||||||
|
|
||||||
enum GameServerStatus {
|
enum GameServerStatus {
|
||||||
PENDING
|
DRAFT
|
||||||
PROVISIONING
|
PROVISIONING
|
||||||
|
INSTALLING
|
||||||
STOPPED
|
STOPPED
|
||||||
STARTING
|
STARTING
|
||||||
RUNNING
|
RUNNING
|
||||||
STOPPING
|
STOPPING
|
||||||
ERROR
|
ERROR
|
||||||
|
DELETING
|
||||||
DELETED
|
DELETED
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +47,12 @@ enum GameNodeStatus {
|
|||||||
OFFLINE
|
OFFLINE
|
||||||
MAINTENANCE
|
MAINTENANCE
|
||||||
DRAINING
|
DRAINING
|
||||||
|
UNREACHABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AllocationStatus {
|
||||||
|
ACTIVE
|
||||||
|
RELEASED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum JobStatus {
|
enum JobStatus {
|
||||||
@@ -51,14 +75,14 @@ model User {
|
|||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
credentials UserCredential[]
|
credentials UserCredential[]
|
||||||
sessions UserSession[]
|
sessions UserSession[]
|
||||||
platformRoles UserPlatformRole[]
|
platformRoles UserPlatformRole[]
|
||||||
gameServers GameServer[]
|
gameServers GameServer[]
|
||||||
auditEvents AuditEvent[]
|
auditEvents AuditEvent[]
|
||||||
emailVerificationTokens EmailVerificationToken[]
|
emailVerificationTokens EmailVerificationToken[]
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
totpCredential TotpCredential?
|
totpCredential TotpCredential?
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -77,15 +101,15 @@ model UserCredential {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model UserSession {
|
model UserSession {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
tokenHash String @unique
|
tokenHash String @unique
|
||||||
expiresAt DateTime @db.Timestamptz(3)
|
expiresAt DateTime @db.Timestamptz(3)
|
||||||
revokedAt DateTime? @db.Timestamptz(3)
|
revokedAt DateTime? @db.Timestamptz(3)
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -180,20 +204,29 @@ model UserPlatformRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model GameServer {
|
model GameServer {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
planId String?
|
planId String?
|
||||||
nodeId String?
|
nodeId String?
|
||||||
name String
|
name String
|
||||||
status GameServerStatus @default(PENDING)
|
status GameServerStatus @default(DRAFT)
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
edition ServerEdition @default(JAVA)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
softwareFamily SoftwareFamily @default(VANILLA)
|
||||||
|
minecraftVersion String @default("1.21.1")
|
||||||
|
eulaAccepted Boolean @default(false)
|
||||||
|
hostPort Int?
|
||||||
|
containerId String?
|
||||||
|
dataPath String?
|
||||||
|
ramMb Int @default(1024)
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
plan Plan? @relation(fields: [planId], references: [id])
|
plan Plan? @relation(fields: [planId], references: [id])
|
||||||
node GameNode? @relation(fields: [nodeId], references: [id])
|
node GameNode? @relation(fields: [nodeId], references: [id])
|
||||||
stateTransitions GameServerStateTransition[]
|
stateTransitions GameServerStateTransition[]
|
||||||
|
allocation GameServerAllocation?
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([nodeId])
|
@@index([nodeId])
|
||||||
@@ -201,14 +234,36 @@ model GameServer {
|
|||||||
@@map("game_servers")
|
@@map("game_servers")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model GameServerAllocation {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
serverId String @unique
|
||||||
|
nodeId String
|
||||||
|
hostPort Int
|
||||||
|
ramMbReserved Int
|
||||||
|
status AllocationStatus @default(ACTIVE)
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
|
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||||
|
node GameNode @relation(fields: [nodeId], references: [id])
|
||||||
|
|
||||||
|
@@unique([nodeId, hostPort])
|
||||||
|
@@index([nodeId])
|
||||||
|
@@map("game_server_allocations")
|
||||||
|
}
|
||||||
|
|
||||||
model GameServerStateTransition {
|
model GameServerStateTransition {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
gameServerId String
|
gameServerId String
|
||||||
fromStatus GameServerStatus?
|
fromStatus GameServerStatus?
|
||||||
toStatus GameServerStatus
|
toStatus GameServerStatus
|
||||||
reason String?
|
reason String?
|
||||||
metadata Json?
|
correlationId String?
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
actorId String?
|
||||||
|
errorCode String?
|
||||||
|
errorMessage String?
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
gameServer GameServer @relation(fields: [gameServerId], references: [id], onDelete: Cascade)
|
gameServer GameServer @relation(fields: [gameServerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -218,18 +273,23 @@ model GameServerStateTransition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model GameNode {
|
model GameNode {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
hostname String
|
hostname String
|
||||||
status GameNodeStatus @default(OFFLINE)
|
status GameNodeStatus @default(OFFLINE)
|
||||||
maxServers Int @default(0)
|
maxServers Int @default(10)
|
||||||
activeServers Int @default(0)
|
activeServers Int @default(0)
|
||||||
metadata Json?
|
lastHeartbeatAt DateTime? @db.Timestamptz(3)
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
agentVersion String?
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
enrollmentTokenHash String?
|
||||||
|
enrolledAt DateTime? @db.Timestamptz(3)
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
gameServers GameServer[]
|
gameServers GameServer[]
|
||||||
heartbeats GameNodeHeartbeat[]
|
heartbeats GameNodeHeartbeat[]
|
||||||
|
allocations GameServerAllocation[]
|
||||||
|
|
||||||
@@map("game_nodes")
|
@@map("game_nodes")
|
||||||
}
|
}
|
||||||
@@ -248,18 +308,18 @@ model GameNodeHeartbeat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Plan {
|
model Plan {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
slug String @unique
|
slug String @unique
|
||||||
description String?
|
description String?
|
||||||
isFree Boolean @default(false)
|
isFree Boolean @default(false)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
maxRamMb Int
|
maxRamMb Int
|
||||||
maxCpuCores Float
|
maxCpuCores Float
|
||||||
maxStorageMb Int
|
maxStorageMb Int
|
||||||
metadata Json?
|
metadata Json?
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
gameServers GameServer[]
|
gameServers GameServer[]
|
||||||
|
|
||||||
|
|||||||
62
packages/database/src/cli/seed-dev.ts
Normal file
62
packages/database/src/cli/seed-dev.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { hashToken } from '@hexahost/auth';
|
||||||
|
|
||||||
|
/** Stable dev node ID — must match NODE_ID in .env for local agent */
|
||||||
|
export const DEV_NODE_ID = '00000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
/** Shown once in seed output; hash stored in DB */
|
||||||
|
export const DEV_NODE_ENROLLMENT_TOKEN =
|
||||||
|
'local-dev-enrollment-token-change-me-32chars';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const freePlan = await prisma.plan.upsert({
|
||||||
|
where: { slug: 'free' },
|
||||||
|
create: {
|
||||||
|
name: 'Free',
|
||||||
|
slug: 'free',
|
||||||
|
description: 'Free tier with limited resources',
|
||||||
|
isFree: true,
|
||||||
|
isActive: true,
|
||||||
|
maxRamMb: 2048,
|
||||||
|
maxCpuCores: 1,
|
||||||
|
maxStorageMb: 5120,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const devNode = await prisma.gameNode.upsert({
|
||||||
|
where: { id: DEV_NODE_ID },
|
||||||
|
create: {
|
||||||
|
id: DEV_NODE_ID,
|
||||||
|
name: 'local-dev',
|
||||||
|
hostname: 'localhost',
|
||||||
|
status: 'OFFLINE',
|
||||||
|
maxServers: 10,
|
||||||
|
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: 'local-dev',
|
||||||
|
hostname: 'localhost',
|
||||||
|
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Seed complete:');
|
||||||
|
console.log(` Plan Free: ${freePlan.id}`);
|
||||||
|
console.log(` Dev node: ${devNode.id} (${devNode.name})`);
|
||||||
|
console.log(` Enrollment token (set NODE_TOKEN): ${DEV_NODE_ENROLLMENT_TOKEN}`);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
specifier: ^8.0.4
|
specifier: ^8.0.4
|
||||||
version: 8.3.0
|
version: 8.3.0
|
||||||
|
'@fastify/websocket':
|
||||||
|
specifier: ^11.0.2
|
||||||
|
version: 11.2.0
|
||||||
'@hexahost/auth':
|
'@hexahost/auth':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/auth
|
version: link:../../packages/auth
|
||||||
@@ -74,6 +77,9 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
ws:
|
||||||
|
specifier: ^8.18.0
|
||||||
|
version: 8.21.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.1
|
specifier: ^3.24.1
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -90,6 +96,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.7
|
specifier: ^22.10.7
|
||||||
version: 22.20.0
|
version: 22.20.0
|
||||||
|
'@types/ws':
|
||||||
|
specifier: ^8.5.14
|
||||||
|
version: 8.18.1
|
||||||
pino-pretty:
|
pino-pretty:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.1.3
|
version: 13.1.3
|
||||||
@@ -169,6 +178,9 @@ importers:
|
|||||||
bullmq:
|
bullmq:
|
||||||
specifier: ^5.34.10
|
specifier: ^5.34.10
|
||||||
version: 5.79.1
|
version: 5.79.1
|
||||||
|
ioredis:
|
||||||
|
specifier: ^5.4.2
|
||||||
|
version: 5.10.1
|
||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^6.10.0
|
specifier: ^6.10.0
|
||||||
version: 6.10.1
|
version: 6.10.1
|
||||||
@@ -591,6 +603,9 @@ packages:
|
|||||||
'@fastify/static@8.3.0':
|
'@fastify/static@8.3.0':
|
||||||
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
|
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
|
||||||
|
|
||||||
|
'@fastify/websocket@11.2.0':
|
||||||
|
resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==}
|
||||||
|
|
||||||
'@formatjs/fast-memoize@3.1.6':
|
'@formatjs/fast-memoize@3.1.6':
|
||||||
resolution: {integrity: sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==}
|
resolution: {integrity: sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==}
|
||||||
|
|
||||||
@@ -1690,6 +1705,9 @@ packages:
|
|||||||
'@types/react@19.2.17':
|
'@types/react@19.2.17':
|
||||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.62.0':
|
'@typescript-eslint/eslint-plugin@8.62.0':
|
||||||
resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==}
|
resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -2411,6 +2429,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
duplexify@4.1.3:
|
||||||
|
resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
|
||||||
|
|
||||||
effect@3.21.0:
|
effect@3.21.0:
|
||||||
resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==}
|
resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==}
|
||||||
|
|
||||||
@@ -3880,6 +3901,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
stream-shift@1.0.3:
|
||||||
|
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -4314,6 +4338,18 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
ws@8.21.0:
|
||||||
|
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4602,6 +4638,15 @@ snapshots:
|
|||||||
fastq: 1.20.1
|
fastq: 1.20.1
|
||||||
glob: 11.1.0
|
glob: 11.1.0
|
||||||
|
|
||||||
|
'@fastify/websocket@11.2.0':
|
||||||
|
dependencies:
|
||||||
|
duplexify: 4.1.3
|
||||||
|
fastify-plugin: 5.1.0
|
||||||
|
ws: 8.21.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
'@formatjs/fast-memoize@3.1.6': {}
|
'@formatjs/fast-memoize@3.1.6': {}
|
||||||
|
|
||||||
'@formatjs/icu-messageformat-parser@3.5.12':
|
'@formatjs/icu-messageformat-parser@3.5.12':
|
||||||
@@ -5501,6 +5546,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.20.0
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -6250,6 +6299,13 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
duplexify@4.1.3:
|
||||||
|
dependencies:
|
||||||
|
end-of-stream: 1.4.5
|
||||||
|
inherits: 2.0.4
|
||||||
|
readable-stream: 3.6.2
|
||||||
|
stream-shift: 1.0.3
|
||||||
|
|
||||||
effect@3.21.0:
|
effect@3.21.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
@@ -7992,6 +8048,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
internal-slot: 1.1.0
|
internal-slot: 1.1.0
|
||||||
|
|
||||||
|
stream-shift@1.0.3: {}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-regex: 8.0.0
|
emoji-regex: 8.0.0
|
||||||
@@ -8529,6 +8587,8 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
ws@8.21.0: {}
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user