add launcher component secret setting support
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-08 15:24:15 +09:00
parent e74c296b7e
commit ec17bba92d
2 changed files with 140 additions and 26 deletions
+5 -1
View File
@@ -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
View File
@@ -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;