diff --git a/src/launcher/dto.ts b/src/launcher/dto.ts index 9455592..5e96ba2 100644 --- a/src/launcher/dto.ts +++ b/src/launcher/dto.ts @@ -33,6 +33,10 @@ export class CreateLauncherVersionDto { } export class UpdateLauncherSourceDto { + @IsOptional() + @IsIn(['launcher-client', 'launcher-backend']) + target?: 'launcher-client' | 'launcher-backend'; + @IsString() repository!: string; @@ -43,6 +47,14 @@ export class UpdateLauncherSourceDto { @IsOptional() @IsString() commitSha?: string; + + @IsOptional() + @IsString() + image?: string; + + @IsOptional() + @IsString() + dockerfile?: string; } export class LinkLauncherVersionSourceDto { diff --git a/src/launcher/launcher.service.ts b/src/launcher/launcher.service.ts index d571a32..3689b37 100644 --- a/src/launcher/launcher.service.ts +++ b/src/launcher/launcher.service.ts @@ -23,9 +23,14 @@ import { const execAsync = promisify(exec); const LAUNCHER_SOURCE_SETTING_KEYS = { - repository: 'launcher.source.repository', - branch: 'launcher.source.branch', - commitSha: 'launcher.source.commit_sha', + repository: 'launcher.client.source.repository', + branch: 'launcher.client.source.branch', + commitSha: 'launcher.client.source.commit_sha', + backendRepository: 'launcher.backend.source.repository', + backendBranch: 'launcher.backend.source.branch', + backendCommitSha: 'launcher.backend.source.commit_sha', + backendImage: 'launcher.backend.image', + backendDockerfile: 'launcher.backend.dockerfile', } as const; @Injectable() @@ -101,11 +106,51 @@ export class LauncherService { } async updateSource(body: UpdateLauncherSourceDto) { + const target = body.target ?? 'launcher-client'; const repository = normalizeRepository(body.repository); const branch = body.branch?.trim() || 'release'; const commitSha = body.commitSha?.trim() || ''; + const isBackend = target === 'launcher-backend'; + const image = body.image?.trim() || this.configService.get('LAUNCHER_BACKEND_IMAGE', 'localhost:5000/studio-launcher-be'); + const dockerfile = body.dockerfile?.trim() || 'Dockerfile'; - await this.launcherPrisma.$transaction([ + const writes = isBackend ? [ + this.upsertCommonSettingByKey( + LAUNCHER_SOURCE_SETTING_KEYS.backendRepository, + '런처 백엔드 소스 저장소', + 'source', + repository, + '런처 백엔드 컨테이너 빌드에 사용할 저장소', + ), + this.upsertCommonSettingByKey( + LAUNCHER_SOURCE_SETTING_KEYS.backendBranch, + '런처 백엔드 소스 브랜치', + 'source', + branch, + '런처 백엔드 컨테이너 빌드 기준 브랜치', + ), + this.upsertCommonSettingByKey( + LAUNCHER_SOURCE_SETTING_KEYS.backendCommitSha, + '런처 백엔드 소스 커밋', + 'source', + commitSha, + '비워두면 브랜치 최신 커밋 기준', + ), + this.upsertCommonSettingByKey( + LAUNCHER_SOURCE_SETTING_KEYS.backendImage, + '런처 백엔드 이미지', + 'source', + image, + '런처 백엔드 컨테이너 이미지 이름', + ), + this.upsertCommonSettingByKey( + LAUNCHER_SOURCE_SETTING_KEYS.backendDockerfile, + '런처 백엔드 Dockerfile', + 'source', + dockerfile, + '런처 백엔드 빌드에 사용할 Dockerfile 경로', + ), + ] : [ this.upsertCommonSettingByKey( LAUNCHER_SOURCE_SETTING_KEYS.repository, '런처 소스 저장소', @@ -127,9 +172,19 @@ export class LauncherService { commitSha, '비워두면 브랜치 최신 커밋 기준', ), - ]); + ]; - return { source: { repository, branch, commitSha: commitSha || null } }; + await this.launcherPrisma.$transaction(writes); + + return { + source: { + repository, + branch, + commitSha: commitSha || null, + image: isBackend ? image : undefined, + dockerfile: isBackend ? dockerfile : undefined, + }, + }; } private async getSourceSettings() { @@ -140,6 +195,11 @@ export class LauncherService { LAUNCHER_SOURCE_SETTING_KEYS.repository, LAUNCHER_SOURCE_SETTING_KEYS.branch, LAUNCHER_SOURCE_SETTING_KEYS.commitSha, + LAUNCHER_SOURCE_SETTING_KEYS.backendRepository, + LAUNCHER_SOURCE_SETTING_KEYS.backendBranch, + LAUNCHER_SOURCE_SETTING_KEYS.backendCommitSha, + LAUNCHER_SOURCE_SETTING_KEYS.backendImage, + LAUNCHER_SOURCE_SETTING_KEYS.backendDockerfile, ], }, }, @@ -148,11 +208,28 @@ export class LauncherService { const repository = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.repository) || null; const branch = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.branch) || 'release'; const commitSha = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.commitSha) || null; + const backendRepository = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.backendRepository) || null; + const backendBranch = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.backendBranch) || 'release'; + const backendCommitSha = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.backendCommitSha) || null; + const backendImage = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.backendImage) || this.configService.get('LAUNCHER_BACKEND_IMAGE', 'localhost:5000/studio-launcher-be'); + const backendDockerfile = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.backendDockerfile) || 'Dockerfile'; return { repository, branch, commitSha, + client: { + repository, + branch, + commitSha, + }, + backend: { + repository: backendRepository, + branch: backendBranch, + commitSha: backendCommitSha, + image: backendImage, + dockerfile: backendDockerfile, + }, }; } @@ -690,27 +767,37 @@ export class LauncherService { if (!body.version?.trim()) throw new BadRequestException('버전을 입력하세요.'); if (!body.artifact?.trim()) throw new BadRequestException('산출물 파일명을 입력하세요.'); const source = await this.getSourceSettings(); - const repository = body.repository ? normalizeRepository(body.repository) : source.repository; - const branch = body.branch?.trim() || source.branch || 'release'; - const commitSha = body.commitSha?.trim() || source.commitSha || null; - const plan = this.createLauncherArtifactPlan(body.version.trim(), branch, commitSha, body.artifact.trim(), body.uploadPath?.trim(), body.downloadUrl?.trim()); - if (!plan.uploadPath) throw new BadRequestException('업로드 경로를 입력하세요.'); - if (!plan.downloadUrl) throw new BadRequestException('다운로드 URL을 입력하세요.'); + const target = body.target?.trim() || 'launcher-client'; + const isBackend = target === 'launcher-backend'; + const repository = body.repository + ? normalizeRepository(body.repository) + : isBackend + ? source.backend.repository + : source.repository; + const branch = body.branch?.trim() || (isBackend ? source.backend.branch : source.branch) || 'release'; + const commitSha = body.commitSha?.trim() || (isBackend ? source.backend.commitSha : source.commitSha) || null; + const plan = isBackend + ? this.createLauncherBackendImagePlan(body.version.trim(), body.artifact.trim(), body.uploadPath?.trim(), body.downloadUrl?.trim()) + : this.createLauncherArtifactPlan(body.version.trim(), branch, commitSha, body.artifact.trim(), body.uploadPath?.trim(), body.downloadUrl?.trim()); + if (!repository) throw new BadRequestException('소스 저장소가 연결되지 않았습니다.'); + if (!plan.uploadPath) throw new BadRequestException(isBackend ? 'Dockerfile 경로를 입력하세요.' : '업로드 경로를 입력하세요.'); + if (!plan.downloadUrl) throw new BadRequestException(isBackend ? '이미지 태그를 입력하세요.' : '다운로드 URL을 입력하세요.'); const logs = [ `[queued] launcher build created: ${body.artifact.trim()}`, + `[target] ${target}`, repository ? `[plan] clone ${this.toCloneUrl(repository)}` : '[plan] source repository is not linked', `[plan] checkout branch ${branch}`, commitSha ? `[plan] checkout commit ${commitSha}` : '[plan] use branch latest commit', - `[plan] build deb package`, - `[plan] upload ${body.artifact.trim()} to ${plan.uploadPath}`, - `[plan] publish download url ${plan.downloadUrl}`, + isBackend ? `[plan] build and push image ${plan.downloadUrl}` : `[plan] build deb package`, + isBackend ? `[plan] dockerfile ${plan.uploadPath}` : `[plan] upload ${body.artifact.trim()} to ${plan.uploadPath}`, + isBackend ? `[plan] image tag ${plan.downloadUrl}` : `[plan] publish download url ${plan.downloadUrl}`, '[pending] waiting for launcher build runner', ].join('\n'); const job = await this.prisma.launcherBuildJob.create({ data: { - target: body.target?.trim() || 'launcher-client', + target, repository, branch, commitSha, @@ -783,6 +870,30 @@ export class LauncherService { await runCommand(`git checkout ${quote(job.commitSha)}`, workDir, append); } + if (job.target === 'launcher-backend') { + const dockerfile = job.uploadPath || 'Dockerfile'; + const imageTag = job.downloadUrl || job.artifact; + await append(`[backend] dockerfile ${dockerfile}`); + await append(`[backend] image ${imageTag}`); + await runCommand( + `docker build -t ${quote(imageTag)} -f ${quote(dockerfile)} .`, + workDir, + append, + ); + await runCommand(`docker push ${quote(imageTag)}`, workDir, append); + await append('[done] launcher backend image build completed'); + + await this.prisma.launcherBuildJob.update({ + where: { id: jobId }, + data: { + status: 'SUCCEEDED', + finishedAt: new Date(), + logs, + }, + }); + return; + } + const buildCommand = process.env.LAUNCHER_BUILD_COMMAND || 'npm ci && npm run build:linux'; await runCommand(buildCommand, workDir, append); @@ -848,6 +959,23 @@ export class LauncherService { }; } + private createLauncherBackendImagePlan( + version: string, + artifact: string, + dockerfile?: string, + imageTag?: string, + ) { + const registry = this.configService.get('BUILD_REGISTRY', 'localhost:5000').replace(/\/$/, ''); + const imageName = artifact.includes(':') + ? artifact + : `${registry}/${artifact.replace(/^\/+/, '')}:${version}`; + + return { + uploadPath: dockerfile || 'Dockerfile', + downloadUrl: imageTag || imageName, + }; + } + private async uploadLauncherArtifactIfConfigured( job: { version: string;