OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / bootstrap-artifacts.js

261 lines · javascript ref:
/**
 * bootstrap-artifacts — produce the curl-installable /bootstrap artifacts by
 * TARRING the local pgh-store app trees (#66, hyper-fleeky-dev).
 *
 * The bootstrap app IS the app: `pgh publish` of a `pgh.bootstrap` app stages a
 * self-contained bare tree (bundled runtime/bare + sh launcher + pruned prod
 * node_modules) and registers THAT as the store tree, which the normal
 * hyper-deploy/OTA install path already brings to every machine. So any machine
 * that has pgh + pgh-sidecar installed ALREADY has both self-contained trees in
 * its store under <store>/apps/<name>.
 *
 * This produces <store>/bootstrap/ — the dir a forge (pear-git-v3) serves at
 * /bootstrap — with ZERO network:
 *   - tar each <store>/apps/<name> whose package.json declares pgh.bootstrap
 *     → <store>/bootstrap/<slug>-bootstrap-bare-<label>.tar.gz
 *     (regenerated only when the app's treeHash changes — cached via
 *     <store>/bootstrap/.bootstrap-state.json),
 *   - SYMLINK install.sh from whichever tree ships scripts/install.sh (the pgh
 *     tree) → <store>/bootstrap/install.sh (a symlink, so it tracks the tree).
 *
 * This is NOT a polling loop. It runs as a byproduct of an install/OTA event
 * (the installer calls it after materializing a bootstrap app) plus a one-time
 * catch-up on sidecar boot. The tarball BYTES need not be deterministic — the
 * durable content-address is the store TREE hash; the tarball is just an
 * ephemeral HTTP serialization, re-derivable on any machine from the same tree.
 */

import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import { spawn } from '#node:child_process'

const STATE_FILE = '.bootstrap-state.json'

export function hostLabel () {
  const o = { linux: 'linux', darwin: 'darwin', win32: 'windows' }[os.platform()] || os.platform()
  const a = { x64: 'x64', arm64: 'arm64' }[os.arch()] || os.arch()
  return o + '-' + a
}

// Absolute path of the served bootstrap dir for a store.
export function bootstrapDir (store) {
  return path.join(store.root, 'bootstrap')
}

// Default: tar srcDir into destTarball (flat — contents at archive root), atomic
// via a tmp file. ASYNC spawn (NOT execFileSync) so a ~400MB tar never blocks the
// caller's event loop — the sidecar runs this at boot + on every bootstrap-app
// install, and a synchronous tar froze its UDS readiness probe (120s timeout).
// Injectable so reconcile unit-tests without shelling out.
function defaultTarTree (srcDir, destTar) {
  const tmp = destTar + '.tmp'
  return new Promise((resolve, reject) => {
    const p = spawn('tar', ['-czf', tmp, '-C', srcDir, '.'], { stdio: 'ignore' })
    p.on('error', reject)
    p.on('exit', (code) => {
      if (code !== 0) return reject(new Error('tar exited with code ' + code))
      try { fs.renameSync(tmp, destTar); resolve() } catch (err) { reject(err) }
    })
  })
}

function readJSON (p) {
  try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null }
}

function slugify (name) {
  return String(name || '').replace(/[^a-z0-9-]/gi, '-')
}

/**
 * Cross-arch seeding. A machine can only TAR its OWN arch — hostLabel() reads
 * os.platform()/os.arch() — so an all-x64 fleet can never produce the
 * linux-arm64 tarball install.sh asks for, and /bootstrap 404s for every arm64
 * VPS. Bootstrap apps already vendor prebuilt cross-arch tarballs at
 * node_modules/<pkg>/dist/<slug>-bootstrap-bare-<label>.tar.gz; this finds them
 * so reconcile can promote them into the served dir. Local tar always wins for
 * the host arch — these only ever fill in the arches we cannot build here.
 */
function vendoredBootstraps (appDir) {
  const out = []
  const seen = new Set()
  const scan = (dist) => {
    let files = []
    try { files = fs.readdirSync(dist) } catch { return }
    for (const f of files) {
      if (!/-bootstrap-bare-.+\.tar\.gz$/.test(f)) continue
      if (seen.has(f)) continue          // own dist/ wins over a vendored copy
      seen.add(f)
      out.push({ name: f, src: path.join(dist, f) })
    }
  }
  // The app's OWN dist/ first — that's the canonical home for these artifacts
  // (they ship alongside a .sha256), so prefer it over whichever dependency
  // happens to carry a copy.
  scan(path.join(appDir, 'dist'))
  const nm = path.join(appDir, 'node_modules')
  let pkgs = []
  try { pkgs = fs.readdirSync(nm) } catch { return out }
  for (const pkg of pkgs) {
    if (pkg.startsWith('.')) continue
    scan(path.join(nm, pkg, 'dist'))
  }
  return out
}

/**
 * Reconcile <store>/bootstrap from the local app trees. Idempotent.
 *
 * @param {TreeStore} store
 * @param {object} [o]
 *   o.tarTree?   async/sync (srcDir, destTar) => void  (default: shell `tar`)
 *   o.label?     os-arch label fallback if a tree lacks pghBootstrap.label
 *   o.log?       (msg) => void
 * @returns {Promise<{ tarred: string[], kept: string[], pruned: string[], installSh: boolean }>}
 */
export async function reconcileBootstrapArtifacts (store, o = {}) {
  const out = { tarred: [], kept: [], pruned: [], installSh: false }
  if (!store) return out
  if (typeof store.init === 'function') store.init()

  const tarTree = o.tarTree || defaultTarTree
  const label = o.label || hostLabel()
  const log = o.log
  const appsDir = store.appsDir
  const dir = bootstrapDir(store)
  fs.mkdirSync(dir, { recursive: true })

  const statePath = path.join(dir, STATE_FILE)
  const prevState = readJSON(statePath) || {}
  const nextState = {}

  let appNames = []
  try { appNames = fs.readdirSync(appsDir) } catch { return out }

  const wantTarballs = new Set()
  let installShTarget = null

  for (const name of appNames) {
    // Skip dotfiles and atomic-swap temp dirs (`<app>.new-<ts>` left by an
    // interrupted swapApp) — they carry the app's package.json but are not a
    // live install (and would collide with / shadow the real app's tarball).
    if (name.startsWith('.') || /\.new-\d+$/.test(name)) continue
    const appDir = path.join(appsDir, name)
    const pkg = readJSON(path.join(appDir, 'package.json'))
    if (!pkg || !pkg.pgh || !pkg.pgh.bootstrap) continue // bootstrap apps only

    // The tarball name is `<slug>-bootstrap-bare-<label>` where <label> MUST be
    // just `<os>-<arch>` (install.sh fetches exactly that). Guard against a
    // malformed pghBootstrap.label (e.g. a tree that baked the stage name in →
    // "bootstrap-bare-linux-x64") by validating the shape; fall back to host.
    let treeLabel = (pkg.pghBootstrap && pkg.pghBootstrap.label) || label
    if (!/^[a-z0-9]+-[a-z0-9]+$/.test(treeLabel)) treeLabel = label
    // Slug = the apps/ DIRECTORY name (the install identity install.sh fetches),
    // NOT pkg.name — an old build could bake the stage name into pkg.name
    // (e.g. "pgh-sidecar-bootstrap-bare"), which would corrupt the tarball name.
    const slug = slugify(name)
    const tarName = `${slug}-bootstrap-bare-${treeLabel}.tar.gz`
    const dest = path.join(dir, tarName)
    wantTarballs.add(tarName)

    const install = readJSON(path.join(appDir, '.pgh-install.json'))
    const treeHash = (install && install.treeHash) || ''
    nextState[tarName] = treeHash

    const fresh = prevState[tarName] === treeHash && treeHash && fs.existsSync(dest)
    if (fresh) {
      out.kept.push(tarName)
    } else {
      await tarTree(appDir, dest)
      out.tarred.push(tarName)
      if (log) log('[bootstrap] tarred ' + name + ' → ' + tarName + ' (tree ' + (treeHash ? treeHash.slice(0, 12) + '…' : 'no-hash') + ')')
    }

    if (!installShTarget) {
      const sh = path.join(appDir, 'scripts', 'install.sh')
      if (fs.existsSync(sh)) installShTarget = sh
    }
  }

  // ── Cross-arch promotion pass ───────────────────────────────────
  // OPT-IN (admin setting). Only a forge backend actually serves /bootstrap over
  // HTTPS, so only a backend needs the foreign-arch tarballs. Ordinary peers pull
  // over hyper-deploy/overlay and never answer a cold-start HTTP fetch — holding
  // another arch's 150MB would be pure cost to them.
  //   pgh app bootstrap-serve pgh-sidecar on
  // sets apps.json `bootstrapServeAllArches`, which the sidecar reads and passes
  // here. Default OFF: build your own arch, promote nothing.
  //
  // Deliberately over ALL app trees, not just pgh.bootstrap ones: a vendored
  // bootstrap tarball is arch-portable BYTES, and it does not live in the tree
  // of the app it bootstraps. Measured on tabertron:
  //   pgh-bootstrap-bare-linux-arm64.tar.gz         → pgh-sidecar tree (bootstrap app)
  //   pgh-sidecar-bootstrap-bare-linux-arm64.tar.gz → pear-git-v3 tree (NOT a bootstrap app)
  // install.sh fetches BOTH and runs under `set -e`, so scanning only bootstrap
  // apps would serve pgh for arm64 and still fail at the sidecar step.
  // Anything already in wantTarballs was tarred locally for the host arch and
  // wins — promotion only ever fills arches we cannot build here.
  if (!o.serveAllArches) {
    if (log) log('[bootstrap] cross-arch promotion off (enable: pgh app bootstrap-serve <app> on)')
  } else {
    for (const name of appNames) {
      if (name.startsWith('.') || /\.new-\d+$/.test(name)) continue
      for (const v of vendoredBootstraps(path.join(appsDir, name))) {
        if (wantTarballs.has(v.name)) continue
        wantTarballs.add(v.name)
        nextState[v.name] = 'vendored'
        const vdest = path.join(dir, v.name)
        // Same-inode means it's already the promoted hardlink — nothing to do.
        // A DIFFERENT inode means the vendored artifact was updated underneath us,
        // so re-link rather than serving stale bytes forever.
        let linked = false
        try { linked = fs.statSync(vdest).ino === fs.statSync(v.src).ino } catch {}
        if (linked) { out.kept.push(v.name); continue }
        try { fs.rmSync(vdest, { force: true }) } catch {}
        try {
          fs.linkSync(v.src, vdest)          // hardlink: zero extra bytes
        } catch {
          try { fs.copyFileSync(v.src, vdest) } catch { continue }
        }
        out.tarred.push(v.name)
        if (log) log('[bootstrap] promoted vendored ' + v.name + ' (cross-arch, from ' + name + ')')
      }
    }
  }

  // install.sh — a symlink (not a copy) so it tracks the current tree.
  const installLink = path.join(dir, 'install.sh')
  if (installShTarget) {
    let cur = null
    try { cur = fs.readlinkSync(installLink) } catch {}
    if (cur !== installShTarget) {
      try { fs.rmSync(installLink, { force: true }) } catch {}
      fs.symlinkSync(installShTarget, installLink)
      if (log) log('[bootstrap] linked install.sh → ' + installShTarget)
    }
    out.installSh = true
  } else {
    // no tree ships install.sh — drop a stale symlink (never a real file)
    let isLink = false
    try { isLink = fs.lstatSync(installLink).isSymbolicLink() } catch {}
    if (isLink) { try { fs.rmSync(installLink, { force: true }) } catch {} }
  }

  // Prune stale tarballs (an app that's gone / no longer bootstrap).
  let existing = []
  try { existing = fs.readdirSync(dir) } catch {}
  for (const f of existing) {
    if (!/-bootstrap-bare-.+\.tar\.gz$/.test(f)) continue
    if (wantTarballs.has(f)) continue
    try { fs.rmSync(path.join(dir, f), { force: true }); out.pruned.push(f) } catch {}
  }

  try { fs.writeFileSync(statePath, JSON.stringify(nextState, null, 2) + '\n') } catch {}

  if (log && (out.tarred.length || out.pruned.length)) {
    log('[bootstrap] reconciled ' + dir + ': tarred ' + out.tarred.length + ', kept ' + out.kept.length + ', pruned ' + out.pruned.length)
  }
  return out
}