diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index ef5b048..b396350 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -30,6 +30,8 @@ const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount'; @Injectable() export class AppsService { + private giteaUsername?: string; + constructor( private readonly prisma: PrismaService, private readonly configService: ConfigService, @@ -288,7 +290,23 @@ export class AppsService { throw new BadRequestException('성공한 빌드 작업만 릴리즈에 포함할 수 있습니다.'); } - const branch = body.branch?.trim() || buildJobs[0]?.branch || 'release'; + 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만 사용할 수 있습니다.'); + } + + await this.promoteReleaseBranches(buildJobs, 'develop', branch); + const commitShas = [...new Set(buildJobs.map((job) => job.commitSha).filter(Boolean))]; const artifact = buildJobs.find((job) => job.artifact)?.artifact ?? null; const imageTags = createReleaseImageTags(buildJobs, app.components); @@ -309,6 +327,143 @@ export class AppsService { return { 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 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('GIT_BIN', 'git'), [ + 'clone', + '--no-checkout', + await this.getAuthenticatedHttpCloneUrl(repository), + workDir, + ]); + await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ + 'fetch', + 'origin', + sourceBranch, + ], { cwd: workDir }); + await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ + 'fetch', + 'origin', + targetBranch, + ], { cwd: workDir }).catch(() => undefined); + + const targetExists = await gitRefExists(workDir, `refs/remotes/origin/${targetBranch}`, this.configService.get('GIT_BIN', 'git')); + if (targetExists) { + await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ + 'checkout', + '-B', + targetBranch, + `origin/${targetBranch}`, + ], { cwd: workDir }); + await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ + 'merge', + '--ff-only', + commitSha, + ], { cwd: workDir }); + } else { + await execFileAsync(this.configService.get('GIT_BIN', 'git'), [ + 'checkout', + '-B', + targetBranch, + commitSha, + ], { cwd: workDir }); + } + + await execFileAsync(this.configService.get('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('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; + } + listDeployments(appKey?: string) { return this.prisma.deploymentJob.findMany({ where: appKey ? { app: { key: appKey } } : undefined, @@ -2314,6 +2469,16 @@ function parseReleaseImageTags(value: Prisma.JsonValue | null) { 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; diff --git a/src/apps/build-runner.service.ts b/src/apps/build-runner.service.ts index 1068a6c..819ec87 100644 --- a/src/apps/build-runner.service.ts +++ b/src/apps/build-runner.service.ts @@ -1,9 +1,10 @@ -import { spawn, type ChildProcess } from 'node:child_process'; +import { execFile, spawn, type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; 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 { Injectable, @@ -17,6 +18,8 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { PrismaService } from '../prisma/prisma.service'; +const execFileAsync = promisify(execFile); + type BuildJobWithComponent = BuildJob & { component: AppComponent & { app: App; @@ -237,6 +240,29 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { onProcessEnd: () => this.activeProcesses.delete(job.id), }); } + + const resolvedCommitSha = await resolveGitHead(workDir, this.configService.get('GIT_BIN', 'git')); + if (resolvedCommitSha && resolvedCommitSha !== job.commitSha) { + const artifact = getBuildArtifact(job.component, job.branch, resolvedCommitSha); + const image = getBuildImage( + job.component, + job.branch, + resolvedCommitSha, + this.configService.get('BUILD_REGISTRY', 'registry.local'), + ); + await this.prisma.buildJob.update({ + where: { id: job.id }, + data: { + commitSha: resolvedCommitSha, + artifact, + image, + }, + }); + job.commitSha = resolvedCommitSha; + job.artifact = artifact; + job.image = image; + await append(`[checkout] resolved commit ${resolvedCommitSha}`); + } } private async executeByComponentType( @@ -567,6 +593,64 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { } } + +async function resolveGitHead(workDir: string, gitBin = 'git') { + const { stdout } = await execFileAsync(gitBin, ['rev-parse', 'HEAD'], { cwd: workDir }); + return stdout.trim() || null; +} + +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, ''); +} + async function runCommand({ jobId, command,