add launcher backend deployment api
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-06 14:21:47 +09:00
parent 189bf3d8bd
commit 855606a215
3 changed files with 126 additions and 2 deletions
+5
View File
@@ -71,6 +71,11 @@ export class LinkLauncherVersionSourceDto {
}
export class DeployLauncherBackendDto {
@IsString()
image!: string;
}
export class CreateLauncherBuildJobDto {
@IsOptional()
@IsString()
+11 -1
View File
@@ -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();
+110 -1
View File
@@ -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<string>('LAUNCHER_BACKEND_NAMESPACE', 'studio-system');
const deployment = this.configService.get<string>('LAUNCHER_BACKEND_DEPLOYMENT', 'studio-launcher-be');
const container = this.configService.get<string>('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<string>('LAUNCHER_BACKEND_NAMESPACE', 'studio-system');
const deployment = this.configService.get<string>('LAUNCHER_BACKEND_DEPLOYMENT', 'studio-launcher-be');
const container = this.configService.get<string>('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<string>('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<string>('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();
}