import { 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 { Injectable, Logger, OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { App, AppComponent, BuildJob } from '@prisma/client'; import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { PrismaService } from '../prisma/prisma.service'; type BuildJobWithComponent = BuildJob & { component: AppComponent & { app: App; }; }; @Injectable() export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(BuildRunnerService.name); private timer?: NodeJS.Timeout; private running = false; private giteaUsername?: string; private r2Client?: S3Client; private readonly activeProcesses = new Map(); constructor( private readonly prisma: PrismaService, private readonly configService: ConfigService, ) {} onModuleInit() { if (this.configService.get('BUILD_RUNNER_ENABLED', 'true') === 'false') { this.logger.log('Build runner disabled by BUILD_RUNNER_ENABLED=false'); return; } const intervalMs = Number( this.configService.get('BUILD_RUNNER_INTERVAL_MS', '5000'), ); this.timer = setInterval(() => void this.tick(), intervalMs); void this.tick(); } onModuleDestroy() { if (this.timer) { clearInterval(this.timer); } for (const child of this.activeProcesses.values()) { terminateProcess(child); } } cancelJob(jobId: string) { const child = this.activeProcesses.get(jobId); if (child) { terminateProcess(child); } } private async tick() { if (this.running) return; this.running = true; try { const job = await this.claimNextJob(); if (job) { await this.runJob(job); } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown build runner error'; this.logger.error(message); } finally { this.running = false; } } private async claimNextJob() { const pending = await this.prisma.buildJob.findFirst({ where: { status: 'PENDING' }, orderBy: { createdAt: 'asc' }, include: { component: { include: { app: true, }, }, }, }); if (!pending) return null; const claimed = await this.prisma.buildJob.updateMany({ where: { id: pending.id, status: 'PENDING' }, data: { status: 'RUNNING', startedAt: new Date(), }, }); if (claimed.count === 0) return null; return this.prisma.buildJob.findUnique({ where: { id: pending.id }, include: { component: { include: { app: true, }, }, }, }) as Promise; } private async runJob(job: BuildJobWithComponent) { const lines = splitLogs(job.logs); const token = this.configService.get('GITEA_ADMIN_TOKEN') || ''; const append = async (line: string) => { lines.push(redact(line, token)); await this.prisma.buildJob.update({ where: { id: job.id }, data: { logs: truncateLogs(lines.join('\n')) }, }); }; const workRoot = this.configService.get( 'BUILD_WORKDIR', path.join(os.tmpdir(), 'studio-admin-builds'), ); const workDir = path.join(workRoot, job.id); try { await fs.rm(workDir, { recursive: true, force: true }); await fs.mkdir(workRoot, { recursive: true }); await append(`[running] build runner picked job ${job.id}`); await this.cloneRepository(job, workDir, append); await this.executeByComponentType(job, workDir, append); await append('[done] build job completed'); if (await this.isCanceled(job.id)) { await append('[cancelled] build job was cancelled'); return; } await this.prisma.buildJob.update({ where: { id: job.id }, data: { status: 'SUCCEEDED', finishedAt: new Date(), logs: truncateLogs(lines.join('\n')), }, }); } catch (error) { if (await this.isCanceled(job.id)) { lines.push('[cancelled] build job stopped'); await this.prisma.buildJob.update({ where: { id: job.id }, data: { status: 'CANCELED', finishedAt: new Date(), logs: truncateLogs(lines.join('\n')), }, }); return; } const message = error instanceof Error ? error.message : 'Unknown build failure'; lines.push(`[failed] ${redact(message, token)}`); await this.prisma.buildJob.update({ where: { id: job.id }, data: { status: 'FAILED', finishedAt: new Date(), logs: truncateLogs(lines.join('\n')), }, }); } finally { if (this.configService.get('BUILD_KEEP_WORKDIR', 'false') !== 'true') { await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined); } } } private async isCanceled(jobId: string) { const job = await this.prisma.buildJob.findUnique({ where: { id: jobId }, select: { status: true }, }); return job?.status === 'CANCELED'; } private async cloneRepository( job: BuildJobWithComponent, workDir: string, append: (line: string) => Promise, ) { const safeUrl = this.getHttpCloneUrl(job.component.repository!); const cloneUrl = await this.getAuthenticatedHttpCloneUrl( job.component.repository!, ); await append(`[clone] ${safeUrl}`); await runCommand({ jobId: job.id, command: this.configService.get('GIT_BIN', 'git'), args: ['clone', '--branch', job.branch, cloneUrl, workDir], safeArgs: ['clone', '--branch', job.branch, safeUrl, workDir], cwd: process.cwd(), append, onProcess: (child) => this.activeProcesses.set(job.id, child), onProcessEnd: () => this.activeProcesses.delete(job.id), }); if (job.commitSha) { await append(`[checkout] ${job.commitSha}`); await runCommand({ jobId: job.id, command: this.configService.get('GIT_BIN', 'git'), args: ['checkout', job.commitSha], cwd: workDir, append, onProcess: (child) => this.activeProcesses.set(job.id, child), onProcessEnd: () => this.activeProcesses.delete(job.id), }); } } private async executeByComponentType( job: BuildJobWithComponent, workDir: string, append: (line: string) => Promise, ) { if (job.image) { await this.buildAndPushImage(job, workDir, append); return; } if (job.component.type === 'MIGRATION') { await append('[migration] repository checkout verified'); await append('[migration] actual DB migration is handled by deployment step'); return; } if (job.component.type === 'ELECTRON') { await this.buildArtifact(job, workDir, append); return; } await append(`[skip] no build action configured for ${job.component.type}`); } private async buildAndPushImage( job: BuildJobWithComponent, workDir: string, append: (line: string) => Promise, ) { const image = this.resolveImage(job.image!); const builder = this.configService.get('BUILD_IMAGE_BUILDER', 'docker'); const dockerfile = this.configService.get('BUILD_DOCKERFILE', 'Dockerfile'); const contextPath = this.configService.get('BUILD_CONTEXT_PATH', '.'); const contextDir = path.resolve(workDir, contextPath); const pushEnabled = this.configService.get('BUILD_PUSH_ENABLED', 'true') !== 'false'; if (image !== job.image) { await this.prisma.buildJob.update({ where: { id: job.id }, data: { image }, }); await append(`[image] rewritten to ${image}`); } if (builder === 'nerdctl') { const namespace = this.configService.get('BUILD_NERDCTL_NAMESPACE'); const prefix = namespace ? ['--namespace', namespace] : []; await runCommand({ jobId: job.id, command: this.configService.get('NERDCTL_BIN', 'nerdctl'), args: [...prefix, 'build', '-t', image, '-f', dockerfile, '.'], cwd: contextDir, append, onProcess: (child) => this.activeProcesses.set(job.id, child), onProcessEnd: () => this.activeProcesses.delete(job.id), }); if (pushEnabled) { await runCommand({ jobId: job.id, command: this.configService.get('NERDCTL_BIN', 'nerdctl'), args: [...prefix, 'push', image], cwd: contextDir, append, onProcess: (child) => this.activeProcesses.set(job.id, child), onProcessEnd: () => this.activeProcesses.delete(job.id), }); } return; } await runCommand({ jobId: job.id, command: this.configService.get('DOCKER_BIN', 'docker'), args: ['build', '-t', image, '-f', dockerfile, '.'], cwd: contextDir, append, onProcess: (child) => this.activeProcesses.set(job.id, child), onProcessEnd: () => this.activeProcesses.delete(job.id), }); if (pushEnabled) { await runCommand({ jobId: job.id, command: this.configService.get('DOCKER_BIN', 'docker'), args: ['push', image], cwd: contextDir, append, onProcess: (child) => this.activeProcesses.set(job.id, child), onProcessEnd: () => this.activeProcesses.delete(job.id), }); } } private resolveImage(image: string) { const registry = this.configService.get('BUILD_REGISTRY'); if (!registry) return image; const imagePath = image.includes('/') ? image.slice(image.indexOf('/') + 1) : image; return `${registry.replace(/\/$/, '')}/${imagePath}`; } private async buildArtifact( job: BuildJobWithComponent, workDir: string, append: (line: string) => Promise, ) { const installCommand = this.configService.get( 'BUILD_ARTIFACT_INSTALL_COMMAND', 'npm ci', ); const buildCommand = this.configService.get( 'BUILD_ELECTRON_COMMAND', 'npm run build:linux', ); const outputDir = this.configService.get( 'BUILD_ARTIFACT_OUTPUT_DIR', 'dist', ); const artifactDir = this.configService.get( 'BUILD_ARTIFACT_DIR', path.join(os.tmpdir(), 'studio-admin-artifacts'), ); await append(`[artifact] install ${installCommand}`); await runShellCommand({ jobId: job.id, command: installCommand, cwd: workDir, append, onProcess: (child) => this.activeProcesses.set(job.id, child), onProcessEnd: () => this.activeProcesses.delete(job.id), }); await append(`[artifact] build ${buildCommand}`); await runShellCommand({ jobId: job.id, command: buildCommand, cwd: workDir, append, onProcess: (child) => this.activeProcesses.set(job.id, child), onProcessEnd: () => this.activeProcesses.delete(job.id), }); const artifact = await findNewestArtifact(path.resolve(workDir, outputDir)); if (!artifact) { throw new Error(`산출물을 찾을 수 없습니다: ${outputDir}`); } const artifactVersion = await readPackageVersion(workDir); await fs.mkdir(artifactDir, { recursive: true }); const destinationName = job.artifact || path.basename(artifact); const destination = path.join(artifactDir, destinationName); await fs.copyFile(artifact, destination); const uploadedArtifact = await this.uploadArtifactIfConfigured( job, destination, destinationName, artifactVersion, append, ); const publicBaseUrl = this.configService.get('BUILD_ARTIFACT_BASE_URL'); const artifactValue = uploadedArtifact ?? (publicBaseUrl ? `${publicBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(destinationName)}` : destination); await this.prisma.buildJob.update({ where: { id: job.id }, data: { artifact: artifactValue }, }); job.artifact = artifactValue; await append(`[artifact] found ${path.relative(workDir, artifact)}`); await append(`[artifact] copied to ${artifactValue}`); } private async uploadArtifactIfConfigured( job: BuildJobWithComponent, artifactPath: string, artifactName: string, artifactVersion: string | null, append: (line: string) => Promise, ) { const bucket = this.configService.get('R2_BUCKET'); const publicBaseUrl = this.configService.get('R2_PUBLIC_BASE_URL'); const accessKeyId = this.configService.get('R2_ACCESS_KEY_ID'); const secretAccessKey = this.configService.get('R2_SECRET_ACCESS_KEY'); if (!bucket || !publicBaseUrl || !accessKeyId || !secretAccessKey) { return null; } const key = this.createArtifactObjectKey(job, artifactName, artifactVersion); const stat = await fs.stat(artifactPath); const sha256 = await sha256File(artifactPath); const client = this.getR2Client(accessKeyId, secretAccessKey); await append(`[r2] upload ${key}`); await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: createReadStream(artifactPath), ContentLength: stat.size, ContentType: contentTypeForFile(artifactName), ContentDisposition: contentDispositionForFile(artifactName), Metadata: { sha256, app: safeMetadataValue(job.component.app.key), component: safeMetadataValue(job.component.name), branch: safeMetadataValue(job.branch), commit: safeMetadataValue(job.commitSha ?? ''), version: safeMetadataValue(artifactVersion ?? ''), }, })); await client.send(new PutObjectCommand({ Bucket: bucket, Key: `${key}.sha256`, Body: `${sha256} ${artifactName}\n`, ContentType: 'text/plain; charset=utf-8', })); const url = `${publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(key)}`; await append(`[r2] uploaded ${url}`); await append(`[r2] sha256 ${sha256}`); return url; } private getR2Client(accessKeyId: string, secretAccessKey: string) { if (this.r2Client) return this.r2Client; const endpoint = this.configService.get('R2_ENDPOINT') || `https://${this.configService.get('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`; this.r2Client = new S3Client({ region: this.configService.get('R2_REGION', 'auto'), endpoint, credentials: { accessKeyId, secretAccessKey, }, forcePathStyle: this.configService.get('R2_FORCE_PATH_STYLE', 'true') !== 'false', }); return this.r2Client; } private createArtifactObjectKey( job: BuildJobWithComponent, artifactName: string, artifactVersion: string | null, ) { const prefix = this.configService.get('R2_ARTIFACT_PREFIX', 'studio-artifacts'); const versionPath = artifactVersion ? [artifactVersion] : [job.branch, 'latest']; return [ prefix, 'apps', job.component.app.key, job.component.name, ...versionPath, artifactName, ].map(sanitizeObjectKeyPart).join('/'); } 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 || this.configService.get('BUILD_GIT_AUTH', 'token') === 'none') { 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 Error(`Gitea 사용자 확인 실패: ${response.status}`); } const data = (await response.json()) as { login?: string }; if (!data.login) { throw new Error('Gitea 토큰 사용자명을 확인할 수 없습니다.'); } this.giteaUsername = data.login; return data.login; } } async function runCommand({ jobId, command, args, safeArgs = args, cwd, append, onProcess, onProcessEnd, }: { jobId: string; command: string; args: string[]; safeArgs?: string[]; cwd: string; append: (line: string) => Promise; onProcess: (child: ChildProcess) => void; onProcessEnd: () => void; }) { await append(`[run] ${command} ${safeArgs.join(' ')}`); await new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true, }); let output = ''; onProcess(child); child.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); }); child.stderr.on('data', (chunk: Buffer) => { output += chunk.toString(); }); child.on('error', (error) => { onProcessEnd(); reject(error); }); child.on('close', async (code) => { onProcessEnd(); try { for (const line of output.split(/\r?\n/).filter(Boolean)) { await append(`[out] ${line}`); } } catch (error) { reject(error); return; } if (code === 0) { resolve(); return; } reject(new Error(`${command} for job ${jobId} exited with code ${code}`)); }); }); } async function runShellCommand({ jobId, command, cwd, append, onProcess, onProcessEnd, }: { jobId: string; command: string; cwd: string; append: (line: string) => Promise; onProcess: (child: ChildProcess) => void; onProcessEnd: () => void; }) { await append(`[run] ${command}`); await new Promise((resolve, reject) => { const child = spawn(command, { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true, shell: true, }); let output = ''; onProcess(child); child.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); }); child.stderr.on('data', (chunk: Buffer) => { output += chunk.toString(); }); child.on('error', (error) => { onProcessEnd(); reject(error); }); child.on('close', async (code) => { onProcessEnd(); try { for (const line of output.split(/\r?\n/).filter(Boolean)) { await append(`[out] ${line}`); } } catch (error) { reject(error); return; } if (code === 0) { resolve(); return; } reject(new Error(`${command} for job ${jobId} exited with code ${code}`)); }); }); } async function findNewestArtifact(dir: string) { const candidates = await findArtifactCandidates(dir).catch(() => []); candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); return candidates[0]?.path ?? null; } async function readPackageVersion(workDir: string) { try { const packageJson = await fs.readFile(path.join(workDir, 'package.json'), 'utf8'); const parsed = JSON.parse(packageJson) as { version?: unknown }; return typeof parsed.version === 'string' && parsed.version.trim() ? parsed.version.trim() : null; } catch { return null; } } async function findArtifactCandidates(dir: string): Promise> { const entries = await fs.readdir(dir, { withFileTypes: true }); const results: Array<{ path: string; mtimeMs: number }> = []; for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...await findArtifactCandidates(entryPath)); continue; } if (!/\.(AppImage|deb|rpm|zip|tar\.gz)$/i.test(entry.name)) continue; const stat = await fs.stat(entryPath); results.push({ path: entryPath, mtimeMs: stat.mtimeMs }); } return results; } async function sha256File(filePath: string) { const hash = createHash('sha256'); await new Promise((resolve, reject) => { const stream = createReadStream(filePath); stream.on('data', (chunk) => hash.update(chunk)); stream.on('error', reject); stream.on('end', resolve); }); return hash.digest('hex'); } function contentTypeForFile(fileName: string) { const lowerName = fileName.toLowerCase(); if (lowerName.endsWith('.tar.gz')) return 'application/gzip'; const extension = path.extname(lowerName); const types: Record = { '.appimage': 'application/octet-stream', '.deb': 'application/vnd.debian.binary-package', '.rpm': 'application/x-rpm', '.zip': 'application/zip', '.json': 'application/json', '.txt': 'text/plain; charset=utf-8', '.yml': 'application/x-yaml', '.yaml': 'application/x-yaml', }; return types[extension] ?? 'application/octet-stream'; } function sanitizeObjectKeyPart(value: string) { return value .trim() .replace(/^\/+|\/+$/g, '') .replace(/[^a-zA-Z0-9._@+=-]+/g, '-') || 'unknown'; } function encodeObjectKey(key: string) { return key.split('/').map(encodeURIComponent).join('/'); } function contentDispositionForFile(fileName: string) { const fallbackName = fileName.replace(/[\\\"\r\n]/g, '_'); return `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`; } function safeMetadataValue(value: string) { return value.replace(/[^\x20-\x7E]/g, '').slice(0, 512); } function terminateProcess(child: ChildProcess) { if (!child.pid || child.killed) return; try { process.kill(-child.pid, 'SIGTERM'); setTimeout(() => { if (!child.killed && child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch { // Process may have already exited. } } }, 3000).unref(); } catch { child.kill('SIGTERM'); } } function splitLogs(logs: string | null) { return logs ? logs.split('\n').filter(Boolean) : []; } function truncateLogs(logs: string) { return logs.length > 200_000 ? logs.slice(-200_000) : logs; } function redact(value: string, secret: string) { if (!secret) return value; return value.split(secret).join(''); }