← 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);
});