import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; import { execFile } from 'node:child_process'; import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { promisify } from 'node:util'; import { LinkComponentSourceDto } from '../gitea/dto'; import { LauncherService } from '../launcher/launcher.service'; import { PrismaService } from '../prisma/prisma.service'; import { BuildRunnerService } from './build-runner.service'; import { CreateAppComponentDto, CreateAppDto, CreateAppReleaseDto, CreateBuildJobDto, CreateDeploymentJobDto, CreateMigrationJobDto, CreateRuntimeEnvProfileDto, CreateRuntimeEnvVariableDto, CreateRuntimeIngressRuleDto, EnsureRuntimeNamespaceDto, } from './dto'; const execFileAsync = promisify(execFile); const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount'; @Injectable() export class AppsService { constructor( private readonly prisma: PrismaService, private readonly configService: ConfigService, private readonly buildRunnerService: BuildRunnerService, private readonly launcherService: LauncherService, ) {} listApps() { return this.prisma.app.findMany({ orderBy: { name: 'asc' }, include: { components: { orderBy: { name: 'asc' }, include: { buildJobs: { where: { status: 'SUCCEEDED' }, orderBy: { createdAt: 'desc' }, take: 1, }, }, }, }, }); } async createApp(body: CreateAppDto) { const app = await this.prisma.app.create({ data: { key: normalizeKey(body.key), name: body.name, description: body.description, namespace: body.namespace, status: body.status ?? 'READY', }, include: { components: true, }, }); return { app }; } async deleteApp(appId: string) { const app = await this.prisma.app.findUnique({ where: { id: appId }, include: { components: true, deployments: true, releases: true, runtimeProfiles: true, }, }); if (!app) throw new NotFoundException('앱을 찾을 수 없습니다.'); await this.prisma.app.delete({ where: { id: appId } }); return { deleted: true, appId, namespace: app.namespace, }; } async createComponent(appId: string, body: CreateAppComponentDto) { const component = await this.prisma.appComponent.create({ data: { appId, name: body.name, type: body.type, imageSource: normalizeImageSource(body.imageSource, body.type), externalImage: normalizeOptionalString(body.externalImage), workloadType: normalizeWorkloadType(body.workloadType, body.type), replicas: body.replicas ?? 1, serviceType: normalizeServiceType(body.serviceType), storageEnabled: body.storageEnabled ?? body.type === 'DATABASE', storageSize: normalizeOptionalString(body.storageSize), storageMountPath: normalizeOptionalString(body.storageMountPath), storageRetain: body.storageRetain ?? true, repository: body.repository ? normalizeRepository(body.repository) : undefined, branch: body.branch, buildTarget: body.buildTarget, port: body.port, targetPort: body.targetPort ?? body.port, ports: normalizeComponentPorts(body.ports, body.port, body.targetPort), }, include: { app: { include: { components: true, }, }, }, }); return { component }; } async updateComponent(componentId: string, body: CreateAppComponentDto) { const component = await this.prisma.appComponent.update({ where: { id: componentId }, data: { name: body.name, type: body.type, imageSource: normalizeImageSource(body.imageSource, body.type), externalImage: normalizeOptionalString(body.externalImage), workloadType: normalizeWorkloadType(body.workloadType, body.type), replicas: body.replicas ?? 1, serviceType: normalizeServiceType(body.serviceType), storageEnabled: body.storageEnabled ?? body.type === 'DATABASE', storageSize: normalizeOptionalString(body.storageSize), storageMountPath: normalizeOptionalString(body.storageMountPath), storageRetain: body.storageRetain ?? true, repository: body.repository ? normalizeRepository(body.repository) : null, branch: body.branch, buildTarget: body.buildTarget, port: body.port, targetPort: body.targetPort ?? body.port, ports: normalizeComponentPorts(body.ports, body.port, body.targetPort), }, include: { app: true }, }); return { component }; } async deleteComponent(componentId: string) { await this.prisma.appComponent.delete({ where: { id: componentId } }); return { deleted: true }; } listSources() { return this.prisma.appComponent.findMany({ orderBy: [{ app: { name: 'asc' } }, { name: 'asc' }], include: { app: true, buildJobs: { orderBy: { createdAt: 'desc' }, take: 1, }, }, }); } async linkComponentSource( componentId: string, body: LinkComponentSourceDto, ) { const component = await this.prisma.appComponent.update({ where: { id: componentId }, data: { repository: normalizeRepository(body.repository), branch: body.branch, buildTarget: body.buildTarget, }, include: { app: true }, }); return { component }; } listReleases(appKey?: string) { return this.prisma.appRelease.findMany({ where: appKey ? { app: { key: appKey } } : undefined, orderBy: { createdAt: 'desc' }, include: { app: true }, }); } async createRelease(body: CreateAppReleaseDto) { const app = await this.prisma.app.findUnique({ where: { id: body.appId }, include: { components: { orderBy: { name: 'asc' }, }, }, }); if (!app) { throw new NotFoundException('앱을 찾을 수 없습니다.'); } const buildJobs = await this.prisma.buildJob.findMany({ where: { id: { in: body.buildJobIds }, status: 'SUCCEEDED', component: { appId: body.appId, }, }, include: { component: true, }, orderBy: { createdAt: 'desc' }, }); if (buildJobs.length !== body.buildJobIds.length) { throw new BadRequestException('성공한 빌드 작업만 릴리즈에 포함할 수 있습니다.'); } const branch = body.branch?.trim() || buildJobs[0]?.branch || 'release'; const commitShas = [...new Set(buildJobs.map((job) => job.commitSha).filter(Boolean))]; const artifact = buildJobs.find((job) => job.artifact)?.artifact ?? null; const imageTags = createReleaseImageTags(buildJobs, app.components); const release = await this.prisma.appRelease.create({ data: { appId: body.appId, version: body.version.trim(), branch, commitSha: commitShas.length === 1 ? commitShas[0] : null, artifact, imageTags, status: 'SUCCEEDED', }, include: { app: true }, }); return { release }; } listDeployments(appKey?: string) { return this.prisma.deploymentJob.findMany({ where: appKey ? { app: { key: appKey } } : undefined, orderBy: { createdAt: 'desc' }, include: { app: true, release: true, }, }); } async createDeploymentJob(body: CreateDeploymentJobDto) { const namespace = normalizeNamespace(body.namespace); const environment = body.environment.trim(); const release = await this.prisma.appRelease.findUnique({ where: { id: body.releaseId }, include: { app: { include: { components: true, }, }, }, }); if (!release) { throw new NotFoundException('릴리즈를 찾을 수 없습니다.'); } if (release.status !== 'SUCCEEDED') { throw new BadRequestException('성공한 릴리즈만 배포할 수 있습니다.'); } const imageTags = parseReleaseImageTags(release.imageTags); const images = imageTags.images; const runtimeProfile = await this.prisma.runtimeEnvProfile.findFirst({ where: { appId: release.appId, environment, namespace, }, include: { ingressRules: { include: { component: true }, orderBy: { host: 'asc' }, }, }, }); if (Object.keys(images).length === 0) { throw new BadRequestException('릴리즈에 배포 가능한 이미지가 없습니다.'); } const namespaceExists = await this.namespaceExists(namespace); if (!namespaceExists) { throw new BadRequestException( `${namespace} namespace가 없습니다. 런타임 구성에서 Namespace 생성/확인을 먼저 실행하세요.`, ); } const deploymentJob = await this.prisma.deploymentJob.create({ data: { appId: release.appId, releaseId: release.id, environment, namespace, status: 'RUNNING', requestedBy: 'admin', startedAt: new Date(), logs: [ `[running] deployment started: ${release.app.name} ${release.version}`, `[target] environment=${environment} namespace=${namespace}`, ].join('\n'), }, include: { app: true, release: true, }, }); try { const logs = splitDeploymentLogs(deploymentJob.logs); for (const [componentName, image] of Object.entries(images)) { const deploymentName = sanitizeK8sName(componentName); const component = release.app.components.find((item) => item.name === componentName); const workloadType = normalizeWorkloadType(component?.workloadType, component?.type); const deploymentExists = workloadType === 'StatefulSet' ? await this.statefulSetExists(namespace, deploymentName) : await this.deploymentExists(namespace, deploymentName); if (!deploymentExists) { logs.push(`[create] ${workloadType.toLowerCase()}/${deploymentName}`); appendOutput( logs, await this.applyK8sManifest( workloadType === 'StatefulSet' ? createStatefulSetManifest({ namespace, name: deploymentName, componentName, image, replicas: component?.replicas ?? 1, ports: getComponentPorts(component), storageEnabled: component?.storageEnabled ?? false, storageSize: component?.storageSize, storageMountPath: component?.storageMountPath, }) : createDeploymentManifest({ namespace, deploymentName, componentName, image, replicas: component?.replicas ?? 1, ports: getComponentPorts(component), }) ), ); } const workloadRef = `${workloadType.toLowerCase()}/${deploymentName}`; logs.push(`[set-image] ${workloadRef} ${deploymentName}=${image}`); const setImageOutput = await this.kubectl([ 'set', 'image', workloadRef, `${deploymentName}=${image}`, '-n', namespace, ]); appendOutput(logs, setImageOutput); logs.push(`[rollout] ${workloadRef}`); const rolloutOutput = await this.kubectl([ 'rollout', 'status', workloadRef, '-n', namespace, '--timeout=180s', ]); appendOutput(logs, rolloutOutput); const componentPorts = getComponentPorts(component); if (componentPorts.length > 0) { logs.push(`[apply] service/${deploymentName}`); appendOutput( logs, await this.applyK8sManifest(createServiceManifest({ namespace, name: deploymentName, ports: componentPorts, serviceType: component?.serviceType, })), ); } } if (runtimeProfile?.ingressRules.length) { for (const rule of runtimeProfile.ingressRules) { const serviceName = sanitizeK8sName(rule.component.name); const ingressName = sanitizeK8sName(`${rule.component.name}-${rule.host}`); logs.push(`[apply] ingress/${ingressName}`); appendOutput( logs, await this.applyK8sManifest(createIngressManifest({ namespace, name: ingressName, host: rule.host, path: rule.path, serviceName, servicePort: rule.servicePort, tlsEnabled: rule.tlsEnabled, tlsSecretName: rule.tlsSecretName, })), ); } } if (body.updateLauncherAppVersion !== false) { try { await this.launcherService.publishAppReleaseToLauncher(release.id); logs.push('[launcher] app version updated'); } catch (launcherError) { const launcherMessage = launcherError instanceof Error ? launcherError.message : 'launcher app version update failed'; logs.push(`[launcher] app version update failed: ${launcherMessage}`); } } else { logs.push('[launcher] app version update skipped'); } logs.push('[done] deployment completed'); const completed = await this.prisma.deploymentJob.update({ where: { id: deploymentJob.id }, data: { status: 'SUCCEEDED', finishedAt: new Date(), logs: logs.join('\n'), }, include: { app: true, release: true, }, }); return { deploymentJob: completed }; } catch (error) { const message = error instanceof Error ? error.message : 'Deployment failed'; const failed = await this.prisma.deploymentJob.update({ where: { id: deploymentJob.id }, data: { status: 'FAILED', finishedAt: new Date(), logs: appendLog(deploymentJob.logs, `[failed] ${message}`), }, include: { app: true, release: true, }, }); return { deploymentJob: failed }; } } listMigrationJobs() { return this.prisma.runtimeControlJob.findMany({ where: { action: 'MIGRATION' }, orderBy: { createdAt: 'desc' }, }); } async createMigrationJob(body: CreateMigrationJobDto) { const namespace = normalizeNamespace(body.namespace); const command = body.command.trim(); const envSecretName = sanitizeK8sName(body.envSecretName); if (!command) { throw new BadRequestException('마이그레이션 명령을 입력해야 합니다.'); } if (!envSecretName) { throw new BadRequestException('환경변수 Secret 이름을 입력해야 합니다.'); } const release = await this.prisma.appRelease.findUnique({ where: { id: body.releaseId }, include: { app: { include: { components: true, }, }, }, }); if (!release) { throw new NotFoundException('릴리즈를 찾을 수 없습니다.'); } const migrationComponent = release.app.components.find( (component) => component.id === body.migrationComponentId, ); if (!migrationComponent || migrationComponent.type !== 'MIGRATION') { throw new BadRequestException('마이그레이션 구성요소를 선택해야 합니다.'); } const images = parseReleaseImageTags(release.imageTags).images; const image = images[body.imageComponentName]; if (!image) { throw new BadRequestException('릴리즈에 실행 이미지가 없습니다.'); } const namespaceExists = await this.namespaceExists(namespace); if (!namespaceExists) { throw new BadRequestException(`${namespace} namespace가 없습니다.`); } const jobName = `${sanitizeK8sName(migrationComponent.name)}-${Date.now().toString(36)}`; const startedAt = new Date(); const request = { appId: release.appId, appName: release.app.name, releaseId: release.id, releaseVersion: release.version, migrationComponentId: migrationComponent.id, migrationComponentName: migrationComponent.name, imageComponentName: body.imageComponentName, image, command, envSecretName, }; const migrationJob = await this.prisma.runtimeControlJob.create({ data: { namespace, kind: 'Job', name: jobName, action: 'MIGRATION', status: 'RUNNING', requestedBy: 'admin', request, startedAt, logs: [ `[running] migration job created: ${release.app.name}/${migrationComponent.name}`, `[target] namespace=${namespace} release=${release.version}`, `[image] ${image}`, `[command] ${command}`, ].join('\n'), }, }); const logs = splitDeploymentLogs(migrationJob.logs); try { logs.push(`[apply] job/${jobName}`); appendOutput( logs, await this.applyK8sManifest(createMigrationJobManifest({ namespace, jobName, image, command, envSecretName, })), ); logs.push(`[wait] job/${jobName}`); await this.waitForJobResult(namespace, jobName, logs); logs.push(`[logs] job/${jobName}`); appendOutput(logs, await this.kubectl(['logs', `job/${jobName}`, '-n', namespace])); logs.push('[done] migration completed'); const completed = await this.prisma.runtimeControlJob.update({ where: { id: migrationJob.id }, data: { status: 'SUCCEEDED', finishedAt: new Date(), logs: logs.join('\n'), }, }); return { migrationJob: completed }; } catch (error) { const message = error instanceof Error ? error.message : 'Migration failed'; try { logs.push(`[logs] job/${jobName}`); appendOutput(logs, await this.kubectl(['logs', `job/${jobName}`, '-n', namespace])); } catch { // 실패한 Job 로그가 아직 없을 수 있습니다. } logs.push(`[failed] ${message}`); const failed = await this.prisma.runtimeControlJob.update({ where: { id: migrationJob.id }, data: { status: 'FAILED', finishedAt: new Date(), logs: logs.join('\n'), }, }); return { migrationJob: failed }; } } listBuildJobs(appKey?: string) { return this.prisma.buildJob.findMany({ where: appKey ? { component: { app: { key: appKey } } } : undefined, orderBy: { createdAt: 'desc' }, include: { component: { include: { app: true, }, }, }, }); } listRuntimeProfiles(appId?: string) { return this.prisma.runtimeEnvProfile.findMany({ where: appId ? { appId } : undefined, orderBy: [{ environment: 'asc' }, { name: 'asc' }], include: { app: true, variables: { orderBy: [{ isSecret: 'asc' }, { key: 'asc' }], include: { component: true, }, }, ingressRules: { orderBy: [{ host: 'asc' }, { path: 'asc' }], include: { component: true, }, }, }, }); } async checkRuntimeNamespace(namespace: string) { const normalizedNamespace = normalizeNamespace(namespace); const exists = await this.namespaceExists(normalizedNamespace); return { namespace: normalizedNamespace, exists }; } async listRuntimeResources(namespace: string) { const normalizedNamespace = normalizeNamespace(namespace); const exists = await this.namespaceExists(normalizedNamespace); if (!exists) { return { namespace: normalizedNamespace, exists: false, summary: { pods: 0, deployments: 0, statefulSets: 0, services: 0, persistentVolumeClaims: 0, }, resources: [], }; } const resourceKinds = [ { kind: 'Pod', command: 'pods' }, { kind: 'Deployment', command: 'deployments' }, { kind: 'StatefulSet', command: 'statefulsets' }, { kind: 'Service', command: 'services' }, { kind: 'Ingress', command: 'ingress' }, { kind: 'PVC', command: 'pvc' }, ]; const resources = []; for (const resourceKind of resourceKinds) { const output = await this.kubectl([ 'get', resourceKind.command, '-n', normalizedNamespace, '-o', 'json', ]); resources.push(...parseRuntimeResources(resourceKind.kind, output)); } return { namespace: normalizedNamespace, exists: true, summary: { pods: resources.filter((resource) => resource.kind === 'Pod').length, deployments: resources.filter((resource) => resource.kind === 'Deployment').length, statefulSets: resources.filter((resource) => resource.kind === 'StatefulSet').length, services: resources.filter((resource) => resource.kind === 'Service').length, persistentVolumeClaims: resources.filter((resource) => resource.kind === 'PVC').length, }, resources, }; } async ensureRuntimeNamespace(body: EnsureRuntimeNamespaceDto) { const namespace = normalizeNamespace(body.namespace); const logs: string[] = []; const created = await this.ensureNamespace(namespace, logs); return { namespace, exists: true, created, logs: logs.filter(Boolean).join('\n'), }; } async restartRuntimeDeployment(namespace: string, deploymentName: string) { const normalizedNamespace = normalizeNamespace(namespace); const normalizedDeploymentName = sanitizeK8sName(deploymentName); if (!normalizedDeploymentName) { throw new BadRequestException('Deployment 이름을 입력해야 합니다.'); } const exists = await this.deploymentExists(normalizedNamespace, normalizedDeploymentName); if (!exists) { throw new NotFoundException(`${normalizedDeploymentName} Deployment를 찾을 수 없습니다.`); } const logs = [ `[restart] deployment/${normalizedDeploymentName}`, `[target] namespace=${normalizedNamespace}`, ]; appendOutput( logs, await this.kubectl([ 'rollout', 'restart', `deployment/${normalizedDeploymentName}`, '-n', normalizedNamespace, ]), ); appendOutput( logs, await this.kubectl([ 'rollout', 'status', `deployment/${normalizedDeploymentName}`, '-n', normalizedNamespace, '--timeout=180s', ]), ); logs.push('[done] deployment restarted'); return { namespace: normalizedNamespace, deploymentName: normalizedDeploymentName, restarted: true, logs: logs.join('\n'), }; } async createRuntimeProfile(body: CreateRuntimeEnvProfileDto) { const app = await this.prisma.app.findUnique({ where: { id: body.appId }, select: { id: true }, }); if (!app) { throw new NotFoundException('앱을 찾을 수 없습니다.'); } const profile = await this.prisma.runtimeEnvProfile.create({ data: { appId: body.appId, name: body.name.trim(), environment: body.environment.trim(), namespace: normalizeNamespace(body.namespace), }, include: { app: true, variables: { include: { component: true, }, }, ingressRules: { include: { component: true, }, }, }, }); return { profile }; } async createRuntimeVariable(profileId: string, body: CreateRuntimeEnvVariableDto) { const profile = await this.prisma.runtimeEnvProfile.findUnique({ where: { id: profileId }, select: { id: true, appId: true }, }); if (!profile) { throw new NotFoundException('런타임 프로파일을 찾을 수 없습니다.'); } if (!body.componentId) { throw new BadRequestException('구성요소를 선택해야 합니다.'); } const component = await this.prisma.appComponent.findFirst({ where: { id: body.componentId, appId: profile.appId }, select: { id: true }, }); if (!component) { throw new BadRequestException('프로파일 앱에 포함된 구성요소만 선택할 수 있습니다.'); } const variable = await this.prisma.runtimeEnvVariable.create({ data: { profileId, componentId: body.componentId, key: body.key.trim(), value: body.value, isSecret: Boolean(body.isSecret), }, include: { component: true, }, }); return { variable }; } async createRuntimeIngressRule(profileId: string, body: CreateRuntimeIngressRuleDto) { const profile = await this.prisma.runtimeEnvProfile.findUnique({ where: { id: profileId }, select: { id: true, appId: true }, }); if (!profile) { throw new NotFoundException('런타임 프로파일을 찾을 수 없습니다.'); } const component = await this.prisma.appComponent.findFirst({ where: { id: body.componentId, appId: profile.appId }, select: { id: true }, }); if (!component) { throw new BadRequestException('프로파일 앱에 포함된 구성요소만 선택할 수 있습니다.'); } const host = body.host.trim().toLowerCase(); const pathValue = normalizeIngressPath(body.path); const rule = await this.prisma.runtimeIngressRule.create({ data: { profileId, componentId: body.componentId, host, path: pathValue, servicePort: body.servicePort, tlsEnabled: Boolean(body.tlsEnabled), tlsSecretName: body.tlsSecretName?.trim() || null, }, include: { component: true }, }); return { rule }; } async deleteRuntimeIngressRule(profileId: string, ruleId: string) { const rule = await this.prisma.runtimeIngressRule.findFirst({ where: { id: ruleId, profileId }, select: { id: true }, }); if (!rule) { throw new NotFoundException('접속 설정을 찾을 수 없습니다.'); } await this.prisma.runtimeIngressRule.delete({ where: { id: rule.id } }); return { deleted: true }; } async deleteRuntimeVariable(profileId: string, variableId: string) { const variable = await this.prisma.runtimeEnvVariable.findFirst({ where: { id: variableId, profileId }, select: { id: true }, }); if (!variable) { throw new NotFoundException('환경변수를 찾을 수 없습니다.'); } await this.prisma.runtimeEnvVariable.delete({ where: { id: variable.id }, }); return { deleted: true }; } async applyRuntimeProfile(profileId: string) { const profile = await this.prisma.runtimeEnvProfile.findUnique({ where: { id: profileId }, include: { app: { include: { components: true, }, }, variables: { include: { component: true, }, }, }, }); if (!profile) { throw new NotFoundException('런타임 프로파일을 찾을 수 없습니다.'); } const namespace = normalizeNamespace(profile.namespace); const grouped = new Map(); for (const variable of profile.variables) { if (!variable.component) continue; const current = grouped.get(variable.component.id) ?? []; current.push(variable); grouped.set(variable.component.id, current); } const databaseComponents = profile.app.components.filter( (component) => component.type === 'DATABASE', ); if (grouped.size === 0 && databaseComponents.length === 0) { throw new BadRequestException('적용할 환경변수가 없습니다.'); } const logs = [ `[running] runtime profile apply: ${profile.app.name}/${profile.name}`, `[target] environment=${profile.environment} namespace=${namespace}`, ]; await this.ensureNamespace(namespace, logs); for (const component of databaseComponents) { const variables = grouped.get(component.id) ?? []; await this.applyDatabaseResources({ namespace, appKey: profile.app.key, componentName: component.name, variables, logs, }); } for (const variables of grouped.values()) { const component = variables[0].component; if (!component) continue; if (component.type === 'DATABASE') continue; const normalVars = variables.filter((variable) => !variable.isSecret); const secretVars = variables.filter((variable) => variable.isSecret); const deploymentName = sanitizeK8sName(component.name); const workloadType = normalizeWorkloadType(component.workloadType, component.type); if (workloadType === 'Job') continue; const workloadRef = `${workloadType.toLowerCase()}/${deploymentName}`; if (normalVars.length > 0) { const configMapName = `${deploymentName}-env`; await this.applyK8sManifest({ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: configMapName, namespace, }, data: Object.fromEntries(normalVars.map((variable) => [variable.key, variable.value])), }); logs.push(`[apply] configmap/${configMapName}`); await this.trySetWorkloadEnv(logs, workloadRef, `--from=configmap/${configMapName}`, namespace); } if (secretVars.length > 0) { const secretName = `${deploymentName}-secret`; await this.applyK8sManifest({ apiVersion: 'v1', kind: 'Secret', metadata: { name: secretName, namespace, }, type: 'Opaque', stringData: Object.fromEntries(secretVars.map((variable) => [variable.key, variable.value])), }); logs.push(`[apply] secret/${secretName}`); await this.trySetWorkloadEnv(logs, workloadRef, `--from=secret/${secretName}`, namespace); } } logs.push('[done] runtime profile applied'); return { applied: true, logs: logs.filter(Boolean).join('\n') }; } async createBuildJob(body: CreateBuildJobDto) { const component = await this.prisma.appComponent.findUnique({ where: { id: body.componentId }, include: { app: true }, }); if (!component) { throw new NotFoundException('앱 구성요소를 찾을 수 없습니다.'); } if (!component.repository) { throw new BadRequestException('구성요소에 연결된 Repository가 없습니다.'); } const branch = body.branch?.trim() || component.branch || 'release'; const commitSha = body.commitSha?.trim() || null; const artifact = getBuildArtifact(component, branch, commitSha); const image = getBuildImage( component, branch, commitSha, this.configService.get('BUILD_REGISTRY', 'registry.local'), ); const buildJob = await this.prisma.buildJob.create({ data: { componentId: component.id, branch, commitSha, artifact, image, status: 'PENDING', logs: getBuildPlanLogs({ appName: component.app.name, componentName: component.name, componentType: component.type, repository: component.repository, branch, commitSha, buildTarget: component.buildTarget, artifact, image, giteaBaseUrl: this.configService.get( 'GITEA_BASE_URL', 'http://localhost:3000', ), }).join('\n'), }, include: { component: { include: { app: true, }, }, }, }); return { buildJob }; } async cancelBuildJob(buildJobId: string) { const buildJob = await this.prisma.buildJob.findUnique({ where: { id: buildJobId }, include: { component: { include: { app: true, }, }, }, }); if (!buildJob) { throw new NotFoundException('빌드 작업을 찾을 수 없습니다.'); } if (!['PENDING', 'RUNNING'].includes(buildJob.status)) { throw new BadRequestException('이미 종료된 빌드 작업입니다.'); } if (buildJob.status === 'RUNNING') { this.buildRunnerService.cancelJob(buildJob.id); } const logs = appendLog(buildJob.logs, '[cancelled] build job cancellation requested'); const cancelled = await this.prisma.buildJob.update({ where: { id: buildJob.id }, data: { status: 'CANCELED', finishedAt: new Date(), logs, }, include: { component: { include: { app: true, }, }, }, }); return { buildJob: cancelled }; } async deleteBuildJob(buildJobId: string) { const buildJob = await this.prisma.buildJob.findUnique({ where: { id: buildJobId }, select: { id: true, status: true, }, }); if (!buildJob) { throw new NotFoundException('빌드 작업을 찾을 수 없습니다.'); } if (buildJob.status === 'RUNNING') { throw new BadRequestException('실행 중인 빌드 작업은 먼저 중지해야 합니다.'); } await this.prisma.buildJob.delete({ where: { id: buildJob.id }, }); return { deleted: true }; } private async kubectl(args: string[]) { const kubectlBin = this.configService.get('KUBECTL_BIN', 'kubectl'); const env = await this.kubectlEnv(); const { stdout, stderr } = await execFileAsync(kubectlBin, args, { env, maxBuffer: 1024 * 1024 * 10, }); return [stdout, stderr].filter(Boolean).join('\n'); } private async trySetWorkloadEnv( logs: string[], workloadRef: string, envSource: string, namespace: string, ) { try { const output = await this.kubectl(['set', 'env', workloadRef, envSource, '-n', namespace]); appendOutput(logs, output); } catch (error) { const message = error instanceof Error ? error.message : 'set env failed'; logs.push(`[warn] ${workloadRef} 환경변수 반영은 건너뜀: ${message}`); } } private async kubectlEnv() { const inClusterHost = process.env.KUBERNETES_SERVICE_HOST; if (inClusterHost) { return { ...process.env, KUBECONFIG: await this.writeInClusterKubeconfig(inClusterHost), }; } const kubeconfig = this.configService.get('KUBECONFIG'); return kubeconfig ? { ...process.env, KUBECONFIG: kubeconfig } : process.env; } private async writeInClusterKubeconfig(host: string) { const port = process.env.KUBERNETES_SERVICE_PORT || '443'; const token = (await fs.readFile(path.join(serviceAccountDir, 'token'), 'utf8')).trim(); const caPath = path.join(serviceAccountDir, 'ca.crt'); const kubeconfigPath = path.join(os.tmpdir(), 'studio-admin-incluster-kubeconfig'); await fs.writeFile(kubeconfigPath, [ 'apiVersion: v1', 'kind: Config', 'clusters:', '- name: in-cluster', ' cluster:', ` server: https://${host}:${port}`, ` certificate-authority: ${caPath}`, 'contexts:', '- name: in-cluster', ' context:', ' cluster: in-cluster', ' user: studio-admin', 'current-context: in-cluster', 'users:', '- name: studio-admin', ' user:', ` token: ${token}`, '', ].join('\n'), 'utf8'); return kubeconfigPath; } private async applyK8sManifest(manifest: Record) { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'studio-runtime-')); const manifestPath = path.join(tempDir, 'manifest.json'); try { await fs.writeFile(manifestPath, JSON.stringify(manifest), 'utf8'); return await this.kubectl(['apply', '-f', manifestPath]); } finally { await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); } } private async applyDatabaseResources(input: { namespace: string; appKey: string; componentName: string; variables: Array<{ key: string; value: string; }>; logs: string[]; }) { const name = sanitizeK8sName(input.componentName); const values = getDatabaseValues(input.appKey, input.variables); input.logs.push(`[database] ${name} service/statefulset`); appendOutput( input.logs, await this.applyK8sManifest(createPostgresSecretManifest({ namespace: input.namespace, name, values, })), ); appendOutput( input.logs, await this.applyK8sManifest(createPostgresServiceManifest({ namespace: input.namespace, name, })), ); appendOutput( input.logs, await this.applyK8sManifest(createPostgresStatefulSetManifest({ namespace: input.namespace, name, })), ); } private async namespaceExists(namespace: string) { try { await this.kubectl(['get', 'namespace', namespace]); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/notfound|not found/i.test(message)) { return false; } throw error; } } private async deploymentExists(namespace: string, deploymentName: string) { try { await this.kubectl(['get', 'deployment', deploymentName, '-n', namespace]); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/notfound|not found/i.test(message)) { return false; } throw error; } } private async statefulSetExists(namespace: string, name: string) { try { await this.kubectl(['get', 'statefulset', name, '-n', namespace]); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/notfound|not found/i.test(message)) { return false; } throw error; } } private async waitForJobResult(namespace: string, jobName: string, logs: string[]) { const startedAt = Date.now(); const timeoutMs = 180_000; while (Date.now() - startedAt < timeoutMs) { const output = await this.kubectl(['get', 'job', jobName, '-n', namespace, '-o', 'json']); const status = getObject(JSON.parse(output).status); const succeeded = getNumber(status.succeeded); const failed = getNumber(status.failed); const conditions = getArray(status.conditions).map(getObject); const isComplete = succeeded > 0 || conditions.some((condition) => getString(condition.type) === 'Complete' && getString(condition.status) === 'True'); const isFailed = failed > 0 || conditions.some((condition) => getString(condition.type) === 'Failed' && getString(condition.status) === 'True'); if (isComplete) { logs.push(`[wait] job/${jobName} completed`); return; } if (isFailed) { throw new Error(`job/${jobName} failed`); } await new Promise((resolve) => setTimeout(resolve, 2000)); } throw new Error(`job/${jobName} timed out`); } private async ensureNamespace(namespace: string, logs: string[]) { const exists = await this.namespaceExists(namespace); if (exists) { logs.push(`[namespace] ${namespace} exists`); return false; } logs.push(`[namespace] create ${namespace}`); appendOutput(logs, await this.kubectl(['create', 'namespace', namespace])); return true; } } function normalizeRepository(repository: string) { return repository .replace(/^https?:\/\/[^/]+\//, '') .replace(/\.git$/, '') .replace(/^\/+/, ''); } function normalizeKey(key: string) { return key.trim().toLowerCase().replace(/\s+/g, '-'); } function normalizeOptionalString(value?: string | null) { const trimmed = value?.trim(); return trimmed || undefined; } function normalizeImageSource(value: string | null | undefined, componentType?: string) { const normalized = value?.trim().toUpperCase(); if (normalized === 'EXTERNAL' || normalized === 'DATABASE') return normalized; if (componentType === 'DATABASE') return 'DATABASE'; return 'SOURCE'; } function normalizeWorkloadType(value?: string | null, componentType?: string) { const normalized = value?.trim().toLowerCase(); if (normalized === 'statefulset') return 'StatefulSet'; if (normalized === 'job' || componentType === 'MIGRATION') return 'Job'; if (componentType === 'DATABASE') return 'StatefulSet'; return 'Deployment'; } function normalizeServiceType(value?: string | null) { const normalized = value?.trim(); if (normalized === 'NodePort' || normalized === 'LoadBalancer') return normalized; return 'ClusterIP'; } function normalizeIngressPath(value?: string) { const trimmed = value?.trim() || '/'; return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; } function normalizeNamespace(namespace: string) { const normalized = sanitizeK8sName(namespace); if (!normalized) { throw new BadRequestException('Namespace를 입력해야 합니다.'); } return normalized; } function appendLog(logs: string | null, line: string) { return [logs, line].filter(Boolean).join('\n'); } function sanitizeK8sName(value: string) { return value .trim() .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/^-+|-+$/g, ''); } function splitDeploymentLogs(logs: string | null) { return logs ? logs.split('\n').filter(Boolean) : []; } function appendOutput(logs: string[], output: string) { for (const line of output.split(/\r?\n/).filter(Boolean)) { logs.push(`[out] ${line}`); } } type ComponentPort = { name: string; port: number; targetPort: number; protocol: string; }; function normalizeComponentPorts( ports?: Array<{ name?: string; port?: number; targetPort?: number; protocol?: string; }> | null, fallbackPort?: number | null, fallbackTargetPort?: number | null, ): Prisma.InputJsonValue | undefined { const normalized = (ports ?? []) .map((port, index) => normalizeComponentPort(port, index)) .filter((port): port is ComponentPort => Boolean(port)); if (normalized.length > 0) { return normalized as unknown as Prisma.InputJsonValue; } if (fallbackPort) { return [ { name: 'http', port: fallbackPort, targetPort: fallbackTargetPort ?? fallbackPort, protocol: 'TCP', }, ]; } return undefined; } function normalizeComponentPort( port: { name?: string; port?: number; targetPort?: number; protocol?: string; }, index: number, ): ComponentPort | null { const servicePort = Number(port.port); if (!Number.isInteger(servicePort) || servicePort < 1 || servicePort > 65535) { return null; } const targetPort = Number(port.targetPort || servicePort); if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) { return null; } const protocol = port.protocol?.toUpperCase() === 'UDP' ? 'UDP' : 'TCP'; const name = sanitizePortName(port.name || (index === 0 ? 'http' : `port-${servicePort}`)); return { name, port: servicePort, targetPort, protocol, }; } function getComponentPorts(component?: { port?: number | null; targetPort?: number | null; ports?: Prisma.JsonValue | null; } | null): ComponentPort[] { if (!component) return []; if (Array.isArray(component.ports)) { const normalized = component.ports .map((value, index) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const item = value as Record; return normalizeComponentPort({ name: typeof item.name === 'string' ? item.name : undefined, port: typeof item.port === 'number' ? item.port : Number(item.port), targetPort: typeof item.targetPort === 'number' ? item.targetPort : Number(item.targetPort), protocol: typeof item.protocol === 'string' ? item.protocol : undefined, }, index); }) .filter((port): port is ComponentPort => Boolean(port)); if (normalized.length > 0) return normalized; } if (!component.port) return []; return [{ name: 'http', port: component.port, targetPort: component.targetPort ?? component.port, protocol: 'TCP', }]; } function sanitizePortName(value: string) { const normalized = value .trim() .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 15); return normalized || 'http'; } function parseRuntimeResources(kind: string, output: string) { const parsed = JSON.parse(output || '{"items":[]}') as { items?: Array>; }; return (parsed.items ?? []).map((item) => { const metadata = getObject(item.metadata); const status = getObject(item.status); const spec = getObject(item.spec); const name = getString(metadata.name); const createdAt = getString(metadata.creationTimestamp); if (kind === 'Pod') { const containerStatuses = getArray(status.containerStatuses); const restarts = containerStatuses.reduce((sum, current) => { const currentObject = getObject(current); return sum + getNumber(currentObject.restartCount); }, 0); return { kind, name, status: getString(status.phase) || '-', ready: `${containerStatuses.filter((current) => getObject(current).ready === true).length}/${containerStatuses.length}`, restarts, age: getAge(createdAt), detail: getArray(spec.containers) .map((container) => getString(getObject(container).image)) .filter(Boolean) .join(', '), }; } if (kind === 'Deployment') { return { kind, name, status: getNumber(status.availableReplicas) >= getNumber(spec.replicas, 1) ? 'Running' : 'Pending', ready: `${getNumber(status.readyReplicas)}/${getNumber(spec.replicas, 1)}`, restarts: null, age: getAge(createdAt), detail: getArray(getObject(getObject(spec.template).spec).containers) .map((container) => getString(getObject(container).image)) .filter(Boolean) .join(', '), }; } if (kind === 'StatefulSet') { return { kind, name, status: getNumber(status.readyReplicas) >= getNumber(spec.replicas, 1) ? 'Running' : 'Pending', ready: `${getNumber(status.readyReplicas)}/${getNumber(spec.replicas, 1)}`, restarts: null, age: getAge(createdAt), detail: getArray(getObject(getObject(spec.template).spec).containers) .map((container) => getString(getObject(container).image)) .filter(Boolean) .join(', '), }; } if (kind === 'Ingress') { const rules = getArray(spec.rules).map(getObject); return { kind, name, status: rules.map((rule) => getString(rule.host)).filter(Boolean).join(', ') || '-', ready: '-', restarts: null, age: getAge(createdAt), detail: rules .flatMap((rule) => getArray(getObject(getObject(rule.http).paths))) .map((path) => getString(getObject(path).path) || '/') .filter(Boolean) .join(', '), }; } if (kind === 'Service') { return { kind, name, status: getString(spec.type) || '-', ready: '-', restarts: null, age: getAge(createdAt), detail: getArray(spec.ports) .map((port) => `${getString(getObject(port).port)}:${getString(getObject(port).targetPort)}`) .filter(Boolean) .join(', '), }; } return { kind, name, status: getString(status.phase) || '-', ready: '-', restarts: null, age: getAge(createdAt), detail: `${getString(spec.storageClassName) || 'default'} · ${getString(getObject(getObject(status).capacity).storage) || getString(getObject(getObject(spec).resources).requests) || '-'}`, }; }); } function getObject(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function getArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function getString(value: unknown) { if (typeof value === 'string') return value; if (typeof value === 'number') return String(value); return ''; } function getNumber(value: unknown, fallback = 0) { return typeof value === 'number' ? value : fallback; } function getAge(createdAt: string) { const created = new Date(createdAt).getTime(); if (!created || Number.isNaN(created)) return '-'; const seconds = Math.max(0, Math.floor((Date.now() - created) / 1000)); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; return `${Math.floor(hours / 24)}d`; } function createDeploymentManifest(input: { namespace: string; deploymentName: string; componentName: string; image: string; replicas?: number | null; ports?: ComponentPort[]; }) { const labels = { 'app.kubernetes.io/name': input.deploymentName, 'app.kubernetes.io/component': sanitizeK8sName(input.componentName), 'app.kubernetes.io/managed-by': 'studio-admin', }; return { apiVersion: 'apps/v1', kind: 'Deployment', metadata: { name: input.deploymentName, namespace: input.namespace, labels, }, spec: { replicas: input.replicas ?? 1, selector: { matchLabels: { 'app.kubernetes.io/name': input.deploymentName, }, }, template: { metadata: { labels, }, spec: { containers: [ { name: input.deploymentName, image: input.image, imagePullPolicy: 'IfNotPresent', ...(input.ports?.length ? { ports: input.ports.map((port) => ({ name: port.name, containerPort: port.targetPort, protocol: port.protocol, })), } : {}), envFrom: [ { configMapRef: { name: `${input.deploymentName}-env`, optional: true, }, }, { secretRef: { name: `${input.deploymentName}-secret`, optional: true, }, }, ], }, ], }, }, }, }; } function createStatefulSetManifest(input: { namespace: string; name: string; componentName: string; image: string; replicas?: number | null; ports?: ComponentPort[]; storageEnabled?: boolean; storageSize?: string | null; storageMountPath?: string | null; }) { const labels = getManagedLabels(input.name, sanitizeK8sName(input.componentName)); const volumeName = 'data'; const mountPath = input.storageMountPath?.trim() || '/data'; return { apiVersion: 'apps/v1', kind: 'StatefulSet', metadata: { name: input.name, namespace: input.namespace, labels, }, spec: { serviceName: input.name, replicas: input.replicas ?? 1, selector: { matchLabels: { 'app.kubernetes.io/name': input.name, }, }, template: { metadata: { labels, }, spec: { containers: [ { name: input.name, image: input.image, imagePullPolicy: 'IfNotPresent', ...(input.ports?.length ? { ports: input.ports.map((port) => ({ name: port.name, containerPort: port.targetPort, protocol: port.protocol, })), } : {}), envFrom: [ { configMapRef: { name: `${input.name}-env`, optional: true, }, }, { secretRef: { name: `${input.name}-secret`, optional: true, }, }, ], ...(input.storageEnabled ? { volumeMounts: [ { name: volumeName, mountPath, }, ], } : {}), }, ], }, }, ...(input.storageEnabled ? { volumeClaimTemplates: [ { metadata: { name: volumeName, }, spec: { accessModes: ['ReadWriteOnce'], resources: { requests: { storage: input.storageSize?.trim() || '10Gi', }, }, }, }, ], } : {}), }, }; } function createMigrationJobManifest(input: { namespace: string; jobName: string; image: string; command: string; envSecretName: string; }) { const labels = getManagedLabels(input.jobName, 'migration'); return { apiVersion: 'batch/v1', kind: 'Job', metadata: { name: input.jobName, namespace: input.namespace, labels, }, spec: { backoffLimit: 0, template: { metadata: { labels, }, spec: { restartPolicy: 'Never', containers: [ { name: 'migration', image: input.image, imagePullPolicy: 'IfNotPresent', command: ['/bin/sh', '-c', input.command], envFrom: [ { secretRef: { name: input.envSecretName, }, }, ], }, ], }, }, }, }; } function getDatabaseValues( appKey: string, variables: Array<{ key: string; value: string; }>, ) { const values = new Map(variables.map((variable) => [variable.key, variable.value])); const databaseName = values.get('POSTGRES_DB') || sanitizeK8sName(appKey).replace(/-/g, '_'); const user = values.get('POSTGRES_USER') || databaseName; const password = values.get('POSTGRES_PASSWORD'); if (!password) { throw new BadRequestException('DATABASE 구성요소에는 POSTGRES_PASSWORD 설정값이 필요합니다.'); } return { POSTGRES_DB: databaseName, POSTGRES_USER: user, POSTGRES_PASSWORD: password, }; } function createServiceManifest(input: { namespace: string; name: string; ports: ComponentPort[]; serviceType?: string | null; }) { return { apiVersion: 'v1', kind: 'Service', metadata: { name: input.name, namespace: input.namespace, labels: getManagedLabels(input.name, 'service'), }, spec: { type: normalizeServiceType(input.serviceType), selector: { 'app.kubernetes.io/name': input.name, }, ports: input.ports.map((port) => ({ name: port.name, port: port.port, targetPort: port.targetPort, protocol: port.protocol, })), }, }; } function createIngressManifest(input: { namespace: string; name: string; host: string; path: string; serviceName: string; servicePort: number; tlsEnabled: boolean; tlsSecretName?: string | null; }) { const manifest: Record = { apiVersion: 'networking.k8s.io/v1', kind: 'Ingress', metadata: { name: input.name, namespace: input.namespace, labels: getManagedLabels(input.name, 'ingress'), }, spec: { rules: [ { host: input.host, http: { paths: [ { path: input.path, pathType: 'Prefix', backend: { service: { name: input.serviceName, port: { number: input.servicePort }, }, }, }, ], }, }, ], }, }; if (input.tlsEnabled && input.tlsSecretName) { (manifest.spec as Record).tls = [ { hosts: [input.host], secretName: input.tlsSecretName, }, ]; } return manifest; } function createPostgresSecretManifest(input: { namespace: string; name: string; values: Record; }) { return { apiVersion: 'v1', kind: 'Secret', metadata: { name: `${input.name}-secret`, namespace: input.namespace, labels: getManagedLabels(input.name, 'database'), }, type: 'Opaque', stringData: input.values, }; } function createPostgresServiceManifest(input: { namespace: string; name: string; }) { return { apiVersion: 'v1', kind: 'Service', metadata: { name: input.name, namespace: input.namespace, labels: getManagedLabels(input.name, 'database'), }, spec: { ports: [ { name: 'postgres', port: 5432, targetPort: 5432, }, ], selector: { 'app.kubernetes.io/name': input.name, }, }, }; } function createPostgresStatefulSetManifest(input: { namespace: string; name: string; }) { const labels = getManagedLabels(input.name, 'database'); return { apiVersion: 'apps/v1', kind: 'StatefulSet', metadata: { name: input.name, namespace: input.namespace, labels, }, spec: { serviceName: input.name, replicas: 1, selector: { matchLabels: { 'app.kubernetes.io/name': input.name, }, }, template: { metadata: { labels, }, spec: { containers: [ { name: input.name, image: 'postgres:16', imagePullPolicy: 'IfNotPresent', ports: [ { name: 'postgres', containerPort: 5432, }, ], envFrom: [ { secretRef: { name: `${input.name}-secret`, }, }, ], volumeMounts: [ { name: 'data', mountPath: '/var/lib/postgresql/data', subPath: 'postgres', }, ], }, ], }, }, volumeClaimTemplates: [ { metadata: { name: 'data', }, spec: { accessModes: ['ReadWriteOnce'], resources: { requests: { storage: '10Gi', }, }, }, }, ], }, }; } function getManagedLabels(name: string, component: string) { return { 'app.kubernetes.io/name': name, 'app.kubernetes.io/component': component, 'app.kubernetes.io/managed-by': 'studio-admin', }; } function parseReleaseImageTags(value: Prisma.JsonValue | null) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { images: {} as Record }; } const imagesValue = (value as Record).images; const images: Record = {}; if (imagesValue && typeof imagesValue === 'object' && !Array.isArray(imagesValue)) { for (const [key, image] of Object.entries(imagesValue)) { if (typeof image === 'string' && image.trim()) { images[key] = image; } } } return { images }; } function createReleaseImageTags( buildJobs: Array<{ id: string; branch: string; commitSha: string | null; artifact: string | null; image: string | null; component: { id: string; name: string; type: string; }; }>, components: Array<{ id: string; name: string; type: string; imageSource?: string | null; externalImage?: string | null; }> = [], ): Prisma.InputJsonObject { const images: Record = {}; const artifacts: Record = {}; for (const job of buildJobs) { if (job.image && !images[job.component.name]) { images[job.component.name] = job.image; } if (job.artifact && !artifacts[job.component.name]) { artifacts[job.component.name] = job.artifact; } } for (const component of components) { const imageSource = normalizeImageSource(component.imageSource, component.type); if ( !images[component.name] && (imageSource === 'EXTERNAL' || imageSource === 'DATABASE') && component.externalImage?.trim() ) { images[component.name] = component.externalImage.trim(); } } return { images, artifacts, buildJobs: buildJobs.map((job) => ({ id: job.id, componentId: job.component.id, component: job.component.name, type: job.component.type, branch: job.branch, commitSha: job.commitSha, image: job.image, artifact: job.artifact, })), }; } function getBuildArtifact( component: { name: string; type: string }, branch: string, commitSha: string | null, ) { const suffix = getBuildSuffix(branch, commitSha); if (component.type === 'ELECTRON') { return `${component.name}-${suffix}.AppImage`; } if (component.type === 'MIGRATION') { return `${component.name}:${branch}:migration-check`; } return null; } function getBuildImage( component: { name: string; type: string }, branch: string, commitSha: string | null, registry: string, ) { if (component.type === 'ELECTRON' || component.type === 'MIGRATION') { return null; } return `${registry.replace(/\/$/, '')}/${sanitizeImageName( component.name, )}:${sanitizeImageTag(getBuildSuffix(branch, commitSha))}`; } function getBuildSuffix(branch: string, commitSha: string | null) { return commitSha ? commitSha.slice(0, 7) : branch; } function sanitizeImageName(value: string) { return value .trim() .toLowerCase() .replace(/[^a-z0-9._/-]+/g, '-') .replace(/^-+|-+$/g, ''); } function sanitizeImageTag(value: string) { return value .trim() .replace(/[^A-Za-z0-9_.-]+/g, '-') .replace(/^-+|-+$/g, ''); } function getBuildPlanLogs(input: { appName: string; componentName: string; componentType: string; repository: string; branch: string; commitSha: string | null; buildTarget: string | null; artifact: string | null; image: string | null; giteaBaseUrl: string; }) { const repoUrl = `${input.giteaBaseUrl.replace(/\/$/, '')}/${input.repository}.git`; const lines = [ `[queued] build job created: ${input.appName}/${input.componentName}`, `[plan] clone ${repoUrl}`, `[plan] checkout branch ${input.branch}`, ]; if (input.commitSha) { lines.push(`[plan] checkout commit ${input.commitSha}`); } lines.push( `[plan] target ${input.buildTarget || input.componentType}`, input.image ? `[plan] build and push image ${input.image}` : `[plan] package artifact ${input.artifact || '-'}`, '[pending] waiting for build runner', ); return lines; }