← back to Auto Deploy Watcher
server.js
402 lines
/**
* auto-deploy-watcher
*
* Watches each enabled project's `.git/refs/heads/main` for changes and runs the
* project's `scripts/deploy-*.sh` when the SHA actually changes.
*
* Safety rails:
* - `enabled: false` is the default — opt-in per project.
* - Only deploy_script values matching `scripts/deploy-*.sh` are accepted.
* - Hard 10-minute timeout per deploy via `perl -e 'alarm shift; exec @ARGV' 600 ...`.
* - Refuses to fire if the previous deploy is still running for the same service.
* - Logs full stdout/stderr to logs/<service>-<sha>.log.
* - Persists last 20 deploys/service to data/deploys.jsonl.
*
* Endpoints:
* GET /healthz — public, no auth
* GET / — Basic Auth, status dashboard
* GET /api/deploys — Basic Auth, JSON. Optional ?service=<name>.
*
* Auth:
* process.env.BASIC_AUTH || 'admin:DWSecure2024!'
* /healthz is exempt.
*/
'use strict';
const express = require('express');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { execFile } = require('node:child_process');
// ---- config ---------------------------------------------------------------
const ROOT = __dirname;
const PORT = Number(process.env.PORT || 9799);
const HOST = process.env.HOST || '127.0.0.1';
const PROJECTS_FILE = path.join(ROOT, 'projects.json');
const LOG_DIR = path.join(ROOT, 'logs');
const DATA_DIR = path.join(ROOT, 'data');
const DEPLOYS_FILE = path.join(DATA_DIR, 'deploys.jsonl');
const DEPLOY_TIMEOUT_SECONDS = 600;
const DEBOUNCE_MS = 500;
const HISTORY_MAX = 20;
const DEPLOY_SCRIPT_RE = /^scripts\/deploy-[A-Za-z0-9._-]+\.sh$/;
const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DWSecure2024!';
const EXPECTED_AUTH_HEADER =
'Basic ' + Buffer.from(BASIC_AUTH, 'utf8').toString('base64');
// ---- state ----------------------------------------------------------------
/** name -> { entry, lastSha, lastDeployAt, running, debounceTimer, watcher, history[] } */
const state = new Map();
// ---- helpers --------------------------------------------------------------
function log(...a) {
console.log('[' + new Date().toISOString() + ']', ...a);
}
function ensureDirs() {
for (const d of [LOG_DIR, DATA_DIR]) {
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
}
}
function readProjects() {
const raw = fs.readFileSync(PROJECTS_FILE, 'utf8');
const cfg = JSON.parse(raw);
if (!cfg || !Array.isArray(cfg.watch)) {
throw new Error('projects.json must contain { "watch": [...] }');
}
return cfg.watch;
}
function validateEntry(entry) {
const errs = [];
if (!entry.name || typeof entry.name !== 'string') errs.push('missing name');
if (!entry.cwd || typeof entry.cwd !== 'string') errs.push('missing cwd');
if (!entry.deploy_script || typeof entry.deploy_script !== 'string') {
errs.push('missing deploy_script');
} else if (!DEPLOY_SCRIPT_RE.test(entry.deploy_script)) {
errs.push('deploy_script must match scripts/deploy-*.sh');
}
if (entry.cwd && !fs.existsSync(entry.cwd)) errs.push('cwd does not exist: ' + entry.cwd);
return errs;
}
function gitHeadSha(cwd) {
return new Promise((resolve, reject) => {
execFile(
'git',
['log', '-1', '--format=%H'],
{ cwd, timeout: 10_000 },
(err, stdout) => {
if (err) reject(err);
else resolve(stdout.trim());
}
);
});
}
async function appendDeploy(record) {
await fsp.appendFile(DEPLOYS_FILE, JSON.stringify(record) + '\n', 'utf8');
}
function pushHistory(name, record) {
const s = state.get(name);
if (!s) return;
s.history.unshift(record);
if (s.history.length > HISTORY_MAX) s.history.length = HISTORY_MAX;
}
// ---- deploy execution -----------------------------------------------------
async function runDeploy(entry, sha) {
const s = state.get(entry.name);
if (!s) return;
if (s.running) {
log('SKIP', entry.name, '— previous deploy still running');
return;
}
const scriptAbs = path.join(entry.cwd, entry.deploy_script);
if (!fs.existsSync(scriptAbs)) {
log('SKIP', entry.name, '— deploy script not found:', scriptAbs);
pushHistory(entry.name, {
service: entry.name,
sha,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
exitCode: null,
error: 'deploy script not found',
logFile: null,
});
return;
}
s.running = true;
const startedAt = new Date();
const logFile = path.join(LOG_DIR, `${entry.name}-${sha.slice(0, 12)}.log`);
const logFd = fs.openSync(logFile, 'a');
fs.writeSync(
logFd,
`=== auto-deploy-watcher ${startedAt.toISOString()} service=${entry.name} sha=${sha} ===\n`
);
log('DEPLOY START', entry.name, sha.slice(0, 12), '->', logFile);
// perl-alarm hard timeout wrapper
const child = spawn(
'perl',
[
'-e',
'alarm shift; exec @ARGV',
String(DEPLOY_TIMEOUT_SECONDS),
'bash',
scriptAbs,
],
{
cwd: entry.cwd,
env: { ...process.env, AUTO_DEPLOY_SHA: sha, AUTO_DEPLOY_SERVICE: entry.name },
stdio: ['ignore', 'pipe', 'pipe'],
}
);
child.stdout.on('data', (d) => fs.writeSync(logFd, d));
child.stderr.on('data', (d) => fs.writeSync(logFd, d));
child.on('close', async (code, signal) => {
const finishedAt = new Date();
fs.writeSync(
logFd,
`\n=== exit code=${code} signal=${signal} duration_ms=${finishedAt - startedAt} ===\n`
);
fs.closeSync(logFd);
s.running = false;
s.lastDeployAt = finishedAt.toISOString();
const record = {
service: entry.name,
sha,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt - startedAt,
exitCode: code,
signal,
logFile,
};
pushHistory(entry.name, record);
try {
await appendDeploy(record);
} catch (e) {
log('appendDeploy error', e.message);
}
log(
'DEPLOY END ',
entry.name,
sha.slice(0, 12),
'code=' + code,
'signal=' + signal,
'dur=' + (finishedAt - startedAt) + 'ms'
);
});
child.on('error', (err) => {
fs.writeSync(logFd, '\n=== spawn error: ' + err.message + ' ===\n');
log('DEPLOY ERROR', entry.name, err.message);
});
}
// ---- watcher --------------------------------------------------------------
async function setupWatcher(entry) {
const errs = validateEntry(entry);
if (errs.length) {
log('SKIP', entry.name || '<unnamed>', '— invalid entry:', errs.join('; '));
return;
}
if (entry.enabled !== true) {
log('disabled', entry.name);
state.set(entry.name, {
entry,
lastSha: null,
lastDeployAt: null,
running: false,
debounceTimer: null,
watcher: null,
history: [],
});
return;
}
const refPath = path.join(entry.cwd, '.git', 'refs', 'heads', 'main');
let initialSha = null;
try {
initialSha = await gitHeadSha(entry.cwd);
} catch (e) {
log('SKIP', entry.name, '— git log failed:', e.message);
return;
}
const slot = {
entry,
lastSha: initialSha,
lastDeployAt: null,
running: false,
debounceTimer: null,
watcher: null,
history: [],
};
state.set(entry.name, slot);
if (!fs.existsSync(refPath)) {
log('WARN ', entry.name, '— refs/heads/main does not exist yet at', refPath);
}
const onChange = () => {
if (slot.debounceTimer) clearTimeout(slot.debounceTimer);
slot.debounceTimer = setTimeout(async () => {
slot.debounceTimer = null;
let sha;
try {
sha = await gitHeadSha(entry.cwd);
} catch (e) {
log('git log failed for', entry.name, e.message);
return;
}
if (sha === slot.lastSha) return; // no real change
log('SHA changed', entry.name, slot.lastSha?.slice(0, 12), '->', sha.slice(0, 12));
slot.lastSha = sha;
runDeploy(entry, sha).catch((e) => log('runDeploy error', entry.name, e.message));
}, DEBOUNCE_MS);
};
try {
slot.watcher = fs.watch(refPath, { persistent: true }, onChange);
slot.watcher.on('error', (err) => log('watch error', entry.name, err.message));
log('watching', entry.name, refPath, 'sha=' + initialSha.slice(0, 12));
} catch (e) {
log('SKIP', entry.name, '— fs.watch failed:', e.message);
}
}
// ---- HTTP -----------------------------------------------------------------
const app = express();
app.disable('x-powered-by');
// Defensive 404 guard — never serve snapshot/backup file paths even if a
// future express.static() mount or reverse proxy exposes the working tree.
app.use((req, res, next) => {
if (/\.(bak|orig|rej)(\b|\.)|\.pre-/i.test(req.path)) {
return res.status(404).send('not found');
}
next();
});
app.get('/healthz', (_req, res) => {
res.json({
ok: true,
service: 'auto-deploy-watcher',
port: PORT,
watching: Array.from(state.values()).filter((s) => s.entry.enabled).length,
total: state.size,
uptimeSec: Math.round(process.uptime()),
});
});
// Basic Auth gate for everything else
app.use((req, res, next) => {
if (req.path === '/healthz') return next();
const got = req.headers.authorization || '';
if (got === EXPECTED_AUTH_HEADER) return next();
res.set('WWW-Authenticate', 'Basic realm="auto-deploy-watcher"');
res.status(401).send('auth required');
});
app.get('/api/deploys', (req, res) => {
const service = typeof req.query.service === 'string' ? req.query.service : null;
if (service) {
const s = state.get(service);
if (!s) return res.status(404).json({ error: 'unknown service', service });
return res.json({ service, history: s.history });
}
const out = {};
for (const [name, s] of state) out[name] = s.history;
res.json(out);
});
app.get('/', (_req, res) => {
const rows = Array.from(state.values()).map((s) => {
const e = s.entry;
const last = s.history[0];
return `<tr>
<td>${e.name}</td>
<td>${e.enabled ? 'yes' : 'no'}</td>
<td><code>${e.cwd}</code></td>
<td><code>${e.deploy_script}</code></td>
<td>${s.lastSha ? s.lastSha.slice(0, 12) : '—'}</td>
<td>${s.running ? 'RUNNING' : 'idle'}</td>
<td>${last ? `${last.finishedAt} exit=${last.exitCode}` : '—'}</td>
</tr>`;
}).join('\n');
res.set('content-type', 'text/html; charset=utf-8').send(`<!doctype html>
<html><head><meta charset="utf-8"><title>auto-deploy-watcher</title>
<style>
body{font:14px -apple-system,BlinkMacSystemFont,sans-serif;margin:24px;color:#111;}
h1{margin:0 0 4px 0;}
small{color:#666;}
table{border-collapse:collapse;margin-top:16px;width:100%;}
th,td{border:1px solid #ddd;padding:6px 10px;text-align:left;font-size:13px;vertical-align:top;}
th{background:#f3f3f3;}
code{background:#f7f7f7;padding:1px 4px;border-radius:3px;}
</style></head>
<body>
<h1>auto-deploy-watcher</h1>
<small>port ${PORT} · uptime ${Math.round(process.uptime())}s · ${state.size} project(s) configured</small>
<p><a href="/api/deploys">/api/deploys</a> · <a href="/healthz">/healthz</a></p>
<table>
<tr><th>service</th><th>enabled</th><th>cwd</th><th>deploy_script</th><th>last sha</th><th>status</th><th>last deploy</th></tr>
${rows || '<tr><td colspan="7"><em>no projects configured</em></td></tr>'}
</table>
</body></html>`);
});
// ---- boot -----------------------------------------------------------------
async function main() {
ensureDirs();
let entries = [];
try {
entries = readProjects();
} catch (e) {
log('FATAL: cannot read projects.json —', e.message);
process.exit(1);
}
log('loaded', entries.length, 'project(s) from projects.json');
for (const entry of entries) {
await setupWatcher(entry);
}
app.listen(PORT, HOST, () => {
log(`listening on http://${HOST}:${PORT}`);
});
}
process.on('SIGTERM', () => {
log('SIGTERM — shutting down');
for (const s of state.values()) s.watcher?.close();
process.exit(0);
});
main().catch((e) => {
log('FATAL', e.stack || e.message);
process.exit(1);
});