Files
studio-admin-be/src/apps/apps.service.ts
T
chyoo 7741f02e9b
ci/woodpecker/push/woodpecker Pipeline was successful
track and retry app release promotions
2026-07-06 17:16:11 +09:00

2837 lines
81 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { execFile } from 'node:child_process';
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { promisify } from 'node:util';
import { LinkComponentSourceDto } from '../gitea/dto';
import { LauncherService } from '../launcher/launcher.service';
import { PrismaService } from '../prisma/prisma.service';
import { BuildRunnerService } from './build-runner.service';
import {
CreateAppComponentDto,
CreateAppDto,
CreateAppReleaseDto,
CreateBuildJobDto,
CreateDeploymentJobDto,
CreateMigrationJobDto,
CreateRuntimeEnvProfileDto,
CreateRuntimeEnvVariableDto,
CreateRuntimeIngressRuleDto,
EnsureRuntimeNamespaceDto,
ReplaceBuildEnvVariablesDto,
} from './dto';
const execFileAsync = promisify(execFile);
const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount';
@Injectable()
export class AppsService {
private giteaUsername?: string;
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
private readonly buildRunnerService: BuildRunnerService,
private readonly launcherService: LauncherService,
) {}
listApps() {
return this.prisma.app.findMany({
orderBy: { name: 'asc' },
include: {
components: {
orderBy: { name: 'asc' },
include: {
buildVariables: {
orderBy: { key: 'asc' },
},
buildJobs: {
where: { status: 'SUCCEEDED' },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
},
},
});
}
async createApp(body: CreateAppDto) {
const app = await this.prisma.app.create({
data: {
key: normalizeKey(body.key),
name: body.name,
description: body.description,
namespace: body.namespace,
status: body.status ?? 'READY',
},
include: {
components: true,
},
});
return { app };
}
async deleteApp(appId: string) {
const app = await this.prisma.app.findUnique({
where: { id: appId },
include: {
components: true,
deployments: true,
releases: true,
runtimeProfiles: true,
},
});
if (!app) throw new NotFoundException('앱을 찾을 수 없습니다.');
await this.prisma.app.delete({ where: { id: appId } });
return {
deleted: true,
appId,
namespace: app.namespace,
};
}
async createComponent(appId: string, body: CreateAppComponentDto) {
const component = await this.prisma.appComponent.create({
data: {
appId,
name: body.name,
type: body.type,
imageSource: normalizeImageSource(body.imageSource, body.type),
externalImage: normalizeOptionalString(body.externalImage),
workloadType: normalizeWorkloadType(body.workloadType, body.type),
replicas: body.replicas ?? 1,
serviceType: normalizeServiceType(body.serviceType),
storageEnabled: body.storageEnabled ?? body.type === 'DATABASE',
storageSize: normalizeOptionalString(body.storageSize),
storageMountPath: normalizeOptionalString(body.storageMountPath),
storageRetain: body.storageRetain ?? true,
repository: body.repository
? normalizeRepository(body.repository)
: undefined,
branch: body.branch,
buildTarget: body.buildTarget,
port: body.port,
targetPort: body.targetPort ?? body.port,
ports: normalizeComponentPorts(body.ports, body.port, body.targetPort),
},
include: {
app: {
include: {
components: true,
},
},
},
});
return { component };
}
async updateComponent(componentId: string, body: CreateAppComponentDto) {
const component = await this.prisma.appComponent.update({
where: { id: componentId },
data: {
name: body.name,
type: body.type,
imageSource: normalizeImageSource(body.imageSource, body.type),
externalImage: normalizeOptionalString(body.externalImage),
workloadType: normalizeWorkloadType(body.workloadType, body.type),
replicas: body.replicas ?? 1,
serviceType: normalizeServiceType(body.serviceType),
storageEnabled: body.storageEnabled ?? body.type === 'DATABASE',
storageSize: normalizeOptionalString(body.storageSize),
storageMountPath: normalizeOptionalString(body.storageMountPath),
storageRetain: body.storageRetain ?? true,
repository: body.repository
? normalizeRepository(body.repository)
: null,
branch: body.branch,
buildTarget: body.buildTarget,
port: body.port,
targetPort: body.targetPort ?? body.port,
ports: normalizeComponentPorts(body.ports, body.port, body.targetPort),
},
include: { app: true },
});
return { component };
}
async deleteComponent(componentId: string) {
await this.prisma.appComponent.delete({ where: { id: componentId } });
return { deleted: true };
}
listSources() {
return this.prisma.appComponent.findMany({
orderBy: [{ app: { name: 'asc' } }, { name: 'asc' }],
include: {
app: true,
buildJobs: {
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
}
async linkComponentSource(
componentId: string,
body: LinkComponentSourceDto,
) {
const component = await this.prisma.appComponent.update({
where: { id: componentId },
data: {
repository: normalizeRepository(body.repository),
branch: body.branch,
buildTarget: body.buildTarget,
},
include: { app: true },
});
return { component };
}
async listComponentBuildEnv(componentId: string) {
const component = await this.prisma.appComponent.findUnique({
where: { id: componentId },
include: {
buildVariables: {
orderBy: { key: 'asc' },
},
},
});
if (!component) {
throw new NotFoundException('앱 구성요소를 찾을 수 없습니다.');
}
return { variables: component.buildVariables };
}
async replaceComponentBuildEnv(
componentId: string,
body: ReplaceBuildEnvVariablesDto,
) {
const component = await this.prisma.appComponent.findUnique({
where: { id: componentId },
});
if (!component) {
throw new NotFoundException('앱 구성요소를 찾을 수 없습니다.');
}
const variables = normalizeBuildEnvVariables(body.variables ?? []);
await this.prisma.$transaction([
this.prisma.buildEnvVariable.deleteMany({ where: { componentId } }),
...(variables.length
? [
this.prisma.buildEnvVariable.createMany({
data: variables.map((variable) => ({
componentId,
key: variable.key,
value: variable.value,
isSecret: variable.isSecret,
})),
}),
]
: []),
]);
return this.listComponentBuildEnv(componentId);
}
listReleases(appKey?: string) {
return this.prisma.appRelease.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' },
include: { app: true },
});
}
async createRelease(body: CreateAppReleaseDto) {
const app = await this.prisma.app.findUnique({
where: { id: body.appId },
include: {
components: {
orderBy: { name: 'asc' },
},
},
});
if (!app) {
throw new NotFoundException('앱을 찾을 수 없습니다.');
}
const buildJobs = await this.prisma.buildJob.findMany({
where: {
id: { in: body.buildJobIds },
status: 'SUCCEEDED',
component: {
appId: body.appId,
},
},
include: {
component: true,
},
orderBy: { createdAt: 'desc' },
});
if (buildJobs.length !== body.buildJobIds.length) {
throw new BadRequestException('성공한 빌드 작업만 릴리즈에 포함할 수 있습니다.');
}
const invalidBranch = buildJobs.find((job) => job.branch !== 'develop');
if (invalidBranch) {
throw new BadRequestException('develop 브랜치에서 성공한 빌드만 릴리즈로 승격할 수 있습니다.');
}
const missingCommit = buildJobs.find((job) => !job.commitSha);
if (missingCommit) {
throw new BadRequestException('실제 커밋 SHA가 기록된 빌드만 릴리즈로 승격할 수 있습니다.');
}
const branch = body.branch?.trim() || 'release';
if (branch !== 'release') {
throw new BadRequestException('앱 릴리즈 대상 브랜치는 release만 사용할 수 있습니다.');
}
const version = body.version.trim();
if (!version) {
throw new BadRequestException('릴리즈 버전을 입력해야 합니다.');
}
const existingRelease = await this.prisma.appRelease.findUnique({
where: {
appId_version: {
appId: body.appId,
version,
},
},
select: {
id: true,
status: true,
},
});
if (existingRelease && !['FAILED', 'CANCELED'].includes(existingRelease.status)) {
throw new BadRequestException(
`이미 같은 버전의 릴리즈가 ${existingRelease.status} 상태로 존재합니다. 새 버전을 입력하세요.`,
);
}
const commitShas = [...new Set(buildJobs.map((job) => job.commitSha).filter(Boolean))];
const releaseLogs = [
`[queued] release creation: ${app.name} ${version}`,
`[source] develop build jobs: ${buildJobs.map((job) => job.id).join(', ')}`,
`[target] branch ${branch}`,
'[pending] waiting for release worker',
];
const releaseData = {
branch,
commitSha: commitShas.length === 1 ? commitShas[0] : null,
artifact: null,
imageTags: {
promotion: {
sourceBranch: 'develop',
targetBranch: branch,
sourceBuildJobIds: buildJobs.map((job) => job.id),
},
},
status: 'PENDING' as const,
logs: releaseLogs.join('\n'),
};
const release = existingRelease
? await this.prisma.appRelease.update({
where: { id: existingRelease.id },
data: releaseData,
include: { app: true },
})
: await this.prisma.appRelease.create({
data: {
appId: body.appId,
version,
...releaseData,
},
include: { app: true },
});
void this.finalizeReleaseFromBuilds(release.id, buildJobs.map((job) => job.id));
return { release };
}
private async finalizeReleaseFromBuilds(releaseId: string, sourceBuildJobIds: string[]) {
try {
await this.appendReleaseLog(releaseId, '[running] release worker started');
const release = await this.prisma.appRelease.findUnique({
where: { id: releaseId },
include: {
app: {
include: { components: { orderBy: { name: 'asc' } } },
},
},
});
if (!release) return;
await this.appendReleaseLog(releaseId, `[target] ${release.app.name} ${release.version} (${release.branch})`);
const sourceBuildJobs = await this.prisma.buildJob.findMany({
where: { id: { in: sourceBuildJobIds } },
include: { component: { include: { app: true } } },
orderBy: { createdAt: 'desc' },
});
if (sourceBuildJobs.length !== sourceBuildJobIds.length) {
throw new Error('릴리즈 기준 빌드 작업을 찾을 수 없습니다.');
}
for (const job of sourceBuildJobs) {
await this.appendReleaseLog(releaseId, `[source] ${job.component.name} develop ${job.commitSha?.slice(0, 7) ?? '-'}`);
}
await this.appendReleaseLog(releaseId, `[promote] develop -> ${release.branch}`);
await this.promoteReleaseBranches(sourceBuildJobs, 'develop', release.branch);
await this.appendReleaseLog(releaseId, '[promote] completed');
const releaseBuildJobs = await this.createReleaseBuildJobs(sourceBuildJobs, release.branch);
for (const job of releaseBuildJobs) {
await this.appendReleaseLog(releaseId, `[build] queued ${job.component.name} ${job.branch} ${job.commitSha?.slice(0, 7) ?? '-'}`);
}
await this.appendReleaseLog(releaseId, '[wait] waiting for release build jobs');
const completedBuildJobs = await this.waitForBuildJobs(releaseBuildJobs.map((job) => job.id));
const failed = completedBuildJobs.find((job) => job.status !== 'SUCCEEDED');
if (failed) {
throw new Error(`${failed.component.name} release 빌드 실패: ${failed.status}`);
}
await this.appendReleaseLog(releaseId, '[build] release build jobs completed');
const commitShas = [...new Set(completedBuildJobs.map((job) => job.commitSha).filter(Boolean))];
const artifact = completedBuildJobs.find((job) => job.artifact)?.artifact ?? null;
const imageTags = createReleaseImageTags(completedBuildJobs, release.app.components);
await this.appendReleaseLog(releaseId, '[done] release completed');
await this.prisma.appRelease.update({
where: { id: release.id },
data: {
commitSha: commitShas.length === 1 ? commitShas[0] : release.commitSha,
artifact,
imageTags,
status: 'SUCCEEDED',
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown release build failure';
const release = await this.prisma.appRelease.findUnique({
where: { id: releaseId },
select: { logs: true },
}).catch(() => null);
await this.prisma.appRelease.update({
where: { id: releaseId },
data: {
status: 'FAILED',
imageTags: { error: message },
logs: truncateReleaseLogs(appendLog(release?.logs ?? null, `[failed] ${message}`)),
},
}).catch(() => undefined);
}
}
private async appendReleaseLog(releaseId: string, line: string) {
const release = await this.prisma.appRelease.findUnique({
where: { id: releaseId },
select: { logs: true },
});
if (!release) return;
await this.prisma.appRelease.update({
where: { id: releaseId },
data: { logs: truncateReleaseLogs(appendLog(release.logs, line)) },
});
}
private async createReleaseBuildJobs(
sourceBuildJobs: Array<{
componentId: string;
commitSha: string | null;
component: {
id: string;
name: string;
type: string;
repository: string | null;
buildTarget: string | null;
app: { name: string };
};
}>,
branch: string,
) {
const jobs = [];
for (const sourceJob of sourceBuildJobs) {
const component = sourceJob.component;
const artifact = getBuildArtifact(component, branch, sourceJob.commitSha);
const image = getBuildImage(
component,
branch,
sourceJob.commitSha,
this.configService.get<string>('BUILD_REGISTRY', 'registry.local'),
);
const job = await this.prisma.buildJob.create({
data: {
componentId: component.id,
branch,
commitSha: sourceJob.commitSha,
artifact,
image,
status: 'PENDING',
logs: getBuildPlanLogs({
appName: component.app.name,
componentName: component.name,
componentType: component.type,
repository: component.repository || '',
branch,
commitSha: sourceJob.commitSha,
buildTarget: component.buildTarget,
artifact,
image,
giteaBaseUrl: this.configService.get<string>(
'GITEA_BASE_URL',
'http://localhost:3000',
),
}).join('\n'),
},
include: { component: true },
});
jobs.push(job);
}
return jobs;
}
private async waitForBuildJobs(buildJobIds: string[]) {
const timeoutMs = Number(this.configService.get<string>('RELEASE_BUILD_TIMEOUT_MS', '1800000'));
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const jobs = await this.prisma.buildJob.findMany({
where: { id: { in: buildJobIds } },
include: { component: true },
});
if (jobs.length === buildJobIds.length && jobs.every((job) => ['SUCCEEDED', 'FAILED', 'CANCELED'].includes(job.status))) {
return jobs;
}
await delay(3000);
}
throw new Error('release 빌드 완료 대기 시간이 초과되었습니다.');
}
private async promoteReleaseBranches(
buildJobs: Array<{
id: string;
branch: string;
commitSha: string | null;
component: {
name: string;
repository: string | null;
};
}>,
sourceBranch: string,
targetBranch: string,
) {
const byRepository = new Map<string, string>();
for (const job of buildJobs) {
const repository = job.component.repository?.trim();
if (!repository || !job.commitSha) continue;
const current = byRepository.get(repository);
if (current && current !== job.commitSha) {
throw new BadRequestException(
`${repository} 저장소에 서로 다른 커밋의 빌드가 선택되었습니다.`,
);
}
byRepository.set(repository, job.commitSha);
}
if (byRepository.size === 0) return;
const workRoot = path.join(os.tmpdir(), 'studio-admin-release-promotions');
await fs.mkdir(workRoot, { recursive: true });
for (const [repository, commitSha] of byRepository) {
const workDir = path.join(workRoot, `${sanitizeImageName(repository)}-${Date.now()}`);
try {
await fs.rm(workDir, { recursive: true, force: true });
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'clone',
'--no-checkout',
await this.getAuthenticatedHttpCloneUrl(repository),
workDir,
]);
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'fetch',
'origin',
sourceBranch,
], { cwd: workDir });
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'fetch',
'origin',
targetBranch,
], { cwd: workDir }).catch(() => undefined);
const targetExists = await gitRefExists(workDir, `refs/remotes/origin/${targetBranch}`, this.configService.get<string>('GIT_BIN', 'git'));
if (targetExists) {
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'checkout',
'-B',
targetBranch,
`origin/${targetBranch}`,
], { cwd: workDir });
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'merge',
'--ff-only',
commitSha,
], { cwd: workDir });
} else {
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'checkout',
'-B',
targetBranch,
commitSha,
], { cwd: workDir });
}
await execFileAsync(this.configService.get<string>('GIT_BIN', 'git'), [
'push',
'origin',
`HEAD:refs/heads/${targetBranch}`,
], { cwd: workDir });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown git promotion failure';
throw new BadRequestException(`${repository} release 브랜치 승격 실패: ${message}`);
} finally {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
private getHttpCloneUrl(repository: string) {
return `${this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
.replace(/\/$/, '')}/${repository}.git`;
}
private async getAuthenticatedHttpCloneUrl(repository: string) {
const baseUrl = this.getHttpCloneUrl(repository);
const token = this.configService.get<string>('GITEA_ADMIN_TOKEN');
if (!token) return baseUrl;
const username =
this.configService.get<string>('BUILD_GIT_USERNAME') ??
(await this.getGiteaUsername(token));
const url = new URL(baseUrl);
url.username = username;
url.password = token;
return url.toString();
}
private async getGiteaUsername(token: string) {
if (this.giteaUsername) return this.giteaUsername;
const response = await fetch(
`${this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
.replace(/\/$/, '')}/api/v1/user`,
{
headers: {
Accept: 'application/json',
Authorization: `token ${token}`,
},
},
);
if (!response.ok) {
throw new BadRequestException(`Gitea 사용자 확인 실패: ${response.status}`);
}
const data = (await response.json()) as { login?: string };
if (!data.login) {
throw new BadRequestException('Gitea 토큰 사용자명을 확인할 수 없습니다.');
}
this.giteaUsername = data.login;
return data.login;
}
listDeployments(appKey?: string) {
return this.prisma.deploymentJob.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' },
include: {
app: true,
release: true,
},
});
}
async createDeploymentJob(body: CreateDeploymentJobDto) {
const namespace = normalizeNamespace(body.namespace);
const environment = body.environment.trim();
const release = await this.prisma.appRelease.findUnique({
where: { id: body.releaseId },
include: {
app: {
include: {
components: true,
},
},
},
});
if (!release) {
throw new NotFoundException('릴리즈를 찾을 수 없습니다.');
}
if (release.status !== 'SUCCEEDED') {
throw new BadRequestException('성공한 릴리즈만 배포할 수 있습니다.');
}
const imageTags = parseReleaseImageTags(release.imageTags);
const images = imageTags.images;
const runtimeProfile = await this.prisma.runtimeEnvProfile.findFirst({
where: {
appId: release.appId,
environment,
namespace,
},
include: {
ingressRules: {
include: { component: true },
orderBy: { host: 'asc' },
},
},
});
if (Object.keys(images).length === 0) {
throw new BadRequestException('릴리즈에 배포 가능한 이미지가 없습니다.');
}
const namespaceExists = await this.namespaceExists(namespace);
if (!namespaceExists) {
throw new BadRequestException(
`${namespace} namespace가 없습니다. 런타임 구성에서 Namespace 생성/확인을 먼저 실행하세요.`,
);
}
const deploymentJob = await this.prisma.deploymentJob.create({
data: {
appId: release.appId,
releaseId: release.id,
environment,
namespace,
status: 'RUNNING',
requestedBy: 'admin',
startedAt: new Date(),
logs: [
`[running] deployment started: ${release.app.name} ${release.version}`,
`[target] environment=${environment} namespace=${namespace}`,
].join('\n'),
},
include: {
app: true,
release: true,
},
});
try {
const logs = splitDeploymentLogs(deploymentJob.logs);
for (const [componentName, image] of Object.entries(images)) {
const deploymentName = sanitizeK8sName(componentName);
const component = release.app.components.find((item) => item.name === componentName);
const workloadType = normalizeWorkloadType(component?.workloadType, component?.type);
const deploymentExists = workloadType === 'StatefulSet'
? await this.statefulSetExists(namespace, deploymentName)
: await this.deploymentExists(namespace, deploymentName);
if (!deploymentExists) {
logs.push(`[create] ${workloadType.toLowerCase()}/${deploymentName}`);
appendOutput(
logs,
await this.applyK8sManifest(
workloadType === 'StatefulSet'
? createStatefulSetManifest({
namespace,
name: deploymentName,
componentName,
image,
replicas: component?.replicas ?? 1,
ports: getComponentPorts(component),
storageEnabled: component?.storageEnabled ?? false,
storageSize: component?.storageSize,
storageMountPath: component?.storageMountPath,
})
: createDeploymentManifest({
namespace,
deploymentName,
componentName,
image,
replicas: component?.replicas ?? 1,
ports: getComponentPorts(component),
})
),
);
}
const workloadRef = `${workloadType.toLowerCase()}/${deploymentName}`;
logs.push(`[set-image] ${workloadRef} ${deploymentName}=${image}`);
const setImageOutput = await this.kubectl([
'set',
'image',
workloadRef,
`${deploymentName}=${image}`,
'-n',
namespace,
]);
appendOutput(logs, setImageOutput);
logs.push(`[rollout] ${workloadRef}`);
const rolloutOutput = await this.kubectl([
'rollout',
'status',
workloadRef,
'-n',
namespace,
'--timeout=180s',
]);
appendOutput(logs, rolloutOutput);
const componentPorts = getComponentPorts(component);
if (componentPorts.length > 0) {
logs.push(`[apply] service/${deploymentName}`);
appendOutput(
logs,
await this.applyK8sManifest(createServiceManifest({
namespace,
name: deploymentName,
ports: componentPorts,
serviceType: component?.serviceType,
})),
);
}
}
if (runtimeProfile?.ingressRules.length) {
for (const rule of runtimeProfile.ingressRules) {
const serviceName = sanitizeK8sName(rule.component.name);
const ingressName = sanitizeK8sName(`${rule.component.name}-${rule.host}-${rule.path}`);
const middlewareName = rule.stripPrefix ? sanitizeK8sName(`${ingressName}-strip`) : null;
if (middlewareName) {
logs.push(`[apply] middleware/${middlewareName}`);
appendOutput(
logs,
await this.applyK8sManifest(createTraefikStripPrefixMiddlewareManifest({
namespace,
name: middlewareName,
prefix: rule.path,
})),
);
}
await this.deleteLegacyIngress(logs, namespace, ingressName);
logs.push(`[apply] ingressroute/${ingressName}`);
appendOutput(
logs,
await this.applyK8sManifest(createTraefikIngressRouteManifest({
namespace,
name: ingressName,
host: rule.host,
path: rule.path,
serviceName,
servicePort: rule.servicePort,
middlewareName,
tlsEnabled: rule.tlsEnabled,
tlsSecretName: rule.tlsSecretName,
})),
);
}
}
if (body.updateLauncherAppVersion !== false) {
try {
await this.launcherService.publishAppReleaseToLauncher(release.id);
logs.push('[launcher] app version updated');
} catch (launcherError) {
const launcherMessage = launcherError instanceof Error ? launcherError.message : 'launcher app version update failed';
logs.push(`[launcher] app version update failed: ${launcherMessage}`);
}
} else {
logs.push('[launcher] app version update skipped');
}
logs.push('[done] deployment completed');
const completed = await this.prisma.deploymentJob.update({
where: { id: deploymentJob.id },
data: {
status: 'SUCCEEDED',
finishedAt: new Date(),
logs: logs.join('\n'),
},
include: {
app: true,
release: true,
},
});
return { deploymentJob: completed };
} catch (error) {
const message = error instanceof Error ? error.message : 'Deployment failed';
const failed = await this.prisma.deploymentJob.update({
where: { id: deploymentJob.id },
data: {
status: 'FAILED',
finishedAt: new Date(),
logs: appendLog(deploymentJob.logs, `[failed] ${message}`),
},
include: {
app: true,
release: true,
},
});
return { deploymentJob: failed };
}
}
listMigrationJobs() {
return this.prisma.runtimeControlJob.findMany({
where: { action: 'MIGRATION' },
orderBy: { createdAt: 'desc' },
});
}
async createMigrationJob(body: CreateMigrationJobDto) {
const namespace = normalizeNamespace(body.namespace);
const command = body.command.trim();
const envSecretName = sanitizeK8sName(body.envSecretName);
if (!command) {
throw new BadRequestException('마이그레이션 명령을 입력해야 합니다.');
}
if (!envSecretName) {
throw new BadRequestException('환경변수 Secret 이름을 입력해야 합니다.');
}
const release = await this.prisma.appRelease.findUnique({
where: { id: body.releaseId },
include: {
app: {
include: {
components: true,
},
},
},
});
if (!release) {
throw new NotFoundException('릴리즈를 찾을 수 없습니다.');
}
const migrationComponent = release.app.components.find(
(component) => component.id === body.migrationComponentId,
);
if (!migrationComponent || migrationComponent.type !== 'MIGRATION') {
throw new BadRequestException('마이그레이션 구성요소를 선택해야 합니다.');
}
const images = parseReleaseImageTags(release.imageTags).images;
const image = images[body.imageComponentName];
if (!image) {
throw new BadRequestException('릴리즈에 실행 이미지가 없습니다.');
}
const namespaceExists = await this.namespaceExists(namespace);
if (!namespaceExists) {
throw new BadRequestException(`${namespace} namespace가 없습니다.`);
}
const jobName = `${sanitizeK8sName(migrationComponent.name)}-${Date.now().toString(36)}`;
const startedAt = new Date();
const request = {
appId: release.appId,
appName: release.app.name,
releaseId: release.id,
releaseVersion: release.version,
migrationComponentId: migrationComponent.id,
migrationComponentName: migrationComponent.name,
imageComponentName: body.imageComponentName,
image,
command,
envSecretName,
};
const migrationJob = await this.prisma.runtimeControlJob.create({
data: {
namespace,
kind: 'Job',
name: jobName,
action: 'MIGRATION',
status: 'RUNNING',
requestedBy: 'admin',
request,
startedAt,
logs: [
`[running] migration job created: ${release.app.name}/${migrationComponent.name}`,
`[target] namespace=${namespace} release=${release.version}`,
`[image] ${image}`,
`[command] ${command}`,
].join('\n'),
},
});
const logs = splitDeploymentLogs(migrationJob.logs);
try {
logs.push(`[apply] job/${jobName}`);
appendOutput(
logs,
await this.applyK8sManifest(createMigrationJobManifest({
namespace,
jobName,
image,
command,
envSecretName,
})),
);
logs.push(`[wait] job/${jobName}`);
await this.waitForJobResult(namespace, jobName, logs);
logs.push(`[logs] job/${jobName}`);
appendOutput(logs, await this.kubectl(['logs', `job/${jobName}`, '-n', namespace]));
logs.push('[done] migration completed');
const completed = await this.prisma.runtimeControlJob.update({
where: { id: migrationJob.id },
data: {
status: 'SUCCEEDED',
finishedAt: new Date(),
logs: logs.join('\n'),
},
});
return { migrationJob: completed };
} catch (error) {
const message = error instanceof Error ? error.message : 'Migration failed';
try {
logs.push(`[logs] job/${jobName}`);
appendOutput(logs, await this.kubectl(['logs', `job/${jobName}`, '-n', namespace]));
} catch {
// 실패한 Job 로그가 아직 없을 수 있습니다.
}
logs.push(`[failed] ${message}`);
const failed = await this.prisma.runtimeControlJob.update({
where: { id: migrationJob.id },
data: {
status: 'FAILED',
finishedAt: new Date(),
logs: logs.join('\n'),
},
});
return { migrationJob: failed };
}
}
listBuildJobs(appKey?: string) {
return this.prisma.buildJob.findMany({
where: appKey ? { component: { app: { key: appKey } } } : undefined,
orderBy: { createdAt: 'desc' },
include: {
component: {
include: {
app: true,
},
},
},
});
}
listRuntimeProfiles(appId?: string) {
return this.prisma.runtimeEnvProfile.findMany({
where: appId ? { appId } : undefined,
orderBy: [{ environment: 'asc' }, { name: 'asc' }],
include: {
app: true,
variables: {
orderBy: [{ isSecret: 'asc' }, { key: 'asc' }],
include: {
component: true,
},
},
ingressRules: {
orderBy: [{ host: 'asc' }, { path: 'asc' }],
include: {
component: true,
},
},
},
});
}
async checkRuntimeNamespace(namespace: string) {
const normalizedNamespace = normalizeNamespace(namespace);
const exists = await this.namespaceExists(normalizedNamespace);
return { namespace: normalizedNamespace, exists };
}
async listRuntimeResources(namespace: string) {
const normalizedNamespace = normalizeNamespace(namespace);
const exists = await this.namespaceExists(normalizedNamespace);
if (!exists) {
return {
namespace: normalizedNamespace,
exists: false,
summary: {
pods: 0,
deployments: 0,
statefulSets: 0,
services: 0,
persistentVolumeClaims: 0,
},
resources: [],
};
}
const resourceKinds = [
{ kind: 'Pod', command: 'pods' },
{ kind: 'Deployment', command: 'deployments' },
{ kind: 'StatefulSet', command: 'statefulsets' },
{ kind: 'Service', command: 'services' },
{ kind: 'Ingress', command: 'ingress' },
{ kind: 'PVC', command: 'pvc' },
];
const resources = [];
for (const resourceKind of resourceKinds) {
const output = await this.kubectl([
'get',
resourceKind.command,
'-n',
normalizedNamespace,
'-o',
'json',
]);
resources.push(...parseRuntimeResources(resourceKind.kind, output));
}
return {
namespace: normalizedNamespace,
exists: true,
summary: {
pods: resources.filter((resource) => resource.kind === 'Pod').length,
deployments: resources.filter((resource) => resource.kind === 'Deployment').length,
statefulSets: resources.filter((resource) => resource.kind === 'StatefulSet').length,
services: resources.filter((resource) => resource.kind === 'Service').length,
persistentVolumeClaims: resources.filter((resource) => resource.kind === 'PVC').length,
},
resources,
};
}
async ensureRuntimeNamespace(body: EnsureRuntimeNamespaceDto) {
const namespace = normalizeNamespace(body.namespace);
const logs: string[] = [];
const created = await this.ensureNamespace(namespace, logs);
return {
namespace,
exists: true,
created,
logs: logs.filter(Boolean).join('\n'),
};
}
async restartRuntimeDeployment(namespace: string, deploymentName: string) {
const normalizedNamespace = normalizeNamespace(namespace);
const normalizedDeploymentName = sanitizeK8sName(deploymentName);
if (!normalizedDeploymentName) {
throw new BadRequestException('Deployment 이름을 입력해야 합니다.');
}
const exists = await this.deploymentExists(normalizedNamespace, normalizedDeploymentName);
if (!exists) {
throw new NotFoundException(`${normalizedDeploymentName} Deployment를 찾을 수 없습니다.`);
}
const logs = [
`[restart] deployment/${normalizedDeploymentName}`,
`[target] namespace=${normalizedNamespace}`,
];
appendOutput(
logs,
await this.kubectl([
'rollout',
'restart',
`deployment/${normalizedDeploymentName}`,
'-n',
normalizedNamespace,
]),
);
appendOutput(
logs,
await this.kubectl([
'rollout',
'status',
`deployment/${normalizedDeploymentName}`,
'-n',
normalizedNamespace,
'--timeout=180s',
]),
);
logs.push('[done] deployment restarted');
return {
namespace: normalizedNamespace,
deploymentName: normalizedDeploymentName,
restarted: true,
logs: logs.join('\n'),
};
}
async createRuntimeProfile(body: CreateRuntimeEnvProfileDto) {
const app = await this.prisma.app.findUnique({
where: { id: body.appId },
select: { id: true },
});
if (!app) {
throw new NotFoundException('앱을 찾을 수 없습니다.');
}
const profile = await this.prisma.runtimeEnvProfile.create({
data: {
appId: body.appId,
name: body.name.trim(),
environment: body.environment.trim(),
namespace: normalizeNamespace(body.namespace),
},
include: {
app: true,
variables: {
include: {
component: true,
},
},
ingressRules: {
include: {
component: true,
},
},
},
});
return { profile };
}
async createRuntimeVariable(profileId: string, body: CreateRuntimeEnvVariableDto) {
const profile = await this.prisma.runtimeEnvProfile.findUnique({
where: { id: profileId },
select: { id: true, appId: true },
});
if (!profile) {
throw new NotFoundException('런타임 프로파일을 찾을 수 없습니다.');
}
if (!body.componentId) {
throw new BadRequestException('구성요소를 선택해야 합니다.');
}
const component = await this.prisma.appComponent.findFirst({
where: { id: body.componentId, appId: profile.appId },
select: { id: true },
});
if (!component) {
throw new BadRequestException('프로파일 앱에 포함된 구성요소만 선택할 수 있습니다.');
}
const variable = await this.prisma.runtimeEnvVariable.create({
data: {
profileId,
componentId: body.componentId,
key: body.key.trim(),
value: body.value,
isSecret: Boolean(body.isSecret),
},
include: {
component: true,
},
});
return { variable };
}
async createRuntimeIngressRule(profileId: string, body: CreateRuntimeIngressRuleDto) {
const profile = await this.prisma.runtimeEnvProfile.findUnique({
where: { id: profileId },
select: { id: true, appId: true },
});
if (!profile) {
throw new NotFoundException('런타임 프로파일을 찾을 수 없습니다.');
}
const component = await this.prisma.appComponent.findFirst({
where: { id: body.componentId, appId: profile.appId },
select: { id: true },
});
if (!component) {
throw new BadRequestException('프로파일 앱에 포함된 구성요소만 선택할 수 있습니다.');
}
const host = body.host.trim().toLowerCase();
const pathValue = normalizeIngressPath(body.path);
const rule = await this.prisma.runtimeIngressRule.create({
data: {
profileId,
componentId: body.componentId,
host,
path: pathValue,
servicePort: body.servicePort,
stripPrefix: Boolean(body.stripPrefix),
tlsEnabled: Boolean(body.tlsEnabled),
tlsSecretName: body.tlsSecretName?.trim() || null,
},
include: { component: true },
});
return { rule };
}
async deleteRuntimeIngressRule(profileId: string, ruleId: string) {
const rule = await this.prisma.runtimeIngressRule.findFirst({
where: { id: ruleId, profileId },
select: { id: true },
});
if (!rule) {
throw new NotFoundException('접속 설정을 찾을 수 없습니다.');
}
await this.prisma.runtimeIngressRule.delete({ where: { id: rule.id } });
return { deleted: true };
}
async deleteRuntimeVariable(profileId: string, variableId: string) {
const variable = await this.prisma.runtimeEnvVariable.findFirst({
where: { id: variableId, profileId },
select: { id: true },
});
if (!variable) {
throw new NotFoundException('환경변수를 찾을 수 없습니다.');
}
await this.prisma.runtimeEnvVariable.delete({
where: { id: variable.id },
});
return { deleted: true };
}
async applyRuntimeProfile(profileId: string) {
const profile = await this.prisma.runtimeEnvProfile.findUnique({
where: { id: profileId },
include: {
app: {
include: {
components: true,
},
},
variables: {
include: {
component: true,
},
},
ingressRules: {
include: {
component: true,
},
orderBy: [
{ host: 'asc' },
{ path: 'asc' },
],
},
},
});
if (!profile) {
throw new NotFoundException('런타임 프로파일을 찾을 수 없습니다.');
}
const namespace = normalizeNamespace(profile.namespace);
const grouped = new Map<string, typeof profile.variables>();
for (const variable of profile.variables) {
if (!variable.component) continue;
const current = grouped.get(variable.component.id) ?? [];
current.push(variable);
grouped.set(variable.component.id, current);
}
const databaseComponents = profile.app.components.filter(
(component) => component.type === 'DATABASE',
);
if (grouped.size === 0 && databaseComponents.length === 0) {
throw new BadRequestException('적용할 환경변수가 없습니다.');
}
const logs = [
`[running] runtime profile apply: ${profile.app.name}/${profile.name}`,
`[target] environment=${profile.environment} namespace=${namespace}`,
];
await this.ensureNamespace(namespace, logs);
for (const component of databaseComponents) {
const variables = grouped.get(component.id) ?? [];
await this.applyDatabaseResources({
namespace,
appKey: profile.app.key,
componentName: component.name,
variables,
logs,
});
}
for (const variables of grouped.values()) {
const component = variables[0].component;
if (!component) continue;
if (component.type === 'DATABASE') continue;
const normalVars = variables.filter((variable) => !variable.isSecret);
const secretVars = variables.filter((variable) => variable.isSecret);
const deploymentName = sanitizeK8sName(component.name);
const workloadType = normalizeWorkloadType(component.workloadType, component.type);
if (workloadType === 'Job') continue;
const workloadRef = `${workloadType.toLowerCase()}/${deploymentName}`;
if (normalVars.length > 0) {
const configMapName = `${deploymentName}-env`;
await this.applyK8sManifest({
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: configMapName,
namespace,
},
data: Object.fromEntries(normalVars.map((variable) => [variable.key, variable.value])),
});
logs.push(`[apply] configmap/${configMapName}`);
await this.trySetWorkloadEnv(logs, workloadRef, `--from=configmap/${configMapName}`, namespace);
}
if (secretVars.length > 0) {
const secretName = `${deploymentName}-secret`;
await this.applyK8sManifest({
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: secretName,
namespace,
},
type: 'Opaque',
stringData: Object.fromEntries(secretVars.map((variable) => [variable.key, variable.value])),
});
logs.push(`[apply] secret/${secretName}`);
await this.trySetWorkloadEnv(logs, workloadRef, `--from=secret/${secretName}`, namespace);
}
}
if (profile.ingressRules.length > 0) {
for (const rule of profile.ingressRules) {
const serviceName = sanitizeK8sName(rule.component.name);
const ingressName = sanitizeK8sName(`${rule.component.name}-${rule.host}-${rule.path}`);
const middlewareName = rule.stripPrefix ? sanitizeK8sName(`${ingressName}-strip`) : null;
if (middlewareName) {
logs.push(`[apply] middleware/${middlewareName}`);
appendOutput(
logs,
await this.applyK8sManifest(createTraefikStripPrefixMiddlewareManifest({
namespace,
name: middlewareName,
prefix: rule.path,
})),
);
}
await this.deleteLegacyIngress(logs, namespace, ingressName);
logs.push(`[apply] ingressroute/${ingressName}`);
appendOutput(
logs,
await this.applyK8sManifest(createTraefikIngressRouteManifest({
namespace,
name: ingressName,
host: rule.host,
path: rule.path,
serviceName,
servicePort: rule.servicePort,
middlewareName,
tlsEnabled: rule.tlsEnabled,
tlsSecretName: rule.tlsSecretName,
})),
);
}
}
logs.push('[done] runtime profile applied');
return { applied: true, logs: logs.filter(Boolean).join('\n') };
}
async createBuildJob(body: CreateBuildJobDto) {
const component = await this.prisma.appComponent.findUnique({
where: { id: body.componentId },
include: { app: true },
});
if (!component) {
throw new NotFoundException('앱 구성요소를 찾을 수 없습니다.');
}
if (!component.repository) {
throw new BadRequestException('구성요소에 연결된 Repository가 없습니다.');
}
const branch = body.branch?.trim() || component.branch || 'release';
const commitSha = body.commitSha?.trim() || null;
const artifact = getBuildArtifact(component, branch, commitSha);
const image = getBuildImage(
component,
branch,
commitSha,
this.configService.get<string>('BUILD_REGISTRY', 'registry.local'),
);
const buildJob = await this.prisma.buildJob.create({
data: {
componentId: component.id,
branch,
commitSha,
artifact,
image,
status: 'PENDING',
logs: getBuildPlanLogs({
appName: component.app.name,
componentName: component.name,
componentType: component.type,
repository: component.repository,
branch,
commitSha,
buildTarget: component.buildTarget,
artifact,
image,
giteaBaseUrl: this.configService.get<string>(
'GITEA_BASE_URL',
'http://localhost:3000',
),
}).join('\n'),
},
include: {
component: {
include: {
app: true,
},
},
},
});
return { buildJob };
}
async cancelBuildJob(buildJobId: string) {
const buildJob = await this.prisma.buildJob.findUnique({
where: { id: buildJobId },
include: {
component: {
include: {
app: true,
},
},
},
});
if (!buildJob) {
throw new NotFoundException('빌드 작업을 찾을 수 없습니다.');
}
if (!['PENDING', 'RUNNING'].includes(buildJob.status)) {
throw new BadRequestException('이미 종료된 빌드 작업입니다.');
}
if (buildJob.status === 'RUNNING') {
this.buildRunnerService.cancelJob(buildJob.id);
}
const logs = appendLog(buildJob.logs, '[cancelled] build job cancellation requested');
const cancelled = await this.prisma.buildJob.update({
where: { id: buildJob.id },
data: {
status: 'CANCELED',
finishedAt: new Date(),
logs,
},
include: {
component: {
include: {
app: true,
},
},
},
});
return { buildJob: cancelled };
}
async deleteBuildJob(buildJobId: string) {
const buildJob = await this.prisma.buildJob.findUnique({
where: { id: buildJobId },
select: {
id: true,
status: true,
},
});
if (!buildJob) {
throw new NotFoundException('빌드 작업을 찾을 수 없습니다.');
}
if (buildJob.status === 'RUNNING') {
throw new BadRequestException('실행 중인 빌드 작업은 먼저 중지해야 합니다.');
}
await this.prisma.buildJob.delete({
where: { id: buildJob.id },
});
return { deleted: true };
}
private async kubectl(args: string[]) {
const kubectlBin = this.configService.get<string>('KUBECTL_BIN', 'kubectl');
const env = await this.kubectlEnv();
const { stdout, stderr } = await execFileAsync(kubectlBin, args, {
env,
maxBuffer: 1024 * 1024 * 10,
});
return [stdout, stderr].filter(Boolean).join('\n');
}
private async trySetWorkloadEnv(
logs: string[],
workloadRef: string,
envSource: string,
namespace: string,
) {
try {
const output = await this.kubectl(['set', 'env', workloadRef, envSource, '-n', namespace]);
appendOutput(logs, output);
} catch (error) {
const message = error instanceof Error ? error.message : 'set env failed';
logs.push(`[warn] ${workloadRef} 환경변수 반영은 건너뜀: ${message}`);
}
}
private async kubectlEnv() {
const inClusterHost = process.env.KUBERNETES_SERVICE_HOST;
if (inClusterHost) {
return {
...process.env,
KUBECONFIG: await this.writeInClusterKubeconfig(inClusterHost),
};
}
const kubeconfig = this.configService.get<string>('KUBECONFIG');
return kubeconfig ? { ...process.env, KUBECONFIG: kubeconfig } : process.env;
}
private async writeInClusterKubeconfig(host: string) {
const port = process.env.KUBERNETES_SERVICE_PORT || '443';
const token = (await fs.readFile(path.join(serviceAccountDir, 'token'), 'utf8')).trim();
const caPath = path.join(serviceAccountDir, 'ca.crt');
const kubeconfigPath = path.join(os.tmpdir(), 'studio-admin-incluster-kubeconfig');
await fs.writeFile(kubeconfigPath, [
'apiVersion: v1',
'kind: Config',
'clusters:',
'- name: in-cluster',
' cluster:',
` server: https://${host}:${port}`,
` certificate-authority: ${caPath}`,
'contexts:',
'- name: in-cluster',
' context:',
' cluster: in-cluster',
' user: studio-admin',
'current-context: in-cluster',
'users:',
'- name: studio-admin',
' user:',
` token: ${token}`,
'',
].join('\n'), 'utf8');
return kubeconfigPath;
}
private async applyK8sManifest(manifest: Record<string, unknown>) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'studio-runtime-'));
const manifestPath = path.join(tempDir, 'manifest.json');
try {
await fs.writeFile(manifestPath, JSON.stringify(manifest), 'utf8');
return await this.kubectl(['apply', '-f', manifestPath]);
} finally {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
}
private async deleteLegacyIngress(logs: string[], namespace: string, name: string) {
try {
const output = await this.kubectl(['delete', 'ingress', name, '-n', namespace, '--ignore-not-found=true']);
if (output.trim()) appendOutput(logs, output);
} catch (error) {
const message = error instanceof Error ? error.message : 'delete legacy ingress failed';
logs.push(`[warn] legacy ingress/${name} 정리는 건너뜀: ${message}`);
}
}
private async applyDatabaseResources(input: {
namespace: string;
appKey: string;
componentName: string;
variables: Array<{
key: string;
value: string;
}>;
logs: string[];
}) {
const name = sanitizeK8sName(input.componentName);
const values = getDatabaseValues(input.appKey, input.variables);
const statefulSetExists = await this.statefulSetExists(input.namespace, name);
input.logs.push(`[database] ${name} secret/service`);
appendOutput(
input.logs,
await this.applyK8sManifest(createPostgresSecretManifest({
namespace: input.namespace,
name,
values,
})),
);
appendOutput(
input.logs,
await this.applyK8sManifest(createPostgresServiceManifest({
namespace: input.namespace,
name,
})),
);
if (statefulSetExists) {
input.logs.push(`[skip] statefulset/${name} already exists`);
return;
}
input.logs.push(`[create] statefulset/${name}`);
appendOutput(
input.logs,
await this.applyK8sManifest(createPostgresStatefulSetManifest({
namespace: input.namespace,
name,
})),
);
}
private async namespaceExists(namespace: string) {
try {
await this.kubectl(['get', 'namespace', namespace]);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/notfound|not found/i.test(message)) {
return false;
}
throw error;
}
}
private async deploymentExists(namespace: string, deploymentName: string) {
try {
await this.kubectl(['get', 'deployment', deploymentName, '-n', namespace]);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/notfound|not found/i.test(message)) {
return false;
}
throw error;
}
}
private async statefulSetExists(namespace: string, name: string) {
try {
await this.kubectl(['get', 'statefulset', name, '-n', namespace]);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/notfound|not found/i.test(message)) {
return false;
}
throw error;
}
}
private async waitForJobResult(namespace: string, jobName: string, logs: string[]) {
const startedAt = Date.now();
const timeoutMs = 180_000;
while (Date.now() - startedAt < timeoutMs) {
const output = await this.kubectl(['get', 'job', jobName, '-n', namespace, '-o', 'json']);
const status = getObject(JSON.parse(output).status);
const succeeded = getNumber(status.succeeded);
const failed = getNumber(status.failed);
const conditions = getArray(status.conditions).map(getObject);
const isComplete = succeeded > 0 || conditions.some((condition) => getString(condition.type) === 'Complete' && getString(condition.status) === 'True');
const isFailed = failed > 0 || conditions.some((condition) => getString(condition.type) === 'Failed' && getString(condition.status) === 'True');
if (isComplete) {
logs.push(`[wait] job/${jobName} completed`);
return;
}
if (isFailed) {
throw new Error(`job/${jobName} failed`);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new Error(`job/${jobName} timed out`);
}
private async ensureNamespace(namespace: string, logs: string[]) {
const exists = await this.namespaceExists(namespace);
if (exists) {
logs.push(`[namespace] ${namespace} exists`);
return false;
}
logs.push(`[namespace] create ${namespace}`);
appendOutput(logs, await this.kubectl(['create', 'namespace', namespace]));
return true;
}
}
function normalizeRepository(repository: string) {
return repository
.replace(/^https?:\/\/[^/]+\//, '')
.replace(/\.git$/, '')
.replace(/^\/+/, '');
}
function normalizeKey(key: string) {
return key.trim().toLowerCase().replace(/\s+/g, '-');
}
function normalizeOptionalString(value?: string | null) {
const trimmed = value?.trim();
return trimmed || undefined;
}
function normalizeBuildEnvVariables(
variables: Array<{ key?: string; value?: string; isSecret?: boolean }>,
) {
const byKey = new Map<string, { key: string; value: string; isSecret: boolean }>();
for (const variable of variables) {
const key = variable.key?.trim();
if (!key) continue;
byKey.set(key, {
key,
value: variable.value ?? '',
isSecret: variable.isSecret ?? false,
});
}
return [...byKey.values()].sort((left, right) => left.key.localeCompare(right.key));
}
function normalizeImageSource(value: string | null | undefined, componentType?: string) {
const normalized = value?.trim().toUpperCase();
if (normalized === 'EXTERNAL' || normalized === 'DATABASE') return normalized;
if (componentType === 'DATABASE') return 'DATABASE';
return 'SOURCE';
}
function normalizeWorkloadType(value?: string | null, componentType?: string) {
const normalized = value?.trim().toLowerCase();
if (normalized === 'statefulset') return 'StatefulSet';
if (normalized === 'job' || componentType === 'MIGRATION') return 'Job';
if (componentType === 'DATABASE') return 'StatefulSet';
return 'Deployment';
}
function normalizeServiceType(value?: string | null) {
const normalized = value?.trim();
if (normalized === 'NodePort' || normalized === 'LoadBalancer') return normalized;
return 'ClusterIP';
}
function normalizeIngressPath(value?: string) {
const trimmed = value?.trim() || '/';
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
}
function normalizeNamespace(namespace: string) {
const normalized = sanitizeK8sName(namespace);
if (!normalized) {
throw new BadRequestException('Namespace를 입력해야 합니다.');
}
return normalized;
}
function appendLog(logs: string | null, line: string) {
return [logs, line].filter(Boolean).join('\n');
}
function truncateReleaseLogs(logs: string) {
return logs.length > 200_000 ? logs.slice(-200_000) : logs;
}
function sanitizeK8sName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function splitDeploymentLogs(logs: string | null) {
return logs ? logs.split('\n').filter(Boolean) : [];
}
function appendOutput(logs: string[], output: string) {
for (const line of output.split(/\r?\n/).filter(Boolean)) {
logs.push(`[out] ${line}`);
}
}
type ComponentPort = {
name: string;
port: number;
targetPort: number;
protocol: string;
};
function normalizeComponentPorts(
ports?: Array<{
name?: string;
port?: number;
targetPort?: number;
protocol?: string;
}> | null,
fallbackPort?: number | null,
fallbackTargetPort?: number | null,
): Prisma.InputJsonValue | undefined {
const normalized = (ports ?? [])
.map((port, index) => normalizeComponentPort(port, index))
.filter((port): port is ComponentPort => Boolean(port));
if (normalized.length > 0) {
return normalized as unknown as Prisma.InputJsonValue;
}
if (fallbackPort) {
return [
{
name: 'http',
port: fallbackPort,
targetPort: fallbackTargetPort ?? fallbackPort,
protocol: 'TCP',
},
];
}
return undefined;
}
function normalizeComponentPort(
port: {
name?: string;
port?: number;
targetPort?: number;
protocol?: string;
},
index: number,
): ComponentPort | null {
const servicePort = Number(port.port);
if (!Number.isInteger(servicePort) || servicePort < 1 || servicePort > 65535) {
return null;
}
const targetPort = Number(port.targetPort || servicePort);
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
return null;
}
const protocol = port.protocol?.toUpperCase() === 'UDP' ? 'UDP' : 'TCP';
const name = sanitizePortName(port.name || (index === 0 ? 'http' : `port-${servicePort}`));
return {
name,
port: servicePort,
targetPort,
protocol,
};
}
function getComponentPorts(component?: {
port?: number | null;
targetPort?: number | null;
ports?: Prisma.JsonValue | null;
} | null): ComponentPort[] {
if (!component) return [];
if (Array.isArray(component.ports)) {
const normalized = component.ports
.map((value, index) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const item = value as Record<string, unknown>;
return normalizeComponentPort({
name: typeof item.name === 'string' ? item.name : undefined,
port: typeof item.port === 'number' ? item.port : Number(item.port),
targetPort: typeof item.targetPort === 'number' ? item.targetPort : Number(item.targetPort),
protocol: typeof item.protocol === 'string' ? item.protocol : undefined,
}, index);
})
.filter((port): port is ComponentPort => Boolean(port));
if (normalized.length > 0) return normalized;
}
if (!component.port) return [];
return [{
name: 'http',
port: component.port,
targetPort: component.targetPort ?? component.port,
protocol: 'TCP',
}];
}
function sanitizePortName(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 15);
return normalized || 'http';
}
function parseRuntimeResources(kind: string, output: string) {
const parsed = JSON.parse(output || '{"items":[]}') as {
items?: Array<Record<string, unknown>>;
};
return (parsed.items ?? []).map((item) => {
const metadata = getObject(item.metadata);
const status = getObject(item.status);
const spec = getObject(item.spec);
const name = getString(metadata.name);
const createdAt = getString(metadata.creationTimestamp);
if (kind === 'Pod') {
const containerStatuses = getArray(status.containerStatuses);
const restarts = containerStatuses.reduce<number>((sum, current) => {
const currentObject = getObject(current);
return sum + getNumber(currentObject.restartCount);
}, 0);
return {
kind,
name,
status: getString(status.phase) || '-',
ready: `${containerStatuses.filter((current) => getObject(current).ready === true).length}/${containerStatuses.length}`,
restarts,
age: getAge(createdAt),
detail: getArray(spec.containers)
.map((container) => getString(getObject(container).image))
.filter(Boolean)
.join(', '),
};
}
if (kind === 'Deployment') {
return {
kind,
name,
status: getNumber(status.availableReplicas) >= getNumber(spec.replicas, 1) ? 'Running' : 'Pending',
ready: `${getNumber(status.readyReplicas)}/${getNumber(spec.replicas, 1)}`,
restarts: null,
age: getAge(createdAt),
detail: getArray(getObject(getObject(spec.template).spec).containers)
.map((container) => getString(getObject(container).image))
.filter(Boolean)
.join(', '),
};
}
if (kind === 'StatefulSet') {
return {
kind,
name,
status: getNumber(status.readyReplicas) >= getNumber(spec.replicas, 1) ? 'Running' : 'Pending',
ready: `${getNumber(status.readyReplicas)}/${getNumber(spec.replicas, 1)}`,
restarts: null,
age: getAge(createdAt),
detail: getArray(getObject(getObject(spec.template).spec).containers)
.map((container) => getString(getObject(container).image))
.filter(Boolean)
.join(', '),
};
}
if (kind === 'Ingress') {
const rules = getArray(spec.rules).map(getObject);
return {
kind,
name,
status: rules.map((rule) => getString(rule.host)).filter(Boolean).join(', ') || '-',
ready: '-',
restarts: null,
age: getAge(createdAt),
detail: rules
.flatMap((rule) => getArray(getObject(getObject(rule.http).paths)))
.map((path) => getString(getObject(path).path) || '/')
.filter(Boolean)
.join(', '),
};
}
if (kind === 'Service') {
return {
kind,
name,
status: getString(spec.type) || '-',
ready: '-',
restarts: null,
age: getAge(createdAt),
detail: getArray(spec.ports)
.map((port) => `${getString(getObject(port).port)}:${getString(getObject(port).targetPort)}`)
.filter(Boolean)
.join(', '),
};
}
return {
kind,
name,
status: getString(status.phase) || '-',
ready: '-',
restarts: null,
age: getAge(createdAt),
detail: `${getString(spec.storageClassName) || 'default'} · ${getString(getObject(getObject(status).capacity).storage) || getString(getObject(getObject(spec).resources).requests) || '-'}`,
};
});
}
function getObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function getArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function getString(value: unknown) {
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
return '';
}
function getNumber(value: unknown, fallback = 0) {
return typeof value === 'number' ? value : fallback;
}
function getAge(createdAt: string) {
const created = new Date(createdAt).getTime();
if (!created || Number.isNaN(created)) return '-';
const seconds = Math.max(0, Math.floor((Date.now() - created) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
function createDeploymentManifest(input: {
namespace: string;
deploymentName: string;
componentName: string;
image: string;
replicas?: number | null;
ports?: ComponentPort[];
}) {
const labels = {
'app.kubernetes.io/name': input.deploymentName,
'app.kubernetes.io/component': sanitizeK8sName(input.componentName),
'app.kubernetes.io/managed-by': 'studio-admin',
};
return {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: input.deploymentName,
namespace: input.namespace,
labels,
},
spec: {
replicas: input.replicas ?? 1,
selector: {
matchLabels: {
'app.kubernetes.io/name': input.deploymentName,
},
},
template: {
metadata: {
labels,
},
spec: {
containers: [
{
name: input.deploymentName,
image: input.image,
imagePullPolicy: 'IfNotPresent',
...(input.ports?.length
? {
ports: input.ports.map((port) => ({
name: port.name,
containerPort: port.targetPort,
protocol: port.protocol,
})),
}
: {}),
envFrom: [
{
configMapRef: {
name: `${input.deploymentName}-env`,
optional: true,
},
},
{
secretRef: {
name: `${input.deploymentName}-secret`,
optional: true,
},
},
],
},
],
},
},
},
};
}
function createStatefulSetManifest(input: {
namespace: string;
name: string;
componentName: string;
image: string;
replicas?: number | null;
ports?: ComponentPort[];
storageEnabled?: boolean;
storageSize?: string | null;
storageMountPath?: string | null;
}) {
const labels = getManagedLabels(input.name, sanitizeK8sName(input.componentName));
const volumeName = 'data';
const mountPath = input.storageMountPath?.trim() || '/data';
return {
apiVersion: 'apps/v1',
kind: 'StatefulSet',
metadata: {
name: input.name,
namespace: input.namespace,
labels,
},
spec: {
serviceName: input.name,
replicas: input.replicas ?? 1,
selector: {
matchLabels: {
'app.kubernetes.io/name': input.name,
},
},
template: {
metadata: {
labels,
},
spec: {
containers: [
{
name: input.name,
image: input.image,
imagePullPolicy: 'IfNotPresent',
...(input.ports?.length
? {
ports: input.ports.map((port) => ({
name: port.name,
containerPort: port.targetPort,
protocol: port.protocol,
})),
}
: {}),
envFrom: [
{
configMapRef: {
name: `${input.name}-env`,
optional: true,
},
},
{
secretRef: {
name: `${input.name}-secret`,
optional: true,
},
},
],
...(input.storageEnabled
? {
volumeMounts: [
{
name: volumeName,
mountPath,
},
],
}
: {}),
},
],
},
},
...(input.storageEnabled
? {
volumeClaimTemplates: [
{
metadata: {
name: volumeName,
},
spec: {
accessModes: ['ReadWriteOnce'],
resources: {
requests: {
storage: input.storageSize?.trim() || '10Gi',
},
},
},
},
],
}
: {}),
},
};
}
function createMigrationJobManifest(input: {
namespace: string;
jobName: string;
image: string;
command: string;
envSecretName: string;
}) {
const labels = getManagedLabels(input.jobName, 'migration');
return {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: {
name: input.jobName,
namespace: input.namespace,
labels,
},
spec: {
backoffLimit: 0,
template: {
metadata: {
labels,
},
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'migration',
image: input.image,
imagePullPolicy: 'IfNotPresent',
command: ['/bin/sh', '-c', input.command],
envFrom: [
{
secretRef: {
name: input.envSecretName,
},
},
],
},
],
},
},
},
};
}
function getDatabaseValues(
appKey: string,
variables: Array<{
key: string;
value: string;
}>,
) {
const values = new Map(variables.map((variable) => [variable.key, variable.value]));
const databaseName = values.get('POSTGRES_DB') || sanitizeK8sName(appKey).replace(/-/g, '_');
const user = values.get('POSTGRES_USER') || databaseName;
const password = values.get('POSTGRES_PASSWORD');
if (!password) {
throw new BadRequestException('DATABASE 구성요소에는 POSTGRES_PASSWORD 설정값이 필요합니다.');
}
return {
POSTGRES_DB: databaseName,
POSTGRES_USER: user,
POSTGRES_PASSWORD: password,
};
}
function createServiceManifest(input: {
namespace: string;
name: string;
ports: ComponentPort[];
serviceType?: string | null;
}) {
return {
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: input.name,
namespace: input.namespace,
labels: getManagedLabels(input.name, 'service'),
},
spec: {
type: normalizeServiceType(input.serviceType),
selector: {
'app.kubernetes.io/name': input.name,
},
ports: input.ports.map((port) => ({
name: port.name,
port: port.port,
targetPort: port.targetPort,
protocol: port.protocol,
})),
},
};
}
function createTraefikIngressRouteManifest(input: {
namespace: string;
name: string;
host: string;
path: string;
serviceName: string;
servicePort: number;
middlewareName?: string | null;
tlsEnabled: boolean;
tlsSecretName?: string | null;
}) {
const manifest: Record<string, unknown> = {
apiVersion: 'traefik.io/v1alpha1',
kind: 'IngressRoute',
metadata: {
name: input.name,
namespace: input.namespace,
labels: getManagedLabels(input.name, 'ingressroute'),
},
spec: {
entryPoints: [input.tlsEnabled ? 'websecure' : 'web'],
routes: [
{
match: `Host(\`${input.host}\`) && PathPrefix(\`${input.path}\`)`,
kind: 'Rule',
...(input.middlewareName ? { middlewares: [{ name: input.middlewareName }] } : {}),
services: [
{
name: input.serviceName,
port: input.servicePort,
},
],
},
],
},
};
if (input.tlsEnabled && input.tlsSecretName) {
(manifest.spec as Record<string, unknown>).tls = {
secretName: input.tlsSecretName,
};
}
return manifest;
}
function createTraefikStripPrefixMiddlewareManifest(input: {
namespace: string;
name: string;
prefix: string;
}) {
return {
apiVersion: 'traefik.io/v1alpha1',
kind: 'Middleware',
metadata: {
name: input.name,
namespace: input.namespace,
labels: getManagedLabels(input.name, 'ingress-middleware'),
},
spec: {
stripPrefix: {
prefixes: [input.prefix],
},
},
};
}
function createPostgresSecretManifest(input: {
namespace: string;
name: string;
values: Record<string, string>;
}) {
return {
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: `${input.name}-secret`,
namespace: input.namespace,
labels: getManagedLabels(input.name, 'database'),
},
type: 'Opaque',
stringData: input.values,
};
}
function createPostgresServiceManifest(input: {
namespace: string;
name: string;
}) {
return {
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: input.name,
namespace: input.namespace,
labels: getManagedLabels(input.name, 'database'),
},
spec: {
ports: [
{
name: 'postgres',
port: 5432,
targetPort: 5432,
},
],
selector: {
'app.kubernetes.io/name': input.name,
},
},
};
}
function createPostgresStatefulSetManifest(input: {
namespace: string;
name: string;
}) {
const labels = getManagedLabels(input.name, 'database');
return {
apiVersion: 'apps/v1',
kind: 'StatefulSet',
metadata: {
name: input.name,
namespace: input.namespace,
labels,
},
spec: {
serviceName: input.name,
replicas: 1,
selector: {
matchLabels: {
'app.kubernetes.io/name': input.name,
},
},
template: {
metadata: {
labels,
},
spec: {
containers: [
{
name: input.name,
image: 'postgres:16',
imagePullPolicy: 'IfNotPresent',
ports: [
{
name: 'postgres',
containerPort: 5432,
},
],
envFrom: [
{
secretRef: {
name: `${input.name}-secret`,
},
},
],
volumeMounts: [
{
name: 'data',
mountPath: '/var/lib/postgresql/data',
subPath: 'postgres',
},
],
},
],
},
},
volumeClaimTemplates: [
{
metadata: {
name: 'data',
},
spec: {
accessModes: ['ReadWriteOnce'],
resources: {
requests: {
storage: '10Gi',
},
},
},
},
],
},
};
}
function getManagedLabels(name: string, component: string) {
return {
'app.kubernetes.io/name': name,
'app.kubernetes.io/component': component,
'app.kubernetes.io/managed-by': 'studio-admin',
};
}
function parseReleaseImageTags(value: Prisma.JsonValue | null) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { images: {} as Record<string, string> };
}
const imagesValue = (value as Record<string, unknown>).images;
const images: Record<string, string> = {};
if (imagesValue && typeof imagesValue === 'object' && !Array.isArray(imagesValue)) {
for (const [key, image] of Object.entries(imagesValue)) {
if (typeof image === 'string' && image.trim()) {
images[key] = image;
}
}
}
return { images };
}
async function gitRefExists(workDir: string, ref: string, gitBin = 'git') {
try {
await execFileAsync(gitBin, ['show-ref', '--verify', '--quiet', ref], { cwd: workDir });
return true;
} catch {
return false;
}
}
function createReleaseImageTags(
buildJobs: Array<{
id: string;
branch: string;
commitSha: string | null;
artifact: string | null;
image: string | null;
component: {
id: string;
name: string;
type: string;
};
}>,
components: Array<{
id: string;
name: string;
type: string;
imageSource?: string | null;
externalImage?: string | null;
}> = [],
): Prisma.InputJsonObject {
const images: Record<string, string> = {};
const artifacts: Record<string, string> = {};
for (const job of buildJobs) {
if (job.image && !images[job.component.name]) {
images[job.component.name] = job.image;
}
if (job.artifact && !artifacts[job.component.name]) {
artifacts[job.component.name] = job.artifact;
}
}
for (const component of components) {
const imageSource = normalizeImageSource(component.imageSource, component.type);
if (
!images[component.name]
&& (imageSource === 'EXTERNAL' || imageSource === 'DATABASE')
&& component.externalImage?.trim()
) {
images[component.name] = component.externalImage.trim();
}
}
return {
images,
artifacts,
buildJobs: buildJobs.map((job) => ({
id: job.id,
componentId: job.component.id,
component: job.component.name,
type: job.component.type,
branch: job.branch,
commitSha: job.commitSha,
image: job.image,
artifact: job.artifact,
})),
};
}
function getBuildArtifact(
component: { name: string; type: string },
branch: string,
commitSha: string | null,
) {
const suffix = getBuildSuffix(branch, commitSha);
if (component.type === 'ELECTRON') {
return `${component.name}-${suffix}.AppImage`;
}
if (component.type === 'MIGRATION') {
return `${component.name}:${branch}:migration-check`;
}
return null;
}
function getBuildImage(
component: { name: string; type: string },
branch: string,
commitSha: string | null,
registry: string,
) {
if (component.type === 'ELECTRON' || component.type === 'MIGRATION') {
return null;
}
return `${registry.replace(/\/$/, '')}/${sanitizeImageName(
component.name,
)}:${sanitizeImageTag(getBuildSuffix(branch, commitSha))}`;
}
function getBuildSuffix(branch: string, commitSha: string | null) {
return commitSha ? commitSha.slice(0, 7) : branch;
}
function sanitizeImageName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._/-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function sanitizeImageTag(value: string) {
return value
.trim()
.replace(/[^A-Za-z0-9_.-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function getBuildPlanLogs(input: {
appName: string;
componentName: string;
componentType: string;
repository: string;
branch: string;
commitSha: string | null;
buildTarget: string | null;
artifact: string | null;
image: string | null;
giteaBaseUrl: string;
}) {
const repoUrl = `${input.giteaBaseUrl.replace(/\/$/, '')}/${input.repository}.git`;
const lines = [
`[queued] build job created: ${input.appName}/${input.componentName}`,
`[plan] clone ${repoUrl}`,
`[plan] checkout branch ${input.branch}`,
];
if (input.commitSha) {
lines.push(`[plan] checkout commit ${input.commitSha}`);
}
lines.push(
`[plan] target ${input.buildTarget || input.componentType}`,
input.image
? `[plan] build and push image ${input.image}`
: `[plan] package artifact ${input.artifact || '-'}`,
'[pending] waiting for build runner',
);
return lines;
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}