OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / observe-public-hive-gateway-rollout.mjs

246 lines · javascript ref:
#!/usr/bin/env node

import { constants as fsConstants } from 'node:fs'
import { open } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const defaultChecker = path.join(scriptDir, 'check-fleet-rollout.mjs')
const MAX_STATE_BYTES = 16 * 1024 * 1024
const usage = `
Usage:
  node scripts/observe-public-hive-gateway-rollout.mjs [observer options] -- <fleet:check-rollout options>

Observer options:
  --sample-interval-ms <ms>  Delay after an observing sample (default: 60000)
  --max-runtime-ms <ms>      Overall observer budget (default: 691200000 / 8 days)
  --checker <absolute-path>  Alternate checker script (fixtures/tests only)
  --help                     Show this help

The forwarded checker options must include the manifest, remote evidence,
window state, output evidence, pinned known_hosts, allowed_signers, target,
and channel=canary. Exit 2 means keep observing; any red/failure stops. Stable
promotion remains a separate explicit command.`

try {
  await main()
} catch (err) {
  console.error(`public gateway observer: ${safeError(err)}`)
  process.exitCode = 1
}

async function main () {
  const { options, forwarded } = parseArgs(process.argv.slice(2))
  if (options.help) {
    console.log(usage.trim())
    return
  }
  assertForwardedGatewayCheck(forwarded)

  const checker = requireAbsolutePath(options.checker || defaultChecker, 'checker')
  const sampleIntervalMs = exactInteger(options.sampleIntervalMs, 60_000, 1_000, 5 * 60_000, 'sample-interval-ms')
  const maxRuntimeMs = exactInteger(options.maxRuntimeMs, 8 * 24 * 60 * 60 * 1000,
    60_000, 8 * 24 * 60 * 60 * 1000, 'max-runtime-ms')
  const statePath = requireAbsolutePath(valueAfter(forwarded, '--gateway-window-state'), 'gateway-window-state')
  const deadline = Date.now() + maxRuntimeMs
  let samples = 0

  console.log(`Public gateway observer started; checker=${checker} interval=${sampleIntervalMs}ms`)
  while (Date.now() < deadline) {
    const code = await runChecker(checker, forwarded)
    samples++
    if (code === 0) {
      console.log(`Public gateway observation complete after ${samples} checker run(s). Stable promotion remains explicit.`)
      return
    }
    if (code !== 2) {
      throw new Error(`rollout checker stopped red with exit ${code}; observation aborted`)
    }

    const state = await readObservationState(statePath)
    const maxProbeGapMs = state.maxProbeGapMs
    if (!Number.isSafeInteger(maxProbeGapMs) || maxProbeGapMs < 60_000 || maxProbeGapMs > 30 * 60_000) {
      throw new Error('observation state contains an invalid signed maxProbeGapMs')
    }
    // Leave at least half the signed gap for checker execution, updater jitter,
    // and scheduling delay. The checker remains authoritative and resets any
    // discontinuous window; this guard avoids knowingly choosing a bad cadence.
    if (sampleIntervalMs * 2 > maxProbeGapMs) {
      throw new Error(`sample interval ${sampleIntervalMs}ms leaves insufficient margin under signed maxProbeGapMs ${maxProbeGapMs}`)
    }
    if (Date.now() + sampleIntervalMs >= deadline) break
    console.log(`Observation incomplete after ${samples} checker run(s); next sample in ${sampleIntervalMs}ms.`)
    await sleep(sampleIntervalMs)
  }
  throw new Error(`observer runtime budget ${maxRuntimeMs}ms expired before the signed window completed`)
}

function parseArgs (argv) {
  const separator = argv.indexOf('--')
  if (separator < 0 && argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
    return { options: { help: true }, forwarded: [] }
  }
  if (separator < 0) throw new Error('a -- separator before fleet checker options is required')
  const own = argv.slice(0, separator)
  const forwarded = argv.slice(separator + 1)
  const options = {}
  const valueOptions = new Set(['sample-interval-ms', 'max-runtime-ms', 'checker'])
  const seen = new Set()
  for (let i = 0; i < own.length; i++) {
    const raw = own[i]
    if (raw === '--help' || raw === '-h') {
      if (seen.has('help')) throw new Error('duplicate --help')
      seen.add('help')
      options.help = true
      continue
    }
    if (!raw.startsWith('--')) throw new Error(`unexpected observer argument ${JSON.stringify(raw)}`)
    const name = raw.slice(2)
    if (!valueOptions.has(name)) throw new Error(`unknown observer option --${name}`)
    if (seen.has(name)) throw new Error(`duplicate observer option --${name}`)
    seen.add(name)
    const value = own[++i]
    if (!value || value.startsWith('--')) throw new Error(`missing value for --${name}`)
    options[camel(name)] = value
  }
  return { options, forwarded }
}

function assertForwardedGatewayCheck (argv) {
  if (argv.length === 0) throw new Error('fleet checker options are required after --')
  const required = [
    '--target',
    '--gateway-evidence',
    '--gateway-manifest',
    '--gateway-window-state',
    '--evidence',
    '--known-hosts',
    '--allowed-signers',
    '--channel'
  ]
  for (const flag of required) {
    const value = valueAfter(argv, flag)
    if (!value) throw new Error(`${flag} is required in forwarded fleet checker options`)
  }
  if (valueAfter(argv, '--channel') !== 'canary') {
    throw new Error('observer is restricted to the canary gateway cohort')
  }
  if (argv.includes('--dry-run')) throw new Error('observer cannot accumulate a dry-run window')
}

function valueAfter (argv, flag) {
  const indexes = []
  for (let i = 0; i < argv.length; i++) if (argv[i] === flag) indexes.push(i)
  if (indexes.length !== 1) return null
  const value = argv[indexes[0] + 1]
  return value && !value.startsWith('--') ? value : null
}

async function runChecker (checker, forwarded) {
  return new Promise((resolve, reject) => {
    const child = spawn(process.execPath, [checker, ...forwarded], {
      stdio: 'inherit',
      env: process.env
    })
    let settled = false
    const finish = (fn, value) => {
      if (settled) return
      settled = true
      process.off('SIGINT', onInterrupt)
      process.off('SIGTERM', onTerminate)
      fn(value)
    }
    const onInterrupt = () => {
      child.kill('SIGINT')
      finish(reject, new Error('observer interrupted'))
    }
    const onTerminate = () => {
      child.kill('SIGTERM')
      finish(reject, new Error('observer terminated'))
    }
    process.once('SIGINT', onInterrupt)
    process.once('SIGTERM', onTerminate)
    child.once('error', err => finish(reject, err))
    child.once('exit', (code, signal) => {
      if (signal) finish(reject, new Error(`rollout checker terminated by ${signal}`))
      else finish(resolve, Number.isInteger(code) ? code : 1)
    })
  })
}

async function readObservationState (file) {
  let handle
  try {
    if (typeof fsConstants.O_NOFOLLOW !== 'number') throw new Error('platform lacks O_NOFOLLOW')
    handle = await open(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW)
    const before = await handle.stat({ bigint: true })
    if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(MAX_STATE_BYTES)) {
      throw new Error('observation state must be a bounded single-link regular file')
    }
    const buffer = Buffer.allocUnsafe(MAX_STATE_BYTES + 1)
    let length = 0
    while (length < buffer.length) {
      const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length)
      if (bytesRead === 0) break
      length += bytesRead
    }
    if (length > MAX_STATE_BYTES) throw new Error('observation state exceeds its read bound')
    const after = await handle.stat({ bigint: true })
    if (!sameSnapshot(before, after) || BigInt(length) !== after.size) {
      throw new Error('observation state changed while being read')
    }
    const value = JSON.parse(buffer.subarray(0, length).toString('utf8'))
    if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('observation state must contain a JSON object')
    return value
  } catch (err) {
    throw new Error(`cannot safely resume observation state: ${safeError(err)}`)
  } finally {
    await handle?.close().catch(() => {})
  }
}

function sameSnapshot (left, right) {
  return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode &&
    left.nlink === right.nlink && left.size === right.size && left.mtimeNs === right.mtimeNs &&
    left.ctimeNs === right.ctimeNs
}

function exactInteger (value, fallback, min, max, label) {
  if (value == null) return fallback
  if (!/^(?:0|[1-9][0-9]*)$/.test(String(value))) throw new Error(`--${label} must be an exact integer`)
  const number = Number(value)
  if (!Number.isSafeInteger(number) || number < min || number > max) {
    throw new Error(`--${label} must be between ${min} and ${max}`)
  }
  return number
}

function requireAbsolutePath (value, label) {
  if (typeof value !== 'string' || !path.isAbsolute(value) || value.length > 4096 || hasControlChars(value)) {
    throw new Error(`${label} must be a bounded absolute path`)
  }
  return value
}

function hasControlChars (value) {
  for (const character of String(value)) {
    const code = character.charCodeAt(0)
    if (code <= 31 || code === 127) return true
  }
  return false
}

function camel (value) {
  return value.replace(/-([a-z])/g, (_, character) => character.toUpperCase())
}

function safeError (err) {
  return String(err?.message || err || 'unknown error').replace(/[\r\n\0]/g, ' ').slice(0, 1000)
}

function sleep (ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}