OverviewFilesCommitsIssuesDocsReleasesPeers

7rnx-proxy / lib / deployer.js

233 lines · javascript ref:
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { generateConfig, generateVhosts } from './config-generator.js'

// Host-routed vhosts land in sites-enabled/ (top-level, loaded by apache2.conf's
// IncludeOptional sites-enabled/*.conf) so <VirtualHost> blocks are valid. The path is
// fixed to match the NOPASSWD sudoers rule installed by setup-7rnx-server.sh.
const VHOSTS_REMOTE = '/etc/apache2/sites-enabled/7rnx-proxy-vhosts.conf'
const VHOSTS_TMP = '/tmp/7rnx-proxy-vhosts.conf'

// POSIX single-quote escaping for values that must be interpolated into a
// remote shell command (e.g. a caller-supplied config path). Wraps the value
// in single quotes and escapes any embedded single quote.
export function shellQuote (s) {
  return "'" + String(s).replace(/'/g, "'\\''") + "'"
}

export class Deployer {
  constructor (registry) {
    this.registry = registry
    this.server = registry.server
  }

  // execFileSync (not execSync) so the LOCAL shell never parses these args —
  // the command string is handed to ssh as a single argv entry and executed by
  // the REMOTE shell. Anything interpolated into `cmd` that comes from user
  // input must still be shellQuote()'d for the remote shell (see fetchRemoteConfig).
  _ssh (cmd) {
    const dest = `${this.server.user}@${this.server.host}`
    return execFileSync('ssh', [dest, cmd], { encoding: 'utf8', timeout: 30000 })
  }

  _scp (localPath, remotePath) {
    const dest = `${this.server.user}@${this.server.host}:${remotePath}`
    return execFileSync('scp', [localPath, dest], { encoding: 'utf8', timeout: 30000 })
  }

  dryRun () {
    return generateConfig(this.registry)
  }

  deploy () {
    const config = generateConfig(this.registry)

    // Write to temp file
    const tmpFile = path.join(os.tmpdir(), '7rnx-proxy.conf')
    fs.writeFileSync(tmpFile, config)

    try {
      // Upload config to remote /tmp
      console.log('Uploading config to 7rnx.net...')
      this._scp(tmpFile, '/tmp/7rnx-proxy.conf')

      // Validate-then-apply with rollback. `apachectl configtest` only checks
      // what is actually Included, so the new file MUST be installed before we
      // can test it — a backup makes the failure path a clean restore. This
      // fixes the old flow, which ran configtest BEFORE copying (so it tested
      // the pre-deploy tree) and then copied + reloaded UNCONDITIONALLY.
      // Uses only cp / apachectl / systemctl reload (the NOPASSWD sudoers set).
      console.log('Validating new config against the live tree (rollback on failure)...')
      const remote = [
        'sudo cp /etc/apache2/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf.bak 2>/dev/null || true',
        'sudo cp /tmp/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf',
        'if sudo apachectl configtest 2>&1; then',
        '  sudo systemctl reload apache2 && echo __DEPLOY_OK__',
        'else',
        '  sudo cp /etc/apache2/7rnx-proxy.conf.bak /etc/apache2/7rnx-proxy.conf 2>/dev/null || true',
        '  echo __DEPLOY_FAILED_ROLLED_BACK__',
        '  exit 1',
        'fi'
      ].join('\n')

      let out
      try {
        out = this._ssh(remote)
      } catch (err) {
        // Non-zero exit (configtest failed → rolled back). Surface remote output.
        const detail = (err.stdout || '') + (err.stderr || '') || err.message
        throw new Error(`config test failed — rolled back, live config unchanged:\n${detail.trim()}`)
      }

      console.log(out.trim())
      if (out.indexOf('__DEPLOY_OK__') === -1) {
        throw new Error('deploy did not confirm success (no __DEPLOY_OK__ marker)')
      }
      console.log('Deploy complete.')
    } finally {
      fs.unlinkSync(tmpFile)
    }
  }

  dryRunVhosts () {
    return generateVhosts(this.registry)
  }

  // Deploy host-routed vhosts (vhost mode). Same validate-then-apply-with-rollback flow
  // as deploy(), but to sites-enabled/ (top-level). Uses only cp / apachectl / systemctl
  // reload — the fixed VHOSTS_REMOTE path matches the extra NOPASSWD sudoers rule.
  deployVhosts () {
    const config = generateVhosts(this.registry)
    const tmpFile = path.join(os.tmpdir(), '7rnx-proxy-vhosts.conf')
    fs.writeFileSync(tmpFile, config)

    try {
      console.log('Uploading vhosts config to 7rnx.net...')
      this._scp(tmpFile, VHOSTS_TMP)

      console.log('Validating vhosts against the live tree (rollback on failure)...')
      const remote = [
        `sudo cp ${VHOSTS_REMOTE} ${VHOSTS_REMOTE}.bak 2>/dev/null || true`,
        `sudo cp ${VHOSTS_TMP} ${VHOSTS_REMOTE}`,
        'if sudo apachectl configtest 2>&1; then',
        '  sudo systemctl reload apache2 && echo __DEPLOY_OK__',
        'else',
        `  sudo cp ${VHOSTS_REMOTE}.bak ${VHOSTS_REMOTE} 2>/dev/null || true`,
        '  echo __DEPLOY_FAILED_ROLLED_BACK__',
        '  exit 1',
        'fi'
      ].join('\n')

      let out
      try {
        out = this._ssh(remote)
      } catch (err) {
        const detail = (err.stdout || '') + (err.stderr || '') || err.message
        throw new Error(`vhosts config test failed — rolled back, live config unchanged:\n${detail.trim()}`)
      }

      console.log(out.trim())
      if (out.indexOf('__DEPLOY_OK__') === -1) {
        throw new Error('vhosts deploy did not confirm success (no __DEPLOY_OK__ marker)')
      }
      console.log('Vhosts deploy complete.')
    } finally {
      fs.unlinkSync(tmpFile)
    }
  }

  status () {
    try {
      console.log('Apache status:')
      const status = this._ssh('sudo systemctl is-active apache2 2>&1')
      console.log('  Service:', status.trim())

      const test = this._ssh('sudo apachectl configtest 2>&1')
      console.log('  Config:', test.trim())

      const conf = this._ssh('test -f /etc/apache2/7rnx-proxy.conf && echo "present" || echo "missing"')
      console.log('  7rnx-proxy.conf:', conf.trim())
    } catch (err) {
      console.error('Failed to check status:', err.message)
    }
  }

  fetchRemoteConfig (configPath) {
    try {
      // configPath may come from the CLI or the web ?path= param — quote it so
      // it cannot break out of `cat <path>` on the remote shell.
      return this._ssh(`cat ${shellQuote(configPath)}`)
    } catch (err) {
      console.error(`Failed to read ${configPath}:`, err.message)
      return null
    }
  }

  checkServices (nodes, services) {
    const results = []
    const nodesByIp = new Map()
    for (const n of nodes) nodesByIp.set(n.petname, n)

    // Group services by node IP to batch SSH calls
    const byIp = new Map()
    for (const svc of services) {
      const node = nodesByIp.get(svc.node)
      if (!node) continue
      if (!byIp.has(node.ip)) byIp.set(node.ip, { node, services: [] })
      byIp.get(node.ip).services.push(svc)
    }

    for (const [ip, { node, services: svcs }] of byIp) {
      // Ports are Number()-coerced in the registry, so this command is built
      // only from numeric values.
      const portChecks = svcs.map(s => s.port).join(' ')
      const cmd = `for p in ${portChecks}; do if ss -tlnp 2>/dev/null | grep -q ":$p "; then echo "$p UP"; else echo "$p DOWN"; fi; done`

      let output
      try {
        if (ip === '10.22.0.1') {
          // tabertron is local — run directly (no local shell parsing of cmd)
          output = execFileSync('bash', ['-c', cmd], { encoding: 'utf8', timeout: 10000 })
        } else {
          output = execFileSync('ssh', [`${node.petname}.local`, cmd], { encoding: 'utf8', timeout: 10000 })
        }
      } catch {
        // Node unreachable
        for (const svc of svcs) {
          results.push({ node: node.petname, ip, name: svc.name, port: svc.port, status: 'UNREACHABLE' })
        }
        continue
      }

      const portStatus = new Map()
      for (const line of output.trim().split('\n')) {
        const [port, status] = line.split(' ')
        portStatus.set(Number(port), status)
      }

      for (const svc of svcs) {
        results.push({
          node: node.petname,
          ip,
          name: svc.name,
          port: svc.port,
          status: portStatus.get(svc.port) || 'UNKNOWN'
        })
      }
    }

    return results
  }

  listRemoteConfigs () {
    try {
      return this._ssh('ls /etc/apache2/sites-enabled/')
    } catch (err) {
      console.error('Failed to list configs:', err.message)
      return null
    }
  }
}