← back to Commercialrealestate

scripts/crcp-drift-canary.js

108 lines

#!/usr/bin/env node
/**
 * crcp-drift-canary.js — READ-ONLY origin-vs-live drift monitor for CRCP.
 *
 * WHY: crcp.agentabrams.com is a Kamatera-local copy synced by MANUAL rsync.
 * It silently drifted 9 days stale (Jul 7->16 2026) because nobody NOTICED —
 * the manual deploy wasn't the failure, the INVISIBILITY was. DTD verdict D
 * (2026-07-16): keep deploys manual (push-frontend-kamatera.sh) + this canary.
 *
 * Compares sha256 of each front-end asset (public/*.html,*.js,*.css) on the
 * origin (this box) vs live (Kamatera), counts drifted files, and fires a
 * WORSENING-ONLY alert (drift count grew vs the stored baseline) to CNCP +
 * George email. Steady-state / recovery / first-run are silent. Touches
 * nothing on prod — one ssh sha256sum read. Always writes data/latest.json
 * so dw-canary-meta-watchdog sees it ran.
 */
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const ROOT = path.join(__dirname, '..');
const PUB = path.join(ROOT, 'public');
const HOST = 'root@45.61.58.125';
const REMOTE = '/root/public-projects/commercialrealestate/public';
const STATE = path.join(ROOT, 'data', 'crcp-drift-state.json');
const LATEST = path.join(ROOT, 'data', 'latest.json');
const EXT = /\.(html|js|css)$/;

function sha(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); }

function localHashes() {
  const out = {};
  for (const f of fs.readdirSync(PUB)) {
    if (!EXT.test(f)) continue;
    out[f] = sha(fs.readFileSync(path.join(PUB, f)));
  }
  return out;
}

function remoteHashes() {
  // single read-only ssh call; sha256sum every html/js/css in the remote public dir
  const cmd = `ssh -o ConnectTimeout=10 -o BatchMode=yes ${HOST} ` +
    `"cd ${REMOTE} 2>/dev/null && for f in *.html *.js *.css; do [ -f \\"\\$f\\" ] && sha256sum \\"\\$f\\"; done"`;
  const raw = execSync(cmd, { encoding: 'utf8', timeout: 30000 });
  const out = {};
  for (const line of raw.trim().split('\n')) {
    const m = line.match(/^([0-9a-f]{64})\s+(.+)$/);
    if (m) out[m[2]] = m[1];
  }
  return out;
}

function main() {
  let live, err = null;
  const local = localHashes();
  try { live = remoteHashes(); } catch (e) { err = String(e.message).split('\n')[0]; }

  const now = new Date().toISOString();
  if (err) {
    // unreachable = UNKNOWN, not a drift alert (transient); still stamp latest.json
    writeLatest({ status: 'UNKNOWN', ts: now, error: err });
    console.log(`[crcp-drift-canary] UNKNOWN — ${err}`);
    return;
  }

  const drifted = [];
  const names = new Set([...Object.keys(local), ...Object.keys(live)]);
  for (const n of names) {
    if (local[n] !== live[n]) {
      drifted.push({ file: n, state: !live[n] ? 'missing-on-live' : !local[n] ? 'extra-on-live' : 'differs' });
    }
  }
  const count = drifted.length;

  const prev = fs.existsSync(STATE) ? JSON.parse(fs.readFileSync(STATE, 'utf8')) : null;
  const baseline = prev ? prev.count : null;
  const worsening = baseline !== null && count > baseline;
  const status = count === 0 ? 'IN_SYNC' : 'DRIFTED';

  writeLatest({ status, ts: now, driftCount: count, drifted, worsening });
  fs.writeFileSync(STATE, JSON.stringify({ count, ts: now, drifted }, null, 2));

  console.log(`[crcp-drift-canary] ${status} — ${count} file(s) drifted` +
    (baseline !== null ? ` (was ${baseline})` : ' (first run — baseline set, silent)'));

  // WORSENING-ONLY alert; first-run / steady / recovery are silent
  if (worsening) {
    const msg = `CRCP live is ${count} front-end file(s) behind origin (was ${baseline}). ` +
      `Files: ${drifted.map(d => d.file + ':' + d.state).join(', ')}. ` +
      `Fix: bash deploy/push-frontend-kamatera.sh`;
    console.log('[crcp-drift-canary] WORSENING — alerting: ' + msg);
    try {
      execSync(`curl -s -m 8 -X POST http://127.0.0.1:3333/api/parking-lot ` +
        `-H 'Content-Type: application/json' ` +
        `-d ${JSON.stringify(JSON.stringify({ title: 'CRCP deploy drift', note: msg, source: 'crcp-drift-canary' }))}`,
        { stdio: 'ignore' });
    } catch (_) { /* CNCP best-effort */ }
  }
}

function writeLatest(o) {
  try { fs.mkdirSync(path.dirname(LATEST), { recursive: true }); } catch (_) {}
  fs.writeFileSync(LATEST, JSON.stringify({ canary: 'crcp-drift', ...o }, null, 2));
}

main();