init studio admin backend
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class ScaleWorkloadDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
replicas!: number;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user