Skip to content
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { deployService } from './utils/k8s';
import { webServer } from '@/web/index';
import { Manager } from './manager';
import { ResourceMonitor } from './manager/resource-monitor';
import { SystemRequiredDeployments } from './deployment/system';
import { defaultFilter, FileControllerManager } from './manager/file-manager';
import { DomainManager } from './manager/domain-manager';
Expand Down Expand Up @@ -28,6 +29,9 @@ try {
// Initialize the Server Manager
Manager.getInstance();

// Start collecting pod resource metrics in the background.
ResourceMonitor.getInstance();

// Initialize the File Controller Manager
await FileControllerManager.initialize('', {
filter: defaultFilter,
Expand Down
73 changes: 73 additions & 0 deletions src/manager/resource-monitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Namespace } from '@/utils/config';
import { type PodData, coreV1Api, k8sMetrics } from '@/utils/k8s';

export class ResourceMonitor {
private static instance: ResourceMonitor | null = null;
private podData: Map<string, PodData> = new Map();

private cronJob: NodeJS.Timeout | null = null;

constructor(interval: number = 1_000 * 0.25) {
this.cronJob = setInterval(async () => {
const allServerPod = await coreV1Api.listNamespacedPod({
namespace: Namespace,
labelSelector: 'app=minecraft-server',
});

allServerPod.items.forEach((pod) => {
const name = pod.metadata!.name!;
const allocatedCpu =
pod.spec?.containers?.[0]!.resources?.requests?.cpu || '0';
const allocatedMemory =
pod.spec?.containers?.[0]!.resources?.requests?.memory || '0';
if (this.podData.has(name)) {
return;
} else {
this.podData.set(name, {
name,
allocatedCpu,
allocatedMemory,
cpu: '0',
memory: '0',
});
}
});

const currentPodsMetrics = await k8sMetrics.getPodMetrics(Namespace);
currentPodsMetrics.items.forEach((podMetric) => {
const name = podMetric.metadata!.name!;
const cpu = podMetric.containers?.[0]!.usage?.cpu || '0';
const memory = podMetric.containers?.[0]!.usage?.memory || '0';
if (this.podData.has(name)) {
const existingData = this.podData.get(name)!;
this.podData.set(name, {
...existingData,
cpu,
memory,
});
}
});
}, interval);
}

public static getInstance(interval?: number) {
if (!this.instance) {
this.instance = new ResourceMonitor(interval);
}
return this.instance;
}

public getPodData() {
return Array.from(this.podData.values());
}

public getPodDataByName(name: string) {
return this.podData.get(name);
}

public clean() {
this.podData.clear();
clearInterval(this.cronJob!);
ResourceMonitor.instance = null;
}
}
68 changes: 68 additions & 0 deletions src/manager/server-mamager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import gateClient from '@/utils/gate';
import {
PhaseEnum,
Watcher,
coreV1Api,
deleteService,
deployService,
getConfigMapData,
getDeploymentData,
k8sApiEndpoint,
k8sLogger,
patchConfigMap,
patchDeployment,
stopDeploymentRollout,
Expand All @@ -27,6 +29,7 @@ import { minecraftServerDeployment } from '@/deployment/minecraft-server';
import { FileController, FileControllerManager } from './file-manager';
import { DomainManager, getTopLevelDomain } from './domain-manager';
import { TaskQueue } from '@/utils/taskQueue';
import { PassThrough } from 'node:stream';

export enum ServerStatusEnum {
RUNNING = 'running',
Expand Down Expand Up @@ -560,6 +563,71 @@ export class Manager {
return Array.from(Manager.servers.values());
}

public static async executeServerPod(
serverName: string,
command: string,
): Promise<string> {
const server = Manager.getServerInfoByName(serverName);
if (!server) {
throw new Error(`Server ${serverName} not found.`);
}
const executeResponse =
await coreV1Api.connectPostNamespacedPodExecWithHttpInfo({
namespace: Namespace,
name: server.nameTemplate.replace('@PlaceHolder@', 'pod'),
command,
stderr: true,
stdin: true,
stdout: true,
});
if (executeResponse.httpStatusCode !== 101) {
throw new Error(
`Failed to execute command on server ${serverName}. HTTP status code: ${executeResponse.httpStatusCode}`,
);
}

const executeResultText = await executeResponse.body.text();
return executeResultText;
Comment on lines +584 to +609
}

public static async readServerLogs(
serverName: string,
lines: number = 100,
): Promise<string> {
const server = Manager.getServerInfoByName(serverName);
if (!server) {
throw new Error(`Server ${serverName} not found.`);
}
const logsResponse = await coreV1Api.readNamespacedPodLogWithHttpInfo({
namespace: Namespace,
name: this.generateName(server, 'deployment'),
tailLines: lines,
});
const logs = await logsResponse.body.text();
return logs;
Comment on lines +612 to +627
}

public static async getFollowedServerLogs(
serverName: string,
): Promise<PassThrough> {
const server = Manager.getServerInfoByName(serverName);
if (!server) {
throw new Error(`Server ${serverName} not found.`);
}
const logStream = new PassThrough();
k8sLogger.log(
Namespace,
server.nameTemplate.replace('@PlaceHolder@', 'deployment'),
'minecraft-server',
logStream,
{
follow: true,
pretty: true,
},
);
Comment on lines +630 to +648
return logStream;
}

public static getServerStatus(
serverName: string,
): ServerStatusEnum | undefined {
Expand Down
71 changes: 71 additions & 0 deletions src/manager/stream-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { PassThrough } from 'node:stream';
import { Manager } from './server-mamager';

export class LogStreamManager {
private static instance: LogStreamManager | null = null;
private logStreams: Map<string, PassThrough> = new Map();
private sendFn: ((subscriptionId: string, data: string) => void) | null =
null;

private constructor(sendFn: (subscriptionId: string, data: string) => void) {
this.sendFn = sendFn;
}

public static getInstance(
sendFn?: (subscriptionId: string, data: string) => void,
) {
if (!this.instance) {
if (!sendFn) {
throw new Error(
'sendFn is required for the first initialization of LogStreamManager',
);
}
this.instance = new LogStreamManager(sendFn);
}
return this.instance;
}

private registerLogStream(serverName: string, logStream: PassThrough) {
this.logStreams.set(serverName, logStream);
}

private unregisterLogStream(serverName: string) {
this.logStreams.delete(serverName);
}

public async createLogStream(serverName: string, subscribtionId: string) {
const logStream = await Manager.getFollowedServerLogs(serverName);
this.registerLogStream(subscribtionId, logStream);

logStream.on('data', (chunk) => {
const data = chunk.toString();
if (this.sendFn) {
this.sendFn(subscribtionId, data);
}
});

logStream.on('error', (error) => {
console.error(`Error in log stream for server ${serverName}:`, error);
if (this.sendFn) {
this.sendFn(subscribtionId, `Error: ${error.message}`);
}
this.unregisterLogStream(subscribtionId);
});

logStream.on('end', () => {
console.log(`Log stream for server ${serverName} ended.`);
if (this.sendFn) {
this.sendFn(subscribtionId, 'Log stream ended.');
}
this.unregisterLogStream(subscribtionId);
});
}

public async closeLogStream(subscribtionId: string) {
const logStream = this.logStreams.get(subscribtionId);
if (logStream) {
logStream.end();
this.unregisterLogStream(subscribtionId);
}
}
}
16 changes: 16 additions & 0 deletions src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ const server = serve({
success: true,
data: { instances: ['preview-server-1', 'preview-server-2'] },
}),
'/api/server-resource': async () =>
Response.json({
status: 'ok',
data: {
name: 'preview-server-1',
cpu: '120m',
memory: '512Mi',
allocatedCpu: '1',
allocatedMemory: '2Gi',
},
}),
'/api/server-logs': async () =>
Response.json({
status: 'ok',
data: '[12:00:00] [Server thread/INFO]: Mock preview log line\n[12:00:01] [Server thread/INFO]: Server is running',
}),
'/api/file-system': async (req: Request) => {
const url = new URL(req.url);
const type = url.searchParams.get('type');
Expand Down
13 changes: 12 additions & 1 deletion src/utils/k8s.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
AppsV1Api,
CoreV1Api,
KubeConfig,
Log,
Metrics,
V1ConfigMap,
V1Deployment,
V1Pod,
Expand All @@ -12,7 +14,6 @@ import type { ServicesDeployments } from './type';
import { Namespace } from './config';
import { spawn } from 'child_process';
import * as yaml from 'js-yaml';
import { fetch } from 'bun';

const kubeConfig = new KubeConfig();

Expand Down Expand Up @@ -99,6 +100,8 @@ if (process.env.KUBERNETES_SERVICE_HOST) {

export const coreV1Api = kubeConfig.makeApiClient(CoreV1Api);
export const appsV1Api = kubeConfig.makeApiClient(AppsV1Api);
export const k8sLogger = new Log(kubeConfig);
export const k8sMetrics = new Metrics(kubeConfig);
export default kubeConfig;

export enum k8sApiEndpoint {
Expand Down Expand Up @@ -1040,3 +1043,11 @@ export async function patchService(
throw err;
}
}

export type PodData = {
cpu: string;
memory: string;
allocatedCpu: string;
allocatedMemory: string;
name: string;
};
29 changes: 29 additions & 0 deletions src/web/api/serverLogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Manager } from '@/manager';

export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const serverName = url.searchParams.get('serverName') || '';
const lines = Number(url.searchParams.get('lines') || '120');

if (!serverName) {
return Response.json(
{ status: 'error', message: 'Missing serverName' },
{ status: 400 },
);
}

try {
const data = await Manager.readServerLogs(serverName, lines);
return Response.json({ status: 'ok', data }, { status: 200 });
} catch (error) {
console.error('Failed to read server logs:', error);
return Response.json(
{ status: 'error', message: 'Failed to read server logs' },
{ status: 500 },
);
}
}

export default {
GET,
};
32 changes: 32 additions & 0 deletions src/web/api/serverResource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ResourceMonitor } from '@/manager/resource-monitor';

export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const serverName = url.searchParams.get('serverName') || '';

if (!serverName) {
return Response.json(
{ status: 'error', message: 'Missing serverName' },
{ status: 400 },
);
}

try {
const resourceData =
ResourceMonitor.getInstance().getPodDataByName(serverName);
return Response.json(
{ status: 'ok', data: resourceData ?? null },
{ status: 200 },
);
Comment on lines +14 to +20
} catch (error) {
console.error('Failed to read server resource data:', error);
return Response.json(
{ status: 'error', message: 'Failed to read server resource data' },
{ status: 500 },
);
}
}

export default {
GET,
};
Loading
Loading