Files
studio-admin-be/src/launcher/launcher.service.ts
T

1469 lines
51 KiB
TypeScript

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<LauncherVersionRow[]>(`
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<string>('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<string>('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<LauncherAppRow[]>(`
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<LauncherUserAppPermissionRow[]>(
`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<Array<{ id: string; app_key: string }>>(
`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<LauncherAppRow[]>(`
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<LauncherAppRow[]>(
`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<LauncherAppRow[]>(
`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<LauncherVersionRow[]>(
`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<LauncherVersionRow[]>(
`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<string>('R2_PUBLIC_BASE_URL');
const r2Enabled = Boolean(
this.configService.get<string>('R2_BUCKET')
&& this.configService.get<string>('R2_ACCESS_KEY_ID')
&& this.configService.get<string>('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<string>('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<void>,
) {
const bucket = this.configService.get<string>('R2_BUCKET');
const publicBaseUrl = this.configService.get<string>('R2_PUBLIC_BASE_URL');
const accessKeyId = this.configService.get<string>('R2_ACCESS_KEY_ID');
const secretAccessKey = this.configService.get<string>('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<string>('R2_ENDPOINT')
|| `https://${this.configService.get<string>('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`;
this.r2Client = new S3Client({
region: this.configService.get<string>('R2_REGION', 'auto'),
endpoint,
credentials: {
accessKeyId,
secretAccessKey,
},
forcePathStyle: this.configService.get<string>('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<string>('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<string>('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<string>('GITEA_BASE_URL', 'http://localhost:3000')
.replace(/\/$/, '')}/${repository}.git`;
}
private async getAuthenticatedHttpCloneUrl(repository: string) {
const baseUrl = this.toCloneUrl(repository);
const token = this.configService.get<string>('GITEA_ADMIN_TOKEN');
if (!token || this.configService.get<string>('BUILD_GIT_AUTH', 'token') === 'none') {
return baseUrl;
}
if (!/^https?:\/\//.test(baseUrl)) return baseUrl;
const username =
this.configService.get<string>('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<string>('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<void>,
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<string, string> } | 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<void>((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);
}