2837 lines
81 KiB
TypeScript
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));
|
|
}
|