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:
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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})_`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user