OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / launcher-in-use.test.js

111 lines · javascript ref:
// The launcher side of the in-use contract (see install-in-use.test.js and
// lib/materializers/atomic.js): a generated launcher runs the app holding a
// SHARED flock on <apps>/<name>.lock, and applies a staged update — through
// pgh's own launcher, with PGH_NO_APPLY=1 — before the app starts.
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import process from '#node:process'
import { spawn, spawnSync } from '#node:child_process'

import { TreeStore } from '../lib/store.js'
import { materializeAtomic, stagedPathFor } from '../lib/materializers/atomic.js'
import { launcherScript } from '../lib/runtime-factory.js'
import { listAppNames } from '../lib/app-state.js'

const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0

function setup (t) {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-launch-'))
  t.teardown(() => { try { fs.rmSync(root, { recursive: true, force: true }) } catch {} })
  const store = new TreeStore({ root: path.join(root, 'store') })
  store.init()
  const binDir = path.join(root, 'bin')
  fs.mkdirSync(binDir)
  // A stand-in `pgh` that records how the launcher called it.
  const calls = path.join(root, 'pgh-calls')
  fs.writeFileSync(path.join(binDir, 'pgh'), '#!/bin/sh\necho "NO_APPLY=$PGH_NO_APPLY $*" >> \'' + calls + '\'\n', { mode: 0o755 })
  const launcher = path.join(binDir, 'app')
  fs.writeFileSync(launcher, launcherScript({
    runtimePath: 'sh',
    entryAbs: path.join(store.appPath('app'), 'run.sh'),
    name: 'app',
    store,
    binDir
  }), { mode: 0o755 })
  return { store, launcher, calls }
}

// An app tree whose run.sh announces its version, then waits.
function appTree (version) {
  return {
    materialize ({ store, appName }) {
      const dir = store.appPath(appName)
      fs.mkdirSync(dir, { recursive: true })
      fs.writeFileSync(path.join(dir, 'VERSION'), version)
      fs.writeFileSync(path.join(dir, 'run.sh'), 'echo "running $(cat "$(dirname "$0")/VERSION")"; exec sleep 60\n')
      return { referenced: [], rootTarget: dir, depCount: 0 }
    }
  }
}

function launch (launcher, env = {}) {
  return new Promise((resolve, reject) => {
    const p = spawn(launcher, [], { env: { ...process.env, ...env } })
    p.stdout.on('data', (d) => { if (String(d).includes('running')) resolve(p) })
    p.on('error', reject)
    p.on('exit', (code) => reject(new Error('launcher exited ' + code)))
  })
}

const version = (store) => fs.readFileSync(path.join(store.appPath('app'), 'VERSION'), 'utf8')

test('the running app holds the lock: an update stages, the live tree stays', { skip: !HAS_FLOCK }, async (t) => {
  const { store, launcher } = setup(t)
  materializeAtomic(store, appTree('1'), { installName: 'app' })
  const app = await launch(launcher)
  t.teardown(() => app.kill('SIGKILL'))

  const r = materializeAtomic(store, appTree('2'), { installName: 'app' })
  t.ok(r.staged, 'staged, because the launched app holds the lock')
  t.is(version(store), '1', 'live tree untouched while it runs')
})

test('a staged update is applied through pgh before the app starts', { skip: !HAS_FLOCK }, async (t) => {
  const { store, launcher, calls } = setup(t)
  materializeAtomic(store, appTree('1'), { installName: 'app' })
  fs.mkdirSync(stagedPathFor(store, 'app'))   // something staged
  const app = await launch(launcher)
  t.teardown(() => app.kill('SIGKILL'))
  t.is(fs.readFileSync(calls, 'utf8').trim(), 'NO_APPLY=1 app apply-staged app',
    'pgh is asked to apply it, with PGH_NO_APPLY=1 (no recursion through pgh\'s own launcher)')
})

test('PGH_NO_APPLY: no apply, no lock (a maintenance invocation)', { skip: !HAS_FLOCK }, async (t) => {
  const { store, launcher, calls } = setup(t)
  materializeAtomic(store, appTree('1'), { installName: 'app' })
  fs.mkdirSync(stagedPathFor(store, 'app'))
  const app = await launch(launcher, { PGH_NO_APPLY: '1' })
  t.teardown(() => app.kill('SIGKILL'))
  t.absent(fs.existsSync(calls), 'pgh not called')
  const r = materializeAtomic(store, appTree('2'), { installName: 'app' })
  t.absent(r.staged, 'no lock held, so the update swaps directly')
})

test('a staged tree is not listed as an app', async (t) => {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-list-'))
  t.teardown(() => { try { fs.rmSync(root, { recursive: true, force: true }) } catch {} })
  for (const n of ['app', 'app.next', 'app.upd', 'app.old']) {
    fs.mkdirSync(path.join(root, 'apps', n), { recursive: true })
    fs.writeFileSync(path.join(root, 'apps', n, '.pgh-install.json'), '{}')
  }
  const prev = process.env.PGH_STORE_ROOT
  process.env.PGH_STORE_ROOT = root
  try {
    t.alike(listAppNames(), ['app'])
  } finally {
    process.env.PGH_STORE_ROOT = prev || ''   // Bare's env refuses delete; '' falls back to the default root
  }
})