diff --git a/src/launcher/dto.ts b/src/launcher/dto.ts index f9fe982..99d7535 100644 --- a/src/launcher/dto.ts +++ b/src/launcher/dto.ts @@ -71,6 +71,11 @@ export class LinkLauncherVersionSourceDto { } +export class DeployLauncherBackendDto { + @IsString() + image!: string; +} + export class CreateLauncherBuildJobDto { @IsOptional() @IsString() diff --git a/src/launcher/launcher.controller.ts b/src/launcher/launcher.controller.ts index c0fa3a2..d72bd7f 100644 --- a/src/launcher/launcher.controller.ts +++ b/src/launcher/launcher.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; -import { CreateLauncherAppLinkDto, CreateLauncherBuildJobDto, CreateLauncherCommonSettingDto, CreateLauncherVersionDto, UpdateLauncherSourceDto, LinkLauncherVersionSourceDto, MoveLauncherAppLinkDto, UpdateLauncherAppLinkDto, UpdateLauncherBuildEnvDto, UpdateLauncherCommonSettingDto, UpdateLauncherUserAppPermissionsDto } from './dto'; +import { CreateLauncherAppLinkDto, CreateLauncherBuildJobDto, CreateLauncherCommonSettingDto, CreateLauncherVersionDto, UpdateLauncherSourceDto, LinkLauncherVersionSourceDto, MoveLauncherAppLinkDto, UpdateLauncherAppLinkDto, UpdateLauncherBuildEnvDto, UpdateLauncherCommonSettingDto, UpdateLauncherUserAppPermissionsDto, DeployLauncherBackendDto } from './dto'; import { LauncherService } from './launcher.service'; @Controller('launcher') @@ -38,6 +38,16 @@ export class LauncherController { return this.launcherService.updateBuildEnv(body); } + @Get('backend-deployment') + getBackendDeployment() { + return this.launcherService.getBackendDeployment(); + } + + @Post('backend-deployment') + deployBackend(@Body() body: DeployLauncherBackendDto) { + return this.launcherService.deployBackend(body); + } + @Get('builds') listBuildJobs() { return this.launcherService.listBuildJobs(); diff --git a/src/launcher/launcher.service.ts b/src/launcher/launcher.service.ts index d56719e..6b3e4c1 100644 --- a/src/launcher/launcher.service.ts +++ b/src/launcher/launcher.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { exec } from 'node:child_process'; +import { exec, execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; @@ -15,6 +15,7 @@ import { CreateLauncherBuildJobDto, CreateLauncherCommonSettingDto, CreateLauncherVersionDto, + DeployLauncherBackendDto, UpdateLauncherSourceDto, LinkLauncherVersionSourceDto, UpdateLauncherAppLinkDto, @@ -24,6 +25,7 @@ import { } from './dto'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); const LAUNCHER_BUILD_ENV_PREFIX = 'launcher.client.build.env.'; const DEFAULT_LAUNCHER_CLIENT_BUILD_ENV = [ { @@ -344,6 +346,86 @@ export class LauncherService { return this.listBuildEnv(); } + + async getBackendDeployment() { + const namespace = this.configService.get('LAUNCHER_BACKEND_NAMESPACE', 'studio-system'); + const deployment = this.configService.get('LAUNCHER_BACKEND_DEPLOYMENT', 'studio-launcher-be'); + const container = this.configService.get('LAUNCHER_BACKEND_CONTAINER', deployment); + const source = await this.getSourceSettings(); + const latestBuild = await this.prisma.launcherBuildJob.findFirst({ + where: { target: 'launcher-backend', status: 'SUCCEEDED' }, + orderBy: { finishedAt: 'desc' }, + }); + + let currentImage: string | null = null; + let available = false; + try { + currentImage = (await this.kubectl([ + 'get', + 'deployment', + deployment, + '-n', + namespace, + '-o', + `jsonpath={.spec.template.spec.containers[?(@.name=="${container}")].image}`, + ])).trim() || null; + available = true; + } catch { + currentImage = null; + } + + return { + namespace, + deployment, + container, + currentImage, + available, + source: source.backend, + latestBuild, + }; + } + + async deployBackend(body: DeployLauncherBackendDto) { + const image = body.image?.trim(); + if (!image) throw new BadRequestException('배포할 이미지가 없습니다.'); + + const namespace = this.configService.get('LAUNCHER_BACKEND_NAMESPACE', 'studio-system'); + const deployment = this.configService.get('LAUNCHER_BACKEND_DEPLOYMENT', 'studio-launcher-be'); + const container = this.configService.get('LAUNCHER_BACKEND_CONTAINER', deployment); + const logs: string[] = []; + + logs.push(`[running] launcher backend deploy`); + logs.push(`[target] ${namespace}/deployment/${deployment}`); + logs.push(`[set-image] ${container}=${image}`); + appendOutput(logs, await this.kubectl([ + 'set', + 'image', + `deployment/${deployment}`, + `${container}=${image}`, + '-n', + namespace, + ])); + logs.push(`[rollout] deployment/${deployment}`); + appendOutput(logs, await this.kubectl([ + 'rollout', + 'status', + `deployment/${deployment}`, + '-n', + namespace, + '--timeout=180s', + ])); + logs.push('[done] launcher backend deployed'); + + return { + deployed: true, + namespace, + deployment, + container, + image, + logs: logs.join('\n'), + }; + } + listBuildJobs() { return this.prisma.launcherBuildJob.findMany({ orderBy: { createdAt: 'desc' }, @@ -1218,6 +1300,28 @@ export class LauncherService { return this.r2Client; } + + private async kubectl(args: string[]) { + const kubectlBin = this.configService.get('KUBECTL_BIN', 'kubectl'); + const env = await this.kubectlEnv(); + try { + const { stdout, stderr } = await execFileAsync(kubectlBin, args, { + env, + maxBuffer: 1024 * 1024 * 10, + }); + return [stdout, stderr].filter(Boolean).join('\n').trim(); + } catch (error) { + const message = error instanceof Error ? error.message : 'kubectl failed'; + throw new BadRequestException(message); + } + } + + private async kubectlEnv() { + const kubeconfig = this.configService.get('KUBECONFIG'); + if (!kubeconfig) return process.env; + return { ...process.env, KUBECONFIG: kubeconfig }; + } + private async readPackageVersion(workDir: string) { try { const packageJson = await readFile(`${workDir}/package.json`, 'utf8'); @@ -1361,6 +1465,11 @@ function mergeBuildEnv( return nextEnv; } +function appendOutput(logs: string[], output?: string) { + if (!output) return; + logs.push(...output.split('\n').filter(Boolean).map((line) => `[out] ${line}`)); +} + function trimOptional(value?: string) { return value === undefined ? null : value.trim(); }