OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / publish-test-drive.js

306 lines · javascript ref:
#!/usr/bin/env node
// scripts/publish-test-drive.js
//
// Synthetic publisher for end-to-end fleet testing.
//
// Creates an ephemeral publisher keypair, builds a small Hyperdrive with
// deterministic content, signs a /api/v1/seed request, posts it to a chosen
// relay, and prints the appKey + publisher pubkey so subsequent tooling
// (observatory, custody-e2e) can watch the result propagate.
//
// Usage:
//
//   node scripts/publish-test-drive.js                          # default: utah, 1MB
//   node scripts/publish-test-drive.js --target singapore-1     # different relay
//   node scripts/publish-test-drive.js --size 10mb              # bigger drive
//   node scripts/publish-test-drive.js --label my-test          # custom appId
//   node scripts/publish-test-drive.js --roundrobin             # post to every relay
//   node scripts/publish-test-drive.js --watch 90               # poll observatory for 90s after publish
//
// What the relay sees:
//   POST /api/v1/seed
//   {
//     appKey, publisherPubkey, publisherSignature,
//     replicationFactor: 3, maxStorageBytes: <drive size * 4>,
//     ttlSeconds: 30 days, bountyRate: 0, revocable: true, durability: 0,
//     name, description, version, type: 'app'
//   }
//
// The drive itself sits in a tmpdir on this machine; the relay pulls blocks
// from us over Hyperswarm just like a real publisher. Drive is held open
// for --hold-seconds (default 300) so the relay has time to pull, then we
// exit and the drive becomes unreachable (deliberate: matches real
// publisher-goes-offline behavior, lets us watch how the fleet handles it).

import Hyperdrive from 'hyperdrive'
import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import sodium from 'sodium-universal'
import b4a from 'b4a'
import { tmpdir } from 'os'
import { mkdir, rm } from 'fs/promises'
import { join } from 'path'
import { randomBytes } from 'crypto'

import { serializeSeedRequestForSigning } from '../packages/core/core/protocol/seed-request.js'

// ── Config ──────────────────────────────────────────────────────────────

const RELAYS = {
  utah: { host: '144.172.101.215', port: 9100 },
  'utah-us': { host: '144.172.91.26', port: 9100 },
  'singapore-1': { host: '104.194.153.179', port: 9100 },
  'singapore-2': { host: '104.194.152.121', port: 9100 },
  bern: { host: '45.59.123.112', port: 9100 }
}

const args = parseArgs(process.argv.slice(2))
const TARGETS = resolveTargets(args)
const SIZE_BYTES = parseSize(args.size || '1mb')
const HOLD_SECONDS = Number(args['hold-seconds'] || 300)
// Synthetic drives self-expire: relay's _runCustodyExpiryPass unseeds
// storageClass:temporary entries once retainUntil passes. Default 60 min;
// --retain-mins 0 disables (permanent — only for deliberate load tests).
const RETAIN_MINS = args['retain-mins'] != null ? Number(args['retain-mins']) : 60
const RETAIN_MS = RETAIN_MINS > 0 ? RETAIN_MINS * 60 * 1000 : 0
const LABEL = args.label || `test-${Date.now().toString(36)}`
const WATCH_SECONDS = args.watch != null ? Number(args.watch) : 0

console.log(`▸ Synthetic publish — ${LABEL}, ${formatBytes(SIZE_BYTES)} → ${TARGETS.join(', ')}\n`)

main().catch(err => {
  console.error('FATAL:', err.message)
  process.exit(1)
})

// ── Main ────────────────────────────────────────────────────────────────

async function main () {
  const storagePath = join(tmpdir(), `hiverelay-publish-${process.pid}-${Date.now()}`)
  await mkdir(storagePath, { recursive: true })

  const store = new Corestore(storagePath)
  const drive = new Hyperdrive(store)
  await drive.ready()

  const appKey = b4a.toString(drive.key, 'hex')
  const discoveryKey = b4a.toString(drive.discoveryKey, 'hex')

  console.log('  drive ready')
  console.log(`    appKey:       ${appKey}`)
  console.log(`    discoveryKey: ${discoveryKey}`)

  // Write content. /manifest.json describes the drive so the relay can
  // index it the same way real publishers do.
  await drive.put('/manifest.json', JSON.stringify({
    id: LABEL,
    name: LABEL,
    description: `Synthetic test drive (${formatBytes(SIZE_BYTES)}) — created ${new Date().toISOString()}`,
    version: '1.0.0',
    author: 'observatory-test-runner',
    contentType: 'app',
    privacyTier: 'public',
    categories: ['test', 'observatory']
  }))

  // Fill /payload.bin with deterministic-but-different-per-run bytes
  const payload = b4a.alloc(SIZE_BYTES)
  randomBytes(SIZE_BYTES).copy(payload)
  await drive.put('/payload.bin', payload)

  await drive.put('/README.md', `# ${LABEL}\n\nSynthetic drive for fleet end-to-end testing.\nPublished at ${new Date().toISOString()}.\nSize: ${formatBytes(SIZE_BYTES)}.\n`)

  const driveBytes = (drive.db?.core?.byteLength || 0) + (drive.blobs?.core?.byteLength || 0)
  console.log(`    size:         ${formatBytes(driveBytes)} (${drive.version} versions)`)

  // ── Publisher keypair ────────────────────────────────────────────────
  const publisherPub = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const publisherSec = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_keypair(publisherPub, publisherSec)
  const publisherPubkey = b4a.toString(publisherPub, 'hex')
  console.log(`    publisher:    ${publisherPubkey}`)

  // ── Build + sign the seed request ───────────────────────────────────
  const replicationFactor = 3
  const maxStorageBytes = Math.max(driveBytes * 4, 64 * 1024 * 1024) // headroom × 4
  const ttlSeconds = 30 * 24 * 3600
  const revocable = true
  const durability = 0
  const unseedFreezeMs = 0
  const bountyRate = 0

  const sigMsg = {
    appKey: drive.key,
    discoveryKeys: [drive.discoveryKey],
    replicationFactor,
    maxStorageBytes,
    ttlSeconds,
    bountyRate,
    revocable,
    unseedFreezeMs,
    durability,
    publisherPubkey: publisherPub
  }
  const toSign = serializeSeedRequestForSigning(sigMsg)
  const signature = b4a.alloc(sodium.crypto_sign_BYTES)
  sodium.crypto_sign_detached(signature, toSign, publisherSec)

  const body = {
    appKey,
    discoveryKeys: [discoveryKey],
    replicationFactor,
    maxStorageBytes,
    ttlSeconds,
    bountyRate,
    revocable,
    unseedFreezeMs,
    durability,
    publisherPubkey,
    publisherSignature: b4a.toString(signature, 'hex'),
    name: LABEL,
    description: `Synthetic test drive (${formatBytes(SIZE_BYTES)})`,
    version: '1.0.0',
    type: 'app',
    privacyTier: 'public',
    // Self-expiring: storageClass:temporary + a short retainUntil makes
    // the relay's existing 60s _runCustodyExpiryPass auto-unseed this
    // entry once retainUntil passes — so synthetic test drives never
    // accumulate as permanent junk on the fleet. --retain-mins 0 opts
    // out (permanent; only for deliberate load tests). storageClass/
    // retainUntil are unsigned metadata (not in
    // serializeSeedRequestForSigning) so this does not affect the
    // publisher signature.
    ...(RETAIN_MS > 0
      ? { storageClass: 'temporary', retainUntil: Date.now() + RETAIN_MS }
      : {})
  }

  // ── Start swarm so the relay can actually pull blocks ────────────────
  const swarm = new Hyperswarm()
  swarm.on('connection', (conn) => store.replicate(conn))
  swarm.join(drive.discoveryKey, { server: true, client: true })
  await swarm.flush()
  console.log('  swarm announced, awaiting relay pulls')
  console.log()

  // ── POST to each target relay ────────────────────────────────────────
  for (const targetId of TARGETS) {
    const target = RELAYS[targetId]
    if (!target) {
      console.error(`  ✗ unknown target: ${targetId}`)
      continue
    }
    const url = `http://${target.host}:${target.port}/api/v1/seed`
    process.stdout.write(`  → ${targetId} (${target.host})  ... `)
    try {
      const res = await fetch(url, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(body)
      })
      const text = await res.text()
      let parsed = null
      try { parsed = JSON.parse(text) } catch (_) {}
      if (res.ok) {
        console.log(`OK ${res.status}  dk=${parsed?.discoveryKey?.slice(0, 12) || '?'} alreadySeeded=${parsed?.alreadySeeded === true}`)
      } else {
        console.log(`FAIL ${res.status}  ${text.slice(0, 200)}`)
      }
    } catch (err) {
      console.log(`ERR  ${err.message}`)
    }
  }
  console.log()

  // ── Optional observatory watch ──────────────────────────────────────
  if (WATCH_SECONDS > 0) {
    await watchObservatory(appKey, WATCH_SECONDS)
  }

  // ── Hold the drive open so relays can pull ───────────────────────────
  console.log(`  holding drive open for ${HOLD_SECONDS}s so relays can pull blocks ...`)
  console.log('  (Ctrl-C to release early)')
  await new Promise(resolve => setTimeout(resolve, HOLD_SECONDS * 1000))

  console.log('  releasing drive')
  try { await swarm.destroy() } catch (_) {}
  try { await drive.close() } catch (_) {}
  try { await store.close() } catch (_) {}
  try { await rm(storagePath, { recursive: true, force: true }) } catch (_) {}
  console.log('done.')
}

// ── Helpers ─────────────────────────────────────────────────────────────

async function watchObservatory (appKey, seconds) {
  // Bypass the observatory's truncated app list — query each relay's
  // catalog directly with a generous pageSize so we see anchors even
  // when the relay has hundreds of apps. The observatory is still the
  // right aggregation point for top-level metrics; per-app lookups go
  // straight to the source.
  console.log(`  watching fleet for anchor propagation (${seconds}s, polling every 5s) ...`)
  const start = Date.now()
  const seenAnchored = new Set()
  while ((Date.now() - start) / 1000 < seconds) {
    const checks = Object.entries(RELAYS).map(async ([id, target]) => {
      if (seenAnchored.has(id)) return
      try {
        const res = await fetch(`http://${target.host}:${target.port}/catalog.json?pageSize=1000`, {
          signal: AbortSignal.timeout(4000)
        })
        const cat = await res.json()
        const ours = (cat.apps || []).find(a => a.appKey === appKey)
        if (ours && ours.anchored) {
          seenAnchored.add(id)
          const elapsed = ((Date.now() - start) / 1000).toFixed(1)
          console.log(`    ✓ anchored on ${id} after ${elapsed}s (anchoredLength=${ours.anchoredLength})`)
        }
      } catch (err) {
        // transient — retry next tick
      }
    })
    await Promise.all(checks)
    if (seenAnchored.size === Object.keys(RELAYS).length) {
      console.log('    all relays anchored — exiting watch early')
      break
    }
    await new Promise(resolve => setTimeout(resolve, 5000))
  }
  console.log(`  watch finished. anchored on ${seenAnchored.size}/${Object.keys(RELAYS).length} relays: ${[...seenAnchored].join(', ') || '(none)'}`)
  console.log()
}

function parseArgs (argv) {
  const out = {}
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i]
    if (a.startsWith('--')) {
      const k = a.slice(2)
      const next = argv[i + 1]
      if (next && !next.startsWith('--')) { out[k] = next; i++ } else { out[k] = true }
    }
  }
  return out
}

function resolveTargets (args) {
  if (args.roundrobin) return Object.keys(RELAYS)
  if (args.target) return [args.target]
  return ['utah'] // default
}

function parseSize (s) {
  const m = String(s).toLowerCase().match(/^(\d+)\s*([kmg]?)b?$/)
  if (!m) throw new Error(`bad --size: ${s}`)
  const n = Number(m[1])
  const mul = { '': 1, k: 1024, m: 1024 * 1024, g: 1024 * 1024 * 1024 }[m[2]]
  return n * mul
}

function formatBytes (n) {
  if (n < 1024) return n + ' B'
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
  if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'
  return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}