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, DecommissionAppDto, 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) { return this.decommissionApp(appId, { unlinkSources: true, scaleDownWorkloads: false, deleteKubernetesResources: false, deletePersistentVolumes: false, }); } async decommissionApp(appId: string, body: DecommissionAppDto = {}) { const app = await this.prisma.app.findUnique({ where: { id: appId }, include: { components: true, deployments: true, releases: true, runtimeProfiles: true, }, }); if (!app) throw new NotFoundException('앱을 찾을 수 없습니다.'); const logs: string[] = [`[app] decommission ${app.name}`]; const namespace = app.namespace ? normalizeNamespace(app.namespace) : ''; if (namespace && (body.scaleDownWorkloads || body.deleteKubernetesResources || body.deletePersistentVolumes)) { const exists = await this.namespaceExists(namespace); if (!exists) { logs.push(`[skip] namespace ${namespace} not found`); } else { if (body.scaleDownWorkloads) { await this.scaleDownAppWorkloads(namespace, app.components, logs); } if (body.deleteKubernetesResources) { await this.deleteAppKubernetesResources(namespace, app.components, logs); } if (body.deletePersistentVolumes) { await this.deleteAppPersistentVolumeClaims(namespace, app.components, logs); } } } if (body.unlinkSources) { const result = await this.prisma.appComponent.updateMany({ where: { appId }, data: { repository: null, branch: null, buildTarget: null, }, }); logs.push(`[db] unlinked ${result.count} component source settings`); } await this.prisma.app.delete({ where: { id: appId } }); logs.push('[db] app deleted'); return { deleted: true, appId, namespace: app.namespace, logs: logs.filter(Boolean).join('\n'), }; } 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); } async listReleases(appKey?: string, page?: string, limit?: string, since?: string) { const pagination = parsePagination(page, limit); const sinceDate = parseSinceDate(since); const where: Prisma.AppReleaseWhereInput = { ...(appKey ? { app: { key: appKey } } : {}), ...(sinceDate ? { createdAt: { gte: sinceDate } } : {}), }; if (!pagination && !sinceDate) { return this.prisma.appRelease.findMany({ where: appKey ? { app: { key: appKey } } : undefined, orderBy: { createdAt: 'desc' }, include: { app: true }, }); } const pageNumber = pagination?.page ?? 1; const pageLimit = pagination?.limit ?? 20; const [items, total] = await this.prisma.$transaction([ this.prisma.appRelease.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (pageNumber - 1) * pageLimit, take: pageLimit, include: { app: true }, }), this.prisma.appRelease.count({ where }), ]); const usedReleases = await this.prisma.appRelease.findMany({ where: appKey ? { app: { key: appKey } } : undefined, select: { artifact: true, imageTags: true, }, }); return { releases: items, total, page: pageNumber, limit: pageLimit, usedBuildIds: getUsedReleaseBuildIds(usedReleases), usedValues: getUsedReleaseValues(usedReleases), }; } 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('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( '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('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(); 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 gitBin = this.configService.get('GIT_BIN', 'git'); const isUnrelatedHistoryError = (message: string) => { const normalized = message.toLowerCase(); return normalized.includes('refusing to merge unrelated histories') || normalized.includes('not possible to fast-forward'); }; const gitUserName = this.configService.get( 'GIT_USER_NAME', 'Autonomy Studio Bot', ); const gitUserEmail = this.configService.get( 'GIT_USER_EMAIL', 'studio-ci@autonomystudio.local', ); const workRoot = path.join(os.tmpdir(), 'studio-admin-release-promotions'); await fs.mkdir(workRoot, { recursive: true }); const mergeOptions = [ '-c', `user.name=${gitUserName}`, '-c', `user.email=${gitUserEmail}`, ]; 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(gitBin, [ 'clone', '--no-checkout', await this.getAuthenticatedHttpCloneUrl(repository), workDir, ]); await execFileAsync(gitBin, [ 'fetch', 'origin', sourceBranch, ], { cwd: workDir }); await execFileAsync(gitBin, [ 'fetch', 'origin', targetBranch, ], { cwd: workDir }).catch(() => undefined); await execFileAsync(gitBin, [ 'config', 'user.name', gitUserName, ], { cwd: workDir }); await execFileAsync(gitBin, [ 'config', 'user.email', gitUserEmail, ], { cwd: workDir }); const targetExists = await gitRefExists(workDir, `refs/remotes/origin/${targetBranch}`, gitBin); if (targetExists) { await execFileAsync(gitBin, [ 'checkout', '-B', targetBranch, `origin/${targetBranch}`, ], { cwd: workDir }); await this.resetPromotionWorktree(gitBin, workDir); try { await execFileAsync(gitBin, [ ...mergeOptions, 'merge', '--ff-only', commitSha, ], { cwd: workDir }); } catch (error) { const message = String(error instanceof Error ? error.message : error); if (!isUnrelatedHistoryError(message)) { throw error; } try { await this.resetPromotionWorktree(gitBin, workDir); await execFileAsync(gitBin, [ ...mergeOptions, 'merge', '--allow-unrelated-histories', '--no-ff', '--no-edit', commitSha, ], { cwd: workDir }); } catch { try { await this.resolveReleaseMergeByTakingSourceOnConflict(gitBin, workDir, mergeOptions, commitSha, repository); } catch { await this.promoteReleaseBySourceSnapshot(gitBin, workDir, commitSha, targetBranch, repository); } } } } else { await execFileAsync(gitBin, [ 'checkout', '-B', targetBranch, commitSha, ], { cwd: workDir }); } await execFileAsync(gitBin, [ '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 async resolveReleaseMergeByTakingSourceOnConflict( gitBin: string, workDir: string, mergeOptions: string[], commitSha: string, repository: string, ) { await this.resetPromotionWorktree(gitBin, workDir); await execFileAsync(gitBin, [ ...mergeOptions, 'merge', '--allow-unrelated-histories', '--no-ff', '--no-edit', '--no-commit', commitSha, ], { cwd: workDir }); await execFileAsync(gitBin, ['checkout', '--theirs', '.'], { cwd: workDir }); await execFileAsync(gitBin, ['add', '-A'], { cwd: workDir }); await execFileAsync(gitBin, ['commit', '--allow-empty', '-m', `${repository} release promotion merge`], { cwd: workDir }); } private async promoteReleaseBySourceSnapshot( gitBin: string, workDir: string, commitSha: string, targetBranch: string, repository: string, ) { await this.resetPromotionWorktree(gitBin, workDir); await execFileAsync(gitBin, [ 'checkout', '-B', targetBranch, `origin/${targetBranch}`, ], { cwd: workDir }); await execFileAsync(gitBin, ['reset', '--hard', `origin/${targetBranch}`], { cwd: workDir }).catch(() => undefined); await execFileAsync(gitBin, ['read-tree', '--reset', '-u', commitSha], { cwd: workDir }); await execFileAsync(gitBin, ['add', '-A'], { cwd: workDir }); const { stdout } = await execFileAsync(gitBin, ['status', '--porcelain'], { cwd: workDir }); if (!stdout.trim()) { return; } await execFileAsync(gitBin, ['commit', '--allow-empty', '-m', `${repository} release promotion snapshot`], { cwd: workDir }); } private async resetPromotionWorktree(gitBin: string, workDir: string) { await execFileAsync(gitBin, ['merge', '--abort'], { cwd: workDir }).catch(() => undefined); await execFileAsync(gitBin, ['rebase', '--abort'], { cwd: workDir }).catch(() => undefined); await execFileAsync(gitBin, ['reset', '--hard'], { cwd: workDir }).catch(() => undefined); await execFileAsync(gitBin, ['clean', '-fd'], { cwd: workDir }).catch(() => undefined); } private getHttpCloneUrl(repository: string) { return `${this.configService .get('GITEA_BASE_URL', 'http://localhost:3000') .replace(/\/$/, '')}/${repository}.git`; } private async getAuthenticatedHttpCloneUrl(repository: string) { const baseUrl = this.getHttpCloneUrl(repository); const token = this.configService.get('GITEA_ADMIN_TOKEN'); if (!token) return baseUrl; const username = this.configService.get('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('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; } async listDeployments(appKey?: string, page?: string, limit?: string, since?: string) { const pagination = parsePagination(page, limit); const sinceDate = parseSinceDate(since); const where: Prisma.DeploymentJobWhereInput = { ...(appKey ? { app: { key: appKey } } : {}), ...(sinceDate ? { createdAt: { gte: sinceDate } } : {}), }; const include = { app: true, release: true, }; if (!pagination && !sinceDate) { return this.prisma.deploymentJob.findMany({ where: appKey ? { app: { key: appKey } } : undefined, orderBy: { createdAt: 'desc' }, include, }); } const pageNumber = pagination?.page ?? 1; const pageLimit = pagination?.limit ?? 20; const [items, total] = await this.prisma.$transaction([ this.prisma.deploymentJob.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (pageNumber - 1) * pageLimit, take: pageLimit, include, }), this.prisma.deploymentJob.count({ where }), ]); const deployedJobs = await this.prisma.deploymentJob.findMany({ where: { ...(appKey ? { app: { key: appKey } } : {}), status: 'SUCCEEDED', releaseId: { not: null }, }, select: { releaseId: true }, }); return { deployments: items, total, page: pageNumber, limit: pageLimit, deployedReleaseIds: deployedJobs .map((job) => job.releaseId) .filter((releaseId): releaseId is string => Boolean(releaseId)), }; } 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 }; } } async listMigrationJobs(appId?: string, page?: string, limit?: string, since?: string) { const pagination = parsePagination(page, limit); const sinceDate = parseSinceDate(since); const where: Prisma.RuntimeControlJobWhereInput = { action: 'MIGRATION', ...(appId ? { request: { path: ['appId'], equals: appId } } : {}), ...(sinceDate ? { createdAt: { gte: sinceDate } } : {}), }; if (!pagination && !sinceDate && !appId) { return this.prisma.runtimeControlJob.findMany({ where: { action: 'MIGRATION' }, orderBy: { createdAt: 'desc' }, }); } const pageNumber = pagination?.page ?? 1; const pageLimit = pagination?.limit ?? 20; const [items, total] = await this.prisma.$transaction([ this.prisma.runtimeControlJob.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (pageNumber - 1) * pageLimit, take: pageLimit, }), this.prisma.runtimeControlJob.count({ where }), ]); return { jobs: items, total, page: pageNumber, limit: pageLimit, }; } 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'), }, }); void this.finalizeMigrationJob(migrationJob.id, { namespace, jobName, image, command, envSecretName, }); return { migrationJob }; } private async finalizeMigrationJob( migrationJobId: string, input: { namespace: string; jobName: string; image: string; command: string; envSecretName: string; }, ) { const migrationJob = await this.prisma.runtimeControlJob.findUnique({ where: { id: migrationJobId }, }); if (!migrationJob) return; const logs = splitDeploymentLogs(migrationJob.logs); try { logs.push(`[apply] job/${input.jobName}`); appendOutput( logs, await this.applyK8sManifest(createMigrationJobManifest({ namespace: input.namespace, jobName: input.jobName, image: input.image, command: input.command, envSecretName: input.envSecretName, })), ); logs.push(`[wait] job/${input.jobName}`); await this.waitForJobResult(input.namespace, input.jobName, logs); logs.push(`[logs] job/${input.jobName}`); appendOutput(logs, await this.kubectl(['logs', `job/${input.jobName}`, '-n', input.namespace])); logs.push('[done] migration completed'); await this.prisma.runtimeControlJob.update({ where: { id: migrationJob.id }, data: { status: 'SUCCEEDED', finishedAt: new Date(), logs: logs.join('\n'), }, }); } catch (error) { const message = error instanceof Error ? error.message : 'Migration failed'; try { logs.push(`[logs] job/${input.jobName}`); appendOutput(logs, await this.kubectl(['logs', `job/${input.jobName}`, '-n', input.namespace])); } catch { // 실패한 Job 로그가 아직 없을 수 있습니다. } logs.push(`[failed] ${message}`); await this.prisma.runtimeControlJob.update({ where: { id: migrationJob.id }, data: { status: 'FAILED', finishedAt: new Date(), logs: logs.join('\n'), }, }); } } async listBuildJobs(appKey?: string, page?: string, limit?: string, since?: string, status?: string) { const pagination = parsePagination(page, limit); const sinceDate = parseSinceDate(since); const where: Prisma.BuildJobWhereInput = { ...(appKey ? { component: { app: { key: appKey } } } : {}), ...(sinceDate ? { createdAt: { gte: sinceDate } } : {}), ...(status ? { status: normalizeJobStatus(status) } : {}), }; const include = { component: { include: { app: true, }, }, }; if (!pagination && !sinceDate) { return this.prisma.buildJob.findMany({ where: { ...(appKey ? { component: { app: { key: appKey } } } : {}), ...(status ? { status: normalizeJobStatus(status) } : {}), }, orderBy: { createdAt: 'desc' }, include, }); } const pageNumber = pagination?.page ?? 1; const pageLimit = pagination?.limit ?? 20; const [items, total] = await this.prisma.$transaction([ this.prisma.buildJob.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (pageNumber - 1) * pageLimit, take: pageLimit, include, }), this.prisma.buildJob.count({ where }), ]); return { jobs: items, total, page: pageNumber, limit: pageLimit, }; } 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(); 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}`); logs.push(`[skip] ${workloadRef} rollout is not triggered`); } 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}`); logs.push(`[skip] ${workloadRef} rollout is not triggered`); } } 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('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( '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('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('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) { 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 scaleDownAppWorkloads( namespace: string, components: Array<{ name: string; type: string; workloadType?: string | null }>, logs: string[], ) { for (const component of components) { const name = sanitizeK8sName(component.name); if (!name) continue; const workloadType = normalizeWorkloadType(component.workloadType, component.type); if (workloadType === 'Job') continue; const resource = workloadType === 'StatefulSet' ? 'statefulset' : 'deployment'; await this.tryKubectl(logs, `[scale] ${resource}/${name} replicas=0`, [ 'scale', `${resource}/${name}`, '--replicas=0', '-n', namespace, ]); } } private async deleteAppKubernetesResources( namespace: string, components: Array<{ name: string; type: string; workloadType?: string | null }>, logs: string[], ) { for (const component of components) { const name = sanitizeK8sName(component.name); if (!name) continue; const workloadType = normalizeWorkloadType(component.workloadType, component.type); if (workloadType !== 'Job') { const workload = workloadType === 'StatefulSet' ? 'statefulset' : 'deployment'; await this.tryKubectl(logs, `[delete] ${workload}/${name}`, [ 'delete', workload, name, '-n', namespace, '--ignore-not-found=true', ]); } for (const resource of ['service', 'ingress', 'configmap', 'secret']) { await this.tryKubectl(logs, `[delete] ${resource}/${name}`, [ 'delete', resource, name, '-n', namespace, '--ignore-not-found=true', ]); } await this.tryKubectl(logs, `[delete] configmap/${name}-env`, [ 'delete', 'configmap', `${name}-env`, '-n', namespace, '--ignore-not-found=true', ]); await this.tryKubectl(logs, `[delete] secret/${name}-secret`, [ 'delete', 'secret', `${name}-secret`, '-n', namespace, '--ignore-not-found=true', ]); } } private async deleteAppPersistentVolumeClaims( namespace: string, components: Array<{ name: string }>, logs: string[], ) { const output = await this.kubectl(['get', 'pvc', '-n', namespace, '-o', 'json']); const parsed = JSON.parse(output) as { items?: Array<{ metadata?: { name?: string } }> }; const names = new Set(components.map((component) => sanitizeK8sName(component.name)).filter(Boolean)); const claims = (parsed.items ?? []) .map((item) => item.metadata?.name ?? '') .filter((claimName) => Array.from(names).some((name) => claimName === name || claimName.startsWith(`data-${name}-`) || claimName.includes(`-${name}-`))); if (claims.length === 0) { logs.push('[pvc] matched persistent volume claims not found'); return; } for (const claim of claims) { await this.tryKubectl(logs, `[delete] pvc/${claim}`, [ 'delete', 'pvc', claim, '-n', namespace, '--ignore-not-found=true', ]); } } private async tryKubectl(logs: string[], label: string, args: string[]) { logs.push(label); try { const output = await this.kubectl(args); if (output.trim()) appendOutput(logs, output); } catch (error) { const message = error instanceof Error ? error.message : 'kubectl failed'; logs.push(`[warn] ${label} failed: ${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(); 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; 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>; }; 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((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 { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } 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 = { 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).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; }) { 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 }; } const imagesValue = (value as Record).images; const images: Record = {}; 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 }; } function getUsedReleaseBuildIds(releases: Array<{ imageTags: Prisma.JsonValue | null }>) { const ids = new Set(); for (const release of releases) { const value = release.imageTags; if (!value || typeof value !== 'object' || Array.isArray(value)) continue; const promotion = (value as Record).promotion; if (!promotion || typeof promotion !== 'object' || Array.isArray(promotion)) continue; const sourceBuildJobIds = (promotion as Record).sourceBuildJobIds; if (!Array.isArray(sourceBuildJobIds)) continue; for (const id of sourceBuildJobIds) { if (typeof id === 'string') ids.add(id); } } return [...ids]; } function getUsedReleaseValues(releases: Array<{ artifact: string | null; imageTags: Prisma.JsonValue | null }>) { const values = new Set(); for (const release of releases) { if (release.artifact) values.add(release.artifact); const parsed = parseReleaseImageTags(release.imageTags); for (const image of Object.values(parsed.images)) { if (image) values.add(image); } if (release.imageTags && typeof release.imageTags === 'object' && !Array.isArray(release.imageTags)) { const artifacts = (release.imageTags as Record).artifacts; if (artifacts && typeof artifacts === 'object' && !Array.isArray(artifacts)) { for (const artifact of Object.values(artifacts)) { if (typeof artifact === 'string' && artifact) values.add(artifact); } } } } return [...values]; } function normalizeJobStatus(status: string) { return status.trim().toUpperCase() as Prisma.EnumJobStatusFilter['equals']; } function parsePagination(page?: string, limit?: string) { if (!page && !limit) return null; const pageNumber = Math.max(1, Number.parseInt(page || '1', 10) || 1); const pageLimit = Math.min(100, Math.max(1, Number.parseInt(limit || '20', 10) || 20)); return { page: pageNumber, limit: pageLimit }; } function parseSinceDate(value?: string) { if (!value) return null; const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } 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 = {}; const artifacts: Record = {}; 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)); }