diff --git a/src/runtime/dto/update-workload-env.dto.ts b/src/runtime/dto/update-workload-env.dto.ts new file mode 100644 index 0000000..510f0c9 --- /dev/null +++ b/src/runtime/dto/update-workload-env.dto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsString, ValidateNested } from 'class-validator'; + +class WorkloadEnvVariableDto { + @IsString() + key!: string; + + @IsString() + value!: string; +} + +export class UpdateWorkloadEnvDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WorkloadEnvVariableDto) + variables!: WorkloadEnvVariableDto[]; +} diff --git a/src/runtime/runtime.controller.ts b/src/runtime/runtime.controller.ts index f4971c5..d6dae24 100644 --- a/src/runtime/runtime.controller.ts +++ b/src/runtime/runtime.controller.ts @@ -1,6 +1,7 @@ -import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ScaleWorkloadDto } from './dto/scale-workload.dto'; +import { UpdateWorkloadEnvDto } from './dto/update-workload-env.dto'; import { RuntimeService } from './runtime.service'; @Controller('runtime') @@ -22,6 +23,31 @@ export class RuntimeController { return this.runtimeService.listPodMetrics(namespace); } + + @Get('workloads/:namespace/:kind/:name/env') + getWorkloadEnv( + @Param('namespace') namespace: string, + @Param('kind') kind: string, + @Param('name') name: string, + ) { + return this.runtimeService.getWorkloadEnv(namespace, kind, name); + } + + @Patch('workloads/:namespace/:kind/:name/env') + updateWorkloadEnv( + @Param('namespace') namespace: string, + @Param('kind') kind: string, + @Param('name') name: string, + @Body() body: UpdateWorkloadEnvDto, + ) { + return this.runtimeService.updateWorkloadEnv( + namespace, + kind, + name, + body.variables, + ); + } + @Post('workloads/:namespace/:kind/:name/restart') restartWorkload( @Param('namespace') namespace: string, diff --git a/src/runtime/runtime.service.ts b/src/runtime/runtime.service.ts index 0bd6271..13c69a1 100644 --- a/src/runtime/runtime.service.ts +++ b/src/runtime/runtime.service.ts @@ -70,6 +70,65 @@ export class RuntimeService { return parsePodMetrics(output, Boolean(namespace), namespace); } + + async getWorkloadEnv(namespace: string, kind: string, name: string) { + const normalizedKind = this.normalizeEnvWorkloadKind(kind); + const workload = await this.kubectlJson([ + 'get', + `${normalizedKind}/${name}`, + '-n', + namespace, + '-o', + 'json', + ]); + const container = workload.spec?.template?.spec?.containers?.[0]; + const env = container?.env ?? []; + + return { + namespace, + target: `${normalizedKind}/${name}`, + container: container?.name ?? null, + variables: env + .map((item: KubectlJson) => ({ + key: item.name, + value: typeof item.value === 'string' ? item.value : '', + editable: typeof item.value === 'string' || item.value === undefined, + source: item.valueFrom ? 'valueFrom' : 'literal', + })) + .filter((item: { key?: string }) => item.key), + }; + } + + async updateWorkloadEnv( + namespace: string, + kind: string, + name: string, + variables: Array<{ key?: string; value?: string }>, + ) { + const normalizedKind = this.normalizeEnvWorkloadKind(kind); + const current = await this.getWorkloadEnv(namespace, normalizedKind, name); + const nextVariables = this.normalizeEnvVariables(variables); + const nextKeys = new Set(nextVariables.map((variable) => variable.key)); + const deleteArgs = current.variables + .filter((variable: { key: string; editable: boolean }) => variable.editable && !nextKeys.has(variable.key)) + .map((variable: { key: string }) => `${variable.key}-`); + const setArgs = nextVariables.map((variable) => `${variable.key}=${variable.value}`); + + if (deleteArgs.length || setArgs.length) { + await this.kubectl([ + 'set', + 'env', + `${normalizedKind}/${name}`, + ...deleteArgs, + ...setArgs, + '-n', + namespace, + ]); + } + + return this.getWorkloadEnv(namespace, normalizedKind, name); + } + async restartWorkload(namespace: string, kind: string, name: string) { await this.kubectl(['rollout', 'restart', `${kind}/${name}`, '-n', namespace]); return { @@ -323,6 +382,36 @@ export class RuntimeService { })); } + + private normalizeEnvWorkloadKind(kind: string) { + const normalized = kind.toLowerCase(); + if (!['deployment', 'statefulset', 'daemonset'].includes(normalized)) { + throw new BadRequestException({ + error: 'unsupported_workload_kind', + message: '환경변수 관리는 Deployment, StatefulSet, DaemonSet만 지원합니다.', + }); + } + + return normalized; + } + + private normalizeEnvVariables(variables: Array<{ key?: string; value?: string }>) { + const byKey = new Map(); + for (const variable of variables ?? []) { + const key = (variable.key ?? '').trim(); + if (!key) continue; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw new BadRequestException({ + error: 'invalid_env_key', + message: `환경변수 키 형식이 올바르지 않습니다: ${key}`, + }); + } + byKey.set(key, { key, value: variable.value ?? '' }); + } + + return [...byKey.values()].sort((left, right) => left.key.localeCompare(right.key)); + } + private async kubectlJson(args: string[]) { const output = await this.kubectl(args); return JSON.parse(output) as KubectlJson;