This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsString, ValidateNested } from 'class-validator';
|
import { IsArray, IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
class WorkloadEnvVariableDto {
|
class WorkloadEnvVariableDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -7,6 +7,10 @@ class WorkloadEnvVariableDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
value!: string;
|
value!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isSecret?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateWorkloadEnvDto {
|
export class UpdateWorkloadEnvDto {
|
||||||
|
|||||||
+135
-25
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
@@ -10,6 +11,7 @@ const execFileAsync = promisify(execFile);
|
|||||||
const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount';
|
const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount';
|
||||||
|
|
||||||
type KubectlJson = Record<string, any>;
|
type KubectlJson = Record<string, any>;
|
||||||
|
type WorkloadEnvVariable = { key: string; value: string; isSecret: boolean };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RuntimeService {
|
export class RuntimeService {
|
||||||
@@ -83,18 +85,34 @@ export class RuntimeService {
|
|||||||
]);
|
]);
|
||||||
const container = workload.spec?.template?.spec?.containers?.[0];
|
const container = workload.spec?.template?.spec?.containers?.[0];
|
||||||
const env = container?.env ?? [];
|
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 {
|
return {
|
||||||
namespace,
|
namespace,
|
||||||
target: `${normalizedKind}/${name}`,
|
target: `${normalizedKind}/${name}`,
|
||||||
container: container?.name ?? null,
|
container: container?.name ?? null,
|
||||||
variables: env
|
variables: variables
|
||||||
.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),
|
.filter((item: { key?: string }) => item.key),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -103,29 +121,59 @@ export class RuntimeService {
|
|||||||
namespace: string,
|
namespace: string,
|
||||||
kind: string,
|
kind: string,
|
||||||
name: string,
|
name: string,
|
||||||
variables: Array<{ key?: string; value?: string }>,
|
variables: Array<{ key?: string; value?: string; isSecret?: boolean }>,
|
||||||
) {
|
) {
|
||||||
const normalizedKind = this.normalizeEnvWorkloadKind(kind);
|
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 current = await this.getWorkloadEnv(namespace, normalizedKind, name);
|
||||||
const nextVariables = this.normalizeEnvVariables(variables);
|
const nextVariables = this.normalizeEnvVariables(variables);
|
||||||
const nextKeys = new Set(nextVariables.map((variable) => variable.key));
|
const nextKeys = new Set(nextVariables.map((variable) => variable.key));
|
||||||
const deleteArgs = current.variables
|
const secretVariables = nextVariables.filter((variable) => variable.isSecret);
|
||||||
.filter((variable: { key: string; editable: boolean }) => variable.editable && !nextKeys.has(variable.key))
|
const secretName = `${name}-env`;
|
||||||
.map((variable: { key: string }) => `${variable.key}-`);
|
|
||||||
const setArgs = nextVariables.map((variable) => `${variable.key}=${variable.value}`);
|
|
||||||
|
|
||||||
if (deleteArgs.length || setArgs.length) {
|
if (secretVariables.length) {
|
||||||
await this.kubectl([
|
await this.applySecret(namespace, secretName, secretVariables);
|
||||||
'set',
|
|
||||||
'env',
|
|
||||||
`${normalizedKind}/${name}`,
|
|
||||||
...deleteArgs,
|
|
||||||
...setArgs,
|
|
||||||
'-n',
|
|
||||||
namespace,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
return this.getWorkloadEnv(namespace, normalizedKind, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,8 +443,8 @@ export class RuntimeService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeEnvVariables(variables: Array<{ key?: string; value?: string }>) {
|
private normalizeEnvVariables(variables: Array<{ key?: string; value?: string; isSecret?: boolean }>) {
|
||||||
const byKey = new Map<string, { key: string; value: string }>();
|
const byKey = new Map<string, WorkloadEnvVariable>();
|
||||||
for (const variable of variables ?? []) {
|
for (const variable of variables ?? []) {
|
||||||
const key = (variable.key ?? '').trim();
|
const key = (variable.key ?? '').trim();
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
@@ -406,12 +454,74 @@ export class RuntimeService {
|
|||||||
message: `환경변수 키 형식이 올바르지 않습니다: ${key}`,
|
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));
|
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[]) {
|
private async kubectlJson(args: string[]) {
|
||||||
const output = await this.kubectl(args);
|
const output = await this.kubectl(args);
|
||||||
return JSON.parse(output) as KubectlJson;
|
return JSON.parse(output) as KubectlJson;
|
||||||
|
|||||||
Reference in New Issue
Block a user