init studio admin backend
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
export const AppStatus = {
|
||||
ACTIVE: 'ACTIVE',
|
||||
READY: 'READY',
|
||||
DISABLED: 'DISABLED',
|
||||
} as const;
|
||||
|
||||
export type AppStatus = (typeof AppStatus)[keyof typeof AppStatus];
|
||||
|
||||
export const ComponentType = {
|
||||
ELECTRON: 'ELECTRON',
|
||||
CONTAINER: 'CONTAINER',
|
||||
STATIC_WEB: 'STATIC_WEB',
|
||||
DATABASE: 'DATABASE',
|
||||
MIGRATION: 'MIGRATION',
|
||||
} as const;
|
||||
|
||||
export type ComponentType = (typeof ComponentType)[keyof typeof ComponentType];
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
|
||||
import { LinkComponentSourceDto } from '../gitea/dto';
|
||||
import { AppsService } from './apps.service';
|
||||
import {
|
||||
CreateAppComponentDto,
|
||||
CreateAppDto,
|
||||
CreateAppReleaseDto,
|
||||
CreateBuildJobDto,
|
||||
CreateDeploymentJobDto,
|
||||
CreateMigrationJobDto,
|
||||
CreateRuntimeEnvProfileDto,
|
||||
CreateRuntimeEnvVariableDto,
|
||||
CreateRuntimeIngressRuleDto,
|
||||
EnsureRuntimeNamespaceDto,
|
||||
} from './dto';
|
||||
|
||||
@Controller('apps')
|
||||
export class AppsController {
|
||||
constructor(private readonly appsService: AppsService) {}
|
||||
|
||||
@Get()
|
||||
listApps() {
|
||||
return this.appsService.listApps();
|
||||
}
|
||||
|
||||
@Post()
|
||||
createApp(@Body() body: CreateAppDto) {
|
||||
return this.appsService.createApp(body);
|
||||
}
|
||||
|
||||
@Delete(':appId')
|
||||
deleteApp(@Param('appId') appId: string) {
|
||||
return this.appsService.deleteApp(appId);
|
||||
}
|
||||
|
||||
@Post(':appId/components')
|
||||
createComponent(
|
||||
@Param('appId') appId: string,
|
||||
@Body() body: CreateAppComponentDto,
|
||||
) {
|
||||
return this.appsService.createComponent(appId, body);
|
||||
}
|
||||
|
||||
@Patch('components/:componentId')
|
||||
updateComponent(
|
||||
@Param('componentId') componentId: string,
|
||||
@Body() body: CreateAppComponentDto,
|
||||
) {
|
||||
return this.appsService.updateComponent(componentId, body);
|
||||
}
|
||||
|
||||
@Delete('components/:componentId')
|
||||
deleteComponent(@Param('componentId') componentId: string) {
|
||||
return this.appsService.deleteComponent(componentId);
|
||||
}
|
||||
|
||||
@Get('sources')
|
||||
listSources() {
|
||||
return this.appsService.listSources();
|
||||
}
|
||||
|
||||
@Get('builds')
|
||||
listBuildJobs(@Query('appKey') appKey?: string) {
|
||||
return this.appsService.listBuildJobs(appKey);
|
||||
}
|
||||
|
||||
@Post('builds')
|
||||
createBuildJob(@Body() body: CreateBuildJobDto) {
|
||||
return this.appsService.createBuildJob(body);
|
||||
}
|
||||
|
||||
@Patch('builds/:buildJobId/cancel')
|
||||
cancelBuildJob(@Param('buildJobId') buildJobId: string) {
|
||||
return this.appsService.cancelBuildJob(buildJobId);
|
||||
}
|
||||
|
||||
@Delete('builds/:buildJobId')
|
||||
deleteBuildJob(@Param('buildJobId') buildJobId: string) {
|
||||
return this.appsService.deleteBuildJob(buildJobId);
|
||||
}
|
||||
|
||||
@Patch('components/:componentId/source')
|
||||
linkComponentSource(
|
||||
@Param('componentId') componentId: string,
|
||||
@Body() body: LinkComponentSourceDto,
|
||||
) {
|
||||
return this.appsService.linkComponentSource(componentId, body);
|
||||
}
|
||||
|
||||
@Get('releases')
|
||||
listReleases(@Query('appKey') appKey?: string) {
|
||||
return this.appsService.listReleases(appKey);
|
||||
}
|
||||
|
||||
@Post('releases')
|
||||
createRelease(@Body() body: CreateAppReleaseDto) {
|
||||
return this.appsService.createRelease(body);
|
||||
}
|
||||
|
||||
@Get('deployments')
|
||||
listDeployments(@Query('appKey') appKey?: string) {
|
||||
return this.appsService.listDeployments(appKey);
|
||||
}
|
||||
|
||||
@Post('deployments')
|
||||
createDeploymentJob(@Body() body: CreateDeploymentJobDto) {
|
||||
return this.appsService.createDeploymentJob(body);
|
||||
}
|
||||
|
||||
@Get('migrations')
|
||||
listMigrationJobs() {
|
||||
return this.appsService.listMigrationJobs();
|
||||
}
|
||||
|
||||
@Post('migrations')
|
||||
createMigrationJob(@Body() body: CreateMigrationJobDto) {
|
||||
return this.appsService.createMigrationJob(body);
|
||||
}
|
||||
|
||||
@Get('runtime-namespaces/:namespace')
|
||||
checkRuntimeNamespace(@Param('namespace') namespace: string) {
|
||||
return this.appsService.checkRuntimeNamespace(namespace);
|
||||
}
|
||||
|
||||
@Get('runtime-namespaces/:namespace/resources')
|
||||
listRuntimeResources(@Param('namespace') namespace: string) {
|
||||
return this.appsService.listRuntimeResources(namespace);
|
||||
}
|
||||
|
||||
@Post('runtime-namespaces')
|
||||
ensureRuntimeNamespace(@Body() body: EnsureRuntimeNamespaceDto) {
|
||||
return this.appsService.ensureRuntimeNamespace(body);
|
||||
}
|
||||
|
||||
@Patch('runtime-deployments/:namespace/:deploymentName/restart')
|
||||
restartRuntimeDeployment(
|
||||
@Param('namespace') namespace: string,
|
||||
@Param('deploymentName') deploymentName: string,
|
||||
) {
|
||||
return this.appsService.restartRuntimeDeployment(namespace, deploymentName);
|
||||
}
|
||||
|
||||
@Get('runtime-profiles')
|
||||
listRuntimeProfiles(@Query('appId') appId?: string) {
|
||||
return this.appsService.listRuntimeProfiles(appId);
|
||||
}
|
||||
|
||||
@Post('runtime-profiles')
|
||||
createRuntimeProfile(@Body() body: CreateRuntimeEnvProfileDto) {
|
||||
return this.appsService.createRuntimeProfile(body);
|
||||
}
|
||||
|
||||
@Post('runtime-profiles/:profileId/variables')
|
||||
createRuntimeVariable(
|
||||
@Param('profileId') profileId: string,
|
||||
@Body() body: CreateRuntimeEnvVariableDto,
|
||||
) {
|
||||
return this.appsService.createRuntimeVariable(profileId, body);
|
||||
}
|
||||
|
||||
@Post('runtime-profiles/:profileId/ingress-rules')
|
||||
createRuntimeIngressRule(
|
||||
@Param('profileId') profileId: string,
|
||||
@Body() body: CreateRuntimeIngressRuleDto,
|
||||
) {
|
||||
return this.appsService.createRuntimeIngressRule(profileId, body);
|
||||
}
|
||||
|
||||
@Delete('runtime-profiles/:profileId/ingress-rules/:ruleId')
|
||||
deleteRuntimeIngressRule(
|
||||
@Param('profileId') profileId: string,
|
||||
@Param('ruleId') ruleId: string,
|
||||
) {
|
||||
return this.appsService.deleteRuntimeIngressRule(profileId, ruleId);
|
||||
}
|
||||
|
||||
@Delete('runtime-profiles/:profileId/variables/:variableId')
|
||||
deleteRuntimeVariable(
|
||||
@Param('profileId') profileId: string,
|
||||
@Param('variableId') variableId: string,
|
||||
) {
|
||||
return this.appsService.deleteRuntimeVariable(profileId, variableId);
|
||||
}
|
||||
|
||||
@Patch('runtime-profiles/:profileId/apply')
|
||||
applyRuntimeProfile(@Param('profileId') profileId: string) {
|
||||
return this.appsService.applyRuntimeProfile(profileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LauncherModule } from '../launcher/launcher.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AppsController } from './apps.controller';
|
||||
import { AppsService } from './apps.service';
|
||||
import { BuildRunnerService } from './build-runner.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, LauncherModule],
|
||||
controllers: [AppsController],
|
||||
providers: [AppsService, BuildRunnerService],
|
||||
})
|
||||
export class AppsModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,782 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { App, AppComponent, BuildJob } from '@prisma/client';
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type BuildJobWithComponent = BuildJob & {
|
||||
component: AppComponent & {
|
||||
app: App;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(BuildRunnerService.name);
|
||||
private timer?: NodeJS.Timeout;
|
||||
private running = false;
|
||||
private giteaUsername?: string;
|
||||
private r2Client?: S3Client;
|
||||
private readonly activeProcesses = new Map<string, ChildProcess>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (this.configService.get<string>('BUILD_RUNNER_ENABLED', 'true') === 'false') {
|
||||
this.logger.log('Build runner disabled by BUILD_RUNNER_ENABLED=false');
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = Number(
|
||||
this.configService.get<string>('BUILD_RUNNER_INTERVAL_MS', '5000'),
|
||||
);
|
||||
|
||||
this.timer = setInterval(() => void this.tick(), intervalMs);
|
||||
void this.tick();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
|
||||
for (const child of this.activeProcesses.values()) {
|
||||
terminateProcess(child);
|
||||
}
|
||||
}
|
||||
|
||||
cancelJob(jobId: string) {
|
||||
const child = this.activeProcesses.get(jobId);
|
||||
if (child) {
|
||||
terminateProcess(child);
|
||||
}
|
||||
}
|
||||
|
||||
private async tick() {
|
||||
if (this.running) return;
|
||||
|
||||
this.running = true;
|
||||
try {
|
||||
const job = await this.claimNextJob();
|
||||
if (job) {
|
||||
await this.runJob(job);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown build runner error';
|
||||
this.logger.error(message);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async claimNextJob() {
|
||||
const pending = await this.prisma.buildJob.findFirst({
|
||||
where: { status: 'PENDING' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
component: {
|
||||
include: {
|
||||
app: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!pending) return null;
|
||||
|
||||
const claimed = await this.prisma.buildJob.updateMany({
|
||||
where: { id: pending.id, status: 'PENDING' },
|
||||
data: {
|
||||
status: 'RUNNING',
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (claimed.count === 0) return null;
|
||||
|
||||
return this.prisma.buildJob.findUnique({
|
||||
where: { id: pending.id },
|
||||
include: {
|
||||
component: {
|
||||
include: {
|
||||
app: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Promise<BuildJobWithComponent | null>;
|
||||
}
|
||||
|
||||
private async runJob(job: BuildJobWithComponent) {
|
||||
const lines = splitLogs(job.logs);
|
||||
const token = this.configService.get<string>('GITEA_ADMIN_TOKEN') || '';
|
||||
const append = async (line: string) => {
|
||||
lines.push(redact(line, token));
|
||||
await this.prisma.buildJob.update({
|
||||
where: { id: job.id },
|
||||
data: { logs: truncateLogs(lines.join('\n')) },
|
||||
});
|
||||
};
|
||||
|
||||
const workRoot = this.configService.get<string>(
|
||||
'BUILD_WORKDIR',
|
||||
path.join(os.tmpdir(), 'studio-admin-builds'),
|
||||
);
|
||||
const workDir = path.join(workRoot, job.id);
|
||||
|
||||
try {
|
||||
await fs.rm(workDir, { recursive: true, force: true });
|
||||
await fs.mkdir(workRoot, { recursive: true });
|
||||
|
||||
await append(`[running] build runner picked job ${job.id}`);
|
||||
await this.cloneRepository(job, workDir, append);
|
||||
await this.executeByComponentType(job, workDir, append);
|
||||
|
||||
await append('[done] build job completed');
|
||||
if (await this.isCanceled(job.id)) {
|
||||
await append('[cancelled] build job was cancelled');
|
||||
return;
|
||||
}
|
||||
await this.prisma.buildJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
finishedAt: new Date(),
|
||||
logs: truncateLogs(lines.join('\n')),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (await this.isCanceled(job.id)) {
|
||||
lines.push('[cancelled] build job stopped');
|
||||
await this.prisma.buildJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'CANCELED',
|
||||
finishedAt: new Date(),
|
||||
logs: truncateLogs(lines.join('\n')),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Unknown build failure';
|
||||
lines.push(`[failed] ${redact(message, token)}`);
|
||||
await this.prisma.buildJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
finishedAt: new Date(),
|
||||
logs: truncateLogs(lines.join('\n')),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (this.configService.get<string>('BUILD_KEEP_WORKDIR', 'false') !== 'true') {
|
||||
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isCanceled(jobId: string) {
|
||||
const job = await this.prisma.buildJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
return job?.status === 'CANCELED';
|
||||
}
|
||||
|
||||
private async cloneRepository(
|
||||
job: BuildJobWithComponent,
|
||||
workDir: string,
|
||||
append: (line: string) => Promise<void>,
|
||||
) {
|
||||
const safeUrl = this.getHttpCloneUrl(job.component.repository!);
|
||||
const cloneUrl = await this.getAuthenticatedHttpCloneUrl(
|
||||
job.component.repository!,
|
||||
);
|
||||
|
||||
await append(`[clone] ${safeUrl}`);
|
||||
await runCommand({
|
||||
jobId: job.id,
|
||||
command: this.configService.get<string>('GIT_BIN', 'git'),
|
||||
args: ['clone', '--branch', job.branch, cloneUrl, workDir],
|
||||
safeArgs: ['clone', '--branch', job.branch, safeUrl, workDir],
|
||||
cwd: process.cwd(),
|
||||
append,
|
||||
onProcess: (child) => this.activeProcesses.set(job.id, child),
|
||||
onProcessEnd: () => this.activeProcesses.delete(job.id),
|
||||
});
|
||||
|
||||
if (job.commitSha) {
|
||||
await append(`[checkout] ${job.commitSha}`);
|
||||
await runCommand({
|
||||
jobId: job.id,
|
||||
command: this.configService.get<string>('GIT_BIN', 'git'),
|
||||
args: ['checkout', job.commitSha],
|
||||
cwd: workDir,
|
||||
append,
|
||||
onProcess: (child) => this.activeProcesses.set(job.id, child),
|
||||
onProcessEnd: () => this.activeProcesses.delete(job.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async executeByComponentType(
|
||||
job: BuildJobWithComponent,
|
||||
workDir: string,
|
||||
append: (line: string) => Promise<void>,
|
||||
) {
|
||||
if (job.image) {
|
||||
await this.buildAndPushImage(job, workDir, append);
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.component.type === 'MIGRATION') {
|
||||
await append('[migration] repository checkout verified');
|
||||
await append('[migration] actual DB migration is handled by deployment step');
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.component.type === 'ELECTRON') {
|
||||
await this.buildArtifact(job, workDir, append);
|
||||
return;
|
||||
}
|
||||
|
||||
await append(`[skip] no build action configured for ${job.component.type}`);
|
||||
}
|
||||
|
||||
private async buildAndPushImage(
|
||||
job: BuildJobWithComponent,
|
||||
workDir: string,
|
||||
append: (line: string) => Promise<void>,
|
||||
) {
|
||||
const image = this.resolveImage(job.image!);
|
||||
const builder = this.configService.get<string>('BUILD_IMAGE_BUILDER', 'docker');
|
||||
const dockerfile = this.configService.get<string>('BUILD_DOCKERFILE', 'Dockerfile');
|
||||
const contextPath = this.configService.get<string>('BUILD_CONTEXT_PATH', '.');
|
||||
const contextDir = path.resolve(workDir, contextPath);
|
||||
const pushEnabled = this.configService.get<string>('BUILD_PUSH_ENABLED', 'true') !== 'false';
|
||||
|
||||
if (image !== job.image) {
|
||||
await this.prisma.buildJob.update({
|
||||
where: { id: job.id },
|
||||
data: { image },
|
||||
});
|
||||
await append(`[image] rewritten to ${image}`);
|
||||
}
|
||||
|
||||
if (builder === 'nerdctl') {
|
||||
const namespace = this.configService.get<string>('BUILD_NERDCTL_NAMESPACE');
|
||||
const prefix = namespace ? ['--namespace', namespace] : [];
|
||||
|
||||
await runCommand({
|
||||
jobId: job.id,
|
||||
command: this.configService.get<string>('NERDCTL_BIN', 'nerdctl'),
|
||||
args: [...prefix, 'build', '-t', image, '-f', dockerfile, '.'],
|
||||
cwd: contextDir,
|
||||
append,
|
||||
onProcess: (child) => this.activeProcesses.set(job.id, child),
|
||||
onProcessEnd: () => this.activeProcesses.delete(job.id),
|
||||
});
|
||||
|
||||
if (pushEnabled) {
|
||||
await runCommand({
|
||||
jobId: job.id,
|
||||
command: this.configService.get<string>('NERDCTL_BIN', 'nerdctl'),
|
||||
args: [...prefix, 'push', image],
|
||||
cwd: contextDir,
|
||||
append,
|
||||
onProcess: (child) => this.activeProcesses.set(job.id, child),
|
||||
onProcessEnd: () => this.activeProcesses.delete(job.id),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await runCommand({
|
||||
jobId: job.id,
|
||||
command: this.configService.get<string>('DOCKER_BIN', 'docker'),
|
||||
args: ['build', '-t', image, '-f', dockerfile, '.'],
|
||||
cwd: contextDir,
|
||||
append,
|
||||
onProcess: (child) => this.activeProcesses.set(job.id, child),
|
||||
onProcessEnd: () => this.activeProcesses.delete(job.id),
|
||||
});
|
||||
|
||||
if (pushEnabled) {
|
||||
await runCommand({
|
||||
jobId: job.id,
|
||||
command: this.configService.get<string>('DOCKER_BIN', 'docker'),
|
||||
args: ['push', image],
|
||||
cwd: contextDir,
|
||||
append,
|
||||
onProcess: (child) => this.activeProcesses.set(job.id, child),
|
||||
onProcessEnd: () => this.activeProcesses.delete(job.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private resolveImage(image: string) {
|
||||
const registry = this.configService.get<string>('BUILD_REGISTRY');
|
||||
if (!registry) return image;
|
||||
|
||||
const imagePath = image.includes('/') ? image.slice(image.indexOf('/') + 1) : image;
|
||||
return `${registry.replace(/\/$/, '')}/${imagePath}`;
|
||||
}
|
||||
|
||||
private async buildArtifact(
|
||||
job: BuildJobWithComponent,
|
||||
workDir: string,
|
||||
append: (line: string) => Promise<void>,
|
||||
) {
|
||||
const installCommand = this.configService.get<string>(
|
||||
'BUILD_ARTIFACT_INSTALL_COMMAND',
|
||||
'npm ci',
|
||||
);
|
||||
const buildCommand = this.configService.get<string>(
|
||||
'BUILD_ELECTRON_COMMAND',
|
||||
'npm run build:linux',
|
||||
);
|
||||
const outputDir = this.configService.get<string>(
|
||||
'BUILD_ARTIFACT_OUTPUT_DIR',
|
||||
'dist',
|
||||
);
|
||||
const artifactDir = this.configService.get<string>(
|
||||
'BUILD_ARTIFACT_DIR',
|
||||
path.join(os.tmpdir(), 'studio-admin-artifacts'),
|
||||
);
|
||||
|
||||
await append(`[artifact] install ${installCommand}`);
|
||||
await runShellCommand({
|
||||
jobId: job.id,
|
||||
command: installCommand,
|
||||
cwd: workDir,
|
||||
append,
|
||||
onProcess: (child) => this.activeProcesses.set(job.id, child),
|
||||
onProcessEnd: () => this.activeProcesses.delete(job.id),
|
||||
});
|
||||
|
||||
await append(`[artifact] build ${buildCommand}`);
|
||||
await runShellCommand({
|
||||
jobId: job.id,
|
||||
command: buildCommand,
|
||||
cwd: workDir,
|
||||
append,
|
||||
onProcess: (child) => this.activeProcesses.set(job.id, child),
|
||||
onProcessEnd: () => this.activeProcesses.delete(job.id),
|
||||
});
|
||||
|
||||
const artifact = await findNewestArtifact(path.resolve(workDir, outputDir));
|
||||
if (!artifact) {
|
||||
throw new Error(`산출물을 찾을 수 없습니다: ${outputDir}`);
|
||||
}
|
||||
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
const destinationName = job.artifact || path.basename(artifact);
|
||||
const destination = path.join(artifactDir, destinationName);
|
||||
await fs.copyFile(artifact, destination);
|
||||
|
||||
const uploadedArtifact = await this.uploadArtifactIfConfigured(
|
||||
job,
|
||||
destination,
|
||||
destinationName,
|
||||
append,
|
||||
);
|
||||
const publicBaseUrl = this.configService.get<string>('BUILD_ARTIFACT_BASE_URL');
|
||||
const artifactValue = uploadedArtifact
|
||||
?? (publicBaseUrl
|
||||
? `${publicBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(destinationName)}`
|
||||
: destination);
|
||||
|
||||
await this.prisma.buildJob.update({
|
||||
where: { id: job.id },
|
||||
data: { artifact: artifactValue },
|
||||
});
|
||||
job.artifact = artifactValue;
|
||||
|
||||
await append(`[artifact] found ${path.relative(workDir, artifact)}`);
|
||||
await append(`[artifact] copied to ${artifactValue}`);
|
||||
}
|
||||
|
||||
private async uploadArtifactIfConfigured(
|
||||
job: BuildJobWithComponent,
|
||||
artifactPath: string,
|
||||
artifactName: string,
|
||||
append: (line: string) => Promise<void>,
|
||||
) {
|
||||
const bucket = this.configService.get<string>('R2_BUCKET');
|
||||
const publicBaseUrl = this.configService.get<string>('R2_PUBLIC_BASE_URL');
|
||||
const accessKeyId = this.configService.get<string>('R2_ACCESS_KEY_ID');
|
||||
const secretAccessKey = this.configService.get<string>('R2_SECRET_ACCESS_KEY');
|
||||
|
||||
if (!bucket || !publicBaseUrl || !accessKeyId || !secretAccessKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = this.createArtifactObjectKey(job, artifactName);
|
||||
const stat = await fs.stat(artifactPath);
|
||||
const sha256 = await sha256File(artifactPath);
|
||||
const client = this.getR2Client(accessKeyId, secretAccessKey);
|
||||
|
||||
await append(`[r2] upload ${key}`);
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: createReadStream(artifactPath),
|
||||
ContentLength: stat.size,
|
||||
ContentType: contentTypeForFile(artifactName),
|
||||
ContentDisposition: contentDispositionForFile(artifactName),
|
||||
Metadata: {
|
||||
sha256,
|
||||
app: safeMetadataValue(job.component.app.key),
|
||||
component: safeMetadataValue(job.component.name),
|
||||
branch: safeMetadataValue(job.branch),
|
||||
commit: safeMetadataValue(job.commitSha ?? ''),
|
||||
},
|
||||
}));
|
||||
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: `${key}.sha256`,
|
||||
Body: `${sha256} ${artifactName}\n`,
|
||||
ContentType: 'text/plain; charset=utf-8',
|
||||
}));
|
||||
|
||||
const url = `${publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(key)}`;
|
||||
await append(`[r2] uploaded ${url}`);
|
||||
await append(`[r2] sha256 ${sha256}`);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private getR2Client(accessKeyId: string, secretAccessKey: string) {
|
||||
if (this.r2Client) return this.r2Client;
|
||||
|
||||
const endpoint = this.configService.get<string>('R2_ENDPOINT')
|
||||
|| `https://${this.configService.get<string>('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`;
|
||||
|
||||
this.r2Client = new S3Client({
|
||||
region: this.configService.get<string>('R2_REGION', 'auto'),
|
||||
endpoint,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
forcePathStyle: this.configService.get<string>('R2_FORCE_PATH_STYLE', 'true') !== 'false',
|
||||
});
|
||||
|
||||
return this.r2Client;
|
||||
}
|
||||
|
||||
private createArtifactObjectKey(job: BuildJobWithComponent, artifactName: string) {
|
||||
const prefix = this.configService.get<string>('R2_ARTIFACT_PREFIX', 'studio-artifacts');
|
||||
const revision = job.commitSha?.trim().slice(0, 7) || job.id;
|
||||
return [
|
||||
prefix,
|
||||
'apps',
|
||||
job.component.app.key,
|
||||
job.component.name,
|
||||
job.branch,
|
||||
revision,
|
||||
artifactName,
|
||||
].map(sanitizeObjectKeyPart).join('/');
|
||||
}
|
||||
|
||||
private getHttpCloneUrl(repository: string) {
|
||||
return `${this.configService
|
||||
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
|
||||
.replace(/\/$/, '')}/${repository}.git`;
|
||||
}
|
||||
|
||||
private async getAuthenticatedHttpCloneUrl(repository: string) {
|
||||
const baseUrl = this.getHttpCloneUrl(repository);
|
||||
const token = this.configService.get<string>('GITEA_ADMIN_TOKEN');
|
||||
if (!token || this.configService.get<string>('BUILD_GIT_AUTH', 'token') === 'none') {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const username =
|
||||
this.configService.get<string>('BUILD_GIT_USERNAME') ??
|
||||
(await this.getGiteaUsername(token));
|
||||
const url = new URL(baseUrl);
|
||||
url.username = username;
|
||||
url.password = token;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private async getGiteaUsername(token: string) {
|
||||
if (this.giteaUsername) return this.giteaUsername;
|
||||
|
||||
const response = await fetch(
|
||||
`${this.configService
|
||||
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
|
||||
.replace(/\/$/, '')}/api/v1/user`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gitea 사용자 확인 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { login?: string };
|
||||
if (!data.login) {
|
||||
throw new Error('Gitea 토큰 사용자명을 확인할 수 없습니다.');
|
||||
}
|
||||
|
||||
this.giteaUsername = data.login;
|
||||
return data.login;
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand({
|
||||
jobId,
|
||||
command,
|
||||
args,
|
||||
safeArgs = args,
|
||||
cwd,
|
||||
append,
|
||||
onProcess,
|
||||
onProcessEnd,
|
||||
}: {
|
||||
jobId: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
safeArgs?: string[];
|
||||
cwd: string;
|
||||
append: (line: string) => Promise<void>;
|
||||
onProcess: (child: ChildProcess) => void;
|
||||
onProcessEnd: () => void;
|
||||
}) {
|
||||
await append(`[run] ${command} ${safeArgs.join(' ')}`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
let output = '';
|
||||
onProcess(child);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
onProcessEnd();
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', async (code) => {
|
||||
onProcessEnd();
|
||||
try {
|
||||
for (const line of output.split(/\r?\n/).filter(Boolean)) {
|
||||
await append(`[out] ${line}`);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} for job ${jobId} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runShellCommand({
|
||||
jobId,
|
||||
command,
|
||||
cwd,
|
||||
append,
|
||||
onProcess,
|
||||
onProcessEnd,
|
||||
}: {
|
||||
jobId: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
append: (line: string) => Promise<void>;
|
||||
onProcess: (child: ChildProcess) => void;
|
||||
onProcessEnd: () => void;
|
||||
}) {
|
||||
await append(`[run] ${command}`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
shell: true,
|
||||
});
|
||||
let output = '';
|
||||
onProcess(child);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
onProcessEnd();
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', async (code) => {
|
||||
onProcessEnd();
|
||||
try {
|
||||
for (const line of output.split(/\r?\n/).filter(Boolean)) {
|
||||
await append(`[out] ${line}`);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} for job ${jobId} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findNewestArtifact(dir: string) {
|
||||
const candidates = await findArtifactCandidates(dir).catch(() => []);
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return candidates[0]?.path ?? null;
|
||||
}
|
||||
|
||||
async function findArtifactCandidates(dir: string): Promise<Array<{ path: string; mtimeMs: number }>> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const results: Array<{ path: string; mtimeMs: number }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...await findArtifactCandidates(entryPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!/\.(AppImage|deb|rpm|zip|tar\.gz)$/i.test(entry.name)) continue;
|
||||
const stat = await fs.stat(entryPath);
|
||||
results.push({ path: entryPath, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function sha256File(filePath: string) {
|
||||
const hash = createHash('sha256');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', resolve);
|
||||
});
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function contentTypeForFile(fileName: string) {
|
||||
const lowerName = fileName.toLowerCase();
|
||||
if (lowerName.endsWith('.tar.gz')) return 'application/gzip';
|
||||
|
||||
const extension = path.extname(lowerName);
|
||||
const types: Record<string, string> = {
|
||||
'.appimage': 'application/octet-stream',
|
||||
'.deb': 'application/vnd.debian.binary-package',
|
||||
'.rpm': 'application/x-rpm',
|
||||
'.zip': 'application/zip',
|
||||
'.json': 'application/json',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.yml': 'application/x-yaml',
|
||||
'.yaml': 'application/x-yaml',
|
||||
};
|
||||
|
||||
return types[extension] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
function sanitizeObjectKeyPart(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.replace(/[^a-zA-Z0-9._@+=-]+/g, '-')
|
||||
|| 'unknown';
|
||||
}
|
||||
|
||||
function encodeObjectKey(key: string) {
|
||||
return key.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
function contentDispositionForFile(fileName: string) {
|
||||
const fallbackName = fileName.replace(/[\\\"\r\n]/g, '_');
|
||||
return `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
function safeMetadataValue(value: string) {
|
||||
return value.replace(/[^\x20-\x7E]/g, '').slice(0, 512);
|
||||
}
|
||||
|
||||
function terminateProcess(child: ChildProcess) {
|
||||
if (!child.pid || child.killed) return;
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (!child.killed && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Process may have already exited.
|
||||
}
|
||||
}
|
||||
}, 3000).unref();
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
function splitLogs(logs: string | null) {
|
||||
return logs ? logs.split('\n').filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function truncateLogs(logs: string) {
|
||||
return logs.length > 200_000 ? logs.slice(-200_000) : logs;
|
||||
}
|
||||
|
||||
function redact(value: string, secret: string) {
|
||||
if (!secret) return value;
|
||||
return value.split(secret).join('<redacted>');
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import { ArrayNotEmpty, IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { AppStatus, ComponentType } from './app-enums';
|
||||
|
||||
export class CreateAppDto {
|
||||
@IsString()
|
||||
key!: string;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
namespace?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AppStatus)
|
||||
status?: AppStatus;
|
||||
}
|
||||
|
||||
export class CreateAppComponentDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsEnum(ComponentType)
|
||||
type!: ComponentType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageSource?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
externalImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
workloadType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
replicas?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
serviceType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
storageEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storageSize?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storageMountPath?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
storageRetain?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
repository?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branch?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
buildTarget?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
port?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
targetPort?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
ports?: Array<{
|
||||
name?: string;
|
||||
port?: number;
|
||||
targetPort?: number;
|
||||
protocol?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class CreateRuntimeIngressRuleDto {
|
||||
@IsUUID()
|
||||
componentId!: string;
|
||||
|
||||
@IsString()
|
||||
host!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
path?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
servicePort!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
tlsEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tlsSecretName?: string;
|
||||
}
|
||||
|
||||
export class CreateBuildJobDto {
|
||||
@IsUUID()
|
||||
componentId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branch?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
commitSha?: string;
|
||||
}
|
||||
|
||||
export class CreateAppReleaseDto {
|
||||
@IsUUID()
|
||||
appId!: string;
|
||||
|
||||
@IsString()
|
||||
version!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branch?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID(undefined, { each: true })
|
||||
buildJobIds!: string[];
|
||||
}
|
||||
|
||||
export class CreateDeploymentJobDto {
|
||||
@IsUUID()
|
||||
releaseId!: string;
|
||||
|
||||
@IsString()
|
||||
environment!: string;
|
||||
|
||||
@IsString()
|
||||
namespace!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
updateLauncherAppVersion?: boolean;
|
||||
}
|
||||
|
||||
export class CreateRuntimeEnvProfileDto {
|
||||
@IsUUID()
|
||||
appId!: string;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
environment!: string;
|
||||
|
||||
@IsString()
|
||||
namespace!: string;
|
||||
}
|
||||
|
||||
export class CreateRuntimeEnvVariableDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
componentId?: string;
|
||||
|
||||
@IsString()
|
||||
key!: string;
|
||||
|
||||
@IsString()
|
||||
value!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export class EnsureRuntimeNamespaceDto {
|
||||
@IsString()
|
||||
namespace!: string;
|
||||
}
|
||||
|
||||
export class CreateMigrationJobDto {
|
||||
@IsUUID()
|
||||
releaseId!: string;
|
||||
|
||||
@IsUUID()
|
||||
migrationComponentId!: string;
|
||||
|
||||
@IsString()
|
||||
imageComponentName!: string;
|
||||
|
||||
@IsString()
|
||||
namespace!: string;
|
||||
|
||||
@IsString()
|
||||
command!: string;
|
||||
|
||||
@IsString()
|
||||
envSecretName!: string;
|
||||
}
|
||||
Reference in New Issue
Block a user