// lib/queue.js — in-process serial deploy queue (one job at a time). // Every step updates the deployments row; all output goes to // ${LOG_DIR}/deploy--.log so the SSE log view can tail it. import fs from 'node:fs'; import path from 'node:path'; import { config, NAME_RE } from './config.js'; import * as db from './db.js'; import { runCmd, paasDeploy, paasInfoCached, parseEnv, readAppEnv } from './paas.js'; import { takeSnapshot } from './snapshot.js'; import { freshAccount, tokenCloneUrl } from './git.js'; // Build-env additions per runtime: PATH prefix for a selected per-app Node // version (registry NODE_VERSION) and, for laravel apps, the exact PHP // version's shim (so `composer` / `php artisan` in build_cmd use it) plus a // shared composer cache. Laravel apps keep the Node prefix too — Vite builds. async function runtimeEnvFor(app) { const info = await paasInfoCached(app); const env = {}; let pathPrefix = ''; const v = info && info.NODE_VERSION ? String(info.NODE_VERSION).trim() : ''; if (v && /^\d+$/.test(v)) pathPrefix += `/opt/node/v${v}/bin:`; if (info && String(info.RUNTIME || 'node') === 'laravel') { const pv = info.PHP_VERSION && /^\d+\.\d+$/.test(String(info.PHP_VERSION)) ? String(info.PHP_VERSION) : ''; if (pv) pathPrefix = `/srv/paas/php/bin/${pv}:` + pathPrefix; env.COMPOSER_HOME = path.join(config.buildDir, '.composer'); env.COMPOSER_NO_INTERACTION = '1'; env.COMPOSER_ALLOW_SUPERUSER = '0'; } if (pathPrefix) env.PATH = pathPrefix + process.env.PATH; return env; } const jobs = []; let running = false; export function enqueueDeploy(project, { source = 'panel', commit = null, uploadZip = null } = {}) { fs.mkdirSync(config.logDir, { recursive: true }); const id = db.insertDeployment({ app: project.name, source, commit_sha: commit, status: 'queued', step: 'waiting in queue', }); const logPath = path.join(config.logDir, `deploy-${project.name}-${id}.log`); db.updateDeployment(id, { log_path: logPath }); fs.writeFileSync(logPath, `[panel] deployment #${id} of ${project.name} queued (source: ${source})\n`); jobs.push({ id, app: project.name, source, commit, logPath, uploadZip }); setImmediate(processQueue); return id; } function processQueue() { if (running) return; const job = jobs.shift(); if (!job) return; running = true; runJob(job) .catch((err) => { try { fs.appendFileSync(job.logPath, `[panel] internal error: ${(err && err.stack) || err}\n`); db.updateDeployment(job.id, { status: 'failed', step: `internal error: ${String((err && err.message) || err).slice(0, 200)}`, finished_at: db.nowSql(), }); } catch { /* nothing left to do */ } }) .finally(() => { running = false; setImmediate(processQueue); }); } async function runJob(job) { const { id, logPath } = job; const log = fs.createWriteStream(logPath, { flags: 'a' }); const writeLog = (s) => { try { log.write(s); } catch { /* ignore */ } }; const say = (s) => writeLog(`[panel] ${s}\n`); const fail = (step, note) => { if (note) say(note); say(`FAILED: ${step}`); db.updateDeployment(id, { status: 'failed', step, finished_at: db.nowSql() }); }; try { // Re-read the project row — it may have changed (or vanished) while queued. const p = db.getProject(job.app); if (!p) { fail('project removed'); return; } const repoDir = path.join(config.buildDir, p.name); const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' }; // Linked git account: clone/fetch over HTTPS with the account token // (fresh-refreshed) — no per-repo deploy keys needed. The token never // appears in logs (masked below). let cloneUrl = p.repo_url; const secrets = []; if (p.git_account_id && p.repo_full) { let acc = db.getGitAccount(p.git_account_id); if (acc) { acc = await freshAccount(acc); const u = tokenCloneUrl(acc, p.repo_full); if (u) { cloneUrl = u; secrets.push(acc.token); } } } const maskedWrite = (s0) => { let out = String(s0); for (const sec of secrets) out = out.split(sec).join('***'); writeLog(out); }; const sh = (cmd, args, opts = {}) => runCmd(cmd, args, { onData: maskedWrite, ...opts }); // ---- 2+3. build & deploy (shared by the git and the ZIP-upload path) ---- const buildAndDeploy = async (sha = null) => { db.updateDeployment(id, { status: 'building', step: p.build_cmd }); say(`build: ${p.build_cmd}`); // Deliberate bash -lc: build_cmd is an admin-authored shell command. // nice/ionice: builds are latency-tolerant — they must never steal CPU/IO // from the Node apps serving live traffic on this box. const buildEnv = { NODE_OPTIONS: '--max-old-space-size=4096', ...process.env, ...(await runtimeEnvFor(p.name)), ...parseEnv(readAppEnv(p.name)), CI: 'true' }; const b = await sh('nice', ['-n', '19', 'ionice', '-c', '3', 'bash', '-lc', p.build_cmd], { cwd: repoDir, env: buildEnv }); if (b.code !== 0) { fail('build failed', `build exited with code ${b.code}`); return; } db.updateDeployment(id, { status: 'deploying', step: 'paas deploy' }); const outDir = path.join(repoDir, p.output_dir); say(`deploy: ${config.paasBin} deploy ${p.name} ${outDir}`); const d = await paasDeploy(p.name, outDir, { deployId: id, source: job.source, commit: sha, onData: writeLog }); // paas updates the row itself when DEPLOY_ID is set — be defensive anyway. const row = db.getDeployment(id); const terminal = row && (row.status === 'live' || row.status === 'failed'); if (d.code !== 0) { if (!terminal) fail('deploy failed', `paas deploy exited with code ${d.code}`); else say(`paas deploy exited with code ${d.code} (status already ${row.status})`); } else if (!terminal) { db.updateDeployment(id, { status: 'live', step: 'live', finished_at: db.nowSql() }); say('deployment is live'); } else { say(`deployment finished: ${row.status}`); } // Refresh the homepage snapshot after a successful deploy (fire and forget). const finalRow = db.getDeployment(id); if (finalRow && finalRow.status === 'live') { paasInfoCached(p.name) .then((info) => { const custom = info && info.DOMAINS ? String(info.DOMAINS).split(',')[0].trim() : ''; const domain = custom || (info && info.SYSTEM_DOMAIN ? String(info.SYSTEM_DOMAIN).trim() : ''); // short delay: right after a deploy Varnish still probes the fresh // backend — snapshotting immediately would capture its 503 if (domain) setTimeout(() => takeSnapshot(p.name, domain), 15000); }) .catch(() => { /* snapshot is best-effort */ }); } }; // ---- 1a. ZIP upload: source comes from an uploaded archive, no git ---- // (fallback path for when the git host is down, or repo-less projects) if (job.uploadZip) { db.updateDeployment(id, { status: 'cloning', step: 'extracting uploaded ZIP' }); fs.mkdirSync(config.buildDir, { recursive: true }); try { if (!fs.existsSync(job.uploadZip)) { fail('upload vanished', `uploaded file not found: ${job.uploadZip}`); return; } say(`extracting upload (${(fs.statSync(job.uploadZip).size / 1048576).toFixed(1)} MB) into ${repoDir}`); fs.rmSync(repoDir, { recursive: true, force: true }); fs.mkdirSync(repoDir, { recursive: true }); // python3 zipfile sanitizes absolute paths and .. components on extract const x = await sh('python3', ['-m', 'zipfile', '-e', job.uploadZip, repoDir], { timeout: 300000 }); if (x.code !== 0) { fail('zip extraction failed', `python3 -m zipfile exited with code ${x.code}`); return; } // GitHub "Download ZIP" archives wrap everything in one top-level // directory — flatten it so build_cmd/output_dir resolve as usual. const entries = fs.readdirSync(repoDir).filter((n) => n !== '__MACOSX'); if (entries.length === 1 && fs.statSync(path.join(repoDir, entries[0])).isDirectory()) { const inner = path.join(repoDir, entries[0]); say(`flattening top-level directory "${entries[0]}"`); for (const child of fs.readdirSync(inner)) { fs.renameSync(path.join(inner, child), path.join(repoDir, child)); } fs.rmdirSync(inner); } } finally { // never keep the archive around — uploads must not bloat the server fs.rmSync(job.uploadZip, { force: true }); say('uploaded ZIP removed after extraction'); } db.updateDeployment(id, { commit_msg: 'ZIP upload (no git)' }); await buildAndDeploy(); return; } // ---- 1. clone / update ---- if (!p.repo_url) { fail('no repository configured', 'this project has no repository URL — deploy via ZIP upload, or set a repository first'); return; } db.updateDeployment(id, { status: 'cloning', step: 'fetching source' }); fs.mkdirSync(config.buildDir, { recursive: true }); if (fs.existsSync(path.join(repoDir, '.git'))) { say(`updating checkout in ${repoDir} (branch ${p.branch})`); await sh('git', ['remote', 'set-url', 'origin', cloneUrl], { cwd: repoDir, env: gitEnv, timeout: 10000 }); let r = await sh('git', ['fetch', 'origin', p.branch], { cwd: repoDir, env: gitEnv, timeout: 300000 }); if (r.code === 0) r = await sh('git', ['checkout', '-f', p.branch], { cwd: repoDir, env: gitEnv, timeout: 60000 }); if (r.code === 0) r = await sh('git', ['reset', '--hard', `origin/${p.branch}`], { cwd: repoDir, env: gitEnv, timeout: 60000 }); if (r.code !== 0) { fail('git update failed', `git exited with code ${r.code}`); return; } } else { say(`cloning ${p.repo_url} (branch ${p.branch})${cloneUrl !== p.repo_url ? ' via connected account' : ''}`); const r = await sh('git', ['clone', '--branch', p.branch, '--', cloneUrl, repoDir], { env: gitEnv, timeout: 600000 }); if (r.code !== 0) { fail('git clone failed', `git exited with code ${r.code}`); return; } } let sha = job.commit || null; const rev = await runCmd('git', ['rev-parse', 'HEAD'], { cwd: repoDir, timeout: 10000 }); if (rev.code === 0 && rev.stdout.trim()) sha = rev.stdout.trim(); if (sha) db.updateDeployment(id, { commit_sha: sha }); say(`HEAD is ${sha || 'unknown'}`); const msgRes = await runCmd('git', ['log', '-1', '--format=%s'], { cwd: repoDir, timeout: 10000 }); if (msgRes.code === 0 && msgRes.stdout.trim()) { db.updateDeployment(id, { commit_msg: msgRes.stdout.trim().split('\n')[0].slice(0, 200) }); } await buildAndDeploy(sha); } finally { log.end(); } }