This commit is contained in:
@@ -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 {
|
||||
|
||||
+135
-25
@@ -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<string, any>;
|
||||
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<string, { key: string; value: string }>();
|
||||
private normalizeEnvVariables(variables: Array<{ key?: string; value?: string; isSecret?: boolean }>) {
|
||||
const byKey = new Map<string, WorkloadEnvVariable>();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user