Ctrl K
ComunidadGuías…

deploy/app-targets.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Where the framework suite finds the apps it has to prove itself against.
 *
 * WHY THIS EXISTS. The deploy/ framework is vendored: synaptomind is its home
 * repo and its test suite, and ziptask (#1110) and subagentix (#1111) each got a
 * `deploy/` COPY plus their own `deploy/app.env`. Neither vendored the suite —
 * deliberately, because those suites are hardwired to `APP="synaptomind"`, to
 * synaptomind's raw URL and to the `~/.synaptomind` tripwire, and dropping them
 * into those repos would turn a 400-test `bun test` into a ~2000-test one that
 * fails for reasons that have nothing to do with the app under test.
 *
 * So the conformance proof has to travel the other way: from the framework's
 * home suite to the OTHER repos' real app.env files. `DEPLOY_APP_ENVS` is how it
 * says where they are:
 *
 *     DEPLOY_APP_ENVS='subagentix=/path/to/subagentix/deploy,ziptask=/path/to/ziptask/deploy' \
 *       bun test ./deploy/app-env-conformance.test.ts
 *
 * Unset (the default, and therefore what CI sees) means "no target": every
 * app-env describe reports SKIPPED, and the suite's own result is unchanged. That
 * is deliberate — an absolute path hardwired into the repo would either be wrong
 * on every other machine or, worse, resolve to some other checkout and pass
 * against the wrong app.
 *
 * WHY A MISSING TARGET IS A FAILURE AND NOT A SKIP. A skip is honest about "this
 * environment has nothing to check" and dishonest about "I named a path and it
 * was wrong". `unresolvedTargets()` keeps the two apart: the app-env suites are
 * gated on RESOLVED targets, and `checkCoverage()` — which always runs — turns
 * every requested-but-unresolved path into a failed assertion. A typo in
 * DEPLOY_APP_ENVS cannot come back green.
 *
 * WHY OVERRIDES ARE APPLIED TO THE FILE, NOT THE ENVIRONMENT. `load_app_env()`
 * SOURCES app.env, so a plain assignment there WINS over the process
 * environment — `RUN_DIR=""` in ziptask's app.env resets whatever the caller set.
 * Both new app.envs carry `RUN_DIR=""` (meaning "${HOME}/.app"), and
 * `resolve_target_user()` then falls back to `getent passwd`, i.e. the operator's
 * REAL ~/.ziptask / ~/.subagentix. That is precisely the #1101 incident (a
 * fixture overwrote the live app.env). So the sandbox lives in the app.env COPY:
 * every path the scripts write to is redirected into the fixture root here, and
 * `assertSandboxed` refuses to emit an override that points anywhere else. The
 * `guardRealStateDir()` tripwire in each suite verifies from the outside that
 * the real state dir did not move.
 */

import { afterAll } from 'bun:test'
import { readAppEnvValues } from './app-env-targets'
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { isAbsolute, join } from 'node:path'

/** One app whose real `deploy/app.env` a sandboxed install will be run against. */
export interface AppTarget {
  /** Short label from DEPLOY_APP_ENVS (e.g. "subagentix"). */
  label: string
  /** Absolute path of the app's deploy/ directory. */
  deployDir: string
  /** The app.env this target means. Always `<deployDir>/app.env`. */
  appEnvPath: string
}

export interface TargetResolution {
  /** Targets that exist and carry both app.env and install.sh, in request order. */
  resolved: AppTarget[]
  /** Every label named in DEPLOY_APP_ENVS, resolved or not. */
  requested: string[]
  /** `label=path` entries that did not resolve, with the reason. */
  unresolved: string[]
}

/** The raw DEPLOY_APP_ENVS value; '' when unset or empty. */
function appEnvsSpec(): string {
  return process.env.DEPLOY_APP_ENVS?.trim() ?? ''
}

/**
 * A target is only usable when it carries BOTH app.env and install.sh.
 *
 * install.sh is what a sandboxed install actually runs; app.env is what the
 * point of the exercise is. A deploy/ directory missing either is a partial
 * vendoring (which is a real state these repos have been in), and treating it as
 * a target would make the suite assert against a DEFAULTED app.env and pass —
 * the vacuous pass this file exists to prevent.
 */
function probe(label: string, dir: string): AppTarget | string {
  if (!isAbsolute(dir)) return `${label}=${dir} (not an absolute path)`
  if (!existsSync(join(dir, 'app.env'))) return `${label}=${dir} (no app.env)`
  if (!existsSync(join(dir, 'install.sh'))) return `${label}=${dir} (no install.sh)`
  return { label, deployDir: dir, appEnvPath: join(dir, 'app.env') }
}

/**
 * Parse DEPLOY_APP_ENVS into targets. Malformed entries become `unresolved`
 * rather than being dropped, so they surface through checkCoverage().
 */
export function resolveTargets(): TargetResolution {
  const out: TargetResolution = { resolved: [], requested: [], unresolved: [] }
  const spec = appEnvsSpec()
  if (!spec) return out
  for (const raw of spec.split(',')) {
    const entry = raw.trim()
    if (!entry) continue
    const eq = entry.indexOf('=')
    if (eq <= 0) {
      out.unresolved.push(`${entry} (expected label=/absolute/path/to/deploy)`)
      continue
    }
    const label = entry.slice(0, eq).trim()
    const dir = entry.slice(eq + 1).trim()
    out.requested.push(label)
    const found = probe(label, dir)
    if (typeof found === 'string') out.unresolved.push(found)
    else out.resolved.push(found)
  }
  return out
}

/**
 * The always-on assertion that keeps an env-var-driven suite honest.
 *
 * Returns the labels that were actually exercised. Throws (i.e. fails the test)
 * when DEPLOY_APP_ENVS named something that did not resolve: a suite that was
 * ASKED to prove an app and quietly proved none is the failure mode an opt-in
 * cross-repo suite is most exposed to.
 */
export function checkCoverage(resolution: TargetResolution, exercised: string[]): string[] {
  const missing = resolution.requested.filter(label => !exercised.includes(label))
  if (missing.length > 0) {
    throw new Error(
      `DEPLOY_APP_ENVS named ${missing.join(', ')} but no test exercised it.\n` +
        `  requested: ${resolution.requested.join(', ') || '(none)'}\n` +
        `  unresolved: ${resolution.unresolved.join('; ') || '(none)'}\n` +
        `  exercised:  ${exercised.join(', ') || '(none)'}\n` +
        `  A requested target that resolves to nothing would leave this suite green\n` +
        `  while proving nothing about that app.`
    )
  }
  const missingFiles = resolution.unresolved.filter(e => /no app\.env|no install\.sh/.test(e))
  if (missingFiles.length > 0) {
    throw new Error(
      `DEPLOY_APP_ENVS pointed at an incomplete deploy/ directory: ${missingFiles.join('; ')}`
    )
  }
  return exercised
}

// App.env parsing and sandbox helpers live in ./app-env-targets.

// ── Production-path containment ──────────────────────────────────────────────

/**
 * Fingerprint the HOST paths a sandboxed install would write if it escaped, and
 * fail the process if any of them moved.
 *
 * WHY NOT JUST ASSERT IN THE REPORT. Every test in these two suites already
 * redirects INSTALL_DIR / DATA_DIR / RUN_DIR into a temp tree, and `bun test`
 * passing is not evidence that the redirection held: #1101 was a green suite that
 * had overwritten the operator's live `app.env` (`deploy/tmp-fixtures.ts` records
 * the incident and the reasoning). The failure was invisible from inside because
 * a fixture asserts on ITS OWN scratch tree while the damage is somewhere else
 * entirely. So containment has to be checked from the outside, at exit.
 *
 * WHY THIS LIST AND NOT `guardRealStateDir()`. That helper guards `~/.synaptomind`,
 * which is this repo's app — correct for the framework suite and useless for
 * ziptask and subagentix, whose installs would land in `~/.ziptask` and
 * `~/.subagentix`. The list itself is built by productionWatchPaths() below, and
 * it is exactly the paths RUN_DIR, INSTALL_DIR, DATA_DIR and HOOKS_DIR resolve to
 * on this host for every resolved target, plus the host service tree.
 *
 * WHAT IS DELIBERATELY EXCLUDED, AND WHY IT IS NOT A CONSTANT. A path that a
 * LIVE, out-of-sandbox service legitimately writes cannot be fingerprinted: it
 * moves under the suite for reasons that have nothing to do with any fixture.
 * The first version of this file already knew that and excluded one such path
 * (`~/.ziptask/data`) by name, because the tracker these suites report through IS
 * the live ziptask service and its WAL changes on every task update. That
 * exclusion became wrong rather than useless when #1123/#1124 moved ziptask's
 * config home to DATA_DIR (ADR addendum R1): the database and settings.json now
 * live in /var/lib/ziptask, so the path that moves on every task update is
 * /var/lib/ziptask itself — the very entry this list watches. Measured, not
 * argued: a run with `DEPLOY_APP_ENVS=…ziptask…` reported
 *   `DEPLOY SANDBOX VIOLATION: /var/lib/ziptask was MODIFIED by a deploy fixture`
 * over 52 passing tests, with the only writer the live service persisting the
 * agent's own tracker bookkeeping.
 *
 * So the exclusion is DERIVED (`liveServiceDataDirs`) rather than hardcoded: a
 * target's DATA_DIR is dropped from the watch list when that app's unit is
 * actually running on this host. Hardcoding a path would have been a second
 * #1124 waiting to happen — the next ADR that moves a config home would leave
 * the exclusion pointing at an empty directory and the false positive would come
 * back under a new name.
 *
 * WHAT THE GUARD KEEPS WATCHING, which is the half that matters: /opt/<app>, the
 * state dir and its scripts/ and hooks/ (the paths install_helper_scripts and
 * install_hook_scripts write), the unit file, and the host's own deploy/app.env —
 * the file #1101 overwrote. Those are the paths a fixture could escape to, and
 * none of them is written by a running service.
 *
 * THE FAILURE DIRECTION. When a target's unit cannot be found (no systemctl, a
 * CI container) or its app.env cannot be sourced, the path stays WATCHED. The
 * blind case is a stopped unit and a false alarm; the loud case is a #1101. That
 * asymmetry is the whole design, so it is worth stating rather than leaving to a
 * reader who assumes the exclusion is unconditional.
 */
export function guardProductionPaths(targets: readonly AppTarget[]): void {
  const watched = productionWatchPaths(targets)
  const before = new Map<string, string | null>()
  for (const path of watched) before.set(path, fingerprintOrNull(path))
  const verify = () => {
    for (const [path, hash] of before) {
      const after = fingerprintOrNull(path)
      if (after !== hash) {
        process.stderr.write(
          `\n!!! DEPLOY SANDBOX VIOLATION: ${path} was ${after === null ? 'CREATED' : 'MODIFIED'} by a deploy fixture.\n` +
            `    This is the #1101 failure mode: an install escaped its temp tree and wrote\n` +
            `    to the host's production path. The suite's own assertions cannot see it.\n\n`
        )
        process.exitCode = 1
      }
    }
  }
  // `afterAll`, not `process.on('exit')`: measured on bun 1.4.2, `bun test` never
  // runs an exit handler, so an exit-only containment check is decoration — it
  // cannot fail a run. See the note on tmp-fixtures.installCleanup, which was
  // written the same way and had the same dead tripwire.
  afterAll(verify)
  process.on('exit', verify)
}

function homeDir(): string {
  const home = spawnSync('bash', ['-c', 'getent passwd "$(id -un)" | cut -d: -f6'], {
    encoding: 'utf8',
    timeout: 30_000
  })
  return home.stdout?.trim() || process.env.HOME || '/root'
}

/**
 * Is this app's unit actually running on this host?
 *
 * `systemctl is-active --quiet` answers through its exit status (0 active), which
 * is the interface a `--no-service`-style caller branches on. A missing systemctl
 * answers false, and false means "nothing is writing there", so the watch list
 * stays armed — the loud direction, deliberately (see the header).
 */
function unitIsLive(unit: string): boolean {
  const res = spawnSync('systemctl', ['is-active', '--quiet', unit], {
    encoding: 'utf8',
    timeout: 10_000
  })
  if (res.error) return false
  return res.status === 0
}

/**
 * The DATA_DIR of every target whose service is live here — the directories a
 * running process owns and writes, which therefore cannot be tripwires.
 *
 * Read from the target's REAL app.env rather than assumed to be `/var/lib/<label>`:
 * a fixture only has to be prevented from writing where the app's own app.env says
 * the app writes. An app.env that cannot be sourced contributes nothing, and its
 * `/var/lib/<label>` therefore stays watched — same asymmetry as unitIsLive.
 */
export function liveServiceDataDirs(
  targets: readonly AppTarget[],
  isLive: (unit: string) => boolean = unitIsLive
): string[] {
  const dirs: string[] = []
  for (const target of targets) {
    if (!isLive(`${target.label}.service`)) continue
    let dataDir: string | undefined
    try {
      dataDir = readAppEnvValues(target.appEnvPath).DATA_DIR
    } catch {
      continue
    }
    const trimmed = dataDir?.replace(/\/+$/, '')
    if (trimmed && !dirs.includes(trimmed)) dirs.push(trimmed)
  }
  return dirs
}

/**
 * The paths guardProductionPaths fingerprints, in order.
 *
 * Exported (and pure) so the composition can be asserted directly: the tripwire
 * itself can only report by writing to stderr and setting process.exitCode, which
 * is a poor thing to test and an easy thing to get wrong. What the tests need to
 * know is which paths are in the list, and a list is checkable.
 *
 * `isLive` is injectable so the composition is testable on a host that is not
 * running the app under test — see ./app-targets.test.ts.
 */
export function productionWatchPaths(
  targets: readonly AppTarget[],
  isLive: (unit: string) => boolean = unitIsLive
): string[] {
  const live = liveServiceDataDirs(targets, isLive)
  const watched: string[] = []
  for (const target of targets) {
    const label = target.label
    for (const path of [
      join('/opt', label),
      join('/var/lib', label),
      join(homeDir(), `.${label}`),
      join(homeDir(), `.${label}`, 'scripts'),
      join(homeDir(), `.${label}`, 'hooks'),
      join('/etc/systemd/system', `${label}.service`),
      // The host app.env itself, which is the file the whole delivery mechanism
      // reads and the one #1101 overwrote.
      join(target.deployDir, 'app.env')
    ]) {
      // A live service's own data dir, and anything under it, is not a tripwire.
      if (live.some(dir => path === dir || path.startsWith(`${dir}/`))) continue
      watched.push(path)
    }
  }
  return watched
}

/**
 * size + mtime + sha256 of every file under `path`, or null when absent.
 *
 * All three, for the same reasons tmp-fixtures.fingerprint uses them: a rewrite
 * with identical bytes still moves the mtime, and an append keeps the size.
 */
function fingerprintOrNull(path: string): string | null {
  if (!existsSync(path)) return null
  const hash = createHash('sha256')
  const walk = (dir: string, rel: string) => {
    for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
      a.name < b.name ? -1 : 1
    )) {
      const childRel = rel ? `${rel}/${entry.name}` : entry.name
      const abs = join(dir, entry.name)
      if (entry.isDirectory()) {
        walk(abs, childRel)
        continue
      }
      const st = statSync(abs)
      hash.update(`${childRel} ${st.size} ${st.mtimeMs} `)
      hash.update(readFileSync(abs))
    }
  }
  try {
    const st = statSync(path)
    hash.update(`${path} ${st.mode} ${st.mtimeMs} `)
    if (st.isDirectory()) walk(path, '')
    else hash.update(readFileSync(path))
  } catch (err) {
    return `unreadable:${(err as Error).message}`
  }
  return hash.digest('hex')
}