feat: initial scaffold - backend, frontend, Prisma schema, Docker

- Prisma schema: User, Session, RepoConfig, Preview, Job, WebhookToken, NoConfigComment, AdminSettings
- Backend: auth (login/logout/me/first-user setup), webhook handler with HMAC verification, EC2 service, SSH service, deploy pipeline, job queue worker, cron workers
- Frontend: Login with first-user detection, Dashboard, PreviewDetail with live log streaming, Settings, Repos config, Admin panel, SetupWizard, Privacy page
- Docker Compose and Dockerfile for self-hosted deployment
- Uses bcryptjs for Node 24 compatibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:18:08 +02:00
parent ca2efdadea
commit 40d484bede
108 changed files with 13393 additions and 0 deletions
+466
View File
@@ -0,0 +1,466 @@
import { prisma } from "../lib/db";
import { createLogger } from "../lib/logger";
import { decrypt, encrypt } from "../lib/encryption";
import { getAdminSettings } from "../lib/adminSettings";
import { env } from "../lib/env";
import {
makeEc2Client,
generateAndImportKeyPair,
createPreviewSecurityGroup,
launchInstance,
waitForInstanceRunning,
terminateInstance,
deleteKeyPairAws,
deleteSecurityGroupAws,
} from "./ec2";
import { connectSsh, type SshSession } from "./ssh";
import {
buildPrCommentBody,
postComment,
updateComment,
} from "./gitea";
import type { Preview, RepoConfig, User } from "@prisma/client";
const log = createLogger("DEPLOY");
const activeSshSessions = new Map<number, SshSession>();
export function abortJobForPreview(previewId: number) {
const session = activeSshSessions.get(previewId);
if (session) {
session.abort();
activeSshSessions.delete(previewId);
}
}
export async function appendLog(previewId: number, text: string) {
const settings = await getAdminSettings();
const preview = await prisma.preview.findUnique({ where: { id: previewId } });
if (!preview) return;
let logs = (preview.logs || "") + text;
if (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) {
const marker = "--- logs truncated ---\n";
while (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) {
const nl = logs.indexOf("\n");
if (nl === -1) break;
logs = logs.slice(nl + 1);
}
logs = marker + logs;
}
await prisma.preview.update({ where: { id: previewId }, data: { logs } });
broadcastLogUpdate(previewId, text);
}
const logSubscribers = new Map<number, Set<(text: string) => void>>();
export function subscribeToLogs(previewId: number, cb: (text: string) => void): () => void {
if (!logSubscribers.has(previewId)) logSubscribers.set(previewId, new Set());
logSubscribers.get(previewId)!.add(cb);
return () => logSubscribers.get(previewId)?.delete(cb);
}
function broadcastLogUpdate(previewId: number, text: string) {
logSubscribers.get(previewId)?.forEach(cb => cb(text));
}
async function updateStatus(previewId: number, status: Preview["status"], extra: Partial<Preview> = {}) {
await prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } });
}
async function updateGiteaComment(user: User, preview: Preview, repoConfig: RepoConfig, statusLine: string, lastLogLines?: string) {
const body = buildPrCommentBody({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: preview.prNumber,
status: statusLine,
commitSha: preview.commitSha,
updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL,
lastLogLines,
instanceIp: preview.instanceIp ?? undefined,
port: preview.port,
});
if (preview.giteaCommentId) {
try {
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, body);
} catch (e) {
log.warn({ e }, "Failed to update Gitea comment");
}
}
}
export async function runDeploy(jobId: number) {
const job = await prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } });
if (!job || !job.preview) {
log.error({ jobId }, "Job or preview not found");
return;
}
await prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: new Date() } });
const preview = job.preview as any;
const repoConfig: RepoConfig = preview.repoConfig;
const user: User = (preview.repoConfig as any).user;
const payload = job.payload as any;
const { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy } = payload;
try {
if (isFirstDeploy) {
await firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl);
} else {
await redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle);
}
await prisma.job.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: new Date() } });
} catch (e: any) {
if (e.message === "ABORTED") {
log.info({ jobId, previewId: preview.id }, "Job aborted");
await prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: "Aborted by newer deploy", finishedAt: new Date() } });
return;
}
log.error({ e, jobId, previewId: preview.id }, "Deploy failed");
await prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: e.message, finishedAt: new Date() } });
const freshPreview = await prisma.preview.findUnique({ where: { id: preview.id } });
if (!freshPreview) return;
const lastLines = (freshPreview.logs || "").split("\n").slice(-10).join("\n");
await updateStatus(preview.id, "FAILED");
const failBody = buildPrCommentBody({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: freshPreview.prNumber,
status: `🔴 Failed — last log lines:\n\`\`\`\n${lastLines}\n\`\`\``,
commitSha: freshPreview.commitSha,
updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL,
});
if (freshPreview.giteaCommentId) {
try {
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, freshPreview.giteaCommentId, failBody);
} catch {}
}
}
}
async function firstDeploy(
jobId: number,
preview: Preview & { repoConfig: RepoConfig },
repoConfig: RepoConfig,
user: User,
commitSha: string,
prNumber: number,
prTitle: string,
cloneUrl: string,
) {
const settings = await getAdminSettings();
const ec2 = makeEc2Client(user);
const previewId = preview.id;
const activeCount = await prisma.preview.count({
where: {
repoConfig: { userId: user.id },
status: { in: ["PROVISIONING", "BUILDING", "RUNNING"] },
id: { not: previewId },
},
});
if (activeCount >= settings.maxConcurrentInstancesPerUser) {
const body = buildPrCommentBody({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber,
status: `🔴 Cannot provision preview — concurrent instance limit (${settings.maxConcurrentInstancesPerUser}) reached.`,
commitSha,
updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL,
});
const commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, body);
await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });
throw new Error("Concurrent instance limit reached");
}
await updateStatus(previewId, "PROVISIONING", { commitSha, prNumber, prTitle });
const commentBody = buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: "🟡 Provisioning EC2 instance...",
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
});
let commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });
checkAbort(previewId);
const keyName = `pp-preview-${previewId}`;
const { privateKey } = await generateAndImportKeyPair(ec2, keyName);
const encPrivateKey = encrypt(privateKey);
await prisma.preview.update({ where: { id: previewId }, data: { sshPrivateKey: encPrivateKey, sshKeyName: keyName } });
checkAbort(previewId);
const sgName = `pp-preview-${previewId}`;
const securityGroupId = await createPreviewSecurityGroup(ec2, sgName, repoConfig.port);
checkAbort(previewId);
const instanceId = await launchInstance({
ec2, region: user.awsRegion!,
instanceType: repoConfig.instanceType,
keyName,
securityGroupId,
tags: {
"pp:managed": "true",
"pp:userId": String(user.id),
"pp:repo": `${repoConfig.repoOwner}/${repoConfig.repoName}`,
"pp:prNumber": String(prNumber),
"pp:previewId": String(previewId),
},
});
await prisma.preview.update({ where: { id: previewId }, data: { instanceId } });
await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running...\n`);
checkAbort(previewId);
const instanceIp = await waitForInstanceRunning(ec2, instanceId);
await prisma.preview.update({ where: { id: previewId }, data: { instanceIp, port: repoConfig.port } });
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,
buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: `🟡 Building... (EC2 ready at ${instanceIp})`,
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
})
);
await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH...\n`);
checkAbort(previewId);
const sshSession = await connectSsh(instanceIp, privateKey, 300_000);
activeSshSessions.set(previewId, sshSession);
try {
if (repoConfig.aptPackages.length > 0) {
await runSshStep(previewId, sshSession, `sudo apt-get install -y ${repoConfig.aptPackages.join(" ")}`);
}
const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
const authCloneUrl = cloneUrl.replace("https://", `https://${user.giteaUsername}:${giteaPat}@`);
await runSshStep(previewId, sshSession, `git clone ${authCloneUrl} /opt/app`);
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`);
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true);
await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: new Date() });
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,
buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: `🟢 Live at http://${instanceIp}:${repoConfig.port}`,
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
})
);
} finally {
sshSession.close();
activeSshSessions.delete(previewId);
}
}
async function redeploy(
jobId: number,
preview: Preview & { repoConfig: RepoConfig },
repoConfig: RepoConfig,
user: User,
commitSha: string,
prNumber: number,
prTitle: string,
) {
const previewId = preview.id;
const instanceIp = preview.instanceIp!;
const privateKey = decrypt(preview.sshPrivateKey!);
const sshSession = await connectSsh(instanceIp, privateKey, 30_000);
activeSshSessions.set(previewId, sshSession);
await appendLog(previewId, `\n--- Redeploy: ${commitSha} ---\n`);
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
const commentBody = buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: `🟡 Building... (EC2 at ${instanceIp})`,
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
});
const newCommentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: newCommentId, commitSha } });
try {
if (repoConfig.useDockerCompose) {
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`);
} else if (freshPreview?.pid) {
await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`);
}
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD`);
await updateStatus(previewId, "BUILDING");
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, false);
await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: new Date() });
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, newCommentId,
buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: `🟢 Live at http://${instanceIp}:${repoConfig.port}`,
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
})
);
} finally {
sshSession.close();
activeSshSessions.delete(previewId);
}
}
async function setupAndBuild(
previewId: number,
sshSession: SshSession,
repoConfig: RepoConfig,
preview: Preview,
commitSha: string,
isFirstProvision: boolean,
) {
await detectAndUseNode(previewId, sshSession);
const envVars = repoConfig.envVars as Record<string, string>;
const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n");
await runSshStep(previewId, sshSession, `cat > /opt/app/.env << 'PPEOF'\n${envContent}\nPPEOF`);
if (isFirstProvision) {
for (const cmd of repoConfig.setupCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);
}
}
await updateStatus(previewId, "BUILDING");
if (repoConfig.useDockerCompose) {
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} up -d --build --force-recreate 2>&1`);
} else {
for (const cmd of repoConfig.buildCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);
}
for (const cmd of repoConfig.postBuildCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);
}
if (repoConfig.runCommand) {
const res = await runSshStep(previewId, sshSession,
`cd /opt/app && nohup ${repoConfig.runCommand} > /opt/app/pp.log 2>&1 & echo $!`
);
const pid = parseInt(res.stdout.trim(), 10);
if (!isNaN(pid)) {
await prisma.preview.update({ where: { id: previewId }, data: { pid } });
}
}
}
}
async function detectAndUseNode(previewId: number, sshSession: SshSession) {
const nvmSource = `export NVM_DIR="/root/.nvm" && source "$NVM_DIR/nvm.sh"`;
const res = await runSshStep(previewId, sshSession, `${nvmSource} && [ -f /opt/app/.nvmrc ] && nvm install && nvm use || nvm use default 2>&1`, false);
}
async function runSshStep(previewId: number, sshSession: SshSession, command: string, throwOnFail = true) {
checkAbortSession(sshSession);
await appendLog(previewId, `$ ${command}\n`);
const res = await sshSession.exec(command);
if (res.stdout) await appendLog(previewId, res.stdout);
if (res.stderr) await appendLog(previewId, res.stderr);
if (throwOnFail && res.code !== 0) {
throw new Error(`Command failed with exit code ${res.code}: ${command}`);
}
return res;
}
function checkAbortSession(session: SshSession) {
if (session.aborted) throw new Error("ABORTED");
}
const abortSignals = new Set<number>();
export function signalAbort(previewId: number) {
abortSignals.add(previewId);
abortJobForPreview(previewId);
}
function checkAbort(previewId: number) {
if (abortSignals.has(previewId)) {
abortSignals.delete(previewId);
throw new Error("ABORTED");
}
}
export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED" = "STOPPED") {
const preview = await prisma.preview.findUnique({
where: { id: previewId },
include: { repoConfig: { include: { user: true } } },
});
if (!preview) return;
const user = (preview.repoConfig as any).user as User;
const repoConfig = preview.repoConfig;
if (preview.instanceId) {
const ec2 = makeEc2Client(user);
try {
await deleteKeyPairAws(ec2, `pp-preview-${previewId}`);
} catch {}
try {
await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`);
} catch {}
try {
await terminateInstance(ec2, preview.instanceId);
} catch {}
}
await prisma.preview.update({
where: { id: previewId },
data: {
status: reason,
stoppedAt: new Date(),
sshPrivateKey: null,
sshKeyName: null,
instanceId: null,
},
});
const stoppedBody = buildPrCommentBody({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: preview.prNumber,
status: "⚫ Stopped (inactivity timeout / PR closed / manual stop)",
commitSha: preview.commitSha,
updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL,
});
if (preview.giteaCommentId) {
try {
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, stoppedBody);
} catch {}
}
}
+242
View File
@@ -0,0 +1,242 @@
import {
EC2Client,
RunInstancesCommand,
TerminateInstancesCommand,
DescribeInstancesCommand,
CreateSecurityGroupCommand,
DeleteSecurityGroupCommand,
AuthorizeSecurityGroupIngressCommand,
DescribeSecurityGroupsCommand,
ImportKeyPairCommand,
DeleteKeyPairCommand,
CreateTagsCommand,
} from "@aws-sdk/client-ec2";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
import { generateKeyPairSync } from "crypto";
import { createLogger } from "../lib/logger";
import { decrypt } from "../lib/encryption";
import type { User } from "@prisma/client";
const log = createLogger("EC2");
const UBUNTU_22_04_AMI: Record<string, string> = {
"us-east-1": "ami-0e86e20dae9224db8",
"us-east-2": "ami-0a0d9cf81c479446a",
"us-west-1": "ami-05c969369880fa2c2",
"us-west-2": "ami-03f8acd418785369b",
"eu-west-1": "ami-0694d931cee176e7d",
"eu-west-2": "ami-0f3d9639a5674d559",
"eu-west-3": "ami-022e307f4b9e39f45",
"eu-central-1": "ami-0faab6bdbac9486fb",
"ap-southeast-1": "ami-0823c236601fef765",
"ap-southeast-2": "ami-07620139298af599e",
"ap-northeast-1": "ami-0b7546e839d7ace12",
"ap-northeast-2": "ami-042e76978adeb8c48",
"ap-south-1": "ami-076e3a557efe1aa9c",
"sa-east-1": "ami-0eed58016fbe42de3",
"ca-central-1": "ami-024f768de9e73d4f4",
"eu-north-1": "ami-00381a880aa48c6c6",
"me-south-1": "ami-09574f34b8dcd2eac",
"af-south-1": "ami-08fdcf06b39fe83ec",
};
export function makeEc2Client(user: User): EC2Client {
const accessKeyId = user.awsAccessKeyId ? decrypt(user.awsAccessKeyId) : "";
const secretAccessKey = user.awsSecretAccessKey ? decrypt(user.awsSecretAccessKey) : "";
return new EC2Client({
region: user.awsRegion!,
credentials: { accessKeyId, secretAccessKey },
});
}
export function makeStsClient(user: User): STSClient {
const accessKeyId = user.awsAccessKeyId ? decrypt(user.awsAccessKeyId) : "";
const secretAccessKey = user.awsSecretAccessKey ? decrypt(user.awsSecretAccessKey) : "";
return new STSClient({
region: user.awsRegion!,
credentials: { accessKeyId, secretAccessKey },
});
}
export async function validateAwsCredentials(user: User): Promise<{ success: boolean; arn?: string; error?: string }> {
try {
const sts = makeStsClient(user);
const res = await sts.send(new GetCallerIdentityCommand({}));
return { success: true, arn: res.Arn };
} catch (e: any) {
return { success: false, error: e.message };
}
}
export function generateSshKeyPair(): { privateKey: string; publicKey: string } {
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs1", format: "pem" },
privateKeyEncoding: { type: "pkcs1", format: "pem" },
});
const pubKeyOpenSsh = rsaPemToOpenSsh(publicKey);
return { privateKey, publicKey: pubKeyOpenSsh };
}
function rsaPemToOpenSsh(pem: string): string {
const { publicKeyEncoding } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs8", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
void publicKeyEncoding;
const der = Buffer.from(
pem.replace(/-----BEGIN RSA PUBLIC KEY-----/, "")
.replace(/-----END RSA PUBLIC KEY-----/, "")
.replace(/\n/g, ""),
"base64"
);
const type = Buffer.from("ssh-rsa");
function encodeBuffer(buf: Buffer): Buffer {
const len = Buffer.allocUnsafe(4);
len.writeUInt32BE(buf.length, 0);
return Buffer.concat([len, buf]);
}
const typeEncoded = encodeBuffer(type);
const rsaKeyData = der;
const base64Key = Buffer.concat([typeEncoded, rsaKeyData]).toString("base64");
return `ssh-rsa ${base64Key} pp-generated`;
}
export async function generateAndImportKeyPair(ec2: EC2Client, keyName: string): Promise<{ privateKey: string }> {
const { privateKey, publicKey } = generateSshKeyPair();
await ec2.send(new ImportKeyPairCommand({
KeyName: keyName,
PublicKeyMaterial: Buffer.from(publicKey),
}));
return { privateKey };
}
export async function createPreviewSecurityGroup(ec2: EC2Client, groupName: string, port: number): Promise<string> {
const describe = await ec2.send(new DescribeSecurityGroupsCommand({
Filters: [{ Name: "group-name", Values: [groupName] }],
}));
if (describe.SecurityGroups && describe.SecurityGroups.length > 0) {
return describe.SecurityGroups[0].GroupId!;
}
const res = await ec2.send(new CreateSecurityGroupCommand({
GroupName: groupName,
Description: `PP Preview security group: ${groupName}`,
}));
const groupId = res.GroupId!;
await ec2.send(new AuthorizeSecurityGroupIngressCommand({
GroupId: groupId,
IpPermissions: [
{
IpProtocol: "tcp",
FromPort: 22,
ToPort: 22,
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
},
{
IpProtocol: "tcp",
FromPort: port,
ToPort: port,
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
},
],
}));
return groupId;
}
const BOOTSTRAP_SCRIPT = `#!/bin/bash
set -e
apt-get update -y
apt-get install -y curl git unzip build-essential
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
apt-get install -y docker-compose-plugin
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
export NVM_DIR="/root/.nvm"
source "$NVM_DIR/nvm.sh"
nvm install --lts
nvm alias default lts/*
`;
export async function launchInstance(opts: {
ec2: EC2Client;
region: string;
instanceType: string;
keyName: string;
securityGroupId: string;
tags: Record<string, string>;
}): Promise<string> {
const ami = UBUNTU_22_04_AMI[opts.region] ?? UBUNTU_22_04_AMI["us-east-1"];
const tagSpecs = Object.entries(opts.tags).map(([k, v]) => ({ Key: k, Value: v }));
tagSpecs.push({ Key: "Name", Value: `pp-preview-${opts.tags["pp:previewId"]}` });
const res = await opts.ec2.send(new RunInstancesCommand({
ImageId: ami,
InstanceType: opts.instanceType as any,
MinCount: 1,
MaxCount: 1,
KeyName: opts.keyName,
SecurityGroupIds: [opts.securityGroupId],
UserData: Buffer.from(BOOTSTRAP_SCRIPT).toString("base64"),
TagSpecifications: [
{ ResourceType: "instance", Tags: tagSpecs },
],
}));
return res.Instances![0].InstanceId!;
}
export async function waitForInstanceRunning(ec2: EC2Client, instanceId: string, maxWaitMs = 300_000): Promise<string> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
const res = await ec2.send(new DescribeInstancesCommand({
InstanceIds: [instanceId],
}));
const inst = res.Reservations?.[0]?.Instances?.[0];
if (inst?.State?.Name === "running" && inst.PublicIpAddress) {
return inst.PublicIpAddress;
}
await sleep(5000);
}
throw new Error(`Instance ${instanceId} did not reach running state within timeout`);
}
export async function terminateInstance(ec2: EC2Client, instanceId: string): Promise<void> {
await ec2.send(new TerminateInstancesCommand({ InstanceIds: [instanceId] }));
}
export async function deleteKeyPairAws(ec2: EC2Client, keyName: string): Promise<void> {
try {
await ec2.send(new DeleteKeyPairCommand({ KeyName: keyName }));
} catch (e) {
log.warn({ e, keyName }, "Failed to delete key pair");
}
}
export async function deleteSecurityGroupAws(ec2: EC2Client, groupName: string): Promise<void> {
try {
const describe = await ec2.send(new DescribeSecurityGroupsCommand({
Filters: [{ Name: "group-name", Values: [groupName] }],
}));
const groupId = describe.SecurityGroups?.[0]?.GroupId;
if (groupId) {
await ec2.send(new DeleteSecurityGroupCommand({ GroupId: groupId }));
}
} catch (e) {
log.warn({ e, groupName }, "Failed to delete security group");
}
}
export async function describeAllManagedInstances(ec2: EC2Client): Promise<any[]> {
const res = await ec2.send(new DescribeInstancesCommand({
Filters: [{ Name: "tag:pp:managed", Values: ["true"] }, { Name: "instance-state-name", Values: ["running", "pending", "stopping", "stopped"] }],
}));
return (res.Reservations ?? []).flatMap(r => r.Instances ?? []);
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}
+133
View File
@@ -0,0 +1,133 @@
import axios from "axios";
import { decrypt } from "../lib/encryption";
import type { User } from "@prisma/client";
export function giteaApi(user: User) {
const pat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
return axios.create({
baseURL: `${user.giteaInstanceUrl}/api/v1`,
headers: {
Authorization: `token ${pat}`,
"Content-Type": "application/json",
},
timeout: 15000,
});
}
export async function validateGiteaUrl(url: string): Promise<{ success: boolean; version?: string; error?: string }> {
try {
const res = await axios.get(`${url}/api/v1/version`, { timeout: 10000 });
return { success: true, version: res.data.version };
} catch (e: any) {
return { success: false, error: e.message };
}
}
export async function fetchUserRepos(user: User): Promise<any[]> {
const api = giteaApi(user);
const repos: any[] = [];
let page = 1;
while (true) {
const res = await api.get(`/repos/search?limit=50&page=${page}`);
const data = res.data?.data ?? [];
if (data.length === 0) break;
repos.push(...data);
if (data.length < 50) break;
page++;
}
return repos;
}
export async function registerWebhook(user: User, owner: string, repo: string, webhookUrl: string, secret: string): Promise<number> {
const api = giteaApi(user);
const res = await api.post(`/repos/${owner}/${repo}/hooks`, {
type: "gitea",
config: {
url: webhookUrl,
secret,
content_type: "json",
},
events: ["pull_request", "issue_comment"],
active: true,
});
return res.data.id;
}
export async function deleteWebhook(user: User, owner: string, repo: string, hookId: string): Promise<void> {
const api = giteaApi(user);
await api.delete(`/repos/${owner}/${repo}/hooks/${hookId}`);
}
export async function updateWebhookSecret(user: User, owner: string, repo: string, hookId: string, webhookUrl: string, newSecret: string): Promise<void> {
const api = giteaApi(user);
await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, {
config: {
url: webhookUrl,
secret: newSecret,
content_type: "json",
},
events: ["pull_request", "issue_comment"],
active: true,
});
}
export async function postComment(user: User, owner: string, repo: string, issueNumber: number, body: string): Promise<number> {
const api = giteaApi(user);
const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body });
return res.data.id;
}
export async function updateComment(user: User, owner: string, repo: string, commentId: number, body: string): Promise<void> {
const api = giteaApi(user);
await api.patch(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { body });
}
export async function checkUserPermission(user: User, owner: string, repo: string, username: string): Promise<boolean> {
try {
const api = giteaApi(user);
const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}`);
return res.status === 204;
} catch {
return false;
}
}
export async function getRepoCollaboratorPermission(user: User, owner: string, repo: string, username: string): Promise<string | null> {
try {
const api = giteaApi(user);
const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}/permission`);
return res.data?.permission ?? null;
} catch {
return null;
}
}
export function buildPrCommentBody(opts: {
owner: string;
repo: string;
prNumber: number;
status: string;
commitSha: string;
updatedAt: Date;
ppBaseUrl: string;
lastLogLines?: string;
instanceIp?: string;
port?: number;
}): string {
const { owner, repo, prNumber, status, commitSha, updatedAt, ppBaseUrl, lastLogLines, instanceIp, port } = opts;
const ts = updatedAt.toISOString().replace("T", " ").slice(0, 19) + " UTC";
let statusLine = status;
if (lastLogLines) {
statusLine += `\n\n\`\`\`\n${lastLogLines}\n\`\`\``;
}
return `## 🚀 PR Preview — \`${owner}/${repo}\` #${prNumber}
**Status:** ${statusLine}
**Commit:** \`${commitSha.slice(0, 8)}\`
**Updated:** ${ts}
---
_Powered by [PR Previews](${ppBaseUrl})_`;
}
+90
View File
@@ -0,0 +1,90 @@
import { Client } from "ssh2";
import { createLogger } from "../lib/logger";
const log = createLogger("SSH");
export interface SshSession {
exec(command: string): Promise<{ stdout: string; stderr: string; code: number }>;
close(): void;
aborted: boolean;
abort(): void;
}
export async function connectSsh(host: string, privateKey: string, maxWaitMs = 300_000): Promise<SshSession> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const conn = await tryConnect(host, privateKey, 10000);
let aborted = false;
return {
get aborted() { return aborted; },
abort() {
aborted = true;
try { conn.end(); } catch {}
},
async exec(command: string) {
if (aborted) throw new Error("SSH session aborted");
return execOnConn(conn, command);
},
close() {
try { conn.end(); } catch {}
},
};
} catch (e: any) {
if (Date.now() - start > maxWaitMs) throw e;
log.debug({ host, error: e.message }, "SSH connect retry");
await sleep(5000);
}
}
throw new Error(`Could not SSH into ${host} within timeout`);
}
function tryConnect(host: string, privateKey: string, timeoutMs: number): Promise<Client> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timer = setTimeout(() => {
conn.end();
reject(new Error(`SSH connection to ${host} timed out`));
}, timeoutMs);
conn.on("ready", () => {
clearTimeout(timer);
resolve(conn);
});
conn.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
conn.connect({
host,
port: 22,
username: "ubuntu",
privateKey,
readyTimeout: timeoutMs,
algorithms: {
serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"],
},
});
});
}
function execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let stderr = "";
stream.on("data", (d: Buffer) => { stdout += d.toString(); });
stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
stream.on("close", (code: number) => {
resolve({ stdout, stderr, code: code ?? 0 });
});
});
});
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}