2293 lines
63 KiB
TypeScript
2293 lines
63 KiB
TypeScript
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<string, typeof profile.variables>();
|
|
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<string>('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<string>(
|
|
'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<string>('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<string>('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<string, unknown>) {
|
|
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<string, unknown>;
|
|
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<Record<string, unknown>>;
|
|
};
|
|
|
|
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<number>((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<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
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<string, unknown> = {
|
|
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<string, unknown>).tls = [
|
|
{
|
|
hosts: [input.host],
|
|
secretName: input.tlsSecretName,
|
|
},
|
|
];
|
|
}
|
|
|
|
return manifest;
|
|
}
|
|
|
|
function createPostgresSecretManifest(input: {
|
|
namespace: string;
|
|
name: string;
|
|
values: Record<string, string>;
|
|
}) {
|
|
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<string, string> };
|
|
}
|
|
|
|
const imagesValue = (value as Record<string, unknown>).images;
|
|
const images: Record<string, string> = {};
|
|
|
|
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<string, string> = {};
|
|
const artifacts: Record<string, string> = {};
|
|
|
|
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;
|
|
}
|