init studio admin backend

This commit is contained in:
2026-07-01 17:30:04 +09:00
commit 4437d9980d
144 changed files with 44964 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
import { IsInt, Min } from 'class-validator';
export class ScaleWorkloadDto {
@IsInt()
@Min(0)
replicas!: number;
}
+111
View File
@@ -0,0 +1,111 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ScaleWorkloadDto } from './dto/scale-workload.dto';
import { RuntimeService } from './runtime.service';
@Controller('runtime')
export class RuntimeController {
constructor(private readonly runtimeService: RuntimeService) {}
@Get('namespaces')
listNamespaces() {
return this.runtimeService.listNamespaces();
}
@Get('workloads')
listWorkloads(@Query('namespace') namespace?: string) {
return this.runtimeService.listWorkloads(namespace);
}
@Get('metrics/pods')
listPodMetrics(@Query('namespace') namespace?: string) {
return this.runtimeService.listPodMetrics(namespace);
}
@Post('workloads/:namespace/:kind/:name/restart')
restartWorkload(
@Param('namespace') namespace: string,
@Param('kind') kind: string,
@Param('name') name: string,
) {
return this.runtimeService.restartWorkload(namespace, kind, name);
}
@Post('workloads/:namespace/:kind/:name/scale')
scaleWorkload(
@Param('namespace') namespace: string,
@Param('kind') kind: string,
@Param('name') name: string,
@Body() body: ScaleWorkloadDto,
) {
return this.runtimeService.scaleWorkload(
namespace,
kind,
name,
body.replicas,
);
}
@Delete('workloads/:namespace/:kind/:name')
deleteWorkload(
@Param('namespace') namespace: string,
@Param('kind') kind: string,
@Param('name') name: string,
@Query('deleteService') deleteService?: string,
) {
return this.runtimeService.deleteWorkload(
namespace,
kind,
name,
deleteService === 'true',
);
}
@Get('workloads/:namespace/:kind/:name/pvcs')
listWorkloadPersistentVolumeClaims(
@Param('namespace') namespace: string,
@Param('kind') kind: string,
@Param('name') name: string,
) {
return this.runtimeService.listWorkloadPersistentVolumeClaims(
namespace,
kind,
name,
);
}
@Delete('workloads/:namespace/:kind/:name/pvcs/:claimName')
deleteWorkloadPersistentVolumeClaim(
@Param('namespace') namespace: string,
@Param('kind') kind: string,
@Param('name') name: string,
@Param('claimName') claimName: string,
) {
return this.runtimeService.deleteWorkloadPersistentVolumeClaim(
namespace,
kind,
name,
claimName,
);
}
@Get('workloads/:namespace/:kind/:name/logs')
getWorkloadLogs(
@Param('namespace') namespace: string,
@Param('kind') kind: string,
@Param('name') name: string,
@Query('tail') tail?: string,
) {
return this.runtimeService.getWorkloadLogs(
namespace,
kind,
name,
tail ? Number(tail) : 200,
);
}
@Get('events')
listEvents(@Query('namespace') namespace?: string) {
return this.runtimeService.listEvents(namespace);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RuntimeController } from './runtime.controller';
import { RuntimeService } from './runtime.service';
@Module({
controllers: [RuntimeController],
providers: [RuntimeService],
})
export class RuntimeModule {}
+445
View File
@@ -0,0 +1,445 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { execFile } from 'node:child_process';
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount';
type KubectlJson = Record<string, any>;
@Injectable()
export class RuntimeService {
constructor(private readonly configService: ConfigService) {}
async listNamespaces() {
const data = await this.kubectlJson(['get', 'namespace', '-o', 'json']);
return data.items.map((item: KubectlJson) => ({
name: item.metadata?.name,
status: item.status?.phase,
age: item.metadata?.creationTimestamp,
}));
}
async listWorkloads(namespace?: string) {
const args = [
'get',
'pods',
'-o',
'json',
...(namespace ? ['-n', namespace] : ['--all-namespaces']),
];
const data = await this.kubectlJson(args);
return data.items.map((pod: KubectlJson) => {
const owner = pod.metadata?.ownerReferences?.[0];
const readyContainers = (pod.status?.containerStatuses ?? []).filter(
(container: KubectlJson) => container.ready,
).length;
const totalContainers = pod.status?.containerStatuses?.length ?? 0;
const restarts = (pod.status?.containerStatuses ?? []).reduce(
(sum: number, container: KubectlJson) => sum + (container.restartCount ?? 0),
0,
);
return {
namespace: pod.metadata?.namespace,
pod: pod.metadata?.name,
workload: owner ? `${owner.kind}/${owner.name}` : 'Pod',
image: pod.spec?.containers?.map((container: KubectlJson) => container.image).join(', '),
ready: `${readyContainers}/${totalContainers}`,
restarts,
node: pod.spec?.nodeName,
status: pod.status?.phase,
startedAt: pod.status?.startTime,
};
});
}
async listPodMetrics(namespace?: string) {
const output = await this.kubectl([
'top',
'pods',
...(namespace ? ['-n', namespace] : ['--all-namespaces']),
'--no-headers',
]);
return parsePodMetrics(output, Boolean(namespace), namespace);
}
async restartWorkload(namespace: string, kind: string, name: string) {
await this.kubectl(['rollout', 'restart', `${kind}/${name}`, '-n', namespace]);
return {
ok: true,
action: 'restart',
target: `${kind}/${name}`,
namespace,
};
}
async scaleWorkload(namespace: string, kind: string, name: string, replicas: number) {
await this.kubectl([
'scale',
`${kind}/${name}`,
`--replicas=${replicas}`,
'-n',
namespace,
]);
return {
ok: true,
action: 'scale',
target: `${kind}/${name}`,
replicas,
namespace,
};
}
async deleteWorkload(
namespace: string,
kind: string,
name: string,
deleteService = false,
) {
await this.kubectl(['delete', `${kind}/${name}`, '-n', namespace]);
if (deleteService) {
await this.kubectl([
'delete',
`service/${name}`,
'-n',
namespace,
'--ignore-not-found=true',
]);
}
return {
ok: true,
action: 'delete',
target: `${kind}/${name}`,
namespace,
deleteService,
};
}
async listWorkloadPersistentVolumeClaims(
namespace: string,
kind: string,
name: string,
) {
const normalizedKind = this.normalizeWorkloadKind(kind);
const claimNames = await this.getStatefulSetClaimTemplateNames(
namespace,
normalizedKind,
name,
);
const data = await this.kubectlJson([
'get',
'pvc',
'-n',
namespace,
'-o',
'json',
]);
const claims = (data.items ?? [])
.filter((item: KubectlJson) => {
const pvcName = item.metadata?.name ?? '';
return claimNames.some((claimName) => pvcName.startsWith(`${claimName}-${name}-`));
})
.map((item: KubectlJson) => ({
name: item.metadata?.name,
status: item.status?.phase,
capacity: item.status?.capacity?.storage,
accessModes: item.status?.accessModes ?? [],
storageClass: item.spec?.storageClassName ?? '-',
volumeName: item.spec?.volumeName,
createdAt: item.metadata?.creationTimestamp,
}));
return {
namespace,
target: `${normalizedKind}/${name}`,
claims,
};
}
async deleteWorkloadPersistentVolumeClaim(
namespace: string,
kind: string,
name: string,
claimName: string,
) {
const { claims, target } = await this.listWorkloadPersistentVolumeClaims(
namespace,
kind,
name,
);
const targetClaim = claims.find((claim: { name?: string }) => claim.name === claimName);
if (!targetClaim) {
throw new NotFoundException({
error: 'pvc_not_found',
message: `PVC ${claimName} is not attached to ${target}`,
});
}
await this.kubectl([
'delete',
`pvc/${claimName}`,
'-n',
namespace,
]);
return {
ok: true,
action: 'delete_pvc',
target,
namespace,
claimName,
};
}
async getWorkloadLogs(namespace: string, kind: string, name: string, tail = 100) {
const normalizedTail = Math.max(20, Math.min(Number(tail) || 100, 500));
const target = await this.resolveLogTarget(namespace, kind, name);
if (!target) {
return {
namespace,
target: `${kind}/${name}`,
logs: '최근 로그를 조회할 실행 파드가 없습니다.',
};
}
try {
const output = await this.kubectl([
'logs',
target,
'-n',
namespace,
`--tail=${normalizedTail}`,
'--all-containers=true',
'--since=1h',
'--request-timeout=5s',
]);
return {
namespace,
target,
logs: output,
};
} catch (error) {
const message = error instanceof Error ? error.message : '로그 조회에 실패했습니다.';
return {
namespace,
target,
logs: `[warn] 최근 로그를 가져오지 못했습니다.\n${message}`,
};
}
}
private async resolveLogTarget(namespace: string, kind: string, name: string) {
const normalizedKind = kind.toLowerCase();
if (normalizedKind === 'pod') {
return `pod/${name}`;
}
if (!['deployment', 'statefulset', 'daemonset', 'job'].includes(normalizedKind)) {
return `${normalizedKind}/${name}`;
}
const workload = await this.kubectlJson([
'get',
`${normalizedKind}/${name}`,
'-n',
namespace,
'-o',
'json',
]);
const selector = this.formatSelector(workload.spec?.selector?.matchLabels);
if (!selector) {
return null;
}
const pods = await this.kubectlJson([
'get',
'pods',
'-n',
namespace,
'-l',
selector,
'-o',
'json',
]);
const pod = (pods.items ?? [])
.sort((left: KubectlJson, right: KubectlJson) => {
const leftRunning = left.status?.phase === 'Running' ? 1 : 0;
const rightRunning = right.status?.phase === 'Running' ? 1 : 0;
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
const leftTime = new Date(left.status?.startTime ?? left.metadata?.creationTimestamp ?? 0).getTime();
const rightTime = new Date(right.status?.startTime ?? right.metadata?.creationTimestamp ?? 0).getTime();
return rightTime - leftTime;
})
.find((item: KubectlJson) => item.metadata?.name);
return pod?.metadata?.name ? `pod/${pod.metadata.name}` : null;
}
private formatSelector(labels?: Record<string, string>) {
if (!labels) return '';
return Object.entries(labels)
.filter(([, value]) => value)
.map(([key, value]) => `${key}=${value}`)
.join(',');
}
async listEvents(namespace?: string) {
const data = await this.kubectlJson([
'get',
'events',
'-o',
'json',
...(namespace ? ['-n', namespace] : ['--all-namespaces']),
]);
return data.items.map((event: KubectlJson) => ({
namespace: event.metadata?.namespace,
type: event.type,
reason: event.reason,
message: event.message,
involvedObject: event.involvedObject
? `${event.involvedObject.kind}/${event.involvedObject.name}`
: undefined,
firstTimestamp: event.firstTimestamp,
lastTimestamp: event.lastTimestamp,
count: event.count,
}));
}
private async kubectlJson(args: string[]) {
const output = await this.kubectl(args);
return JSON.parse(output) as KubectlJson;
}
private normalizeWorkloadKind(kind: string) {
if (kind.toLowerCase() !== 'statefulset') {
throw new BadRequestException({
error: 'unsupported_workload_kind',
message: 'PVC delete is supported only for StatefulSet workloads',
});
}
return 'statefulset';
}
private async getStatefulSetClaimTemplateNames(
namespace: string,
kind: string,
name: string,
): Promise<string[]> {
const statefulSet = await this.kubectlJson([
'get',
`${kind}/${name}`,
'-n',
namespace,
'-o',
'json',
]);
const templates = statefulSet.spec?.volumeClaimTemplates ?? [];
return templates
.map((template: KubectlJson) => template.metadata?.name)
.filter(Boolean);
}
private async kubectl(args: string[]) {
const kubectlBin = this.configService.get<string>('KUBECTL_BIN', 'kubectl');
const env = await this.kubectlEnv();
try {
const { stdout } = await execFileAsync(kubectlBin, args, {
env,
maxBuffer: 1024 * 1024 * 10,
});
return stdout;
} catch (error) {
const message = error instanceof Error ? error.message : 'kubectl failed';
throw new InternalServerErrorException({
error: 'kubectl_failed',
message,
args,
});
}
}
private async kubectlEnv() {
const inClusterHost = process.env.KUBERNETES_SERVICE_HOST;
if (inClusterHost) {
return {
...process.env,
KUBECONFIG: await this.writeInClusterKubeconfig(inClusterHost),
};
}
const kubeconfig = this.configService.get<string>('KUBECONFIG');
return kubeconfig ? { ...process.env, KUBECONFIG: kubeconfig } : process.env;
}
private async writeInClusterKubeconfig(host: string) {
const port = process.env.KUBERNETES_SERVICE_PORT || '443';
const token = (await fs.readFile(path.join(serviceAccountDir, 'token'), 'utf8')).trim();
const caPath = path.join(serviceAccountDir, 'ca.crt');
const kubeconfigPath = path.join(os.tmpdir(), 'studio-admin-incluster-kubeconfig');
await fs.writeFile(kubeconfigPath, [
'apiVersion: v1',
'kind: Config',
'clusters:',
'- name: in-cluster',
' cluster:',
` server: https://${host}:${port}`,
` certificate-authority: ${caPath}`,
'contexts:',
'- name: in-cluster',
' context:',
' cluster: in-cluster',
' user: studio-admin',
'current-context: in-cluster',
'users:',
'- name: studio-admin',
' user:',
` token: ${token}`,
'',
].join('\n'), 'utf8');
return kubeconfigPath;
}
}
function parsePodMetrics(output: string, namespacedOutput: boolean, namespace?: string) {
return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const columns = line.split(/\s+/);
const [podNamespace, pod, cpu, memory] = namespacedOutput
? [namespace, columns[0], columns[1], columns[2]]
: [columns[0], columns[1], columns[2], columns[3]];
return {
namespace: podNamespace,
pod,
cpu,
memory,
};
})
.filter((metric) => metric.namespace && metric.pod);
}