OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / runtime-factory.js

392 lines · javascript ref:
/**
 * runtime-factory — batteries-included pgh-runtime wiring.
 *
 * pgh-runtime's core (installRelease / PghRuntime) is transport-agnostic: it
 * takes an injected fetcher + store + materializer. This module is the
 * pear-runtime-equivalent "batteries included" layer for the content-addressed
 * world — it wires pgh's real NetworkFetcher + TreeStore + atomic node
 * materializer and finishes the install (launcher + manifest), so an embedder
 * gets a complete, pgh-managed app from `{ name }` alone.
 *
 * Dependency direction is one-way (pgh -> pgh-runtime). pgh-runtime never
 * imports pgh, so its pure core stays dependency-free; only this convenience
 * layer pulls the pgh-specific pieces together.
 *
 * Closes the three "complete updater" gaps:
 *   1. launcher + install manifest (so a FIRST install is runnable + visible
 *      to `pgh app list`, full parity with `pgh app install`);
 *   2. apply hook (the `onUpdate` callback / `updated` event drives the
 *      exit-75 -> supervisor respawn the embedder owns);
 *   3. default fetcher/store/materializer (no hand-wiring).
 */

import process from '#node:process' // NOT a global under bare — execPath below needs it
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import { homedir } from '#node:os'
import { Client } from 'hyper-svc'

import { TreeStore } from './store.js'
import { NetworkFetcher } from './network-fetcher.js'
import { materialize as nodeMaterialize } from './materializers/node.js'
import { materialize as godotMaterialize } from './materializers/godot.js'
import { atomicMaterializer, lockPathFor, stagedPathFor } from './materializers/atomic.js'
import { PghRuntime, installRelease } from 'pgh-runtime'
import { makeGetLatestRelease } from './resolve-release.js'

function defaultBinDir () {
  return process.env.PGH_BIN_DIR || path.join(homedir(), '.local', 'bin')
}

function defaultSidecarSock () {
  // Honour PGH_SIDECAR_SOCK like every other client path (daemon-client.js,
  // commands/*). This also lets an '@'-prefixed abstract-namespace socket be
  // used where SELinux denies pathname sockets (unrooted Android / celltron).
  return process.env.PGH_SIDECAR_SOCK || path.join(homedir(), '.pgh', 'svc.sock')
}

// Prefer the pgh-bundled bare runtime in the same store; fall back to PATH bare.
// The runtime an OTA install/update should exec. DELEGATES to resolveBundledRuntime — it must not
// reimplement the resolution.
//
// It used to check a SINGLE candidate (apps/pgh/runtime/bare) and otherwise return the bare NAME. That
// made it a THIRD copy of the resolution logic, and worse, one that SHADOWED the fixed one: finalizeInstall
// does `runtimePath || resolveBundledRuntime(...)`, and this value is always truthy, so the five-candidate
// resolver was never consulted on the path this feeds. The exact drift #17 fixed between commands/app.js
// and this file, surviving one level down.
//
// Delegating also gains the candidate that matters most here: process.execPath. An OTA update is applied
// BY a process already running under bare, so even if every bundled-runtime lookup misses, the runtime
// executing us is absolute and by definition able to run the stack.
export function defaultRuntimePath (store, name, { execPath } = {}) {
  // resolveBundledRuntime keys its per-install and sibling lookups off installPath, so give it the real
  // one when we know the app; otherwise a path under appsDir so the sibling scan still resolves.
  const installPath = name ? store.appPath(name) : path.join(store.appsDir, '_')
  return resolveBundledRuntime(installPath, 'bare', detectHost(), execPath ? { execPath } : {})
}

/**
 * The materializer for a runtime kind, as the RELEASE declares it.
 *
 * Exported because it is the whole of the selection logic and the only part of
 * createRuntime worth testing without a network: given a kind, which layout.
 *
 * Throwing on an unknown kind is deliberate. The alternative — defaulting to the
 * npm layout — would materialize a godot app as if it were a node one and produce
 * an install that exists, passes every "did it install" check, and does not run.
 */
export function selectMaterializer (runtimeKind) {
  return loadMaterializer(runtimeKind || 'node').materialize
}

// Select the materializer for a runtime — node/bare share the npm layout;
// godot uses its own. Parity with app.js loadMaterializer (the live path).
function loadMaterializer (runtime) {
  switch (runtime) {
    case 'node':
    case 'bare':
      return { materialize: nodeMaterialize }
    case 'godot':
      return { materialize: godotMaterialize }
    default:
      throw new Error('no materializer for runtime: ' + runtime)
  }
}

// Launcher interpreter: bare AND godot run under bare; node under node.
// Matches app.js (interp = bare for bare|godot, else node).
function runtimeInterp (runtime) {
  return runtime === 'node' ? 'node' : 'bare'
}

// Resolve the launcher's runtime to an ABSOLUTE path wherever possible.
//
// A launcher that says `exec bare <entry>` depends on `bare` being on the ambient PATH, and a
// non-interactive `ssh host 'app ...'` gets a minimal PATH that usually does not include ~/.local/bin.
// Observed on ashatron: `ssh ashatron 'hyper-proto-peer diag'` died with `exec: bare: not found` while the
// identical launcher worked on stortron, purely because of PATH — the worst kind of per-host difference.
//
// This used to claim "Parity with app.js resolveBundledRuntime" and did not have it: app.js falls back to
// resolveRuntimePath (process.execPath when it is already the interp), this one fell back to the bare
// NAME. finalizeInstall is the modern install path, so the PATH-dependent launcher is the one that got
// written. Now there is ONE implementation, exported and used by both.
//
// Candidates, most specific first:
//   1-2. a runtime bundled with THIS install (multi-arch, then root-level)
//   3.   the runtime currently executing us — absolute, and by definition able to run this stack
//   4.   a sibling app's bundled runtime in the same store; pgh is bootstrap:true, so on a pgh-managed
//        machine it is present by construction. This covers an app that ships no runtime of its own while
//        the CLI runs under node (execPath is 'node', so 3 does not apply).
//   5.   the bare name, i.e. PATH — last resort, and the only case that can still fail like ashatron did.
export function resolveBundledRuntime (installPath, interp, host, { execPath = process.execPath } = {}) {
  if (interp === 'bare' && installPath) {
    const cands = [
      path.join(installPath, 'by-arch', host, 'runtime', 'bare'),
      path.join(installPath, 'runtime', 'bare')
    ]
    for (const c of cands) { try { if (fs.existsSync(c)) return c } catch {} }
  }
  if (execPath && path.basename(execPath) === interp) return execPath
  if (interp === 'bare' && installPath) {
    const appsDir = path.dirname(installPath) // <store>/apps
    for (const sib of ['pgh', 'pgh-sidecar']) {
      for (const c of [
        path.join(appsDir, sib, 'by-arch', host, 'runtime', 'bare'),
        path.join(appsDir, sib, 'runtime', 'bare')
      ]) {
        try { if (fs.existsSync(c)) return c } catch {}
      }
    }
  }
  return interp
}

function detectHost () {
  let host = os.platform() + '-' + os.arch()
  try {
    const osr = fs.readFileSync('/etc/os-release', 'utf8')
    if (/^ID=alpine/m.test(osr)) host += '-musl'
  } catch {}
  return host
}

/**
 * The launcher script for a registry install (`pgh app install|update`, OTA).
 * Besides exec'ing the app it implements the IN-USE contract of
 * materializers/atomic.js: while the app runs it holds a SHARED flock on
 * <apps>/<name>.lock — fd 9, inherited by the app and its children, released
 * by the kernel when they exit or crash — so an update stages its tree as
 * <name>.next instead of swapping it under the running app; and a staged
 * update is applied HERE, before the app starts, so it always starts on one
 * complete tree.
 *   - apply-staged runs through pgh's own launcher with PGH_NO_APPLY=1, which
 *     skips both steps there: no recursion when pgh itself is the app, and no
 *     lock held by the very process trying to take it exclusively;
 *   - `: >>lock` first, because a failed `exec 9>>` would kill the shell;
 *   - -w 30: waits out an apply in progress, never blocks the start for good;
 *   - no flock(1) (macOS) -> the old two-line launcher behaviour.
 * @param {{ runtimePath, entryAbs, name?, store?, binDir? }} o — without
 *   name+store (adopted dev installs, legacy paths) the plain launcher.
 */
export function launcherScript ({ runtimePath, entryAbs, name, store, binDir }) {
  const run = 'exec ' + runtimePath + ' ' + entryAbs + ' "$@"\n'
  if (!name || !store) return '#!/bin/sh\n' + run
  const q = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"
  const lock = q(lockPathFor(store, name))
  const staged = q(stagedPathFor(store, name))
  const pgh = q(path.join(binDir || defaultBinDir(), 'pgh'))
  return '#!/bin/sh\n' +
    '# pgh in-use lock: an update never swaps this app\'s tree while it runs (pgh lib/materializers/atomic.js)\n' +
    'if [ -z "$PGH_NO_APPLY" ] && command -v flock >/dev/null 2>&1; then\n' +
    '  if [ -d ' + staged + ' ] && [ -x ' + pgh + ' ]; then PGH_NO_APPLY=1 ' + pgh + ' app apply-staged ' + q(name) + ' >/dev/null 2>&1 || true; fi\n' +
    '  if : >>' + lock + ' 2>/dev/null; then exec 9>>' + lock + '; flock -s -w 30 9 || true; fi\n' +
    'fi\n' + run
}

function writeLauncher (launcherPath, runtimePath, entryAbs, lockOpts = {}) {
  fs.mkdirSync(path.dirname(launcherPath), { recursive: true })
  // Replace any existing file/symlink first — writing THROUGH a symlink into
  // the store would corrupt shared content (see app.js writeLauncher note).
  try { fs.unlinkSync(launcherPath) } catch {}
  fs.writeFileSync(launcherPath, launcherScript({ runtimePath, entryAbs, ...lockOpts }), { mode: 0o755 })
}

/**
 * A path resolved inside a STAGED tree, rewritten to where it will live once
 * applied (the launcher must never point into <name>.next).
 */
export function livePath (p, contentPath, installPath) {
  return (contentPath !== installPath && typeof p === 'string' && p.startsWith(contentPath + path.sep))
    ? installPath + p.slice(contentPath.length)
    : p
}

/**
 * Write the launcher + install manifest for an already-materialized install,
 * making it a complete, runnable, `pgh app list`-visible app. Reads the
 * installed package.json's pgh block for entrypoint/runtime/upgrade. The
 * manifest lives inside the install dir (store/apps/<name>/.pgh-install.json),
 * matching app-state.installManifestFile so the CLI sees the same app.
 *
 * @returns {{ launcher: string, entrypoint: string, manifest: string }}
 */
export function finalizeInstall ({ store, name, release = {}, host, binDir, runtimePath, binName }) {
  binDir = binDir || defaultBinDir()
  host = host || detectHost()
  const installPath = store.appPath(name)
  // An update that found the app running left its tree STAGED (atomic.js) — the
  // new tree's package.json and manifest are there; the launcher still points
  // at the live path. A direct swap drops any staged tree, so "staged exists"
  // means the latest install is the staged one.
  const stagedPath = stagedPathFor(store, name)
  const contentPath = fs.existsSync(stagedPath) ? stagedPath : installPath

  // Read the installed package.json's pgh block for entrypoint/runtime/etc.
  let pgh = {}
  try { pgh = JSON.parse(fs.readFileSync(path.join(contentPath, 'package.json'), 'utf8')).pgh || {} } catch {}
  const entry = pgh.entrypoint ||
    (fs.existsSync(path.join(contentPath, 'index.js')) ? 'index.js' :
     fs.existsSync(path.join(contentPath, 'server.js')) ? 'server.js' : 'index.js')
  const runtime = release.runtime || pgh.runtime || 'bare'
  // Explicit binName (e.g. when installed under --name) wins, so a custom-named
  // install can't clobber another app's launcher (parity with app.js). Else
  // pkg.pgh.bin, else the app name.
  binName = binName || pgh.bin || name
  const upgrade = release.upgrade || pgh.upgrade || null

  // Launcher — prefer the per-install bundled bare runtime (parity with
  // app.js resolveBundledRuntime); else caller-provided, else interp on PATH.
  const interp = runtimeInterp(runtime)
  const rtPath = runtimePath || livePath(resolveBundledRuntime(contentPath, interp, host), contentPath, installPath)
  fs.mkdirSync(binDir, { recursive: true })
  const launcher = path.join(binDir, binName)
  writeLauncher(launcher, rtPath, path.join(installPath, entry), { name, store, binDir })

  // Full install manifest — byte-parity with installViaRegistry (app.js:622-636)
  // so `pgh app list/start/boot` read the same fields. Into the tree that will
  // go live: the staged one when the update was staged.
  const manifestPath = path.join(contentPath, '.pgh-install.json')
  fs.writeFileSync(manifestPath, JSON.stringify({
    name,
    publishedName: release.name || name,
    version: release.version || '0.0.0',
    treeHash: release.treeHash || null,
    runtime,
    installedAt: new Date().toISOString(),
    sourcePath: installPath,
    entrypoint: entry,
    upgrade,
    defaults: pgh.defaults || {},
    args: Array.isArray(pgh.args) ? pgh.args : [],
    depends: Array.isArray(pgh.depends) ? pgh.depends : [],
    autostart: !!pgh.autostart,
    readiness: pgh.readiness || { type: 'pidfile' },
    // Run by a systemd unit, not pgh's supervisor (systemd.js)
    ...(pgh.systemd ? { systemd: pgh.systemd } : {}),
    source: 'pgh-runtime'
  }, null, 2))

  return { launcher, entrypoint: entry, manifest: manifestPath, runtime }
}

/**
 * One-shot COMPLETE install: fetch the closure, materialize atomically, AND
 * write the launcher + manifest. Full parity with `pgh app install`, driven by
 * pgh-runtime's installRelease.
 */
export async function installComplete ({ store, release, fetcher, name, host, binDir, runtimePath, skipPath, binName }) {
  name = name || release.name
  host = host || detectHost()
  const runtime = release.runtime || 'bare'
  const res = await installRelease(store, release, fetcher, {
    appName: name,
    host,
    skipPath,
    materialize: atomicMaterializer(loadMaterializer(runtime))
  })
  const fin = finalizeInstall({ store, name, release, host, binDir, runtimePath, binName })
  return { ...res, ...fin, installPath: store.appPath(name) }
}

/**
 * Batteries-included PghRuntime. Resolves the publisher's deploy topic via the
 * pgh-sidecar (the supported same-machine path — avoids the registry-corestore
 * lock), wires the real NetworkFetcher + TreeStore + atomic node materializer,
 * and finalizes (launcher + manifest) on every install/update.
 *
 *   const rt = await createRuntime({ name: 'godot-keet' })
 *   rt.on('updated', ({ release }) => { ... exit 75 for respawn ... })
 *   await rt.start()
 *
 * @param {object} opts
 * @param {string} opts.name              app name
 * @param {string} [opts.runtime]         override the materializer kind; default
 *                                        is the RELEASE's own `runtime` field
 * @param {string} [opts.registryKeyHex]  publisher registry key (the hex in
 *                                        pgh.upgrade). WITH it, resolution works
 *                                        without a sidecar — see resolve-release.js.
 *                                        WITHOUT it, a local sidecar is required.
 * @param {string} [opts.sidecarSock]     pgh-sidecar UDS (default ~/.pgh/svc.sock)
 * @param {TreeStore} [opts.store]        default: device store (~/.local/share/store)
 * @param {string} [opts.host]            default: detected (e.g. linux-x64)
 * @param {number} [opts.interval]        poll interval ms (default PghRuntime's)
 * @param {string} [opts.currentTreeHash] tree this app currently runs at
 * @param {string} [opts.binDir]          launcher dir (default ~/.local/bin)
 * @param {function} [opts.onUpdate]      called after finalize on each update (apply hook)
 * @returns {Promise<PghRuntime>}
 */
export async function createRuntime (opts = {}) {
  if (!opts.name) throw new Error('createRuntime requires opts.name')
  const sidecarSock = opts.sidecarSock || defaultSidecarSock()
  const store = opts.store || new TreeStore({})
  store.init()
  const host = opts.host || detectHost()
  const binDir = opts.binDir || defaultBinDir()
  const runtimePath = opts.runtimePath || defaultRuntimePath(store, opts.name)

  // TWO RESOLVERS, and which one you get depends on whether you told us the
  // registry key.
  //
  // Without it we can only ask a local pgh-sidecar "what is the latest <name>",
  // which is fine on a pgh-managed machine and impossible anywhere else — no
  // sidecar, no answer, and embedded OTA stops at the first step even though the
  // transfer half works peer-to-peer. That was the gap: NetworkFetcher's
  // getLatestRelease reads the LOCAL registry, and a standalone install has no
  // local registry to read.
  //
  // WITH a registry key (it is the hex in `pgh.upgrade`,
  // pgh://<registryKeyHex>/<name>) we use the shared resolver, which tries the
  // sidecar first and otherwise replicates the publisher's registry directly over
  // Hyperswarm. Same ordering, same fd-lock discipline — see resolve-release.js.
  const resolveLatest = opts.registryKeyHex
    ? makeGetLatestRelease({ registryKeyHex: opts.registryKeyHex })
    : async (name) => {
      const c = await Client.connectUds(sidecarSock)
      try { return await c.call('releases-latest', { name }) } finally { try { await c.close() } catch {} }
    }

  const latest = await resolveLatest(opts.name)
  if (!latest || !latest.serverKey) {
    throw new Error('createRuntime: no release with serverKey for ' + opts.name)
  }

  const fetcher = new NetworkFetcher({ topic: latest.serverKey, timeout: opts.timeout || 30000 })
  // Route getLatestRelease through the sidecar (avoids the registry-corestore
  // lock the default direct-open would deadlock on, same-machine).
  fetcher.getLatestRelease = resolveLatest
  await fetcher.ready()

  const runtime = new PghRuntime({
    name: opts.name,
    fetcher,
    store,
    host,
    interval: opts.interval,
    currentTreeHash: opts.currentTreeHash,
    // FOLLOWS THE RELEASE, not the caller and not a constant. This was
    // hardcoded to nodeMaterialize, which is correct for every node/bare app —
    // hyper-tps declares runtime 'bare' — and silently wrong for a godot one,
    // where it would produce an install that looks complete and does not run.
    // The live entries carry `runtime` (verified against the registry: hyper-tps
    // resolves with runtime:"bare"), even though release-registry.js's schema
    // comment predates the field and does not list it.
    //
    // Resolved ONCE, from the release we started against. An app that changes
    // runtime kind mid-life is a re-install, not an update.
    materialize: atomicMaterializer({
      materialize: selectMaterializer(opts.runtime || latest.runtime)
    })
  })

  // Finalize (launcher + manifest) after every install/update, then hand off
  // the apply decision (respawn) to the embedder.
  runtime.on('updated', (info) => {
    try { finalizeInstall({ store, name: opts.name, release: info.release || {}, binDir, runtimePath }) } catch (err) { runtime.emit('error', err) }
    if (typeof opts.onUpdate === 'function') opts.onUpdate(info)
  })

  runtime._fetcher = fetcher // expose for close()
  return runtime
}