Phase2
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 10s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 12:01:25 +02:00
parent 58961000eb
commit c4077d4673
93 changed files with 4099 additions and 165 deletions

View File

@@ -54,3 +54,20 @@ export {
type SessionListResponse,
type MessageResponse,
} 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';

View 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

View File

@@ -10,7 +10,8 @@
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"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": {
"@prisma/client": "^6.3.1"

View File

@@ -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');

View File

@@ -13,14 +13,32 @@ enum UserStatus {
DELETED
}
enum ServerEdition {
JAVA
BEDROCK
}
enum SoftwareFamily {
VANILLA
PAPER
PURPUR
FABRIC
FORGE
NEOFORGE
QUILT
BEDROCK_DEDICATED
}
enum GameServerStatus {
PENDING
DRAFT
PROVISIONING
INSTALLING
STOPPED
STARTING
RUNNING
STOPPING
ERROR
DELETING
DELETED
}
@@ -29,6 +47,12 @@ enum GameNodeStatus {
OFFLINE
MAINTENANCE
DRAINING
UNREACHABLE
}
enum AllocationStatus {
ACTIVE
RELEASED
}
enum JobStatus {
@@ -51,14 +75,14 @@ model User {
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
credentials UserCredential[]
sessions UserSession[]
platformRoles UserPlatformRole[]
gameServers GameServer[]
auditEvents AuditEvent[]
emailVerificationTokens EmailVerificationToken[]
passwordResetTokens PasswordResetToken[]
totpCredential TotpCredential?
credentials UserCredential[]
sessions UserSession[]
platformRoles UserPlatformRole[]
gameServers GameServer[]
auditEvents AuditEvent[]
emailVerificationTokens EmailVerificationToken[]
passwordResetTokens PasswordResetToken[]
totpCredential TotpCredential?
@@map("users")
}
@@ -77,15 +101,15 @@ model UserCredential {
}
model UserSession {
id String @id @default(uuid())
userId String
tokenHash String @unique
expiresAt DateTime @db.Timestamptz(3)
revokedAt DateTime? @db.Timestamptz(3)
ipAddress String?
userAgent String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
id String @id @default(uuid())
userId String
tokenHash String @unique
expiresAt DateTime @db.Timestamptz(3)
revokedAt DateTime? @db.Timestamptz(3)
ipAddress String?
userAgent String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -180,20 +204,29 @@ model UserPlatformRole {
}
model GameServer {
id String @id @default(uuid())
userId String
planId String?
nodeId String?
name String
status GameServerStatus @default(PENDING)
version Int @default(1)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
id String @id @default(uuid())
userId String
planId String?
nodeId String?
name String
status GameServerStatus @default(DRAFT)
version Int @default(1)
edition ServerEdition @default(JAVA)
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])
plan Plan? @relation(fields: [planId], references: [id])
node GameNode? @relation(fields: [nodeId], references: [id])
stateTransitions GameServerStateTransition[]
allocation GameServerAllocation?
@@index([userId])
@@index([nodeId])
@@ -201,14 +234,36 @@ model GameServer {
@@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 {
id String @id @default(uuid())
gameServerId String
fromStatus GameServerStatus?
toStatus GameServerStatus
reason String?
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
id String @id @default(uuid())
gameServerId String
fromStatus GameServerStatus?
toStatus GameServerStatus
reason String?
correlationId String?
actorId String?
errorCode String?
errorMessage String?
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
gameServer GameServer @relation(fields: [gameServerId], references: [id], onDelete: Cascade)
@@ -218,18 +273,23 @@ model GameServerStateTransition {
}
model GameNode {
id String @id @default(uuid())
name String @unique
hostname String
status GameNodeStatus @default(OFFLINE)
maxServers Int @default(0)
activeServers Int @default(0)
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
id String @id @default(uuid())
name String @unique
hostname String
status GameNodeStatus @default(OFFLINE)
maxServers Int @default(10)
activeServers Int @default(0)
lastHeartbeatAt DateTime? @db.Timestamptz(3)
agentVersion String?
enrollmentTokenHash String?
enrolledAt DateTime? @db.Timestamptz(3)
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
gameServers GameServer[]
heartbeats GameNodeHeartbeat[]
gameServers GameServer[]
heartbeats GameNodeHeartbeat[]
allocations GameServerAllocation[]
@@map("game_nodes")
}
@@ -248,18 +308,18 @@ model GameNodeHeartbeat {
}
model Plan {
id String @id @default(uuid())
name String @unique
slug String @unique
description String?
isFree Boolean @default(false)
isActive Boolean @default(true)
maxRamMb Int
maxCpuCores Float
id String @id @default(uuid())
name String @unique
slug String @unique
description String?
isFree Boolean @default(false)
isActive Boolean @default(true)
maxRamMb Int
maxCpuCores Float
maxStorageMb Int
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
gameServers GameServer[]

View 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