import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { exec } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; import { basename } from 'node:path'; import { promisify } from 'node:util'; import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; import { PrismaService } from '../prisma/prisma.service'; import { CreateLauncherAppLinkDto, CreateLauncherBuildJobDto, CreateLauncherCommonSettingDto, CreateLauncherVersionDto, UpdateLauncherSourceDto, LinkLauncherVersionSourceDto, UpdateLauncherAppLinkDto, UpdateLauncherCommonSettingDto, UpdateLauncherUserAppPermissionsDto, } from './dto'; const execAsync = promisify(exec); const LAUNCHER_SOURCE_SETTING_KEYS = { 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() export class LauncherService { private r2Client?: S3Client; private giteaUsername?: string; constructor( private readonly prisma: PrismaService, private readonly launcherPrisma: LauncherPrismaService, private readonly configService: ConfigService, ) {} async listVersions() { const rows = await this.launcherPrisma.$queryRawUnsafe(` SELECT id, app_key, display_name, latest_version, minimum_supported_version, download_url, release_notes, repository, branch, commit_sha, is_enabled, created_at, updated_at FROM launcher_app_versions WHERE app_key = 'launcher' ORDER BY updated_at DESC `); return rows.map(mapLauncherVersionRow); } listCommonSettings() { return this.launcherPrisma.launcherCommonSetting.findMany({ orderBy: [{ isActive: 'desc' }, { type: 'asc' }, { name: 'asc' }], }); } async createCommonSetting(body: CreateLauncherCommonSettingDto) { const setting = await this.launcherPrisma.launcherCommonSetting.create({ data: { key: body.key.trim(), name: body.name.trim(), type: body.type.trim(), value: body.value.trim(), description: body.description?.trim() || null, isActive: body.isActive ?? true, isSecret: body.isSecret ?? false, }, }); return { setting }; } async updateCommonSetting(id: string, body: UpdateLauncherCommonSettingDto) { const setting = await this.launcherPrisma.launcherCommonSetting.update({ where: { id }, data: { key: body.key === undefined ? undefined : body.key.trim(), name: body.name === undefined ? undefined : body.name.trim(), type: body.type === undefined ? undefined : body.type.trim(), value: body.value === undefined ? undefined : body.value.trim(), description: body.description === undefined ? undefined : body.description.trim() || null, isActive: body.isActive, isSecret: body.isSecret, }, }); return { setting }; } async deleteCommonSetting(id: string) { await this.launcherPrisma.launcherCommonSetting.delete({ where: { id } }); return { deleted: true }; } async getSource() { const source = await this.getSourceSettings(); return { source }; } 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'; 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, '런처 소스 저장소', 'source', repository, '런처 클라이언트 빌드에 사용할 저장소', ), this.upsertCommonSettingByKey( LAUNCHER_SOURCE_SETTING_KEYS.branch, '런처 소스 브랜치', 'source', branch, '런처 클라이언트 빌드 기준 브랜치', ), this.upsertCommonSettingByKey( LAUNCHER_SOURCE_SETTING_KEYS.commitSha, '런처 소스 커밋', 'source', commitSha, '비워두면 브랜치 최신 커밋 기준', ), ]; await this.launcherPrisma.$transaction(writes); return { source: { repository, branch, commitSha: commitSha || null, image: isBackend ? image : undefined, dockerfile: isBackend ? dockerfile : undefined, }, }; } private async getSourceSettings() { const rows = await this.launcherPrisma.launcherCommonSetting.findMany({ where: { key: { in: [ 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, ], }, }, }); const byKey = new Map(rows.map((row) => [row.key, row.value])); 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, }, }; } private upsertCommonSettingByKey( key: string, name: string, type: string, value: string, description: string, ) { return this.launcherPrisma.launcherCommonSetting.upsert({ where: { key }, create: { key, name, type, value, description, isActive: true, isSecret: false, }, update: { name, type, value, description, isActive: true, isSecret: false, }, }); } listBuildJobs() { return this.prisma.launcherBuildJob.findMany({ orderBy: { createdAt: 'desc' }, take: 30, }); } async listAppLinks() { const [launcherRows, adminApps] = await Promise.all([ this.launcherPrisma.$queryRawUnsafe(` SELECT app.id, app.app_key, app.display_name, app.description, app.subtitle, app.detail_url, app.video_path, app.icon_key, app.launch_url, app.dev_app_path, app.exec_path, app.sort_order, app.is_enabled, version.id AS version_id, version.latest_version, version.download_url FROM launcher_apps app LEFT JOIN launcher_app_versions version ON version.app_key = app.app_key ORDER BY app.sort_order ASC, app.display_name ASC `), this.prisma.app.findMany({ orderBy: { name: 'asc' }, include: { releases: { orderBy: { createdAt: 'desc' }, take: 1, }, }, }), ]); const linkedKeys = new Set( launcherRows.filter((row) => row.is_enabled).map((row) => row.app_key), ); return { linked: launcherRows.filter((row) => row.is_enabled).map(mapLauncherAppLinkRow), unlinked: adminApps .filter((app) => !linkedKeys.has(app.key)) .map((app) => ({ id: app.id, key: app.key, name: app.name, description: app.description, namespace: app.namespace, status: app.status, releases: app.releases, })), }; } async listUserAppPermissions(userId: string) { const rows = await this.launcherPrisma.$queryRawUnsafe( `SELECT app.id AS app_id, app.app_key, app.display_name, app.description, app.sort_order, app.is_enabled, permission.id AS permission_id, permission.can_view, permission.can_launch, permission.assigned_at FROM launcher_apps app LEFT JOIN launcher_user_apps permission ON permission.app_id = app.id AND permission.auth_user_id = $1::uuid WHERE app.is_enabled = true ORDER BY app.sort_order ASC, app.display_name ASC`, userId, ); return { permissions: rows.map((row) => ({ appId: row.app_id, appKey: row.app_key, displayName: row.display_name, description: row.description, sortOrder: row.sort_order, isEnabled: row.is_enabled, permissionId: row.permission_id, canView: row.can_view ?? false, canLaunch: row.can_launch ?? false, assignedAt: row.assigned_at, })), }; } async updateUserAppPermissions(userId: string, body: UpdateLauncherUserAppPermissionsDto) { const inputByAppKey = new Map( body.permissions.map((permission) => [ permission.appKey, { canView: permission.canView, canLaunch: permission.canView && permission.canLaunch, }, ]), ); const apps = await this.launcherPrisma.$queryRawUnsafe>( `SELECT id, app_key FROM launcher_apps WHERE is_enabled = true`, ); const appKeys = new Set(apps.map((app) => app.app_key)); const invalidKeys = [...inputByAppKey.keys()].filter((appKey) => !appKeys.has(appKey)); if (invalidKeys.length) { throw new BadRequestException(`알 수 없는 앱 권한입니다: ${invalidKeys.join(', ')}`); } for (const app of apps) { const input = inputByAppKey.get(app.app_key); if (!input || !input.canView) { await this.launcherPrisma.$executeRawUnsafe( `DELETE FROM launcher_user_apps WHERE auth_user_id = $1::uuid AND app_id = $2::uuid`, userId, app.id, ); continue; } await this.launcherPrisma.$executeRawUnsafe( `INSERT INTO launcher_user_apps ( auth_user_id, app_id, can_view, can_launch, assigned_at, created_at, updated_at ) VALUES ( $1::uuid, $2::uuid, $3, $4, now(), now(), now() ) ON CONFLICT (auth_user_id, app_id) DO UPDATE SET can_view = EXCLUDED.can_view, can_launch = EXCLUDED.can_launch, assigned_at = now(), updated_at = now()`, userId, app.id, input.canView, input.canLaunch, ); } return this.listUserAppPermissions(userId); } async syncAdminAppsFromLauncher() { const launcherRows = await this.launcherPrisma.$queryRawUnsafe(` SELECT app.id, app.app_key, app.display_name, app.description, app.subtitle, app.detail_url, app.video_path, app.icon_key, app.launch_url, app.dev_app_path, app.exec_path, app.sort_order, app.is_enabled, version.id AS version_id, version.latest_version, version.download_url FROM launcher_apps app LEFT JOIN launcher_app_versions version ON version.app_key = app.app_key ORDER BY app.sort_order ASC, app.display_name ASC `); const targetRows = launcherRows.filter((row) => row.app_key.toLowerCase() !== 'launcher'); const syncedApps = []; const syncedReleases = []; for (const row of targetRows) { const appKey = normalizeLauncherAppKey(row.app_key); const status = row.is_enabled ? 'ACTIVE' : 'DISABLED'; const existingByKey = await this.prisma.app.findUnique({ where: { key: appKey } }); const existingById = await this.prisma.app.findUnique({ where: { id: row.id }, include: { releases: true } }); let app; if (existingByKey) { app = await this.prisma.app.update({ where: { id: existingByKey.id }, data: { name: row.display_name, description: row.description, status }, }); if (existingById && existingById.id !== existingByKey.id) { for (const release of existingById.releases) { await this.prisma.appRelease.upsert({ where: { appId_version: { appId: app.id, version: release.version } }, create: { appId: app.id, version: release.version, branch: release.branch, commitSha: release.commitSha, artifact: release.artifact, imageTags: release.imageTags as any, status: release.status, }, update: { artifact: release.artifact, imageTags: release.imageTags as any, status: release.status, }, }); } await this.prisma.app.delete({ where: { id: existingById.id } }); } } else if (existingById) { app = await this.prisma.app.update({ where: { id: existingById.id }, data: { key: appKey, name: row.display_name, description: row.description, status }, }); } else { app = await this.prisma.app.create({ data: { id: row.id, key: appKey, name: row.display_name, description: row.description, status, }, }); } syncedApps.push(app); if (row.app_key !== appKey) { const duplicate = await this.prisma.app.findUnique({ where: { key: row.app_key }, include: { releases: true }, }); if (duplicate && duplicate.id !== app.id) { for (const release of duplicate.releases) { await this.prisma.appRelease.upsert({ where: { appId_version: { appId: app.id, version: release.version } }, create: { appId: app.id, version: release.version, branch: release.branch, commitSha: release.commitSha, artifact: release.artifact, imageTags: release.imageTags as any, status: release.status, }, update: { artifact: release.artifact, imageTags: release.imageTags as any, status: release.status, }, }); } await this.prisma.app.delete({ where: { id: duplicate.id } }); } await this.launcherPrisma.$executeRawUnsafe( `UPDATE launcher_app_versions SET app_key = $2, updated_at = now() WHERE app_key = $1`, row.app_key, appKey, ); await this.launcherPrisma.$executeRawUnsafe( `UPDATE launcher_apps SET app_key = $2, updated_at = now() WHERE app_key = $1`, row.app_key, appKey, ); } if (row.latest_version) { const release = await this.prisma.appRelease.upsert({ where: { appId_version: { appId: app.id, version: row.latest_version } }, create: { appId: app.id, version: row.latest_version, branch: 'release', artifact: row.download_url, status: 'SUCCEEDED', }, update: { artifact: row.download_url, status: 'SUCCEEDED', }, }); syncedReleases.push(release); } } return { syncedApps: syncedApps.length, syncedReleases: syncedReleases.length }; } async createAppLink(body: CreateLauncherAppLinkDto) { const app = await this.prisma.app.findUnique({ where: { id: body.appId }, include: { releases: { orderBy: { createdAt: 'desc' }, take: 1 } }, }); if (!app) throw new NotFoundException('앱을 찾을 수 없습니다.'); const maxRows = await this.launcherPrisma.$queryRawUnsafe<{ max_order: number | null }[]>( `SELECT MAX(sort_order) AS max_order FROM launcher_apps`, ); const nextOrder = body.displayOrder ?? ((maxRows[0]?.max_order ?? 0) + 1); const release = app.releases[0]; const displayName = body.displayName?.trim() || app.name; const description = body.description === undefined ? (app.description || '') : body.description.trim(); const subtitle = body.subtitle === undefined ? (release?.version || null) : body.subtitle.trim(); const iconKey = body.iconKey?.trim() || app.key.toLowerCase().replace(/[^a-z0-9_-]/g, '-'); await this.launcherPrisma.$executeRawUnsafe( `INSERT INTO launcher_apps ( id, app_key, display_name, description, subtitle, detail_url, video_path, icon_key, launch_url, dev_app_path, exec_path, sort_order, is_enabled, created_at, updated_at ) VALUES ( $1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, true, now(), now() ) ON CONFLICT (app_key) DO UPDATE SET display_name = EXCLUDED.display_name, description = EXCLUDED.description, subtitle = EXCLUDED.subtitle, detail_url = COALESCE(EXCLUDED.detail_url, launcher_apps.detail_url), video_path = COALESCE(EXCLUDED.video_path, launcher_apps.video_path), icon_key = EXCLUDED.icon_key, launch_url = COALESCE(EXCLUDED.launch_url, launcher_apps.launch_url), dev_app_path = COALESCE(EXCLUDED.dev_app_path, launcher_apps.dev_app_path), exec_path = COALESCE(EXCLUDED.exec_path, launcher_apps.exec_path), sort_order = CASE WHEN launcher_apps.sort_order = 0 THEN EXCLUDED.sort_order ELSE launcher_apps.sort_order END, is_enabled = true, updated_at = now()`, app.id, app.key, displayName, description, subtitle, trimOptional(body.detailUrl), trimOptional(body.videoPath), iconKey, trimOptional(body.launchUrl), trimOptional(body.devAppPath), trimOptional(body.execPath), nextOrder, ); if (release) { await this.upsertLauncherAppVersion({ appKey: app.key, displayName, version: release.version, artifact: body.artifact?.trim() || getReleaseClientArtifact(release), }); } const rows = await this.launcherPrisma.$queryRawUnsafe( `SELECT app.id, app.app_key, app.display_name, app.description, app.subtitle, app.detail_url, app.video_path, app.icon_key, app.launch_url, app.dev_app_path, app.exec_path, app.sort_order, app.is_enabled, version.id AS version_id, version.latest_version, version.download_url FROM launcher_apps app LEFT JOIN launcher_app_versions version ON version.app_key = app.app_key WHERE app.app_key = $1`, app.key, ); return { link: mapLauncherAppLinkRow(rows[0]) }; } async updateAppLink(id: string, body: UpdateLauncherAppLinkDto) { const rows = await this.launcherPrisma.$queryRawUnsafe( `WITH updated AS ( UPDATE launcher_apps SET sort_order = COALESCE($2, sort_order), is_enabled = COALESCE($3, is_enabled), display_name = COALESCE($4, display_name), description = COALESCE($5, description), subtitle = COALESCE($6, subtitle), detail_url = COALESCE($7, detail_url), video_path = COALESCE($8, video_path), icon_key = COALESCE($9, icon_key), launch_url = COALESCE($10, launch_url), dev_app_path = COALESCE($11, dev_app_path), exec_path = COALESCE($12, exec_path), updated_at = now() WHERE id = $1::uuid RETURNING * ) SELECT app.id, app.app_key, app.display_name, app.description, app.subtitle, app.detail_url, app.video_path, app.icon_key, app.launch_url, app.dev_app_path, app.exec_path, app.sort_order, app.is_enabled, version.id AS version_id, version.latest_version, version.download_url FROM updated app LEFT JOIN launcher_app_versions version ON version.app_key = app.app_key`, id, body.displayOrder ?? null, body.isEnabled ?? null, trimOptional(body.displayName), trimOptional(body.description), trimOptional(body.subtitle), trimOptional(body.detailUrl), trimOptional(body.videoPath), trimOptional(body.iconKey), trimOptional(body.launchUrl), trimOptional(body.devAppPath), trimOptional(body.execPath), ); if (!rows[0]) throw new NotFoundException('앱 연결 정보를 찾을 수 없습니다.'); const app = await this.prisma.app.findFirst({ where: { key: rows[0].app_key }, include: { releases: { where: { status: 'SUCCEEDED' }, orderBy: { createdAt: 'desc' }, take: 1, }, }, }); const release = app?.releases[0]; if (release) { await this.upsertLauncherAppVersion({ appKey: rows[0].app_key, displayName: rows[0].display_name, version: release.version, artifact: body.artifact?.trim() || getReleaseClientArtifact(release), }); } return { link: mapLauncherAppLinkRow(rows[0]) }; } async publishAppReleaseToLauncher(releaseId: string) { const release = await this.prisma.appRelease.findUnique({ where: { id: releaseId }, include: { app: true }, }); if (!release) throw new NotFoundException('릴리즈를 찾을 수 없습니다.'); if (release.status !== 'SUCCEEDED') { throw new BadRequestException('성공한 릴리즈만 런처 앱 버전으로 반영할 수 있습니다.'); } await this.upsertLauncherAppVersion({ appKey: release.app.key, displayName: release.app.name, version: release.version, artifact: getReleaseClientArtifact(release), }); } private async upsertLauncherAppVersion(input: { appKey: string; displayName: string; version: string; artifact: string | null; }) { await this.launcherPrisma.$executeRawUnsafe( `INSERT INTO launcher_app_versions ( id, app_key, display_name, latest_version, minimum_supported_version, download_url, release_notes, is_enabled, created_at, updated_at ) VALUES ( gen_random_uuid(), $1, $2, $3, NULL, $4, NULL, true, now(), now() ) ON CONFLICT (app_key) DO UPDATE SET display_name = EXCLUDED.display_name, latest_version = EXCLUDED.latest_version, download_url = COALESCE(EXCLUDED.download_url, launcher_app_versions.download_url), is_enabled = true, updated_at = now()`, input.appKey, input.displayName, input.version, input.artifact, ); } async moveAppLink(id: string, direction: 'up' | 'down') { const rows = await this.launcherPrisma.$queryRawUnsafe<{ id: string; sort_order: number }[]>( `SELECT id, sort_order FROM launcher_apps WHERE id = $1::uuid`, id, ); const current = rows[0]; if (!current) throw new NotFoundException('앱 연결 정보를 찾을 수 없습니다.'); const neighbors = await this.launcherPrisma.$queryRawUnsafe<{ id: string; sort_order: number }[]>( direction === 'up' ? `SELECT id, sort_order FROM launcher_apps WHERE is_enabled = true AND sort_order < $1 ORDER BY sort_order DESC LIMIT 1` : `SELECT id, sort_order FROM launcher_apps WHERE is_enabled = true AND sort_order > $1 ORDER BY sort_order ASC LIMIT 1`, current.sort_order, ); const neighbor = neighbors[0]; if (!neighbor) return this.listAppLinks(); await this.launcherPrisma.$transaction([ this.launcherPrisma.$executeRawUnsafe(`UPDATE launcher_apps SET sort_order = $2, updated_at = now() WHERE id = $1::uuid`, current.id, neighbor.sort_order), this.launcherPrisma.$executeRawUnsafe(`UPDATE launcher_apps SET sort_order = $2, updated_at = now() WHERE id = $1::uuid`, neighbor.id, current.sort_order), ]); return this.listAppLinks(); } async deleteAppLink(id: string) { await this.launcherPrisma.$executeRawUnsafe( `UPDATE launcher_apps SET is_enabled = false, updated_at = now() WHERE id = $1::uuid`, id, ); return { deleted: true }; } async createVersion(body: CreateLauncherVersionDto) { const rows = await this.launcherPrisma.$queryRawUnsafe( `INSERT INTO launcher_app_versions ( id, app_key, display_name, latest_version, minimum_supported_version, download_url, release_notes, repository, branch, commit_sha, is_enabled, created_at, updated_at ) VALUES ( gen_random_uuid(), 'launcher', 'Autonomy Studio Launcher', $1, $2, $3, $4, $5, $6, $7, true, now(), now() ) ON CONFLICT (app_key) DO UPDATE SET latest_version = EXCLUDED.latest_version, minimum_supported_version = EXCLUDED.minimum_supported_version, download_url = EXCLUDED.download_url, release_notes = EXCLUDED.release_notes, repository = EXCLUDED.repository, branch = EXCLUDED.branch, commit_sha = EXCLUDED.commit_sha, is_enabled = true, updated_at = now() RETURNING id, app_key, display_name, latest_version, minimum_supported_version, download_url, release_notes, repository, branch, commit_sha, is_enabled, created_at, updated_at`, body.version.trim(), body.minimumVersion?.trim() || null, body.downloadUrl.trim(), body.releaseNotes?.trim() || null, body.repository ? normalizeRepository(body.repository) : null, body.branch?.trim() || null, body.commitSha?.trim() || null, ); return { version: mapLauncherVersionRow(rows[0]) }; } async createBuildJob(body: CreateLauncherBuildJobDto) { const source = await this.getSourceSettings(); const target = body.target?.trim() || 'launcher-client'; const isBackend = target === 'launcher-backend'; if (isBackend && !body.artifact?.trim()) throw new BadRequestException('이미지 이름을 입력하세요.'); const requestedVersion = body.version?.trim() || 'package-json'; const requestedArtifact = body.artifact?.trim() || 'autonomy-studio-launcher.deb'; 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(requestedVersion, requestedArtifact, body.uploadPath?.trim(), body.downloadUrl?.trim()) : this.createLauncherArtifactPlan(requestedVersion, branch, commitSha, requestedArtifact, 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: ${requestedArtifact}`, `[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', isBackend ? `[plan] build and push image ${plan.downloadUrl}` : `[plan] build deb package`, isBackend ? `[plan] dockerfile ${plan.uploadPath}` : `[plan] upload ${requestedArtifact} to ${plan.uploadPath}`, '[plan] final version will be read from package.json after clone', 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, repository, branch, commitSha, version: requestedVersion, artifact: requestedArtifact, uploadPath: plan.uploadPath, downloadUrl: plan.downloadUrl, logs, }, }); void this.runBuildJob(job.id); return { job }; } async linkVersionSource(id: string, body: LinkLauncherVersionSourceDto) { const rows = await this.launcherPrisma.$queryRawUnsafe( `UPDATE launcher_app_versions SET repository = $2, branch = $3, commit_sha = $4, updated_at = now() WHERE id = $1::uuid RETURNING id, app_key, display_name, latest_version, minimum_supported_version, download_url, release_notes, repository, branch, commit_sha, is_enabled, created_at, updated_at`, id, normalizeRepository(body.repository), body.branch?.trim() || null, body.commitSha?.trim() || null, ); if (!rows[0]) throw new NotFoundException('런처 버전을 찾을 수 없습니다.'); return { version: mapLauncherVersionRow(rows[0]) }; } private async runBuildJob(jobId: string) { const job = await this.prisma.launcherBuildJob.findUnique({ where: { id: jobId } }); if (!job) return; let logs = job.logs || ''; const append = async (...lines: string[]) => { logs = [logs, ...lines].filter(Boolean).join('\n'); await this.prisma.launcherBuildJob.update({ where: { id: jobId }, data: { logs }, }); }; try { if (!job.repository) throw new Error('런처 소스 저장소가 연결되지 않았습니다.'); await this.prisma.launcherBuildJob.update({ where: { id: jobId }, data: { status: 'RUNNING', startedAt: new Date() }, }); await append(`[running] launcher build runner picked job ${jobId}`); const workRoot = process.env.LAUNCHER_BUILD_WORKDIR || '/tmp/studio-admin-launcher-builds'; const workDir = `${workRoot}/${jobId}`; await rm(workDir, { recursive: true, force: true }); await mkdir(workRoot, { recursive: true }); const safeCloneUrl = this.toCloneUrl(job.repository); const cloneUrl = await this.getAuthenticatedHttpCloneUrl(job.repository); await runCommand( `git clone --branch ${quote(job.branch)} ${quote(cloneUrl)} ${quote(workDir)}`, process.cwd(), append, `git clone --branch ${quote(job.branch)} ${quote(safeCloneUrl)} ${quote(workDir)}`, ); if (job.commitSha) { await runCommand(`git checkout ${quote(job.commitSha)}`, workDir, append); } if (job.target === 'launcher-backend') { const packageVersion = (await this.readPackageVersion(workDir)) || job.version; const dockerfile = job.uploadPath || 'Dockerfile'; const imagePlan = this.createLauncherBackendImagePlan(packageVersion, job.artifact, dockerfile); const imageTag = imagePlan.downloadUrl; await append(`[backend] package version ${packageVersion}`); await append(`[backend] dockerfile ${dockerfile}`); await append(`[backend] image ${imageTag}`); await this.prisma.launcherBuildJob.update({ where: { id: jobId }, data: { version: packageVersion, uploadPath: dockerfile, downloadUrl: 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(), version: packageVersion, uploadPath: dockerfile, downloadUrl: imageTag, logs, }, }); return; } const buildCommand = process.env.LAUNCHER_BUILD_COMMAND || 'npm ci && npm run build:linux'; await runCommand(buildCommand, workDir, append); const { stdout } = await runCommand(`find ${quote(workDir)} -type f -name '*.deb' | head -n 1`, workDir, append); const debPath = stdout.trim().split('\n').filter(Boolean)[0]; if (!debPath) throw new Error('.deb 산출물을 찾지 못했습니다.'); const packageVersion = (await this.readPackageVersion(workDir)) || job.version; const artifactName = basename(debPath); const artifactPlan = this.createLauncherArtifactPlan(packageVersion, job.branch, job.commitSha, artifactName, job.uploadPath, job.downloadUrl); const finalJob = { version: packageVersion, branch: job.branch, commitSha: job.commitSha, artifact: artifactName, uploadPath: artifactPlan.uploadPath, }; await append( `[artifact] package version ${packageVersion}`, `[artifact] found ${artifactName}`, `[artifact] upload key ${artifactPlan.uploadPath}`, ); await this.prisma.launcherBuildJob.update({ where: { id: jobId }, data: { version: packageVersion, artifact: artifactName, uploadPath: artifactPlan.uploadPath, downloadUrl: artifactPlan.downloadUrl, }, }); const uploadedUrl = await this.uploadLauncherArtifactIfConfigured(finalJob, debPath, append); if (!uploadedUrl) { await mkdir(artifactPlan.uploadPath, { recursive: true }); const destination = `${artifactPlan.uploadPath.replace(/\/$/, '')}/${artifactName}`; await copyFile(debPath, destination); await append(`[upload] ${destination}`); } await append('[done] launcher build completed'); await this.prisma.launcherBuildJob.update({ where: { id: jobId }, data: { status: 'SUCCEEDED', finishedAt: new Date(), version: packageVersion, artifact: artifactName, uploadPath: artifactPlan.uploadPath, downloadUrl: uploadedUrl || artifactPlan.downloadUrl, logs, }, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); await append(`[failed] ${message}`); await this.prisma.launcherBuildJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), logs }, }); } } private createLauncherArtifactPlan( version: string, branch: string, commitSha: string | null, artifact: string, uploadPath?: string, downloadUrl?: string, ) { const key = this.createLauncherArtifactObjectKey(version, branch, commitSha, artifact); const publicBaseUrl = this.configService.get('R2_PUBLIC_BASE_URL'); const r2Enabled = Boolean( this.configService.get('R2_BUCKET') && this.configService.get('R2_ACCESS_KEY_ID') && this.configService.get('R2_SECRET_ACCESS_KEY') && publicBaseUrl, ); if (r2Enabled) { return { uploadPath: key, downloadUrl: `${publicBaseUrl!.replace(/\/$/, '')}/${encodeObjectKey(key)}`, }; } return { uploadPath: uploadPath ?? '', downloadUrl: downloadUrl ?? '', }; } 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; branch: string; commitSha: string | null; artifact: string; uploadPath: string; }, artifactPath: string, append: (...lines: 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 = job.uploadPath || this.createLauncherArtifactObjectKey(job.version, job.branch, job.commitSha, job.artifact); const fileStat = await 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: fileStat.size, ContentType: 'application/vnd.debian.binary-package', ContentDisposition: contentDispositionForFile(job.artifact), Metadata: { sha256, target: 'launcher', version: safeMetadataValue(job.version), branch: safeMetadataValue(job.branch), commit: safeMetadataValue(job.commitSha ?? ''), }, })); await client.send(new PutObjectCommand({ Bucket: bucket, Key: `${key}.sha256`, Body: `${sha256} ${job.artifact}\n`, ContentType: 'text/plain; charset=utf-8', })); const latestDebName = 'autonomy-studio-launcher.deb'; const latestDebKey = this.createLauncherLatestObjectKey(latestDebName); const installScriptKey = this.createLauncherLatestObjectKey('install.sh'); const latestDebUrl = `${publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(latestDebKey)}`; const installScript = createLauncherInstallScript(latestDebUrl); await append(`[r2] update latest deb ${latestDebKey}`); await client.send(new PutObjectCommand({ Bucket: bucket, Key: latestDebKey, Body: createReadStream(artifactPath), ContentLength: fileStat.size, ContentType: 'application/vnd.debian.binary-package', ContentDisposition: contentDispositionForFile(latestDebName), Metadata: { sha256, target: 'launcher', version: safeMetadataValue(job.version), branch: safeMetadataValue(job.branch), commit: safeMetadataValue(job.commitSha ?? ''), alias: 'latest', }, })); await append(`[r2] update install script ${installScriptKey}`); await client.send(new PutObjectCommand({ Bucket: bucket, Key: installScriptKey, Body: installScript, ContentType: 'text/x-shellscript; charset=utf-8', ContentDisposition: contentDispositionForFile('install.sh'), Metadata: { target: 'launcher', version: safeMetadataValue(job.version), alias: 'latest', }, })); const url = `${publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(key)}`; await append( `[r2] uploaded ${url}`, `[r2] latest deb ${latestDebUrl}`, `[r2] install command curl -fsSL ${publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(installScriptKey)} | bash`, `[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 async readPackageVersion(workDir: string) { try { const packageJson = await readFile(`${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; } } private createLauncherArtifactObjectKey(version: string, branch: string, commitSha: string | null, artifact: string) { const prefix = this.configService.get('R2_ARTIFACT_PREFIX', 'studio-artifacts'); const revision = commitSha?.slice(0, 7) || 'latest'; return [ prefix, 'launcher', version, branch, revision, artifact, ].map(sanitizeObjectKeyPart).join('/'); } private createLauncherLatestObjectKey(fileName: string) { const prefix = this.configService.get('R2_ARTIFACT_PREFIX', 'studio-artifacts'); return [ prefix, 'launcher', 'latest', fileName, ].map(sanitizeObjectKeyPart).join('/'); } private toCloneUrl(repository: string) { if (/^(https?:\/\/|ssh:\/\/|git@)/.test(repository)) return repository; return `${this.configService .get('GITEA_BASE_URL', 'http://localhost:3000') .replace(/\/$/, '')}/${repository}.git`; } private async getAuthenticatedHttpCloneUrl(repository: string) { const baseUrl = this.toCloneUrl(repository); const token = this.configService.get('GITEA_ADMIN_TOKEN'); if (!token || this.configService.get('BUILD_GIT_AUTH', 'token') === 'none') { return baseUrl; } if (!/^https?:\/\//.test(baseUrl)) 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( command: string, cwd: string, append: (...lines: string[]) => Promise, safeCommand = command, ) { await append(`[run] ${safeCommand}`); const result = await execAsync(command, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, maxBuffer: 1024 * 1024 * 20, shell: '/bin/bash', }); const lines = [result.stdout, result.stderr] .filter(Boolean) .flatMap((output) => output.split('\n').filter(Boolean).map((line) => `[out] ${line}`)); if (lines.length) await append(...lines); return result; } function trimOptional(value?: string) { return value === undefined ? null : value.trim(); } function normalizeLauncherAppKey(key: string) { return key.trim().toLowerCase().replace(/\s+/g, '-'); } function normalizeRepository(repository: string) { return repository .replace(/^https?:\/\/[^/]+\//, '') .replace(/^ssh:\/\/git@[^/]+\//, '') .replace(/\.git$/, '') .replace(/^\/+/, ''); } function quote(value: string) { return `'${value.replace(/'/g, "'\''")}'`; } type LauncherVersionRow = { id: string; app_key: string; display_name: string; latest_version: string; minimum_supported_version: string | null; download_url: string | null; release_notes: string | null; repository: string | null; branch: string | null; commit_sha: string | null; is_enabled: boolean; created_at: Date; updated_at: Date; }; type LauncherAppRow = { id: string; app_key: string; display_name: string; description: string | null; subtitle: string | null; detail_url: string | null; video_path: string | null; icon_key: string | null; launch_url: string | null; dev_app_path: string | null; exec_path: string | null; sort_order: number; is_enabled: boolean; version_id: string | null; latest_version: string | null; download_url: string | null; }; type LauncherUserAppPermissionRow = { app_id: string; app_key: string; display_name: string; description: string | null; sort_order: number; is_enabled: boolean; permission_id: string | null; can_view: boolean | null; can_launch: boolean | null; assigned_at: Date | null; }; function mapLauncherVersionRow(row: LauncherVersionRow) { return { id: row.id, version: row.latest_version, minimumVersion: row.minimum_supported_version, repository: row.repository, branch: row.branch, commitSha: row.commit_sha, artifact: row.download_url ? row.download_url.split('/').pop() || row.download_url : '', downloadUrl: row.download_url || '', releaseNotes: row.release_notes, isActive: row.is_enabled, createdAt: row.created_at, updatedAt: row.updated_at, }; } function mapLauncherAppLinkRow(row: LauncherAppRow) { return { id: row.id, appId: row.id, releaseId: row.version_id, displayOrder: row.sort_order, isEnabled: row.is_enabled, displayName: row.display_name, description: row.description, subtitle: row.subtitle, detailUrl: row.detail_url, videoPath: row.video_path, iconKey: row.icon_key, launchUrl: row.launch_url, devAppPath: row.dev_app_path, execPath: row.exec_path, artifact: row.download_url, createdAt: new Date().toISOString(), app: { id: row.id, key: row.app_key, name: row.display_name, description: row.description, namespace: null, status: row.is_enabled ? 'ACTIVE' : 'DISABLED', }, release: row.latest_version ? { id: row.version_id || row.id, appId: row.id, version: row.latest_version, branch: 'release', status: 'SUCCEEDED', artifact: row.download_url, imageTags: null, createdAt: new Date().toISOString(), app: { id: row.id, key: row.app_key, name: row.display_name, description: row.description, namespace: null, status: row.is_enabled ? 'ACTIVE' : 'DISABLED', }, } : null, }; } function mapLauncherUnlinkedAppRow(row: LauncherAppRow) { return { id: row.id, key: row.app_key, name: row.display_name, description: row.description, namespace: null, status: row.is_enabled ? 'ACTIVE' : 'DISABLED', releases: row.latest_version ? [{ id: row.version_id || row.id, appId: row.id, version: row.latest_version, branch: 'release', status: 'SUCCEEDED', artifact: row.download_url, imageTags: null, createdAt: new Date().toISOString(), app: { id: row.id, key: row.app_key, name: row.display_name, description: row.description, namespace: null, status: row.is_enabled ? 'ACTIVE' : 'DISABLED', }, }] : [], }; } function getReleaseClientArtifact(release: { artifact: string | null; imageTags?: unknown }) { const imageTags = release.imageTags as { artifacts?: Record } | null | undefined; const artifacts = Object.values(imageTags?.artifacts ?? {}).filter(Boolean); return artifacts[0] ?? release.artifact ?? null; } 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 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 createLauncherInstallScript(debUrl: string) { return `#!/usr/bin/env bash set -euo pipefail DEB_URL="${debUrl}" DEB_PATH="/tmp/autonomy-studio-launcher.deb" echo "Autonomy Studio Launcher 다운로드 중..." if command -v curl >/dev/null 2>&1; then curl -fL "$DEB_URL" -o "$DEB_PATH" elif command -v wget >/dev/null 2>&1; then wget -O "$DEB_PATH" "$DEB_URL" else echo "curl 또는 wget이 필요합니다." >&2 exit 1 fi echo "Autonomy Studio Launcher 설치 중..." sudo apt install -y "$DEB_PATH" echo "설치가 완료되었습니다." `; } function safeMetadataValue(value: string) { return value.replace(/[^\x20-\x7E]/g, '').slice(0, 512); }