OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / cutover-prod-copy.test.js

170 lines · javascript ref:
// #52 Phase 1.5 — THE cutover proof. Boot the experimental daemon on a COPY
// of prod's REAL store (allowBackup) and assert full parity: identity address
// preserved, orgs migrated v1→v2 (#37), org issues migrated into rooms (#42),
// IdentityRegistry users read back, schema version stamped. This is the
// strongest pre-cutover evidence — real data, real boot path, real migration.
//
// SAFETY: copies ~/pgv3-storage read-only to a tmp dir and only ever touches
// the COPY. The live prod daemon on the original is undisturbed (separate
// inode + allowBackup). Skips if prod store / orgs fixture is absent.
//
// Heavy (copies ~11G of prod data). Run manually, under BARE:
//   bare test/cutover-prod-copy.test.js
//
// NOT node, despite what this said until 2026-07-31: importing ../index.js pulls hyper-overlay's
// bare-path, which dereferences the `Bare` global at module scope and throws under node before a single
// assertion runs. The stale instruction reads as "this test is broken" rather than "wrong runtime" —
// which is roughly how it came to be dismissed as broken while being the only test positioned to catch
// the hypercore-storage downgrade (see org #24).

import test from 'brittle'
import process from '#node:process'
import { execSync } from '../lib/_compat-exec.js'
import { existsSync, mkdirSync, readFileSync, rmSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { Client } from 'hyper-svc'

// Read prod identity BEFORE importing the daemon (daemon reads env at boot).
process.env.PGV3_USE_RAW_IDENTITY = '1'
const { startPearGit } = await import('../index.js')

const PROD_STORAGE = '/home/fleeky/pgv3-storage'
const PROD_ORGS_FIXTURE = '/tmp/prod-orgs.json' // pgh --json org list > /tmp/prod-orgs.json

test('cutover: experimental daemon boots on a COPY of prod store, full parity', async (t) => {
  // Booting on a COPY OF REAL PROD DATA is not a unit test: this store carries
  // ~140 repos and a dozen orgs, and boot mounts every one of them. brittle's
  // default 30s expires mid-mount, which reads as a hang rather than as "the
  // daemon is working". Give it room proportional to the real workload.
  t.timeout(180000)
  if (!existsSync(PROD_STORAGE)) { t.pass('skipped — prod storage absent'); return }

  const prodIdentity = JSON.parse(readFileSync(join(PROD_STORAGE, 'identity.json'), 'utf8'))
  const prodOrgs = existsSync(PROD_ORGS_FIXTURE) ? JSON.parse(readFileSync(PROD_ORGS_FIXTURE, 'utf8')) : null
  // prod may already be cut over (v2) — capture its version so the invariant
  // is "the test only touches the COPY, never mutates prod", not "prod is v0".
  const readVer = (dir) => { try { return JSON.parse(readFileSync(join(dir, 'schema-version.json'), 'utf8')).version } catch { return 0 } }
  const prodVerBefore = readVer(PROD_STORAGE)

  // ONE FIXED ROOT, SWEPT AT START — not mkdtemp. This copy is ~11G, and mkdtemp meant every
  // interrupted run left a fresh 11G directory behind forever: on 2026-07-30 eight of them had
  // accumulated in /tmp (88G) from runs killed between the `cp -r` below and teardown.
  //
  // t.teardown alone cannot fix that — it only runs when the test COMPLETES, and the failure mode is
  // precisely a run that does not. A fixed path is what actually bounds the damage: the sweep below
  // reclaims the previous crashed run's copy, so at most ONE stale copy can ever exist regardless of
  // how the process dies. The exit/signal hooks are a best-effort bonus on top, not the mechanism.
  //
  // TRADE-OFF, stated: two concurrent runs of THIS test on one machine would now collide. That was
  // never a supported thing to do (they would copy 22G of prod), and bounded disk is worth more.
  const ROOT = join(tmpdir(), 'cutover-prod-copy')
  try { rmSync(ROOT, { recursive: true, force: true }) } catch {}
  mkdirSync(ROOT, { recursive: true })
  const cleanup = () => { try { rmSync(ROOT, { recursive: true, force: true }) } catch {} }
  // Registered BEFORE the expensive copy, so an early abort or a boot failure is covered too — the
  // old code only registered teardown after a successful boot.
  t.teardown(cleanup)
  try { process.on('exit', cleanup) } catch {}
  for (const sig of ['SIGINT', 'SIGTERM']) {
    try { process.on(sig, () => { cleanup(); process.exit(130) }) } catch {}
  }

  // 1. read-only copy of the live prod store → tmp (original untouched).
  // `copy` must NOT exist beforehand: `cp -r src dst` creates dst as the copy. Pre-creating it would
  // nest the store one level deeper and every path below would miss.
  const copy = join(ROOT, 'storage')
  execSync('cp -r ' + JSON.stringify(PROD_STORAGE) + ' ' + JSON.stringify(copy))
  // drop the copied live socket so the daemon makes its own
  try { rmSync(join(copy, 'svc.sock')) } catch {}
  t.ok(existsSync(join(copy, 'store')), 'store copied')
  t.is(readVer(copy), prodVerBefore, 'copy starts at prod\'s current schema version (v' + prodVerBefore + ')')

  // 1b. a FIXTURE bare repo (never the real ~/git-repos — the daemon may write
  // hooks into reposDir). Proves the MIGRATED daemon still serves repos — the
  // core forge function, which the original cutover proof omitted.
  // Both fixtures live UNDER the swept root too, so the one cleanup covers everything. They were
  // separate mkdtemps before, which is why 22 stray cutover-repo*/cutover-repos* dirs outlived their
  // runs alongside the big ones.
  const work = join(ROOT, 'work')
  execSync('git init -q "' + work + '"')
  execSync('git -C "' + work + '" -c user.email=t@t -c user.name=t commit --allow-empty -q -m init')
  const reposDir = join(ROOT, 'repos')
  mkdirSync(reposDir, { recursive: true })
  execSync('git clone -q --bare "' + work + '" "' + join(reposDir, 'fixture.git') + '"')

  // 2. boot the REAL experimental daemon on the copy (allowBackup, hermetic)
  let node
  try {
    node = await startPearGit({
      storage: copy,
      uds: join(copy, 'svc.sock'),
      reposDir,
      http: { port: 0 },
      swarm: false,
      deploy: false,
      autoImportSSH: false,
      allowBackup: true
    })
  } catch (err) {
    t.fail('daemon failed to boot on prod-store copy: ' + err.message)
    cleanup() // was `rmSync(copy)` — which left the parent dir AND both fixtures behind
    return
  }
  // Close the daemon THEN remove its storage, in one teardown, so the order does not depend on
  // brittle's teardown sequencing. cleanup() is idempotent (force:true), so the earlier registration
  // running as well is a harmless no-op.
  t.teardown(async () => { try { await node.close() } catch {}; cleanup() })

  // 3. IDENTITY — same address (publicKey) as prod
  t.is(node.identity.publicKeyHex, prodIdentity.publicKey,
    'daemon identity == prod identity (punch:// address preserved)')

  // 4. ORGS — migrated v1 → v2, all present
  const orgs = await node.orgs.list()
  t.ok(orgs.length > 0, 'orgs present after migration (' + orgs.length + ')')
  if (prodOrgs) {
    // subset: every org in the (possibly-stale) fixture must be migrated; the
    // live copy may have MORE orgs created since the fixture was captured.
    const migratedNames = new Set(orgs.map(o => o.name))
    const missing = prodOrgs.map(o => o.name).filter(n => !migratedNames.has(n))
    t.alike(missing, [], 'every prod-fixture org migrated (missing: ' + JSON.stringify(missing) + ')')
    // members preserved on the org that has them
    for (const po of prodOrgs.filter(o => (o.members || []).length > 0)) {
      const mig = await node.orgs.get(po.name)
      const ids = new Set((mig.members || []).map(m => m.userId))
      for (const pm of po.members) {
        t.ok(ids.has(pm.userId), po.name + ' member ' + String(pm.userId).slice(0, 8) + ' preserved')
      }
    }
  }

  // 5. ORG ISSUES — readable through the linked-core room (migrated #42)
  let issuesTotal = 0
  for (const o of orgs) {
    const tracker = await node.issueRegistry.getForOrg(o.name)
    const list = await tracker.list()
    issuesTotal += list.length
  }
  t.ok(issuesTotal >= 0, 'org issue trackers all open + readable post-migration (' + issuesTotal + ' org issues)')

  // 6. IDENTITY REGISTRY — users read back under the new code (schema pass-through)
  const users = await node.identityRegistry.listUsers()
  t.ok(Array.isArray(users), 'IdentityRegistry.listUsers() reads prod identities (' + users.length + ')')

  // 6b. REPO SERVING — the migrated daemon still serves bare repos from reposDir
  // (the core forge function). list-repos reflects reposDir even without swarm.
  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => { try { await client.close() } catch {} })
  const repoList = await client.call('list-repos', {})
  t.is(repoList.active, true, 'sync/repo service active on the migrated daemon')
  t.ok(repoList.repoCount >= 1, 'migrated daemon serves bare repos from reposDir (' + repoList.repoCount + ')')

  // 7. VERSION — the COPY ends at target v2 (whether it started v0 and migrated,
  // or started v2 and the boot was an idempotent no-op).
  t.ok(existsSync(join(copy, 'schema-version.json')), 'schema-version.json present on the copy')
  t.is(readVer(copy), 2, 'copy at schema v2 after boot')
  // INVARIANT: the test only touches the COPY — prod's version is unchanged.
  t.is(readVer(PROD_STORAGE), prodVerBefore, 'prod ORIGINAL version unchanged by the test (v' + prodVerBefore + ')')
})