feat: wire daemon console/files/config/players and improve runtime fallbacks
This commit is contained in:
parent
614d25c189
commit
44c439e2f9
|
|
@ -10,6 +10,8 @@
|
|||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.0",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
|
|
@ -18,6 +20,7 @@
|
|||
"@fastify/websocket": "^11.0.0",
|
||||
"@sinclair/typebox": "^0.34.0",
|
||||
"@source/database": "workspace:*",
|
||||
"@source/proto": "workspace:*",
|
||||
"@source/shared": "workspace:*",
|
||||
"argon2": "^0.41.0",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import helmet from '@fastify/helmet';
|
|||
import rateLimit from '@fastify/rate-limit';
|
||||
import dbPlugin from './plugins/db.js';
|
||||
import authPlugin from './plugins/auth.js';
|
||||
import socketPlugin from './plugins/socket.js';
|
||||
import authRoutes from './routes/auth/index.js';
|
||||
import organizationRoutes from './routes/organizations/index.js';
|
||||
import internalRoutes from './routes/internal/index.js';
|
||||
|
|
@ -41,6 +42,7 @@ await app.register(rateLimit, {
|
|||
await app.register(cookie);
|
||||
await app.register(dbPlugin);
|
||||
await app.register(authPlugin);
|
||||
await app.register(socketPlugin);
|
||||
|
||||
// Error handler
|
||||
app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,494 @@
|
|||
import grpc from '@grpc/grpc-js';
|
||||
import protoLoader from '@grpc/proto-loader';
|
||||
import type { PowerAction } from '@source/shared';
|
||||
import { PROTO_PATH } from '@source/proto';
|
||||
|
||||
export interface DaemonNodeConnection {
|
||||
fqdn: string;
|
||||
grpcPort: number;
|
||||
daemonToken: string;
|
||||
}
|
||||
|
||||
export interface DaemonPortMapping {
|
||||
host_port: number;
|
||||
container_port: number;
|
||||
protocol: 'tcp' | 'udp';
|
||||
}
|
||||
|
||||
export interface DaemonCreateServerRequest {
|
||||
uuid: string;
|
||||
docker_image: string;
|
||||
memory_limit: number;
|
||||
disk_limit: number;
|
||||
cpu_limit: number;
|
||||
startup_command: string;
|
||||
environment: Record<string, string>;
|
||||
ports: DaemonPortMapping[];
|
||||
install_plugin_urls: string[];
|
||||
}
|
||||
|
||||
interface DaemonServerResponse {
|
||||
uuid: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface DaemonStatusResponse {
|
||||
uuid: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface EmptyResponse {
|
||||
[key: string]: never;
|
||||
}
|
||||
|
||||
interface DaemonFileListResponseRaw {
|
||||
files: {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
size: number;
|
||||
modified_at: number;
|
||||
mime_type: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DaemonFileContentRaw {
|
||||
data: Uint8Array | Buffer;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
interface DaemonPlayerListRaw {
|
||||
players: {
|
||||
name: string;
|
||||
uuid: string;
|
||||
connected_at: number;
|
||||
}[];
|
||||
max_players: number;
|
||||
}
|
||||
|
||||
export interface DaemonConsoleOutput {
|
||||
uuid: string;
|
||||
line: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface DaemonConsoleStreamHandle {
|
||||
stream: grpc.ClientReadableStream<DaemonConsoleOutput>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface DaemonFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface DaemonPlayersResponse {
|
||||
players: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
connectedAt: number;
|
||||
}>;
|
||||
maxPlayers: number;
|
||||
}
|
||||
|
||||
type UnaryCallback<TResponse> = (error: grpc.ServiceError | null, response: TResponse) => void;
|
||||
|
||||
interface DaemonServiceClient extends grpc.Client {
|
||||
createServer(
|
||||
request: DaemonCreateServerRequest,
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonServerResponse>,
|
||||
): void;
|
||||
deleteServer(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
setPowerState(
|
||||
request: { uuid: string; action: number },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
getServerStatus(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonStatusResponse>,
|
||||
): void;
|
||||
streamConsole(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
): grpc.ClientReadableStream<DaemonConsoleOutput>;
|
||||
sendCommand(
|
||||
request: { uuid: string; command: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
listFiles(
|
||||
request: { uuid: string; path: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonFileListResponseRaw>,
|
||||
): void;
|
||||
readFile(
|
||||
request: { uuid: string; path: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonFileContentRaw>,
|
||||
): void;
|
||||
writeFile(
|
||||
request: { uuid: string; path: string; data: Uint8Array | Buffer },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
deleteFiles(
|
||||
request: { uuid: string; paths: string[] },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
getActivePlayers(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonPlayerListRaw>,
|
||||
): void;
|
||||
}
|
||||
|
||||
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
|
||||
keepCase: true,
|
||||
longs: Number,
|
||||
enums: Number,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
});
|
||||
|
||||
const loaded = grpc.loadPackageDefinition(packageDefinition) as {
|
||||
gamepanel?: {
|
||||
daemon?: {
|
||||
DaemonService?: grpc.ServiceClientConstructor;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const DaemonServiceCtor = loaded.gamepanel?.daemon?.DaemonService;
|
||||
if (!DaemonServiceCtor) {
|
||||
throw new Error('Failed to load DaemonService gRPC definition');
|
||||
}
|
||||
const DaemonService = DaemonServiceCtor;
|
||||
|
||||
const POWER_ACTIONS: Record<PowerAction, number> = {
|
||||
start: 0,
|
||||
stop: 1,
|
||||
restart: 2,
|
||||
kill: 3,
|
||||
};
|
||||
|
||||
function buildGrpcTarget(fqdn: string, grpcPort: number): string {
|
||||
const trimmed = fqdn.trim();
|
||||
if (!trimmed) throw new Error('Node FQDN is empty');
|
||||
|
||||
if (trimmed.includes('://')) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const host = parsed.hostname || parsed.host;
|
||||
if (!host) throw new Error('Node FQDN has no hostname');
|
||||
if (parsed.port) return `${host}:${parsed.port}`;
|
||||
return `${host}:${grpcPort}`;
|
||||
} catch {
|
||||
// Fall through to raw handling below.
|
||||
}
|
||||
}
|
||||
|
||||
const withoutPath = trimmed.replace(/\/.*$/, '');
|
||||
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
|
||||
return /\]:\d+$/.test(withoutPath) ? withoutPath : `${withoutPath}:${grpcPort}`;
|
||||
}
|
||||
if (/^[^:]+:\d+$/.test(withoutPath)) return withoutPath;
|
||||
if (withoutPath.includes(':')) return `[${withoutPath}]:${grpcPort}`;
|
||||
return `${withoutPath}:${grpcPort}`;
|
||||
}
|
||||
|
||||
function getMetadata(daemonToken: string): grpc.Metadata {
|
||||
const metadata = new grpc.Metadata();
|
||||
metadata.set('authorization', `Bearer ${daemonToken}`);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function createClient(node: DaemonNodeConnection): DaemonServiceClient {
|
||||
const target = buildGrpcTarget(node.fqdn, node.grpcPort);
|
||||
return new DaemonService(
|
||||
target,
|
||||
grpc.credentials.createInsecure(),
|
||||
) as unknown as DaemonServiceClient;
|
||||
}
|
||||
|
||||
function waitForReady(client: grpc.Client, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.waitForReady(Date.now() + timeoutMs, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function callUnary<TResponse>(
|
||||
invoke: (callback: UnaryCallback<TResponse>) => void,
|
||||
timeoutMs: number,
|
||||
): Promise<TResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let completed = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
reject(new Error(`gRPC request timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
invoke((error, response) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toBuffer(data: Uint8Array | Buffer): Buffer {
|
||||
if (Buffer.isBuffer(data)) return data;
|
||||
return Buffer.from(data);
|
||||
}
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
|
||||
const DEFAULT_RPC_TIMEOUT_MS = 20_000;
|
||||
|
||||
export async function daemonCreateServer(
|
||||
node: DaemonNodeConnection,
|
||||
request: DaemonCreateServerRequest,
|
||||
): Promise<DaemonServerResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonServerResponse>(
|
||||
(callback) => client.createServer(request, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonDeleteServer(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) => client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonSetPowerState(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
action: PowerAction,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonGetServerStatus(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<DaemonStatusResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonStatusResponse>(
|
||||
(callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonOpenConsoleStream(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<DaemonConsoleStreamHandle> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const stream = client.streamConsole({ uuid: serverUuid }, getMetadata(node.daemonToken));
|
||||
|
||||
const close = () => {
|
||||
try {
|
||||
stream.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
client.close();
|
||||
};
|
||||
|
||||
stream.on('end', () => client.close());
|
||||
stream.on('error', () => client.close());
|
||||
|
||||
return { stream, close };
|
||||
} catch (error) {
|
||||
client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonSendCommand(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) => client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonListFiles(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
path: string,
|
||||
): Promise<DaemonFileEntry[]> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonFileListResponseRaw>(
|
||||
(callback) => client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return response.files.map((file) => ({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isDirectory: file.is_directory,
|
||||
size: Number(file.size),
|
||||
modifiedAt: Number(file.modified_at),
|
||||
mimeType: file.mime_type,
|
||||
}));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonReadFile(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
path: string,
|
||||
): Promise<{ data: Buffer; mimeType: string }> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonFileContentRaw>(
|
||||
(callback) => client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
data: toBuffer(response.data),
|
||||
mimeType: response.mime_type,
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonWriteFile(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
path: string,
|
||||
data: string | Buffer,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.writeFile(
|
||||
{ uuid: serverUuid, path, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data },
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonDeleteFiles(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
paths: string[],
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) => client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonGetActivePlayers(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<DaemonPlayersResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonPlayerListRaw>(
|
||||
(callback) => client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
players: response.players.map((player) => ({
|
||||
name: player.name,
|
||||
id: player.uuid,
|
||||
connectedAt: Number(player.connected_at),
|
||||
})),
|
||||
maxPlayers: Number(response.max_players),
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import { nodes, organizationMembers, servers } from '@source/database';
|
||||
import { ROLES } from '@source/shared';
|
||||
import type { Role } from '@source/shared';
|
||||
import type { AccessTokenPayload } from '../lib/jwt.js';
|
||||
import {
|
||||
daemonOpenConsoleStream,
|
||||
daemonSendCommand,
|
||||
type DaemonConsoleStreamHandle,
|
||||
type DaemonNodeConnection,
|
||||
} from '../lib/daemon.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
io: SocketIOServer;
|
||||
}
|
||||
}
|
||||
|
||||
type ConsolePermission = 'console.read' | 'console.write';
|
||||
|
||||
export default fp(async (app: FastifyInstance) => {
|
||||
const io = new SocketIOServer(app.server, {
|
||||
path: '/socket.io',
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
},
|
||||
});
|
||||
|
||||
app.decorate('io', io);
|
||||
|
||||
const activeStreams = new Map<string, DaemonConsoleStreamHandle>();
|
||||
|
||||
io.use((socket, next) => {
|
||||
const token = typeof socket.handshake.auth?.token === 'string'
|
||||
? socket.handshake.auth.token
|
||||
: null;
|
||||
|
||||
if (!token) {
|
||||
next(new Error('Unauthorized'));
|
||||
return;
|
||||
}
|
||||
|
||||
const verifier = (app as any).jwt?.verify;
|
||||
if (typeof verifier !== 'function') {
|
||||
next(new Error('Authentication is not configured'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = verifier(token) as AccessTokenPayload;
|
||||
(socket.data as { user?: AccessTokenPayload }).user = payload;
|
||||
next();
|
||||
} catch {
|
||||
next(new Error('Unauthorized'));
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const cleanupSocketStream = () => {
|
||||
const current = activeStreams.get(socket.id);
|
||||
if (!current) return;
|
||||
current.close();
|
||||
activeStreams.delete(socket.id);
|
||||
};
|
||||
|
||||
socket.on('server:console:join', async (payload: unknown) => {
|
||||
const serverId = typeof (payload as { serverId?: unknown })?.serverId === 'string'
|
||||
? ((payload as { serverId: string }).serverId)
|
||||
: '';
|
||||
if (!serverId) {
|
||||
socket.emit('server:console:output', { line: '[error] Invalid server id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = (socket.data as { user?: AccessTokenPayload }).user;
|
||||
if (!user) {
|
||||
socket.emit('server:console:output', { line: '[error] Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const server = await getServerContext(app, serverId);
|
||||
if (!server) {
|
||||
socket.emit('server:console:output', { line: '[error] Server not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await hasConsolePermission(app, user, server.organizationId, 'console.read');
|
||||
if (!allowed) {
|
||||
socket.emit('server:console:output', { line: '[error] Missing permission: console.read' });
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupSocketStream();
|
||||
|
||||
try {
|
||||
const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid);
|
||||
streamHandle.stream.on('data', (output) => {
|
||||
socket.emit('server:console:output', { line: output.line });
|
||||
});
|
||||
streamHandle.stream.on('end', () => {
|
||||
activeStreams.delete(socket.id);
|
||||
socket.emit('server:console:output', { line: '[console] Stream ended' });
|
||||
});
|
||||
streamHandle.stream.on('error', (error) => {
|
||||
activeStreams.delete(socket.id);
|
||||
app.log.warn(
|
||||
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
||||
'Console stream failed',
|
||||
);
|
||||
socket.emit('server:console:output', { line: '[error] Console stream failed' });
|
||||
});
|
||||
|
||||
activeStreams.set(socket.id, streamHandle);
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
||||
'Failed to open console stream',
|
||||
);
|
||||
socket.emit('server:console:output', { line: '[error] Failed to open console stream' });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('server:console:leave', () => {
|
||||
cleanupSocketStream();
|
||||
});
|
||||
|
||||
socket.on('server:console:command', async (payload: unknown) => {
|
||||
const body = payload as {
|
||||
serverId?: unknown;
|
||||
orgId?: unknown;
|
||||
command?: unknown;
|
||||
};
|
||||
|
||||
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
|
||||
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
|
||||
const command = typeof body.command === 'string' ? body.command.trim() : '';
|
||||
|
||||
if (!serverId || !orgId || !command) {
|
||||
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = (socket.data as { user?: AccessTokenPayload }).user;
|
||||
if (!user) {
|
||||
socket.emit('server:console:output', { line: '[error] Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const server = await getServerContext(app, serverId, orgId);
|
||||
if (!server) {
|
||||
socket.emit('server:console:output', { line: '[error] Server not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await hasConsolePermission(app, user, orgId, 'console.write');
|
||||
if (!allowed) {
|
||||
socket.emit('server:console:output', { line: '[error] Missing permission: console.write' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await daemonSendCommand(server.node, server.serverUuid, command);
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
||||
'Failed to send console command',
|
||||
);
|
||||
socket.emit('server:console:output', { line: '[error] Failed to send command' });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
cleanupSocketStream();
|
||||
});
|
||||
});
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
for (const handle of activeStreams.values()) {
|
||||
handle.close();
|
||||
}
|
||||
activeStreams.clear();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
io.close(() => resolve());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function hasConsolePermission(
|
||||
app: FastifyInstance,
|
||||
user: AccessTokenPayload,
|
||||
orgId: string,
|
||||
permission: ConsolePermission,
|
||||
): Promise<boolean> {
|
||||
if (user.isSuperAdmin) return true;
|
||||
|
||||
const member = await app.db.query.organizationMembers.findFirst({
|
||||
where: and(
|
||||
eq(organizationMembers.organizationId, orgId),
|
||||
eq(organizationMembers.userId, user.sub),
|
||||
),
|
||||
columns: {
|
||||
role: true,
|
||||
customPermissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) return false;
|
||||
|
||||
const custom = (member.customPermissions ?? {}) as Record<string, boolean>;
|
||||
if (permission in custom) {
|
||||
return Boolean(custom[permission]);
|
||||
}
|
||||
|
||||
const rolePerms = ROLES[member.role as Role]?.permissions ?? [];
|
||||
return (rolePerms as readonly string[]).includes(permission);
|
||||
}
|
||||
|
||||
async function getServerContext(
|
||||
app: FastifyInstance,
|
||||
serverId: string,
|
||||
orgId?: string,
|
||||
): Promise<{
|
||||
organizationId: string;
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
} | null> {
|
||||
const whereClause = orgId
|
||||
? and(eq(servers.id, serverId), eq(servers.organizationId, orgId))
|
||||
: eq(servers.id, serverId);
|
||||
|
||||
const [row] = await app.db
|
||||
.select({
|
||||
organizationId: servers.organizationId,
|
||||
serverUuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(whereClause);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
organizationId: row.organizationId,
|
||||
serverUuid: row.serverUuid,
|
||||
node: {
|
||||
fqdn: row.nodeFqdn,
|
||||
grpcPort: row.nodeGrpcPort,
|
||||
daemonToken: row.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { servers, games } from '@source/database';
|
||||
import { servers, games, nodes } from '@source/database';
|
||||
import type { GameConfigFile, ConfigParser } from '@source/shared';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
|
||||
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
|
|
@ -60,16 +61,26 @@ export default async function configRoutes(app: FastifyInstance) {
|
|||
};
|
||||
await requirePermission(request, orgId, 'config.read');
|
||||
|
||||
const { game, server, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
|
||||
// TODO: Read file from daemon via gRPC
|
||||
// For now, return empty parsed result (will be connected in Phase 4 integration)
|
||||
let raw = '';
|
||||
try {
|
||||
const file = await daemonReadFile(node, server.uuid, configFile.path);
|
||||
raw = file.data.toString('utf8');
|
||||
} catch (error) {
|
||||
if (!isMissingConfigFileError(error)) {
|
||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon');
|
||||
throw new AppError(502, 'Failed to read config file from daemon', 'DAEMON_CONFIG_READ_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
const entries = raw ? parseConfig(raw, configFile.parser as ConfigParser) : [];
|
||||
return {
|
||||
path: configFile.path,
|
||||
parser: configFile.parser,
|
||||
editableKeys: configFile.editableKeys ?? null,
|
||||
entries: [],
|
||||
raw: '',
|
||||
entries,
|
||||
raw,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -98,7 +109,7 @@ export default async function configRoutes(app: FastifyInstance) {
|
|||
const { entries } = request.body as { entries: { key: string; value: string }[] };
|
||||
await requirePermission(request, orgId, 'config.write');
|
||||
|
||||
const { configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
|
||||
// If editableKeys is set, only allow those keys
|
||||
if (configFile.editableKeys && configFile.editableKeys.length > 0) {
|
||||
|
|
@ -111,11 +122,24 @@ export default async function configRoutes(app: FastifyInstance) {
|
|||
}
|
||||
}
|
||||
|
||||
// Serialize the entries
|
||||
const content = serializeConfig(entries, configFile.parser as ConfigParser);
|
||||
let originalContent: string | undefined;
|
||||
try {
|
||||
const current = await daemonReadFile(node, server.uuid, configFile.path);
|
||||
originalContent = current.data.toString('utf8');
|
||||
} catch (error) {
|
||||
if (!isMissingConfigFileError(error)) {
|
||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write');
|
||||
throw new AppError(502, 'Failed to read existing config file', 'DAEMON_CONFIG_READ_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Write file to daemon via gRPC
|
||||
// For now, just return success
|
||||
const content = serializeConfig(
|
||||
entries,
|
||||
configFile.parser as ConfigParser,
|
||||
originalContent,
|
||||
);
|
||||
|
||||
await daemonWriteFile(node, server.uuid, configFile.path, content);
|
||||
return { success: true, path: configFile.path, content };
|
||||
},
|
||||
);
|
||||
|
|
@ -127,13 +151,22 @@ async function getServerConfig(
|
|||
serverId: string,
|
||||
configIndex: number,
|
||||
) {
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
id: servers.id,
|
||||
uuid: servers.uuid,
|
||||
gameId: servers.gameId,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
|
||||
const game = await app.db.query.games.findFirst({
|
||||
where: eq(games.id, server.gameId),
|
||||
where: eq(games.id, server.gameId as string),
|
||||
});
|
||||
if (!game) throw AppError.notFound('Game not found');
|
||||
|
||||
|
|
@ -141,5 +174,20 @@ async function getServerConfig(
|
|||
const configFile = configFiles[configIndex];
|
||||
if (!configFile) throw AppError.notFound('Config file not found');
|
||||
|
||||
return { game, server, configFile };
|
||||
const node: DaemonNodeConnection = {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
};
|
||||
|
||||
return { game, server, node, configFile };
|
||||
}
|
||||
|
||||
function isMissingConfigFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('No such file or directory') ||
|
||||
message.includes('Server responded with NOT_FOUND') ||
|
||||
message.includes('status code 404')
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { nodes, servers } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import {
|
||||
daemonDeleteFiles,
|
||||
daemonListFiles,
|
||||
daemonReadFile,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
|
||||
const FileParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export default async function fileRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
...FileParamSchema,
|
||||
querystring: Type.Object({
|
||||
path: Type.Optional(Type.String()),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { path } = request.query as { path?: string };
|
||||
|
||||
await requirePermission(request, orgId, 'files.read');
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
|
||||
const files = await daemonListFiles(
|
||||
serverContext.node,
|
||||
serverContext.serverUuid,
|
||||
path?.trim() || '/',
|
||||
);
|
||||
|
||||
return { files };
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/read',
|
||||
{
|
||||
schema: {
|
||||
...FileParamSchema,
|
||||
querystring: Type.Object({
|
||||
path: Type.String({ minLength: 1 }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { path } = request.query as { path: string };
|
||||
|
||||
await requirePermission(request, orgId, 'files.read');
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
|
||||
const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path);
|
||||
return { data: content.data.toString('utf8') };
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/write',
|
||||
{
|
||||
schema: {
|
||||
...FileParamSchema,
|
||||
body: Type.Object({
|
||||
path: Type.String({ minLength: 1 }),
|
||||
data: Type.String(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { path, data } = request.body as { path: string; data: string };
|
||||
|
||||
await requirePermission(request, orgId, 'files.write');
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
|
||||
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, data);
|
||||
return { success: true, path };
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/delete',
|
||||
{
|
||||
schema: {
|
||||
...FileParamSchema,
|
||||
body: Type.Object({
|
||||
paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { paths } = request.body as { paths: string[] };
|
||||
|
||||
await requirePermission(request, orgId, 'files.delete');
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
|
||||
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, paths);
|
||||
return { success: true, paths };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
}> {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
uuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return {
|
||||
serverUuid: server.uuid,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,20 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and, count } from 'drizzle-orm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { setTimeout as sleep } from 'timers/promises';
|
||||
import { servers, allocations, nodes, games } from '@source/database';
|
||||
import type { PowerAction } from '@source/shared';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import {
|
||||
daemonCreateServer,
|
||||
daemonDeleteServer,
|
||||
daemonGetServerStatus,
|
||||
daemonSetPowerState,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
import {
|
||||
ServerParamSchema,
|
||||
CreateServerSchema,
|
||||
|
|
@ -14,16 +22,123 @@ import {
|
|||
PowerActionSchema,
|
||||
} from './schemas.js';
|
||||
import configRoutes from './config.js';
|
||||
import fileRoutes from './files.js';
|
||||
import pluginRoutes from './plugins.js';
|
||||
import playerRoutes from './players.js';
|
||||
import scheduleRoutes from './schedules.js';
|
||||
import backupRoutes from './backups.js';
|
||||
|
||||
type MutableServerStatus = 'installing' | 'running' | 'stopped' | 'error';
|
||||
|
||||
function mapDaemonStatus(rawStatus: string): MutableServerStatus | null {
|
||||
switch (rawStatus.toLowerCase()) {
|
||||
case 'installing':
|
||||
return 'installing';
|
||||
case 'running':
|
||||
return 'running';
|
||||
case 'stopped':
|
||||
return 'stopped';
|
||||
case 'starting':
|
||||
return 'running';
|
||||
case 'stopping':
|
||||
return 'stopped';
|
||||
case 'error':
|
||||
return 'error';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDaemonEnvironment(
|
||||
gameEnvVarsRaw: unknown,
|
||||
overrides: Record<string, string> | undefined,
|
||||
memoryLimitBytes: number,
|
||||
): Record<string, string> {
|
||||
const environment: Record<string, string> = {};
|
||||
|
||||
if (Array.isArray(gameEnvVarsRaw)) {
|
||||
for (const item of gameEnvVarsRaw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
const key = typeof record.key === 'string' ? record.key.trim() : '';
|
||||
if (!key) continue;
|
||||
|
||||
const defaultValue = record.default;
|
||||
if (defaultValue === undefined || defaultValue === null) continue;
|
||||
|
||||
environment[key] = String(defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(overrides ?? {})) {
|
||||
environment[key] = String(value);
|
||||
}
|
||||
|
||||
if ('MEMORY' in environment && !(overrides && 'MEMORY' in overrides)) {
|
||||
const memoryMiB = Math.max(256, Math.floor(memoryLimitBytes / (1024 * 1024)));
|
||||
environment.MEMORY = `${memoryMiB}M`;
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
async function syncServerInstallStatus(
|
||||
app: FastifyInstance,
|
||||
node: DaemonNodeConnection,
|
||||
serverId: string,
|
||||
serverUuid: string,
|
||||
): Promise<void> {
|
||||
const maxAttempts = 120;
|
||||
const intervalMs = 5_000;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
if (attempt > 1) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
|
||||
try {
|
||||
const daemonStatus = await daemonGetServerStatus(node, serverUuid);
|
||||
const mapped = mapDaemonStatus(daemonStatus.state);
|
||||
if (!mapped) continue;
|
||||
if (mapped === 'installing') continue;
|
||||
|
||||
const now = new Date();
|
||||
await app.db
|
||||
.update(servers)
|
||||
.set({
|
||||
status: mapped,
|
||||
installedAt: mapped === 'running' || mapped === 'stopped' ? now : null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(servers.id, serverId));
|
||||
|
||||
app.log.info(
|
||||
{ serverId, serverUuid, status: mapped, attempt },
|
||||
'Synchronized install status from daemon',
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{ error, serverId, serverUuid, attempt },
|
||||
'Failed to poll daemon server status',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
app.log.warn(
|
||||
{ serverId, serverUuid },
|
||||
'Timed out while waiting for daemon install completion',
|
||||
);
|
||||
}
|
||||
|
||||
export default async function serverRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
// Register sub-routes
|
||||
await app.register(configRoutes, { prefix: '/:serverId/config' });
|
||||
await app.register(fileRoutes, { prefix: '/:serverId/files' });
|
||||
await app.register(pluginRoutes, { prefix: '/:serverId/plugins' });
|
||||
await app.register(playerRoutes, { prefix: '/:serverId/players' });
|
||||
await app.register(scheduleRoutes, { prefix: '/:serverId/schedules' });
|
||||
await app.register(backupRoutes, { prefix: '/:serverId/backups' });
|
||||
|
||||
|
|
@ -128,24 +243,80 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||
status: 'installing',
|
||||
})
|
||||
.returning();
|
||||
if (!server) {
|
||||
throw new AppError(500, 'Failed to create server record', 'SERVER_CREATE_FAILED');
|
||||
}
|
||||
|
||||
// Assign allocation to server
|
||||
await app.db
|
||||
.update(allocations)
|
||||
.set({ serverId: server!.id, isDefault: true })
|
||||
.set({ serverId: server.id, isDefault: true })
|
||||
.where(eq(allocations.id, body.allocationId));
|
||||
|
||||
// TODO: Send gRPC CreateServer to daemon
|
||||
// This will be implemented in Phase 4
|
||||
const nodeConnection: DaemonNodeConnection = {
|
||||
fqdn: node.fqdn,
|
||||
grpcPort: node.grpcPort,
|
||||
daemonToken: node.daemonToken,
|
||||
};
|
||||
|
||||
const daemonRequest = {
|
||||
uuid: server.uuid,
|
||||
docker_image: game.dockerImage,
|
||||
memory_limit: server.memoryLimit,
|
||||
disk_limit: server.diskLimit,
|
||||
cpu_limit: server.cpuLimit,
|
||||
startup_command: body.startupOverride ?? game.startupCommand,
|
||||
environment: buildDaemonEnvironment(game.environmentVars, body.environment, server.memoryLimit),
|
||||
ports: [
|
||||
{
|
||||
host_port: allocation.port,
|
||||
container_port: game.defaultPort,
|
||||
protocol: 'tcp' as const,
|
||||
},
|
||||
],
|
||||
install_plugin_urls: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const daemonResponse = await daemonCreateServer(nodeConnection, daemonRequest);
|
||||
const daemonStatus = mapDaemonStatus(daemonResponse.status) ?? 'installing';
|
||||
const now = new Date();
|
||||
|
||||
const [updatedServer] = await app.db
|
||||
.update(servers)
|
||||
.set({
|
||||
status: daemonStatus,
|
||||
installedAt: daemonStatus === 'running' || daemonStatus === 'stopped' ? now : null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(servers.id, server.id))
|
||||
.returning();
|
||||
|
||||
if (daemonStatus === 'installing') {
|
||||
void syncServerInstallStatus(app, nodeConnection, server.id, server.uuid);
|
||||
}
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId: server!.id,
|
||||
serverId: server.id,
|
||||
action: 'server.create',
|
||||
metadata: { name: body.name, gameSlug: game.slug, nodeId: body.nodeId },
|
||||
});
|
||||
|
||||
return reply.code(201).send(server);
|
||||
return reply.code(201).send(updatedServer ?? server);
|
||||
} catch (error) {
|
||||
app.log.error(
|
||||
{ error, serverId: server.id, serverUuid: server.uuid, nodeId: node.id },
|
||||
'Failed to provision server on daemon',
|
||||
);
|
||||
|
||||
await app.db
|
||||
.update(servers)
|
||||
.set({ status: 'error', updatedAt: new Date() })
|
||||
.where(eq(servers.id, server.id));
|
||||
|
||||
throw new AppError(502, 'Failed to provision server on daemon', 'DAEMON_CREATE_FAILED');
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/organizations/:orgId/servers/:serverId
|
||||
|
|
@ -217,21 +388,43 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'server.delete');
|
||||
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
id: servers.id,
|
||||
uuid: servers.uuid,
|
||||
name: servers.name,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
|
||||
try {
|
||||
await daemonDeleteServer(
|
||||
{
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
server.uuid,
|
||||
);
|
||||
} catch (error) {
|
||||
app.log.error(
|
||||
{ error, serverId: server.id, serverUuid: server.uuid },
|
||||
'Failed to delete server on daemon',
|
||||
);
|
||||
throw new AppError(502, 'Failed to delete server on daemon', 'DAEMON_DELETE_FAILED');
|
||||
}
|
||||
|
||||
// Release allocations
|
||||
await app.db
|
||||
.update(allocations)
|
||||
.set({ serverId: null })
|
||||
.set({ serverId: null, isDefault: false })
|
||||
.where(eq(allocations.serverId, serverId));
|
||||
|
||||
// TODO: Send gRPC DeleteServer to daemon
|
||||
|
||||
await app.db.delete(servers).where(eq(servers.id, serverId));
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
|
|
@ -239,6 +432,8 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||
metadata: { name: server.name, uuid: server.uuid },
|
||||
});
|
||||
|
||||
await app.db.delete(servers).where(eq(servers.id, serverId));
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
|
|
@ -256,18 +451,43 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||
} as const;
|
||||
await requirePermission(request, orgId, permMap[action]);
|
||||
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
id: servers.id,
|
||||
uuid: servers.uuid,
|
||||
status: servers.status,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
|
||||
if (server.status === 'suspended') {
|
||||
throw AppError.badRequest('Cannot send power action to a suspended server');
|
||||
}
|
||||
|
||||
// TODO: Send gRPC SetPowerState to daemon
|
||||
// For now, just update status optimistically
|
||||
const statusMap: Record<PowerAction, string> = {
|
||||
try {
|
||||
await daemonSetPowerState(
|
||||
{
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
server.uuid,
|
||||
action,
|
||||
);
|
||||
} catch (error) {
|
||||
app.log.error(
|
||||
{ error, serverId: server.id, serverUuid: server.uuid, action },
|
||||
'Failed to send power action to daemon',
|
||||
);
|
||||
throw new AppError(502, 'Failed to send power action to daemon', 'DAEMON_POWER_FAILED');
|
||||
}
|
||||
|
||||
const statusMap: Record<PowerAction, MutableServerStatus> = {
|
||||
start: 'running',
|
||||
stop: 'stopped',
|
||||
restart: 'running',
|
||||
|
|
@ -276,7 +496,11 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||
|
||||
await app.db
|
||||
.update(servers)
|
||||
.set({ status: statusMap[action] as any, updatedAt: new Date() })
|
||||
.set({
|
||||
status: statusMap[action],
|
||||
installedAt: statusMap[action] === 'running' ? new Date() : undefined,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(servers.id, serverId));
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { nodes, servers } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { daemonGetActivePlayers, type DaemonNodeConnection } from '../../lib/daemon.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export default async function playerRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', { schema: ParamSchema }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'server.read');
|
||||
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
const players = await daemonGetActivePlayers(serverContext.node, serverContext.serverUuid);
|
||||
|
||||
return {
|
||||
players: players.players.map((player) => ({
|
||||
name: player.name,
|
||||
steamid: player.id || undefined,
|
||||
})),
|
||||
maxPlayers: players.maxPlayers,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
}> {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
uuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return {
|
||||
serverUuid: server.uuid,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ use bollard::container::{
|
|||
use bollard::image::CreateImageOptions;
|
||||
use bollard::models::{HostConfig, PortBinding};
|
||||
use futures::StreamExt;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::info;
|
||||
|
||||
use crate::docker::DockerManager;
|
||||
|
|
@ -21,6 +22,56 @@ pub fn container_name(server_uuid: &str) -> String {
|
|||
}
|
||||
|
||||
impl DockerManager {
|
||||
async fn run_exec(&self, container_name: &str, cmd: Vec<String>) -> Result<String> {
|
||||
let exec = self
|
||||
.client()
|
||||
.create_exec(
|
||||
container_name,
|
||||
bollard::exec::CreateExecOptions::<String> {
|
||||
cmd: Some(cmd),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut captured = String::new();
|
||||
match self.client()
|
||||
.start_exec(&exec.id, None::<bollard::exec::StartExecOptions>)
|
||||
.await?
|
||||
{
|
||||
bollard::exec::StartExecResults::Attached { mut output, .. } => {
|
||||
while let Some(chunk) = output.next().await {
|
||||
let chunk = chunk?;
|
||||
captured.push_str(&chunk.to_string());
|
||||
}
|
||||
}
|
||||
bollard::exec::StartExecResults::Detached => {}
|
||||
}
|
||||
|
||||
// Wait briefly for completion and collect exit code.
|
||||
for _ in 0..30 {
|
||||
let status = self.client().inspect_exec(&exec.id).await?;
|
||||
if !status.running.unwrap_or(false) {
|
||||
let code = status.exit_code.unwrap_or(0);
|
||||
if code == 0 {
|
||||
return Ok(captured);
|
||||
}
|
||||
return Err(anyhow::anyhow!("exec command failed with exit code {}", code));
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("exec command timeout"))
|
||||
}
|
||||
|
||||
pub async fn rcon_command(&self, server_uuid: &str, command: &str) -> Result<String> {
|
||||
let name = container_name(server_uuid);
|
||||
self.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Pull a Docker image if not already present.
|
||||
pub async fn pull_image(&self, image: &str) -> Result<()> {
|
||||
info!(image = %image, "Pulling Docker image");
|
||||
|
|
@ -241,23 +292,20 @@ impl DockerManager {
|
|||
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
|
||||
let name = container_name(server_uuid);
|
||||
|
||||
let exec = self
|
||||
.client()
|
||||
.create_exec(
|
||||
&name,
|
||||
bollard::exec::CreateExecOptions {
|
||||
cmd: Some(vec!["sh", "-c", &format!("echo '{}' > /proc/1/fd/0", command)]),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
// Preferred path for Minecraft-like images where rcon-cli is available.
|
||||
if self
|
||||
.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()])
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.client()
|
||||
.start_exec(&exec.id, None::<bollard::exec::StartExecOptions>)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
// Generic fallback: write directly to PID 1 stdin.
|
||||
let escaped = command.replace('\'', "'\"'\"'");
|
||||
let shell_cmd = format!("printf '%s\\n' '{}' > /proc/1/fd/0", escaped);
|
||||
self.run_exec(&name, vec!["sh".to_string(), "-c".to_string(), shell_cmd])
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use futures::StreamExt;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::{Request, Response, Status};
|
||||
use tracing::{info, error};
|
||||
use tracing::{info, error, warn};
|
||||
|
||||
use crate::server::{ServerManager, PortMap};
|
||||
use crate::filesystem::FileSystem;
|
||||
|
|
@ -241,9 +242,6 @@ impl DaemonService for DaemonServiceImpl {
|
|||
self.check_auth(&request)?;
|
||||
let uuid = request.into_inner().uuid;
|
||||
|
||||
// Verify server exists
|
||||
let _ = self.server_manager.get_server(&uuid).await.map_err(Status::from)?;
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(256);
|
||||
let docker = self.server_manager.docker().clone();
|
||||
|
||||
|
|
@ -482,10 +480,151 @@ impl DaemonService for DaemonServiceImpl {
|
|||
request: Request<ServerIdentifier>,
|
||||
) -> Result<Response<PlayerList>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
// TODO: implement game-specific player queries (RCON)
|
||||
let uuid = request.into_inner().uuid;
|
||||
|
||||
let fs = self.get_fs(&uuid);
|
||||
let properties = match fs.read_file("server.properties").await {
|
||||
Ok(data) => parse_properties_map(&String::from_utf8_lossy(&data)),
|
||||
Err(_) => HashMap::new(),
|
||||
};
|
||||
let max_from_properties = properties
|
||||
.get("max-players")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let rcon_enabled_from_properties = properties
|
||||
.get("enable-rcon")
|
||||
.map(|v| v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
let rcon_password_from_properties = properties
|
||||
.get("rcon.password")
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.cloned();
|
||||
let rcon_port_from_properties = properties
|
||||
.get("rcon.port")
|
||||
.and_then(|v| v.parse::<u16>().ok())
|
||||
.unwrap_or(25575);
|
||||
|
||||
// Try RCON-based player discovery for known games when runtime spec exists.
|
||||
if let Ok(spec) = self.server_manager.get_server(&uuid).await {
|
||||
let image = spec.docker_image.to_lowercase();
|
||||
|
||||
if image.contains("minecraft") {
|
||||
let password_from_env = spec
|
||||
.environment
|
||||
.get("RCON_PASSWORD")
|
||||
.or_else(|| spec.environment.get("MCRCON_PASSWORD"))
|
||||
.filter(|v| !v.trim().is_empty());
|
||||
|
||||
let password = password_from_env
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
if rcon_enabled_from_properties {
|
||||
rcon_password_from_properties.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(password) = password {
|
||||
let host = spec
|
||||
.environment
|
||||
.get("RCON_HOST")
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let port = spec
|
||||
.environment
|
||||
.get("RCON_PORT")
|
||||
.and_then(|v| v.parse::<u16>().ok())
|
||||
.unwrap_or(rcon_port_from_properties);
|
||||
let address = format!("{}:{}", host, port);
|
||||
|
||||
match crate::game::minecraft::get_players(&address, &password).await {
|
||||
Ok((players, max)) => {
|
||||
let mapped = players
|
||||
.into_iter()
|
||||
.map(|p| Player {
|
||||
name: p.name,
|
||||
uuid: String::new(),
|
||||
connected_at: 0,
|
||||
})
|
||||
.collect();
|
||||
return Ok(Response::new(PlayerList {
|
||||
players: mapped,
|
||||
max_players: max as i32,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(uuid = %uuid, error = %e, "Minecraft RCON player query failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if image.contains("csgo") || image.contains("cs2") {
|
||||
if let Some(password) = spec
|
||||
.environment
|
||||
.get("SRCDS_RCONPW")
|
||||
.or_else(|| spec.environment.get("RCON_PASSWORD"))
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
{
|
||||
let host = spec
|
||||
.environment
|
||||
.get("RCON_HOST")
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let port = spec
|
||||
.environment
|
||||
.get("RCON_PORT")
|
||||
.and_then(|v| v.parse::<u16>().ok())
|
||||
.unwrap_or(27015);
|
||||
let address = format!("{}:{}", host, port);
|
||||
|
||||
match crate::game::cs2::get_players(&address, password).await {
|
||||
Ok((players, max)) => {
|
||||
let mapped = players
|
||||
.into_iter()
|
||||
.map(|p| Player {
|
||||
name: p.name,
|
||||
uuid: p.steamid,
|
||||
connected_at: 0,
|
||||
})
|
||||
.collect();
|
||||
return Ok(Response::new(PlayerList {
|
||||
players: mapped,
|
||||
max_players: max as i32,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(uuid = %uuid, error = %e, "CS2 RCON player query failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for restarted daemon / missing runtime spec:
|
||||
// try querying `rcon-cli list` inside the container and parse output.
|
||||
if let Ok(output) = self.server_manager.docker().rcon_command(&uuid, "list").await {
|
||||
let (names, max) = parse_minecraft_list_output(&output);
|
||||
if !names.is_empty() || max > 0 {
|
||||
let mapped = names
|
||||
.into_iter()
|
||||
.map(|name| Player {
|
||||
name,
|
||||
uuid: String::new(),
|
||||
connected_at: 0,
|
||||
})
|
||||
.collect();
|
||||
return Ok(Response::new(PlayerList {
|
||||
players: mapped,
|
||||
max_players: if max > 0 { max } else { max_from_properties },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Response::new(PlayerList {
|
||||
players: vec![],
|
||||
max_players: 0,
|
||||
max_players: max_from_properties,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
@ -509,3 +648,55 @@ fn calculate_cpu_percent(stats: &bollard::container::Stats) -> f64 {
|
|||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_properties_map(content: &str) -> HashMap<String, String> {
|
||||
let mut props = HashMap::new();
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('!') {
|
||||
continue;
|
||||
}
|
||||
let mut parts = trimmed.splitn(2, '=');
|
||||
let Some(key) = parts.next() else { continue };
|
||||
let Some(value) = parts.next() else { continue };
|
||||
props.insert(key.trim().to_string(), value.trim().to_string());
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
fn parse_minecraft_list_output(output: &str) -> (Vec<String>, i32) {
|
||||
let mut max_players = 0i32;
|
||||
let mut names = Vec::new();
|
||||
|
||||
// Typical response:
|
||||
// "There are 1 of a max of 20 players online: player1, player2"
|
||||
let parts: Vec<&str> = output.splitn(2, ':').collect();
|
||||
|
||||
if let Some(header) = parts.first() {
|
||||
let mut first_number_seen = false;
|
||||
for token in header.split_whitespace() {
|
||||
if let Ok(value) = token.parse::<i32>() {
|
||||
if !first_number_seen {
|
||||
first_number_seen = true;
|
||||
} else {
|
||||
max_players = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if parts.len() > 1 {
|
||||
let players = parts[1].trim();
|
||||
if !players.is_empty() {
|
||||
for name in players.split(',') {
|
||||
let clean = name.trim();
|
||||
if !clean.is_empty() {
|
||||
names.push(clean.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(names, max_players)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,58 +130,66 @@ impl ServerManager {
|
|||
|
||||
/// Start a server.
|
||||
pub async fn start_server(&self, uuid: &str) -> Result<(), DaemonError> {
|
||||
let mut managed = false;
|
||||
{
|
||||
let mut servers = self.servers.write().await;
|
||||
let spec = servers
|
||||
.get_mut(uuid)
|
||||
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?;
|
||||
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
if !spec.can_transition_to(&ServerState::Starting) {
|
||||
return Err(DaemonError::InvalidStateTransition {
|
||||
current: spec.state.to_string(),
|
||||
requested: "starting".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
spec.state = ServerState::Starting;
|
||||
drop(servers);
|
||||
managed = true;
|
||||
}
|
||||
}
|
||||
|
||||
self.docker.start_container(uuid).await.map_err(|e| {
|
||||
DaemonError::Internal(format!("Failed to start container: {}", e))
|
||||
})?;
|
||||
|
||||
if managed {
|
||||
let mut servers = self.servers.write().await;
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
spec.state = ServerState::Running;
|
||||
}
|
||||
} else {
|
||||
info!(uuid = %uuid, "Started container without managed runtime state");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop a server.
|
||||
pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> {
|
||||
let mut managed = false;
|
||||
{
|
||||
let mut servers = self.servers.write().await;
|
||||
let spec = servers
|
||||
.get_mut(uuid)
|
||||
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?;
|
||||
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
if !spec.can_transition_to(&ServerState::Stopping) {
|
||||
return Err(DaemonError::InvalidStateTransition {
|
||||
current: spec.state.to_string(),
|
||||
requested: "stopping".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
spec.state = ServerState::Stopping;
|
||||
drop(servers);
|
||||
managed = true;
|
||||
}
|
||||
}
|
||||
|
||||
self.docker.stop_container(uuid, 30).await.map_err(|e| {
|
||||
DaemonError::Internal(format!("Failed to stop container: {}", e))
|
||||
})?;
|
||||
|
||||
if managed {
|
||||
let mut servers = self.servers.write().await;
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
spec.state = ServerState::Stopped;
|
||||
}
|
||||
} else {
|
||||
info!(uuid = %uuid, "Stopped container without managed runtime state");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
210
pnpm-lock.yaml
210
pnpm-lock.yaml
|
|
@ -50,12 +50,21 @@ importers:
|
|||
'@fastify/websocket':
|
||||
specifier: ^11.0.0
|
||||
version: 11.2.0
|
||||
'@grpc/grpc-js':
|
||||
specifier: ^1.14.0
|
||||
version: 1.14.3
|
||||
'@grpc/proto-loader':
|
||||
specifier: ^0.8.0
|
||||
version: 0.8.0
|
||||
'@sinclair/typebox':
|
||||
specifier: ^0.34.0
|
||||
version: 0.34.48
|
||||
'@source/database':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/database
|
||||
'@source/proto':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/proto
|
||||
'@source/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
|
|
@ -1006,6 +1015,15 @@ packages:
|
|||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@grpc/grpc-js@1.14.3':
|
||||
resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
|
||||
engines: {node: '>=12.10.0'}
|
||||
|
||||
'@grpc/proto-loader@0.8.0':
|
||||
resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
|
@ -1038,6 +1056,9 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2':
|
||||
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
|
||||
|
||||
'@lukeed/ms@2.0.2':
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -1064,6 +1085,36 @@ packages:
|
|||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2':
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
|
||||
'@protobufjs/base64@1.1.2':
|
||||
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||
|
||||
'@protobufjs/codegen@2.0.4':
|
||||
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.0':
|
||||
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
|
||||
|
||||
'@protobufjs/fetch@1.1.0':
|
||||
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
|
||||
|
||||
'@protobufjs/float@1.0.2':
|
||||
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||
|
||||
'@protobufjs/inquire@1.1.0':
|
||||
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
|
||||
|
||||
'@protobufjs/path@1.1.2':
|
||||
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||
|
||||
'@protobufjs/pool@1.1.0':
|
||||
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||
|
||||
'@protobufjs/utf8@1.1.0':
|
||||
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
|
|
@ -1820,6 +1871,10 @@ packages:
|
|||
ajv@8.18.0:
|
||||
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -1921,6 +1976,10 @@ packages:
|
|||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -2114,6 +2173,9 @@ packages:
|
|||
electron-to-chromium@1.5.302:
|
||||
resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
|
|
@ -2323,6 +2385,10 @@ packages:
|
|||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-nonce@1.0.1:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -2392,6 +2458,10 @@ packages:
|
|||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -2468,9 +2538,15 @@ packages:
|
|||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lodash.camelcase@4.3.0:
|
||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
long@5.3.2:
|
||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
|
|
@ -2691,6 +2767,10 @@ packages:
|
|||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
protobufjs@7.5.4:
|
||||
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
pump@3.0.3:
|
||||
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
|
||||
|
||||
|
|
@ -2772,6 +2852,10 @@ packages:
|
|||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -2895,9 +2979,17 @@ packages:
|
|||
stream-shift@1.0.3:
|
||||
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-json-comments@3.1.1:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -3118,6 +3210,10 @@ packages:
|
|||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
|
|
@ -3153,9 +3249,21 @@ packages:
|
|||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3721,6 +3829,18 @@ snapshots:
|
|||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@grpc/grpc-js@1.14.3':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.8.0
|
||||
'@js-sdsl/ordered-map': 4.4.2
|
||||
|
||||
'@grpc/proto-loader@0.8.0':
|
||||
dependencies:
|
||||
lodash.camelcase: 4.3.0
|
||||
long: 5.3.2
|
||||
protobufjs: 7.5.4
|
||||
yargs: 17.7.2
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.7':
|
||||
|
|
@ -3751,6 +3871,8 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2': {}
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
|
|
@ -3771,6 +3893,29 @@ snapshots:
|
|||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
|
||||
'@protobufjs/codegen@2.0.4': {}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.0': {}
|
||||
|
||||
'@protobufjs/fetch@1.1.0':
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/inquire': 1.1.0
|
||||
|
||||
'@protobufjs/float@1.0.2': {}
|
||||
|
||||
'@protobufjs/inquire@1.1.0': {}
|
||||
|
||||
'@protobufjs/path@1.1.2': {}
|
||||
|
||||
'@protobufjs/pool@1.1.0': {}
|
||||
|
||||
'@protobufjs/utf8@1.1.0': {}
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
|
@ -4511,6 +4656,8 @@ snapshots:
|
|||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
|
@ -4619,6 +4766,12 @@ snapshots:
|
|||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
|
|
@ -4710,6 +4863,8 @@ snapshots:
|
|||
|
||||
electron-to-chromium@1.5.302: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
|
|
@ -5070,6 +5225,8 @@ snapshots:
|
|||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-nonce@1.0.1: {}
|
||||
|
||||
get-tsconfig@4.13.6:
|
||||
|
|
@ -5121,6 +5278,8 @@ snapshots:
|
|||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
|
@ -5180,8 +5339,12 @@ snapshots:
|
|||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
lodash.camelcase@4.3.0: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
long@5.3.2: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
|
@ -5378,6 +5541,21 @@ snapshots:
|
|||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
protobufjs@7.5.4:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/base64': 1.1.2
|
||||
'@protobufjs/codegen': 2.0.4
|
||||
'@protobufjs/eventemitter': 1.1.0
|
||||
'@protobufjs/fetch': 1.1.0
|
||||
'@protobufjs/float': 1.0.2
|
||||
'@protobufjs/inquire': 1.1.0
|
||||
'@protobufjs/path': 1.1.2
|
||||
'@protobufjs/pool': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.0
|
||||
'@types/node': 22.19.11
|
||||
long: 5.3.2
|
||||
|
||||
pump@3.0.3:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
|
|
@ -5449,6 +5627,8 @@ snapshots:
|
|||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
|
@ -5601,10 +5781,20 @@ snapshots:
|
|||
|
||||
stream-shift@1.0.3: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strip-json-comments@5.0.3: {}
|
||||
|
|
@ -5799,6 +5989,12 @@ snapshots:
|
|||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
|
@ -5809,8 +6005,22 @@ snapshots:
|
|||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
string-width: 4.2.3
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||
|
|
|
|||
Loading…
Reference in New Issue