40d484bede
- 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>
441 lines
18 KiB
JavaScript
441 lines
18 KiB
JavaScript
"use strict";
|
|
var __defProp = Object.defineProperty;
|
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
var __export = (target, all) => {
|
|
for (var name in all)
|
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
};
|
|
var __copyProps = (to, from, except, desc) => {
|
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
for (let key of __getOwnPropNames(from))
|
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
}
|
|
return to;
|
|
};
|
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
var deploy_exports = {};
|
|
__export(deploy_exports, {
|
|
abortJobForPreview: () => abortJobForPreview,
|
|
appendLog: () => appendLog,
|
|
runDeploy: () => runDeploy,
|
|
signalAbort: () => signalAbort,
|
|
stopPreview: () => stopPreview,
|
|
subscribeToLogs: () => subscribeToLogs
|
|
});
|
|
module.exports = __toCommonJS(deploy_exports);
|
|
var import_db = require("../lib/db");
|
|
var import_logger = require("../lib/logger");
|
|
var import_encryption = require("../lib/encryption");
|
|
var import_adminSettings = require("../lib/adminSettings");
|
|
var import_env = require("../lib/env");
|
|
var import_ec2 = require("./ec2");
|
|
var import_ssh = require("./ssh");
|
|
var import_gitea = require("./gitea");
|
|
const log = (0, import_logger.createLogger)("DEPLOY");
|
|
const activeSshSessions = /* @__PURE__ */ new Map();
|
|
function abortJobForPreview(previewId) {
|
|
const session = activeSshSessions.get(previewId);
|
|
if (session) {
|
|
session.abort();
|
|
activeSshSessions.delete(previewId);
|
|
}
|
|
}
|
|
async function appendLog(previewId, text) {
|
|
const settings = await (0, import_adminSettings.getAdminSettings)();
|
|
const preview = await import_db.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 import_db.prisma.preview.update({ where: { id: previewId }, data: { logs } });
|
|
broadcastLogUpdate(previewId, text);
|
|
}
|
|
const logSubscribers = /* @__PURE__ */ new Map();
|
|
function subscribeToLogs(previewId, cb) {
|
|
if (!logSubscribers.has(previewId)) logSubscribers.set(previewId, /* @__PURE__ */ new Set());
|
|
logSubscribers.get(previewId).add(cb);
|
|
return () => logSubscribers.get(previewId)?.delete(cb);
|
|
}
|
|
function broadcastLogUpdate(previewId, text) {
|
|
logSubscribers.get(previewId)?.forEach((cb) => cb(text));
|
|
}
|
|
async function updateStatus(previewId, status, extra = {}) {
|
|
await import_db.prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } });
|
|
}
|
|
async function updateGiteaComment(user, preview, repoConfig, statusLine, lastLogLines) {
|
|
const body = (0, import_gitea.buildPrCommentBody)({
|
|
owner: repoConfig.repoOwner,
|
|
repo: repoConfig.repoName,
|
|
prNumber: preview.prNumber,
|
|
status: statusLine,
|
|
commitSha: preview.commitSha,
|
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
ppBaseUrl: import_env.env.PP_BASE_URL,
|
|
lastLogLines,
|
|
instanceIp: preview.instanceIp ?? void 0,
|
|
port: preview.port
|
|
});
|
|
if (preview.giteaCommentId) {
|
|
try {
|
|
await (0, import_gitea.updateComment)(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, body);
|
|
} catch (e) {
|
|
log.warn({ e }, "Failed to update Gitea comment");
|
|
}
|
|
}
|
|
}
|
|
async function runDeploy(jobId) {
|
|
const job = await import_db.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 import_db.prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } });
|
|
const preview = job.preview;
|
|
const repoConfig = preview.repoConfig;
|
|
const user = preview.repoConfig.user;
|
|
const payload = job.payload;
|
|
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 import_db.prisma.job.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date() } });
|
|
} catch (e) {
|
|
if (e.message === "ABORTED") {
|
|
log.info({ jobId, previewId: preview.id }, "Job aborted");
|
|
await import_db.prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: "Aborted by newer deploy", finishedAt: /* @__PURE__ */ new Date() } });
|
|
return;
|
|
}
|
|
log.error({ e, jobId, previewId: preview.id }, "Deploy failed");
|
|
await import_db.prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: e.message, finishedAt: /* @__PURE__ */ new Date() } });
|
|
const freshPreview = await import_db.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 = (0, import_gitea.buildPrCommentBody)({
|
|
owner: repoConfig.repoOwner,
|
|
repo: repoConfig.repoName,
|
|
prNumber: freshPreview.prNumber,
|
|
status: `\u{1F534} Failed \u2014 last log lines:
|
|
\`\`\`
|
|
${lastLines}
|
|
\`\`\``,
|
|
commitSha: freshPreview.commitSha,
|
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
ppBaseUrl: import_env.env.PP_BASE_URL
|
|
});
|
|
if (freshPreview.giteaCommentId) {
|
|
try {
|
|
await (0, import_gitea.updateComment)(user, repoConfig.repoOwner, repoConfig.repoName, freshPreview.giteaCommentId, failBody);
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
async function firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl) {
|
|
const settings = await (0, import_adminSettings.getAdminSettings)();
|
|
const ec2 = (0, import_ec2.makeEc2Client)(user);
|
|
const previewId = preview.id;
|
|
const activeCount = await import_db.prisma.preview.count({
|
|
where: {
|
|
repoConfig: { userId: user.id },
|
|
status: { in: ["PROVISIONING", "BUILDING", "RUNNING"] },
|
|
id: { not: previewId }
|
|
}
|
|
});
|
|
if (activeCount >= settings.maxConcurrentInstancesPerUser) {
|
|
const body = (0, import_gitea.buildPrCommentBody)({
|
|
owner: repoConfig.repoOwner,
|
|
repo: repoConfig.repoName,
|
|
prNumber,
|
|
status: `\u{1F534} Cannot provision preview \u2014 concurrent instance limit (${settings.maxConcurrentInstancesPerUser}) reached.`,
|
|
commitSha,
|
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
ppBaseUrl: import_env.env.PP_BASE_URL
|
|
});
|
|
const commentId2 = await (0, import_gitea.postComment)(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, body);
|
|
await import_db.prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId2 } });
|
|
throw new Error("Concurrent instance limit reached");
|
|
}
|
|
await updateStatus(previewId, "PROVISIONING", { commitSha, prNumber, prTitle });
|
|
const commentBody = (0, import_gitea.buildPrCommentBody)({
|
|
owner: repoConfig.repoOwner,
|
|
repo: repoConfig.repoName,
|
|
prNumber,
|
|
status: "\u{1F7E1} Provisioning EC2 instance...",
|
|
commitSha,
|
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
ppBaseUrl: import_env.env.PP_BASE_URL
|
|
});
|
|
let commentId = await (0, import_gitea.postComment)(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
|
|
await import_db.prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });
|
|
checkAbort(previewId);
|
|
const keyName = `pp-preview-${previewId}`;
|
|
const { privateKey } = await (0, import_ec2.generateAndImportKeyPair)(ec2, keyName);
|
|
const encPrivateKey = (0, import_encryption.encrypt)(privateKey);
|
|
await import_db.prisma.preview.update({ where: { id: previewId }, data: { sshPrivateKey: encPrivateKey, sshKeyName: keyName } });
|
|
checkAbort(previewId);
|
|
const sgName = `pp-preview-${previewId}`;
|
|
const securityGroupId = await (0, import_ec2.createPreviewSecurityGroup)(ec2, sgName, repoConfig.port);
|
|
checkAbort(previewId);
|
|
const instanceId = await (0, import_ec2.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 import_db.prisma.preview.update({ where: { id: previewId }, data: { instanceId } });
|
|
await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running...
|
|
`);
|
|
checkAbort(previewId);
|
|
const instanceIp = await (0, import_ec2.waitForInstanceRunning)(ec2, instanceId);
|
|
await import_db.prisma.preview.update({ where: { id: previewId }, data: { instanceIp, port: repoConfig.port } });
|
|
await (0, import_gitea.updateComment)(
|
|
user,
|
|
repoConfig.repoOwner,
|
|
repoConfig.repoName,
|
|
commentId,
|
|
(0, import_gitea.buildPrCommentBody)({
|
|
owner: repoConfig.repoOwner,
|
|
repo: repoConfig.repoName,
|
|
prNumber,
|
|
status: `\u{1F7E1} Building... (EC2 ready at ${instanceIp})`,
|
|
commitSha,
|
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
ppBaseUrl: import_env.env.PP_BASE_URL
|
|
})
|
|
);
|
|
await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH...
|
|
`);
|
|
checkAbort(previewId);
|
|
const sshSession = await (0, import_ssh.connectSsh)(instanceIp, privateKey, 3e5);
|
|
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 ? (0, import_encryption.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: /* @__PURE__ */ new Date() });
|
|
const freshPreview = await import_db.prisma.preview.findUnique({ where: { id: previewId } });
|
|
await (0, import_gitea.updateComment)(
|
|
user,
|
|
repoConfig.repoOwner,
|
|
repoConfig.repoName,
|
|
commentId,
|
|
(0, import_gitea.buildPrCommentBody)({
|
|
owner: repoConfig.repoOwner,
|
|
repo: repoConfig.repoName,
|
|
prNumber,
|
|
status: `\u{1F7E2} Live at http://${instanceIp}:${repoConfig.port}`,
|
|
commitSha,
|
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
ppBaseUrl: import_env.env.PP_BASE_URL
|
|
})
|
|
);
|
|
} finally {
|
|
sshSession.close();
|
|
activeSshSessions.delete(previewId);
|
|
}
|
|
}
|
|
async function redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle) {
|
|
const previewId = preview.id;
|
|
const instanceIp = preview.instanceIp;
|
|
const privateKey = (0, import_encryption.decrypt)(preview.sshPrivateKey);
|
|
const sshSession = await (0, import_ssh.connectSsh)(instanceIp, privateKey, 3e4);
|
|
activeSshSessions.set(previewId, sshSession);
|
|
await appendLog(previewId, `
|
|
--- Redeploy: ${commitSha} ---
|
|
`);
|
|
const freshPreview = await import_db.prisma.preview.findUnique({ where: { id: previewId } });
|
|
const commentBody = (0, import_gitea.buildPrCommentBody)({
|
|
owner: repoConfig.repoOwner,
|
|
repo: repoConfig.repoName,
|
|
prNumber,
|
|
status: `\u{1F7E1} Building... (EC2 at ${instanceIp})`,
|
|
commitSha,
|
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
ppBaseUrl: import_env.env.PP_BASE_URL
|
|
});
|
|
const newCommentId = await (0, import_gitea.postComment)(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
|
|
await import_db.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: /* @__PURE__ */ new Date() });
|
|
await (0, import_gitea.updateComment)(
|
|
user,
|
|
repoConfig.repoOwner,
|
|
repoConfig.repoName,
|
|
newCommentId,
|
|
(0, import_gitea.buildPrCommentBody)({
|
|
owner: repoConfig.repoOwner,
|
|
repo: repoConfig.repoName,
|
|
prNumber,
|
|
status: `\u{1F7E2} Live at http://${instanceIp}:${repoConfig.port}`,
|
|
commitSha,
|
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
ppBaseUrl: import_env.env.PP_BASE_URL
|
|
})
|
|
);
|
|
} finally {
|
|
sshSession.close();
|
|
activeSshSessions.delete(previewId);
|
|
}
|
|
}
|
|
async function setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, isFirstProvision) {
|
|
await detectAndUseNode(previewId, sshSession);
|
|
const envVars = repoConfig.envVars;
|
|
const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n");
|
|
await runSshStep(previewId, sshSession, `cat > /opt/app/.env << 'PPEOF'
|
|
${envContent}
|
|
PPEOF`);
|
|
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 import_db.prisma.preview.update({ where: { id: previewId }, data: { pid } });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
async function detectAndUseNode(previewId, 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, sshSession, command, throwOnFail = true) {
|
|
checkAbortSession(sshSession);
|
|
await appendLog(previewId, `$ ${command}
|
|
`);
|
|
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) {
|
|
if (session.aborted) throw new Error("ABORTED");
|
|
}
|
|
const abortSignals = /* @__PURE__ */ new Set();
|
|
function signalAbort(previewId) {
|
|
abortSignals.add(previewId);
|
|
abortJobForPreview(previewId);
|
|
}
|
|
function checkAbort(previewId) {
|
|
if (abortSignals.has(previewId)) {
|
|
abortSignals.delete(previewId);
|
|
throw new Error("ABORTED");
|
|
}
|
|
}
|
|
async function stopPreview(previewId, reason = "STOPPED") {
|
|
const preview = await import_db.prisma.preview.findUnique({
|
|
where: { id: previewId },
|
|
include: { repoConfig: { include: { user: true } } }
|
|
});
|
|
if (!preview) return;
|
|
const user = preview.repoConfig.user;
|
|
const repoConfig = preview.repoConfig;
|
|
if (preview.instanceId) {
|
|
const ec2 = (0, import_ec2.makeEc2Client)(user);
|
|
try {
|
|
await (0, import_ec2.deleteKeyPairAws)(ec2, `pp-preview-${previewId}`);
|
|
} catch {
|
|
}
|
|
try {
|
|
await (0, import_ec2.deleteSecurityGroupAws)(ec2, `pp-preview-${previewId}`);
|
|
} catch {
|
|
}
|
|
try {
|
|
await (0, import_ec2.terminateInstance)(ec2, preview.instanceId);
|
|
} catch {
|
|
}
|
|
}
|
|
await import_db.prisma.preview.update({
|
|
where: { id: previewId },
|
|
data: {
|
|
status: reason,
|
|
stoppedAt: /* @__PURE__ */ new Date(),
|
|
sshPrivateKey: null,
|
|
sshKeyName: null,
|
|
instanceId: null
|
|
}
|
|
});
|
|
const stoppedBody = (0, import_gitea.buildPrCommentBody)({
|
|
owner: repoConfig.repoOwner,
|
|
repo: repoConfig.repoName,
|
|
prNumber: preview.prNumber,
|
|
status: "\u26AB Stopped (inactivity timeout / PR closed / manual stop)",
|
|
commitSha: preview.commitSha,
|
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
ppBaseUrl: import_env.env.PP_BASE_URL
|
|
});
|
|
if (preview.giteaCommentId) {
|
|
try {
|
|
await (0, import_gitea.updateComment)(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, stoppedBody);
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
// Annotate the CommonJS export names for ESM import in node:
|
|
0 && (module.exports = {
|
|
abortJobForPreview,
|
|
appendLog,
|
|
runDeploy,
|
|
signalAbort,
|
|
stopPreview,
|
|
subscribeToLogs
|
|
});
|
|
//# sourceMappingURL=deploy.js.map
|