init studio admin backend
This commit is contained in:
@@ -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>');
|
||||
}
|
||||
Reference in New Issue
Block a user