From ec17bba92dd1cd280f2ecf715ec28f30aea4a9be Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 8 Jul 2026 15:24:15 +0900 Subject: [PATCH] add launcher component secret setting support --- src/runtime/dto/update-workload-env.dto.ts | 6 +- src/runtime/runtime.service.ts | 160 +++++++++++++++++---- 2 files changed, 140 insertions(+), 26 deletions(-) diff --git a/src/runtime/dto/update-workload-env.dto.ts b/src/runtime/dto/update-workload-env.dto.ts index 510f0c9..0d309c6 100644 --- a/src/runtime/dto/update-workload-env.dto.ts +++ b/src/runtime/dto/update-workload-env.dto.ts @@ -1,5 +1,5 @@ import { Type } from 'class-transformer'; -import { IsArray, IsString, ValidateNested } from 'class-validator'; +import { IsArray, IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator'; class WorkloadEnvVariableDto { @IsString() @@ -7,6 +7,10 @@ class WorkloadEnvVariableDto { @IsString() value!: string; + + @IsOptional() + @IsBoolean() + isSecret?: boolean; } export class UpdateWorkloadEnvDto { diff --git a/src/runtime/runtime.service.ts b/src/runtime/runtime.service.ts index 13c69a1..6483941 100644 --- a/src/runtime/runtime.service.ts +++ b/src/runtime/runtime.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -10,6 +11,7 @@ const execFileAsync = promisify(execFile); const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount'; type KubectlJson = Record; +type WorkloadEnvVariable = { key: string; value: string; isSecret: boolean }; @Injectable() export class RuntimeService { @@ -83,18 +85,34 @@ export class RuntimeService { ]); const container = workload.spec?.template?.spec?.containers?.[0]; const env = container?.env ?? []; + const variables = await Promise.all( + env.map(async (item: KubectlJson) => { + const secretRef = item.valueFrom?.secretKeyRef; + if (secretRef?.name && secretRef?.key) { + return { + key: item.name, + value: await this.readSecretValue(namespace, secretRef.name, secretRef.key), + editable: true, + source: 'secret', + isSecret: true, + }; + } + + return { + key: item.name, + value: typeof item.value === 'string' ? item.value : '', + editable: typeof item.value === 'string' || item.value === undefined, + source: item.valueFrom ? 'valueFrom' : 'literal', + isSecret: false, + }; + }), + ); 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', - })) + variables: variables .filter((item: { key?: string }) => item.key), }; } @@ -103,29 +121,59 @@ export class RuntimeService { namespace: string, kind: string, name: string, - variables: Array<{ key?: string; value?: string }>, + variables: Array<{ key?: string; value?: string; isSecret?: boolean }>, ) { 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]; + if (!container) { + throw new NotFoundException({ + error: 'container_not_found', + message: `${normalizedKind}/${name} has no containers`, + }); + } 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}`); + const secretVariables = nextVariables.filter((variable) => variable.isSecret); + const secretName = `${name}-env`; - if (deleteArgs.length || setArgs.length) { - await this.kubectl([ - 'set', - 'env', - `${normalizedKind}/${name}`, - ...deleteArgs, - ...setArgs, - '-n', - namespace, - ]); + if (secretVariables.length) { + await this.applySecret(namespace, secretName, secretVariables); } + const preservedEnv = (container.env ?? []).filter((item: KubectlJson) => { + if (!item.name) return false; + const currentVariable = current.variables.find((variable: { key: string }) => variable.key === item.name); + if (currentVariable?.editable) return false; + return !nextKeys.has(item.name); + }); + const managedEnv = nextVariables.map((variable) => ( + variable.isSecret + ? { + name: variable.key, + valueFrom: { + secretKeyRef: { + name: secretName, + key: variable.key, + }, + }, + } + : { + name: variable.key, + value: variable.value, + } + )); + + await this.patchWorkloadEnv(namespace, normalizedKind, name, [...preservedEnv, ...managedEnv]); + return this.getWorkloadEnv(namespace, normalizedKind, name); } @@ -395,8 +443,8 @@ export class RuntimeService { return normalized; } - private normalizeEnvVariables(variables: Array<{ key?: string; value?: string }>) { - const byKey = new Map(); + private normalizeEnvVariables(variables: Array<{ key?: string; value?: string; isSecret?: boolean }>) { + const byKey = new Map(); for (const variable of variables ?? []) { const key = (variable.key ?? '').trim(); if (!key) continue; @@ -406,12 +454,74 @@ export class RuntimeService { message: `환경변수 키 형식이 올바르지 않습니다: ${key}`, }); } - byKey.set(key, { key, value: variable.value ?? '' }); + byKey.set(key, { key, value: variable.value ?? '', isSecret: Boolean(variable.isSecret) }); } return [...byKey.values()].sort((left, right) => left.key.localeCompare(right.key)); } + private async readSecretValue(namespace: string, name: string, key: string) { + try { + const secret = await this.kubectlJson([ + 'get', + `secret/${name}`, + '-n', + namespace, + '-o', + 'json', + ]); + const encoded = secret.data?.[key]; + return typeof encoded === 'string' ? Buffer.from(encoded, 'base64').toString('utf8') : ''; + } catch { + return ''; + } + } + + private async applySecret(namespace: string, name: string, variables: WorkloadEnvVariable[]) { + const args = [ + 'create', + 'secret', + 'generic', + name, + '-n', + namespace, + ...variables.map((variable) => `--from-literal=${variable.key}=${variable.value}`), + '--dry-run=client', + '-o', + 'json', + ]; + const secret = await this.kubectlJson(args); + await this.applyJson(secret); + } + + private async patchWorkloadEnv(namespace: string, kind: string, name: string, env: KubectlJson[]) { + await this.kubectl([ + 'patch', + `${kind}/${name}`, + '-n', + namespace, + '--type=json', + '-p', + JSON.stringify([ + { + op: 'add', + path: '/spec/template/spec/containers/0/env', + value: env, + }, + ]), + ]); + } + + private async applyJson(value: unknown) { + const filePath = path.join(os.tmpdir(), `studio-kubectl-apply-${randomUUID()}.json`); + await fs.writeFile(filePath, JSON.stringify(value), 'utf8'); + try { + await this.kubectl(['apply', '-f', filePath]); + } finally { + await fs.unlink(filePath).catch(() => undefined); + } + } + private async kubectlJson(args: string[]) { const output = await this.kubectl(args); return JSON.parse(output) as KubectlJson;