deploy/install.sh.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { spawnSync } from 'node:child_process'
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
readlinkSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
cpSync,
statSync,
symlinkSync,
utimesSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { guardRealStateDir, installCleanup, isolatedEnv, mkTempTree } from './tmp-fixtures'
// Temp-tree ownership: the sweep removes every tree mkTempTree hands out, after
// each test including one that throws. This file's own afterEach hooks are kept
// (they release earlier, and the sweep is `force`), but no tree depends on a
// call site remembering them — see tmp-fixtures.ts.
installCleanup()
// Fingerprint the operator's real ~/.synaptomind for the whole file: this is
// the check that would have caught #1101, which overwrote the production
// app.env while every assertion in this file stayed green.
guardRealStateDir()
// ── Constants ────────────────────────────────────────────────────────────────
const INSTALL_SH = join(import.meta.dir, 'install.sh')
const REAL_COMMON_SH = join(import.meta.dir, 'lib', 'common.sh')
const DEFAULT_BASE = 'https://raw.githubusercontent.com/zumik3-del/synaptomind/main/deploy'
// Stub curl: records every invocation, and serves a real common.sh when asked
// for a `.../common.sh` URL so load_common() succeeds and the script proceeds
// to fetch app.env. Every other fetch fails (exit 22) — no network is used.
const STUB_CURL = [
'#!/usr/bin/env bash',
'url=""; out=""; prev=""',
'for a in "$@"; do',
' case "$a" in *://*) url="$a" ;; esac',
' if [ "$prev" = "-o" ]; then out="$a"; fi',
' prev="$a"',
'done',
// Log only the URL (one per line) so tests can assert exact values.
'printf \'%s\\n\' "$url" >> "$STUB_CURL_LOG"',
'case "$url" in',
' */common.sh)',
' if [ -n "${STUB_CURL_SERVE_COMMON:-}" ] && [ -n "$out" ]; then',
' cp "$STUB_CURL_SERVE_COMMON" "$out"; exit 0',
' fi',
' ;;',
'esac',
'exit 22'
].join('\n')
// ── Fixtures ─────────────────────────────────────────────────────────────────
let FIXTURE_DIR = ''
let STUB_BIN = ''
let CURL_LOG = ''
beforeEach(() => {
FIXTURE_DIR = mkTempTree('synapto-install-fix-')
STUB_BIN = join(FIXTURE_DIR, 'bin')
CURL_LOG = join(FIXTURE_DIR, 'curl.log')
mkdirSync(STUB_BIN, { recursive: true })
writeFileSync(join(STUB_BIN, 'curl'), STUB_CURL)
chmodSync(join(STUB_BIN, 'curl'), 0o755)
writeFileSync(CURL_LOG, '')
})
afterEach(() => {
rmSync(FIXTURE_DIR, { recursive: true, force: true })
})
/**
* The environment for a spawn of install.sh.
*
* Built by isolatedEnv(), so RUN_DIR and every other state-derived path are
* pinned inside FIXTURE_DIR. Before task #1104 this spread `...process.env` with
* NO RUN_DIR at all, which meant these spawns resolved RUN_DIR through
* resolve_target_user() → `getent passwd` → the operator's REAL
* ~/.synaptomind. The piped runs below happen to abort before
* install_helper_scripts() (the stub curl refuses app.env), and `--help` aborts
* in parse_args — so nothing was written on those paths by luck of ordering, not
* by construction. A fixture that survives one reordering of main() would have
* overwritten the production config.
*/
function testEnv(extra: Record<string, string> = {}): Record<string, string> {
const env = isolatedEnv(FIXTURE_DIR, extra) as Record<string, string | undefined>
// The derivation only applies when these are INHERITED values, so the deletes
// run against the parent environment BEFORE `extra` is merged in — otherwise a
// test that deliberately sets APP_ENV_URL has its own override deleted and
// silently asserts on the derived default instead (which is how
// 'an explicit APP_ENV_URL wins over the derived default' broke when this
// helper moved to isolatedEnv).
for (const key of ['LIB_RAW_URL', 'APP_ENV_URL', 'DEPLOY_RAW_URL']) {
if (!(key in extra)) delete env[key]
}
return {
...env,
PATH: `${STUB_BIN}:${process.env.PATH}`,
STUB_CURL_LOG: CURL_LOG,
STUB_CURL_SERVE_COMMON: REAL_COMMON_SH
} as Record<string, string>
}
function curlCalls(): string[] {
const raw = readFileSync(CURL_LOG, 'utf8').trim()
return raw ? raw.split('\n') : []
}
/** Run install.sh through a pipe (`cat install.sh | bash`), like the one-liner. */
function runPiped(extra: Record<string, string> = {}) {
const res = spawnSync('bash', [], {
encoding: 'utf8',
input: readFileSync(INSTALL_SH, 'utf8'),
env: testEnv(extra),
cwd: FIXTURE_DIR,
timeout: 15_000
})
return { res, calls: curlCalls() }
}
/** Run install.sh as a local checkout (`bash deploy/install.sh --help`). */
function runLocal() {
const res = spawnSync('bash', [INSTALL_SH, '--help'], {
encoding: 'utf8',
env: testEnv(),
cwd: FIXTURE_DIR,
timeout: 15_000
})
return { res, calls: curlCalls() }
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('install.sh — syntax', () => {
test('passes `bash -n`', () => {
const res = spawnSync('bash', ['-n', INSTALL_SH], { encoding: 'utf8' })
expect(res.status, res.stderr).toBe(0)
})
})
describe('install.sh — piped raw-base derivation', () => {
test('piped run with no env derives the published base for common.sh and app.env', () => {
const { res, calls } = runPiped()
// common.sh was served, app.env fetch failed → pre-install abort.
expect(res.status).toBe(1)
expect(calls).toEqual([`${DEFAULT_BASE}/lib/common.sh`, `${DEFAULT_BASE}/app.env`])
})
test('DEPLOY_RAW_URL relocates the base for both derived URLs', () => {
const base = 'https://example.test/deploy'
const { calls } = runPiped({ DEPLOY_RAW_URL: base })
expect(calls).toEqual([`${base}/lib/common.sh`, `${base}/app.env`])
})
test('an explicit LIB_RAW_URL wins over the derived default', () => {
const custom = 'https://example.test/custom/common.sh'
const { calls } = runPiped({ LIB_RAW_URL: custom })
// app.env still comes from the default base (only LIB_RAW_URL was overridden)
expect(calls).toEqual([custom, `${DEFAULT_BASE}/app.env`])
})
test('an explicit APP_ENV_URL wins over the derived default', () => {
const custom = 'https://example.test/only.env'
const { calls } = runPiped({ APP_ENV_URL: custom })
expect(calls).toEqual([`${DEFAULT_BASE}/lib/common.sh`, custom])
})
test('local checkout run does not fetch the published raw URLs', () => {
const { res, calls } = runLocal()
expect(res.status, res.stderr).toBe(0)
expect(res.stdout).toContain('install.sh')
expect(calls).toHaveLength(0)
})
})
// ════════════════════════════════════════════════════════════════════════════
// DIST=binary — release tarball install (ADR 0001 §2.9)
//
// Everything runs in a scratch tree with --no-service, so no systemd call is
// ever made and nothing outside the fixture directory is written (AGENTS.md §8:
// a previous "sandboxed" demo restarted the live service five times).
// ════════════════════════════════════════════════════════════════════════════
const APP = 'synaptomind'
const REAL_TARBALL = resolve(
import.meta.dir,
'..',
'dist',
'synaptomind-v0.8.0-linux-x86_64.tar.gz'
)
/**
* A stub payload: `synaptomind --version` prints the expected line.
*
* config.json.example mirrors the SHIPPED example (config.json.example:3-7):
* both listeners, and both at the values the example ships — `server.port` 3005
* and `mcp.httpPort` 3006. The mcp block is not decoration: without an
* `httpPort` key the seeded config has nothing for the aligner to rewrite, and
* any assertion about mcp.httpPort passes VACUOUSLY (the key is simply absent).
* Widening the fixture is what makes those assertions able to fail at all.
*/
function stubPayload(version: string): Record<string, string> {
return {
[`${APP}`]: `#!/bin/sh\necho "${APP} ${version}"\n`,
'vec0.so': 'stub vec0\n',
'lib/libonnxruntime.so.1': 'stub onnxruntime\n',
'config.json.example':
'{ "server": { "port": 3005, "host": "127.0.0.1" }, "mcp": { "httpPort": 3006 } }\n',
'.env.example': 'SYNAPTOMIND_SECRET=\n'
}
}
/** The ports seed_binary_tree's app.env resolves to unless a test overrides them. */
const FIXTURE_PORT = 3999
/** The value the SHIPPED example carries — an unaligned seed leaves it behind. */
const EXAMPLE_HTTP_PORT = 3006
/** The seeded config.json of a binary install, parsed. */
function seededConfig(root: string): { server: { port: number }; mcp: { httpPort: number } } {
const raw = readFileSync(join(root, 'opt', APP, 'config.json'), 'utf8')
return JSON.parse(raw)
}
/**
* Rewrite the install.sh COPY inside a scratch tree so a test can prove its own
* assertion is load-bearing. Never touches the repo's install.sh: the mutation
* exists only inside the fixture the current test already cleans up.
*
* Throwing when the target text is absent is deliberate. A reformat that moved
* the line would otherwise leave the mutation a silent no-op and the non-vacuity
* test would quietly stop testing anything.
*/
function mutateInstallScript(deployDir: string, from: string, to: string): void {
const path = join(deployDir, 'install.sh')
const src = readFileSync(path, 'utf8')
if (!src.includes(from)) {
throw new Error(`mutation target absent from install.sh: ${JSON.stringify(from)}`)
}
writeFileSync(path, src.replace(from, to))
}
/**
* A systemctl stub that MODELS a unit instead of just recording the argv.
*
* WHY THIS EXISTS. The 0.9.0 defect is invisible to a logging stub: `systemctl
* start` and `systemctl restart` both "succeed" against a stub that exits 0, so
* a test could only ever assert which WORD was used. The defect is not the word —
* it is that `start` on an ALREADY ACTIVE unit is a no-op, so the process that
* goes on serving is the OLD one. Modelling that needs state:
*
* <stateDir>/running the version the currently-serving process reports. It is
* read from the payload ON DISK at the moment of the
* (re)start, which is what ExecStart does — so replacing
* the binary without re-launching leaves the old string
* here, exactly as the old process kept answering on the
* real 0.9.0 cutover.
* <stateDir>/active the unit exists and is running.
* <stateDir>/events one line per verb, so a test can assert a no-op as an
* OBSERVED event rather than infer it.
*
* `is-active` answers the real exit code (3 = inactive), which is the part of
* the interface a `--no-service`-style caller branches on.
*
* The paths are baked in rather than passed through the environment: a stub that
* silently found no state directory would answer "not running" to everything and
* every assertion built on it would pass for the wrong reason — the failure mode
* that produced two vacuous tests in this directory in the two days before
* task #1103.
*/
function statefulSystemctlStub(opts: { stateDir: string; payload: string }): string {
const { stateDir, payload } = opts
const state = (f: string) => JSON.stringify(join(stateDir, f))
const bin = JSON.stringify(payload)
return [
'#!/usr/bin/env bash',
// The privileged log is still written: the boundary assertions in this file
// depend on every call being recorded, state or no state.
'printf \'%s\' "$0" >> "$STUB_PRIV_LOG"',
'for a in "$@"; do printf \' %s\' "$a" >> "$STUB_PRIV_LOG"; done',
'printf \'\\n\' >> "$STUB_PRIV_LOG"',
'mkdir -p ' + JSON.stringify(stateDir),
// What a fresh ExecStart of the on-disk payload would serve. Deliberately
// NOT the tag install.sh was asked for: this is the payload, which is the
// whole distinction the cutover turned on.
'launch() {',
` v="$(${bin} --version 2>/dev/null | awk '{print $2}')"`,
` printf '%s' "\${v#v}" > ${state('running')}`,
` : > ${state('active')}`,
` printf '%s launched %s\\n' "\${1}" "\${v#v}" >> ${state('events')}`,
'}',
'case "${1:-}" in',
' is-system-running) echo running ;;',
' is-active) if [ -f ' + state('active') + ' ]; then exit 0; else exit 3; fi ;;',
// The no-op under test. Real systemd leaves a running unit untouched.
' start)',
' if [ -f ' + state('active') + ' ]; then',
` printf 'start NO-OP already active, still serving %s\\n' "$(cat ${state('running')})" >> ${state('events')}`,
' else',
' launch start',
' fi ;;',
// The other rejected alternative: a no-op when the unit is NOT running, so a
// first install never comes up. Modelled so the choice is testable, not just
// asserted in a comment.
' try-restart)',
' if [ -f ' + state('active') + ' ]; then launch try-restart;',
` else printf 'try-restart NO-OP unit inactive, nothing started\\n' >> ${state('events')}; fi ;;`,
' restart) launch restart ;;',
' stop)',
` rm -f ${state('active')} ${state('running')}`,
` printf 'stop\\n' >> ${state('events')}`,
' ;;',
'esac',
'exit 0'
].join('\n')
}
/** The version the process currently serving would report; '' when nothing is. */
function servingVersion(stateDir: string): string {
const f = join(stateDir, 'running')
return existsSync(f) ? readFileSync(f, 'utf8') : ''
}
/** One line per systemctl verb the stub handled, in order. */
function unitEvents(stateDir: string): string[] {
const f = join(stateDir, 'events')
if (!existsSync(f)) return []
const raw = readFileSync(f, 'utf8').trim()
return raw ? raw.split('\n') : []
}
/**
* Lay out <root>/<tag>/<asset> as a release directory and tar it into a payload
* with a single top-level directory, mirroring the published asset.
*/
function seedRelease(
root: string,
tag: string,
payload: Record<string, string>,
opts: { topDir?: string; asset?: string } = {}
): string {
const version = tag.replace(/^v/, '')
const topDir = opts.topDir ?? `${APP}-${version}-linux-x86_64`
const asset = opts.asset ?? `${APP}-${tag}-linux-x86_64.tar.gz`
const stage = join(root, '.build', topDir)
for (const [rel, content] of Object.entries(payload)) {
const full = join(stage, rel)
mkdirSync(resolve(full, '..'), { recursive: true })
writeFileSync(full, content)
}
chmodSync(join(stage, APP), 0o755)
mkdirSync(join(root, tag), { recursive: true })
const tarball = join(root, tag, asset)
const res = spawnSync('tar', ['-czf', tarball, '-C', join(root, '.build'), topDir], {
encoding: 'utf8'
})
expect(res.status, res.stderr).toBe(0)
return tarball
}
/**
* Copy deploy/ into a scratch tree, replace app.env with a binary-mode one and
* neutralise every privileged call: PATH stubs for sudo and systemctl that
* record their argv and do nothing.
*/
function seedBinaryTree(
root: string,
opts: {
releasesBase: string
appEnv?: Record<string, string>
execSudo?: boolean
/**
* Make the systemctl stub STATEFUL: it models an installed unit's active
* state and the version of the payload a running process was started from.
* See stubUnitStateSystemctl — this is what lets a test say "the old
* process survived" as an OBSERVED fact rather than as a claim about the
* words on the command line.
*/
unitState?: boolean
}
): { deployDir: string; stubsDir: string; sudoLog: string; stateDir: string } {
const deployDir = join(root, 'deploy')
const stubsDir = join(root, 'stubs')
const sudoLog = join(root, 'privileged.log')
const stateDir = join(root, 'unitstate')
cpSync(join(import.meta.dir), deployDir, { recursive: true })
mkdirSync(stubsDir, { recursive: true })
mkdirSync(stateDir, { recursive: true })
writeFileSync(sudoLog, '')
// sudo/systemctl stubs: log and exit 0. Nothing privileged is ever executed —
// the stub is what stands in for sudo, so the REAL sudo is never invoked and
// no real system file is touched.
//
// `execSudo: true` makes the sudo stub EXEC its arguments instead, which a
// test that asserts on the BYTES of a written unit needs (a logging stub
// writes nothing at all, so the unit on disk would be an unexercised path).
// It is opt-in for exactly that reason: it writes, and only ever inside this
// fixture's own root, which the stub's guard enforces.
for (const name of ['sudo', 'systemctl']) {
const stub = join(stubsDir, name)
const lines = [
'#!/usr/bin/env bash',
'printf \'%s\' "$0" >> "$STUB_PRIV_LOG"',
'for a in "$@"; do printf \' %s\' "$a" >> "$STUB_PRIV_LOG"; done',
'printf \'\\n\' >> "$STUB_PRIV_LOG"'
]
if (name === 'systemctl') {
if (opts.unitState) {
writeFileSync(
stub,
statefulSystemctlStub({
stateDir,
payload: join(root, 'opt', APP, APP)
})
)
chmodSync(stub, 0o755)
continue
}
lines.push('[ "$1" = "is-system-running" ] && echo running', 'exit 0')
} else if (opts.execSudo) {
lines.push(
// Only paths under this fixture may be touched, and only the write
// primitives install.sh uses; anything else is refused loudly rather
// than silently succeeding.
'for a in "$@"; do',
// `--` separator or a relative path is an argument, not a target, and
// constraining it would make the stub deny what it is meant to allow.
' case "$a" in',
' /*)',
// Allowed: the fixture root (where the unit lives) and mktemp -d under
// TMPDIR, which is where install.sh renders the unit before staging it.
// Anything else is refused — a stub that execs must not be one a stray
// absolute path can steer into a real system file.
` case "$a" in ${root}/*|"${tmpdir()}"/*) ;; *) echo "STUB: refused path $a" >&2; exit 99 ;; esac`,
' ;;',
' esac',
'done',
'case "${1:-}" in',
// systemctl is allowed so `sudo systemctl …` resolves to THIS fixture\'s
// stub rather than being refused. install_service/start_and_verify route
// daemon-reload, enable and start through run_root, so refusing it would
// make the privileged log indistinguishable from "the stub was never
// reached" — the very distinction a test that must prove no REAL
// systemctl ran depends on.
' touch|chmod|cp|mv|rm|install|systemctl) ;;',
' *) echo "STUB: refused command ${1:-}" >&2; exit 99 ;;',
'esac',
'exec "$@"'
)
} else {
lines.push(':', 'exit 0')
}
writeFileSync(stub, lines.join('\n'))
chmodSync(stub, 0o755)
}
const env = {
APP_NAME: APP,
DIST: 'binary',
INSTALL_DIR: join(root, 'opt', APP),
DATA_DIR: join(root, 'data'),
RUN_DIR: join(root, 'run'),
PORT: String(FIXTURE_PORT),
// Nothing serves /health in these tests, and install.sh's start_and_verify
// polls it for HEALTH_TIMEOUT. A test that gets as far as a SUCCESSFUL unit
// install reaches that poll, so the budget is 1s instead of the shipped 60s
// — otherwise the test times out on the wait rather than on an assertion.
HEALTH_URL: 'http://127.0.0.1:1/health',
HEALTH_TIMEOUT: '1',
RELEASES_BASE: opts.releasesBase,
RELEASE_API: '',
CHECKOUT_POLICY: 'stable',
REQUIRES_BUN: 'no',
SYSTEM_DEP_CMDS: '',
SERVICE_USER: '',
SEED_FILES: 'config.json.example:config.json .env.example:.env',
GENERATE_SECRET_IN: '.env',
HOOKS_DIR: '',
...opts.appEnv
}
// load_app_env() SOURCES this file, so every value needs its own quoting.
// The two template keys are single-quoted for the documented reason: a
// double-quoted ${APP_NAME}-${TAG} would be expanded here at source time and
// render_template() would have nothing left to substitute (ADR §2.8).
const SINGLE_QUOTED = new Set(['ASSET_PATTERN', 'APP_VERSION_CMD'])
const defaults = {
ASSET_PATTERN: '${APP_NAME}-${TAG}-${OS}-${ARCH}.tar.gz',
APP_VERSION_CMD: '${BIN} --version'
}
const merged: Record<string, string> = { ...defaults, ...env }
writeFileSync(
join(deployDir, 'app.env'),
Object.entries(merged)
.map(([k, v]) => `${k}=${SINGLE_QUOTED.has(k) ? `'${v}'` : `"${v}"`}`)
.join('\n') + '\n'
)
return { deployDir, stubsDir, sudoLog, stateDir }
}
/**
* Run install.sh from the scratch tree. Never passes --no-service implicitly.
*
* `fixtureRoot` is the fixture's own tree, taken from `deployDir`'s parent rather
* than from a describe-scoped `let ROOT`: two describes in this file each keep
* their own ROOT, and a module-level read of either one is a wiring bug that
* only shows up in the other describe. isolatedEnv pins RUN_DIR (and HOOKS_DIR /
* UNIT_FILE / DATA_DIR) inside it, so this spawn cannot reach the operator's real
* ~/.synaptomind even though resolve_target_user() would otherwise derive it from
* `getent passwd` (task #1104). PATH inheritance is kept — the PATH stub is what
* makes the sudo/systemctl stubs resolve.
*/
function runBinaryInstall(
deployDir: string,
stubsDir: string,
sudoLog: string,
args: string[],
extraEnv: Record<string, string> = {}
) {
const fixtureRoot = dirname(deployDir)
return spawnSync('bash', [join(deployDir, 'install.sh'), ...args], {
encoding: 'utf8',
env: isolatedEnv(fixtureRoot, {
PATH: `${stubsDir}:${process.env.PATH}`,
STUB_PRIV_LOG: sudoLog,
// Keep a stray APP_ENV_URL/LIB_RAW_URL from the outer env out of the way.
APP_ENV_URL: '',
LIB_RAW_URL: '',
...extraEnv
}),
timeout: 60_000
})
}
/** Temporary download/staging artefacts that must never survive a run. */
function tempLeftovers(dir: string): string[] {
return readdirSync(dir).filter(
f =>
f.startsWith('.stage.') || f === `.${APP}.tar.gz` || /^\.synaptomind\.\d+\.tar\.gz$/.test(f)
)
}
describe('install.sh — DIST=binary tarball install', () => {
let ROOT = ''
let RELEASES = ''
beforeEach(() => {
ROOT = mkTempTree('synapto-bin-')
RELEASES = join(ROOT, 'releases')
mkdirSync(RELEASES, { recursive: true })
})
afterEach(() => {
rmSync(ROOT, { recursive: true, force: true })
})
test('extracts the payload: executable at the root, vec0.so, lib/libonnxruntime.so.1', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
const installDir = join(ROOT, 'opt', APP)
expect(res.status, res.stderr + res.stdout).toBe(0)
expect(existsSync(join(installDir, APP))).toBe(true)
expect(existsSync(join(installDir, 'vec0.so'))).toBe(true)
expect(existsSync(join(installDir, 'lib', 'libonnxruntime.so.1'))).toBe(true)
// The executable bit must survive the extract.
expect(statSync(join(installDir, APP)).mode & 0o111).not.toBe(0)
// Seeding resolves against the extracted payload.
expect(existsSync(join(installDir, 'config.json'))).toBe(true)
expect(existsSync(join(installDir, '.env'))).toBe(true)
expect(readFileSync(join(installDir, '.env'), 'utf8')).toMatch(/SYNAPTOMIND_SECRET=\S{8,}/)
})
test('a fresh install leaves no tarball or staging dir behind', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, res.stderr + res.stdout).toBe(0)
// A ~119 MB tarball per install would otherwise leak into INSTALL_DIR.
expect(tempLeftovers(join(ROOT, 'opt', APP))).toEqual([])
})
// ══════════════════════════════════════════════════════════════════════════
// F7 — a --force re-install must not rewrite a ~119 MB .prev set of
// byte-identical files, and the rollback story must say what that means.
//
// binary_keep_previous() copied vec0.so, lib/libonnxruntime.so.1 and the
// executable into <file>.prev on every install, including a --force
// re-install of the SAME payload. The copies are byte-identical, so nothing
// is gained, while the cost is the full payload written again onto the same
// filesystem as the database — and / on this host reached 100% with zero
// bytes free during the 0.9.0 review.
//
// A re-install of the same payload therefore keeps the copy it has, and says
// so: the .prev then describes THIS version, not an older one, so a rollback
// to it is a no-op — a fact the operator has to be told, not left to infer.
// ══════════════════════════════════════════════════════════════════════════
test('a --force re-install of the same payload does not rewrite an identical .prev (F7)', () => {
// Run 1 is a fresh install (nothing to keep: there is no previous payload).
// Run 2 is the first one that CAN write a .prev, so the stamp goes after it.
//
// execSudo, or this test proves nothing: the .prev copy goes through
// write_file_atomically, which stages and renames via run_root, and the
// logging sudo stub executes nothing — every run would "pass" by never
// writing a file at all.
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`,
execSudo: true
})
const installDir = join(ROOT, 'opt', APP)
const args = ['--version', 'v0.8.0', '--force', '--no-service']
expect(runBinaryInstall(deployDir, stubsDir, sudoLog, args).status).toBe(0)
const second = runBinaryInstall(deployDir, stubsDir, sudoLog, args)
expect(second.status, second.stderr + second.stdout).toBe(0)
// Stamp every .prev with a known old mtime. A rewrite updates it; a skipped
// copy leaves it exactly as it was, which is the whole assertion.
const prevs = ['vec0.so.prev', 'lib/libonnxruntime.so.1.prev', `${APP}.prev`]
const STAMP = new Date('2001-02-03T04:05:06Z')
for (const rel of prevs) {
expect(existsSync(join(installDir, rel)), `${rel} must exist after the second install`).toBe(
true
)
utimesSync(join(installDir, rel), STAMP, STAMP)
}
const third = runBinaryInstall(deployDir, stubsDir, sudoLog, args)
expect(third.status, third.stderr + third.stdout).toBe(0)
for (const rel of prevs) {
expect(
statSync(join(installDir, rel)).mtimeMs,
`${rel} was rewritten with byte-identical content`
).toBe(STAMP.getTime())
}
// The story is told, not left to be inferred: the kept copy describes THIS
// version, so rolling back to it restores this version.
expect(third.stdout).toContain('byte-identical')
expect(third.stdout).toMatch(/same version|this version/i)
}, 60_000)
test('a --force re-install of a CHANGED payload still refreshes every .prev (F7)', () => {
// The other direction, and the one that must not regress: skipping must be
// decided by CONTENT, never by "a .prev already exists". Three payloads in a
// row, each re-published under the same tag, so run 4 sees a .prev that
// differs from the installed file and has to be replaced.
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`,
// The .prev write runs through the shared atomic writer, so the sudo stub
// has to execute for it (see the test above).
execSudo: true
})
const installDir = join(ROOT, 'opt', APP)
const args = ['--version', 'v0.8.0', '--force', '--no-service']
const republish = (vec: string, onnx: string) => {
rmSync(join(RELEASES, 'v0.8.0'), { recursive: true, force: true })
seedRelease(RELEASES, 'v0.8.0', {
...stubPayload('v0.8.0'),
'vec0.so': vec,
'lib/libonnxruntime.so.1': onnx
})
}
// 1) stub, 2) stub again (the .prev is created, byte-identical to installed)
expect(runBinaryInstall(deployDir, stubsDir, sudoLog, args).status).toBe(0)
expect(runBinaryInstall(deployDir, stubsDir, sudoLog, args).status).toBe(0)
expect(readFileSync(join(installDir, 'vec0.so.prev'), 'utf8')).toBe('stub vec0\n')
// 3) a rebuilt artifact. The rollback point is the state from BEFORE this
// run, so .prev legitimately still holds the stub bytes here — the copy
// would be identical, which is exactly the case F7 stops paying for.
republish('rebuilt vec0\n', 'rebuilt onnxruntime\n')
const third = runBinaryInstall(deployDir, stubsDir, sudoLog, args)
expect(third.status, third.stderr + third.stdout).toBe(0)
expect(readFileSync(join(installDir, 'vec0.so'), 'utf8')).toBe('rebuilt vec0\n')
expect(readFileSync(join(installDir, 'vec0.so.prev'), 'utf8')).toBe('stub vec0\n')
// 4) a second rebuild. Now the installed file and .prev DIFFER, so the skip
// must not apply: rolling back has to restore "rebuilt", not the stub.
// This is the assertion that fails if the skip is ever decided by
// "a .prev already exists" instead of by content.
republish('rebuilt2 vec0\n', 'rebuilt2 onnxruntime\n')
const fourth = runBinaryInstall(deployDir, stubsDir, sudoLog, args)
expect(fourth.status, fourth.stderr + fourth.stdout).toBe(0)
expect(readFileSync(join(installDir, 'vec0.so'), 'utf8')).toBe('rebuilt2 vec0\n')
expect(readFileSync(join(installDir, 'vec0.so.prev'), 'utf8')).toBe('rebuilt vec0\n')
expect(readFileSync(join(installDir, 'lib/libonnxruntime.so.1.prev'), 'utf8')).toBe(
'rebuilt onnxruntime\n'
)
}, 60_000)
// ══════════════════════════════════════════════════════════════════════════
// F5 — a bare version leaked into stdout.
//
// binary_install_payload() ended with `printf '%s' "$got"`, and BOTH callers
// invoke it uncaptured (install.sh: install_binary; update.sh: update_binary),
// so the version reached the operator's terminal as an unlabelled fragment
// glued to whatever line came next — "0.8.2[synaptomind] Running post-update
// hook…". It is now INSTALLED_VERSION, like RESOLVED_TAG and STAGED_PAYLOAD:
// a variable, because a command substitution would run the swap in a subshell
// where cleanup_add could not reach the caller's EXIT trap, and because no
// caller could have used the printed value anyway.
// ══════════════════════════════════════════════════════════════════════════
test('no bare version is written to stdout (F5)', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, res.stderr + res.stdout).toBe(0)
// Every stdout line belongs to the [app]-prefixed log. The leak was a line
// with no prefix at all, so assert on the shape of the whole stream rather
// than on a substring a future log line could contain.
for (const line of res.stdout.split('\n')) {
if (line.trim() === '') continue
expect(line, `unprefixed stdout line: ${JSON.stringify(line)}`).toMatch(
/^\[synaptomind\]|^\[app\]|^===|^ [A-Za-z]|^$/
)
}
expect(res.stdout).not.toMatch(/^0\.8\.2/)
// The version is still reported — as a labelled line.
expect(res.stdout).toContain('Installed')
expect(res.stdout).toContain('0.8.0')
})
test('a payload missing lib/libonnxruntime.so.1 aborts by name, with nothing swapped', () => {
const payload = stubPayload('v0.8.0')
delete payload['lib/libonnxruntime.so.1']
seedRelease(RELEASES, 'v0.8.0', payload)
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status).toBe(1)
expect(res.stderr).toContain('lib/libonnxruntime.so.1')
// Nothing may be touched: a unit pointing at a half-installed payload dies
// on ERR_DLOPEN_FAILED at start.
const installDir = join(ROOT, 'opt', APP)
expect(existsSync(join(installDir, APP))).toBe(false)
expect(existsSync(join(installDir, 'vec0.so'))).toBe(false)
})
test('a payload whose binary reports the wrong version aborts, with nothing swapped', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.7.1'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status).toBe(1)
expect(res.stderr).toContain('version check')
expect(existsSync(join(ROOT, 'opt', APP, APP))).toBe(false)
})
test('an archive with two top-level directories is refused, with nothing swapped', () => {
// Build the bad archive directly: a well-formed payload plus a second
// top-level directory, which is what a malformed release upload looks like.
const build = join(ROOT, 'build')
for (const top of [`${APP}-0.8.0-linux-x86_64`, 'spill']) {
mkdirSync(join(build, top), { recursive: true })
if (top.startsWith(APP)) {
for (const [rel, content] of Object.entries(stubPayload('v0.8.0'))) {
const full = join(build, top, rel)
mkdirSync(resolve(full, '..'), { recursive: true })
writeFileSync(full, content)
}
chmodSync(join(build, top, APP), 0o755)
} else {
writeFileSync(join(build, top, 'stray'), 'x')
}
}
mkdirSync(join(RELEASES, 'v0.8.0'), { recursive: true })
const res = spawnSync(
'tar',
[
'-czf',
join(RELEASES, 'v0.8.0', `${APP}-v0.8.0-linux-x86_64.tar.gz`),
'-C',
build,
`${APP}-0.8.0-linux-x86_64`,
'spill'
],
{ encoding: 'utf8' }
)
expect(res.status, res.stderr).toBe(0)
const tree = seedBinaryTree(ROOT, { releasesBase: `file://${RELEASES}` })
const res2 = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res2.status).toBe(1)
expect(res2.stderr).toContain('exactly one top-level directory')
// The point of staging: a malformed archive must not spill into INSTALL_DIR.
expect(existsSync(join(ROOT, 'opt', APP, APP))).toBe(false)
expect(existsSync(join(ROOT, 'opt', APP, 'spill'))).toBe(false)
})
test('a platform with no published asset fails by name, not with a 404', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
// detect_os/detect_arch read `uname`; stubbing it puts the installer on an
// arm64 host, which v1 has no asset for (ADR §2.7).
const unameStub = join(stubsDir, 'uname')
writeFileSync(
unameStub,
[
'#!/usr/bin/env bash',
'case "$1" in -s) echo Linux ;; -m) echo aarch64 ;; *) echo Linux ;; esac'
].join('\n')
)
chmodSync(unameStub, 0o755)
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
// Must be the named preflight error, not a 404 from url_get a few lines later.
expect(res.status).toBe(1)
expect(res.stderr).toContain('no release asset for linux-arm64')
expect(res.stderr).toContain('supported: linux-x86_64')
expect(res.stderr).not.toContain('download failed')
})
test('a branch CHECKOUT_POLICY is rejected in binary mode', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`,
appEnv: { CHECKOUT_POLICY: 'dev' }
})
// --version short-circuits resolution, so resolution must be exercised.
// isolatedEnv, not a bare `...process.env`: this is one of the two spawn
// sites in this file that never went through testEnv(), and it is exactly
// the shape that let #1101 write to the operator's real app.env. The root
// is derived from deployDir so this stays correct in whichever describe runs.
const res = spawnSync('bash', [join(deployDir, 'install.sh'), '--no-service'], {
encoding: 'utf8',
env: isolatedEnv(dirname(deployDir), {
PATH: `${stubsDir}:${process.env.PATH}`,
STUB_PRIV_LOG: sudoLog
}),
timeout: 60_000
})
expect(res.status).toBe(1)
expect(res.stderr).toContain('requires DIST=source')
})
test('--no-service never reaches sudo or systemctl', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, res.stderr + res.stdout).toBe(0)
expect(readFileSync(sudoLog, 'utf8')).toBe('')
})
// ── the unit-write failure path ───────────────────────────────────────────
// install_service() warns and returns 0 when the unit cannot be written, so
// install.sh still reports success. That is defensible for a FRESH install —
// there is no service to misreport and the summary says "Service: skipped" —
// but the same path on a re-install over an existing unit leaves that stale
// unit in place, and the next boot then starts the new payload without
// Environment=LD_LIBRARY_PATH. The characterising assertions below are the
// ones that make that visible; changing the exit code is install.sh's own
// call, not this suite's.
test('a unit that cannot be written: the install still succeeds but claims no service', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const unitFile = join(ROOT, 'unit', `${APP}.service`)
mkdirSync(join(ROOT, 'unit'), { recursive: true })
const stale = '# unit from a previous install, without LD_LIBRARY_PATH\n'
writeFileSync(unitFile, stale)
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`,
appEnv: { UNIT_FILE: unitFile, APP_DESC: 'Synaptomind — thought-graph engine' }
})
// sudo that refuses every write primitive (no tty for a password) and does
// nothing else. Nothing privileged is executed, exactly as in the other
// tests here. The refusal list names all of them because the unit is now
// replaced by a staged rename: the first command a run reaches is `touch`.
writeFileSync(
join(stubsDir, 'sudo'),
[
'#!/usr/bin/env bash',
'printf \'sudo\' >> "$STUB_PRIV_LOG"',
'for a in "$@"; do printf \' %s\' "$a" >> "$STUB_PRIV_LOG"; done',
'printf \'\\n\' >> "$STUB_PRIV_LOG"',
'case " $* " in *" cp "*|*" chmod "*|*" mv "*|*" touch "*) exit 1 ;; esac',
'exit 0'
].join('\n')
)
chmodSync(join(stubsDir, 'sudo'), 0o755)
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, ['--version', 'v0.8.0'])
expect(res.status, res.stderr + res.stdout).toBe(0)
// It must NOT claim a service it did not install, and it must name the
// command the operator needs instead.
expect(res.stdout).toContain('Service: skipped')
expect(res.stdout).toContain('Start:')
const log = readFileSync(sudoLog, 'utf8')
expect(log).toContain(dirname(unitFile))
// No daemon-reload and no enable: the unit was never written, so systemd
// must not be told the new body is live.
expect(log).not.toContain('daemon-reload')
expect(log).not.toContain('enable')
// The payload is complete — the failure is scoped to the unit.
const installDir = join(ROOT, 'opt', APP)
expect(existsSync(join(installDir, 'lib', 'libonnxruntime.so.1'))).toBe(true)
// The pre-existing unit is left exactly as it was: this is the deferred
// harm, and it is asserted rather than left to a reader's imagination.
expect(readFileSync(unitFile, 'utf8')).toBe(stale)
})
// ── the cutover path, atomically (task #1094) ────────────────────────────
// install.sh is what a FRESH install runs, i.e. exactly the 0.9.0 cutover,
// and it carried `run_root cp -f "$tmp" "$unit" && run_root chmod 644
// "$unit"` — the identical O_TRUNC-over-the-live-unit pattern and the forced
// 644 that #1092 called a blocker in update.sh, at an entry point outside that
// diff. The trigger stopped being hypothetical when / on this host reached 100%
// with zero bytes free during the 0.9.0 review.
const HAND_EDITED_SECRETED = [
'# hand-edited production unit, mode 600, with a secret in it',
'[Unit]',
`Description=${APP} — thought-graph engine (v0.7.1)`,
'',
'[Service]',
'Type=simple',
'Environment=SYNAPTOMIND_API_TOKEN=super-secret-value',
`ExecStart=${join(ROOT, 'opt', APP)}`,
'Restart=always',
'',
'[Install]',
'WantedBy=multi-user.target',
''
].join('\n')
/**
* A `cp` that dies after truncating its destination, the way cp(1) does when
* the write fails with ENOSPC/EIO or the process is killed mid-copy. Every cp
* aimed at the unit directory dies; every other cp passes through, so the
* payload install still happens for real. Installed into the fixture's stubs.
*/
function breakCpForTheUnitDir(stubsDir: string, unitDir: string): void {
const realCp = spawnSync('bash', ['-c', 'command -v cp'], { encoding: 'utf8' }).stdout.trim()
const p = join(stubsDir, 'cp')
writeFileSync(
p,
[
'#!/usr/bin/env bash',
`dst="\${@: -1}"; src="\${@: -2:1}"`,
`case "$dst" in ${unitDir}/*) ;; *) exec ${realCp} "$@" ;; esac`,
': > "$dst" # cp(1) opens the destination O_TRUNC...',
'head -c 24 "$src" > "$dst" # ...and only part of the payload lands',
'echo "cp: error writing $dst: No space left on device" >&2',
'exit 1'
].join('\n')
)
chmodSync(p, 0o755)
}
/** Staging files left in the unit directory (a name systemd never loads). */
function stagedLeftovers(unitFile: string): string[] {
return readdirSync(dirname(unitFile)).filter(f => f.includes('.new.'))
}
/**
* A /health that answers, so a test which gets as far as a SUCCESSFUL unit
* install also gets past start_and_verify (a failed health check makes
* install.sh exit 1 on its own account, which would mask the assertion this
* test is about). Nothing is started: the stub answers the one URL and lets
* every other invocation reach the real curl, so the file:// release
* download still happens for real.
*/
function stubHealth(stubsDir: string, body: string, log?: string): void {
const realCurl = spawnSync('bash', ['-c', 'command -v curl'], {
encoding: 'utf8'
}).stdout.trim()
const p = join(stubsDir, 'curl')
const lines = ['#!/usr/bin/env bash', 'for a in "$@"; do', ' case "$a" in', ' */health)']
// Record what was served BEFORE answering. A stub that cannot record is
// worse than no stub: an assertion that "the health check was consulted"
// then holds vacuously, because a stub that was never reached and one that
// was reached but wrote nothing are indistinguishable (a harness that could
// not record was found doing exactly that in this directory the day before).
if (log) {
lines.push(` printf '%s %s\\n' "$a" ${JSON.stringify(body)} >> ${JSON.stringify(log)}`)
}
// JSON.stringify because the file is a bash program: the body's own double
// quotes have to be escaped for bash, and its escaping undone on the way out.
lines.push(` printf '%s' ${JSON.stringify(body)}`, ' exit 0 ;;', ' esac', 'done')
lines.push(`exec ${realCurl} "$@"`)
writeFileSync(p, lines.join('\n'))
chmodSync(p, 0o755)
}
function healthyBody(version: string): string {
return JSON.stringify({ status: 'ok', version, checks: { database: 'ok', embedder: 'ok' } })
}
function stubHealthy(stubsDir: string, version: string): void {
stubHealth(stubsDir, healthyBody(version))
}
/**
* A /health whose answer is whatever the stateful systemctl stub says is
* SERVING — so the endpoint and the unit model cannot drift apart, and a test
* observes the real consequence of a no-op (`/health` keeps answering with the
* old version) instead of asserting on the word "restart".
*
* When nothing is running there is no answer at all: curl exits 7 (couldn't
* connect), which is what a closed port gives, so `wait_health` sees a silence
* and reaches its timeout verdict rather than a fabricated body.
*/
function stubServingHealth(stubsDir: string, stateDir: string, log?: string): void {
const realCurl = spawnSync('bash', ['-c', 'command -v curl'], {
encoding: 'utf8'
}).stdout.trim()
const p = join(stubsDir, 'curl')
const running = JSON.stringify(join(stateDir, 'running'))
const lines = [
'#!/usr/bin/env bash',
'for a in "$@"; do',
' case "$a" in',
' */health)',
` if [ ! -f ${running} ]; then`,
// rc 7 = CURLE_COULDNT_CONNECT, so an unserved port is unserved.
" printf 'curl: (7) Failed to connect\\n' >&2",
' exit 7',
' fi',
' v="$(cat ' + running + ')"'
]
if (log) {
// Recorded before answering, and it records the SERVED version, so a test
// can prove which payload the endpoint was asked about and not merely that
// something polled it.
lines.push(` printf '%s %s\\n' "$a" "$v" >> ${JSON.stringify(log)}`)
}
lines.push(
` printf '{"status":"ok","version":"%s","checks":{"database":"ok","embedder":"ok"}}' "$v"`,
' exit 0 ;;',
' esac',
'done',
`exec ${realCurl} "$@"`
)
writeFileSync(p, lines.join('\n'))
chmodSync(p, 0o755)
}
/**
* Rewrite the lib/common.sh COPY inside a scratch tree. Same contract as
* mutateInstallScript, for the shared gate helper: the mutation exists only
* inside the fixture the current test already cleans up, and an absent target
* throws rather than turning the proof into a silent no-op.
*/
function mutateCommonScript(deployDir: string, from: string, to: string): void {
const path = join(deployDir, 'lib', 'common.sh')
const src = readFileSync(path, 'utf8')
if (!src.includes(from)) {
throw new Error(`mutation target absent from common.sh: ${JSON.stringify(from)}`)
}
writeFileSync(path, src.replace(from, to))
}
test('a copy that dies partway leaves the existing unit byte-identical', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const unitDir = join(ROOT, 'unit')
const unitFile = join(unitDir, `${APP}.service`)
mkdirSync(unitDir, { recursive: true })
writeFileSync(unitFile, HAND_EDITED_SECRETED)
chmodSync(unitFile, 0o600)
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`,
appEnv: { UNIT_FILE: unitFile, APP_DESC: 'Synaptomind — thought-graph engine' },
execSudo: true
})
breakCpForTheUnitDir(stubsDir, unitDir)
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, ['--version', 'v0.8.0'])
expect(res.status, res.stderr + res.stdout).toBe(0)
// The counterexample: `cp -f` opened the live unit O_TRUNC and left 24 bytes
// of it ("[Unit]" / "Description=") while the run reported "cannot write
// … skipping service installation". The unit is the operator's, byte for byte.
expect(readFileSync(unitFile, 'utf8')).toBe(HAND_EDITED_SECRETED)
// Still 600: the old `chmod 644` was reached only after the successful copy,
// and would have widened a unit carrying Environment= secrets anyway.
expect(statSync(unitFile).mode & 0o777).toBe(0o600)
// No litter in the directory systemd scans.
expect(stagedLeftovers(unitFile)).toEqual([])
// And the message reports the real on-disk state rather than the intent:
// the unit is still there, 289 bytes, not the 24-byte stub the write left.
expect(res.stderr).toContain('on disk now:')
expect(res.stderr).toContain(`${Buffer.byteLength(HAND_EDITED_SECRETED)} bytes`)
expect(res.stderr).toContain('cannot write')
expect(res.stdout).toContain('Service: skipped')
})
test('a successful install keeps the replaced unit as <unit>.bak, at its own mode', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const unitDir = join(ROOT, 'unit')
const unitFile = join(unitDir, `${APP}.service`)
mkdirSync(unitDir, { recursive: true })
writeFileSync(unitFile, HAND_EDITED_SECRETED)
chmodSync(unitFile, 0o600)
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`,
appEnv: { UNIT_FILE: unitFile, APP_DESC: 'Synaptomind — thought-graph engine' },
execSudo: true
})
stubHealthy(stubsDir, '0.8.0')
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, ['--version', 'v0.8.0'])
expect(res.status, res.stderr + res.stdout).toBe(0)
// The new unit carries the line a binary install exists to deliver...
const unit = readFileSync(unitFile, 'utf8')
expect(unit).toContain(`Environment=LD_LIBRARY_PATH=${join(ROOT, 'opt', APP)}/lib`)
// ...at the OPERATOR's mode, not a forced 644, and the body it replaced is
// recoverable: a plain re-run cannot fix a bad install, and the pre-update
// hook does not cover /etc.
expect(statSync(unitFile).mode & 0o777).toBe(0o600)
const backup = `${unitFile}.bak`
expect(readFileSync(backup, 'utf8')).toBe(HAND_EDITED_SECRETED)
expect(statSync(backup).mode & 0o777).toBe(0o600)
expect(stagedLeftovers(unitFile)).toEqual([])
expect(readFileSync(sudoLog, 'utf8')).toContain('daemon-reload')
})
test('a symlinked unit is written through, not replaced by a regular file at 777', () => {
// The `systemctl link` shape. DECIDED (task #1094): follow the link and swap
// the file it points at. `mv -f` over a link would replace the link with a
// regular file — changing the unit's shape, orphaning the file systemd was
// NOT loading — and `stat -c '%a'` without -L reports the LINK's own mode
// (777, a symlink always is), landing a world-writable unit systemd warns
// about. A fresh install's unit is a regular file, so this is about not
// surprising an operator who linked one.
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const unitDir = join(ROOT, 'unit')
const unitFile = join(unitDir, `${APP}.service`)
const real = join(ROOT, 'linked', `${APP}.service`)
mkdirSync(unitDir, { recursive: true })
mkdirSync(join(ROOT, 'linked'), { recursive: true })
writeFileSync(real, HAND_EDITED_SECRETED)
chmodSync(real, 0o600)
symlinkSync(real, unitFile)
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`,
appEnv: { UNIT_FILE: unitFile, APP_DESC: 'Synaptomind — thought-graph engine' },
execSudo: true
})
stubHealthy(stubsDir, '0.8.0')
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, ['--version', 'v0.8.0'])
expect(res.status, res.stderr + res.stdout).toBe(0)
expect(lstatSync(unitFile).isSymbolicLink(), 'the link must survive the install').toBe(true)
expect(readFileSync(real, 'utf8')).toContain(
`Environment=LD_LIBRARY_PATH=${join(ROOT, 'opt', APP)}/lib`
)
// The target's own mode, never the link's 777.
expect(statSync(real).mode & 0o777).toBe(0o600)
// The .bak beside the link is a plain file, also at the unit's mode.
const backup = `${unitFile}.bak`
expect(lstatSync(backup).isSymbolicLink()).toBe(false)
expect(statSync(backup).mode & 0o777).toBe(0o600)
// The operator is told their link was followed, not silently reshaped.
expect(res.stdout).toContain('is a link; wrote through it to')
})
// ── a value systemd would not parse is refused before anything is written ──
// #1096 F1/F3. The renderer guards the substituted values (a trailing
// backslash CONTINUES the next directive away — the whole hardening block can
// go, with `systemd-analyze verify` still reporting success). install.sh is
// the FRESH-install path, i.e. the 0.9.0 cutover, and it is the one place
// where the value is read for the first time on a host: nothing is written
// before the render, so the refusal can be the whole answer.
test('a value systemd would fold aborts the install, with no unit written', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const unitFile = join(ROOT, 'unit', `${APP}.service`)
mkdirSync(join(ROOT, 'unit'), { recursive: true })
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`,
appEnv: { UNIT_FILE: unitFile, APP_DESC: 'Synaptomind — thought-graph engine' },
execSudo: true
})
// DATA_DIR ending in a backslash: `ReadWritePaths=<install> <data>\` swallows
// the PrivateTmp=true that follows it. Single-quoted, because the file is
// SOURCED and a backslash inside single quotes is literal — the reachable
// shape of this value, not a shape the test had to invent.
const envFile = join(deployDir, 'app.env')
writeFileSync(
envFile,
readFileSync(envFile, 'utf8').replace(/^DATA_DIR=.*$/m, `DATA_DIR='${join(ROOT, 'data')}\\'`)
)
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, ['--version', 'v0.8.0'])
expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(1)
expect(res.stdout).not.toContain('Done.')
// The operator is told WHICH value to fix, and what it would have done.
expect(res.stderr).toContain('DATA_DIR')
expect(res.stderr).toContain('backslash')
// Nothing was installed, so nothing is claimed: no unit, no daemon-reload,
// no enable — and no "Service: installed" in the summary.
expect(existsSync(unitFile)).toBe(false)
const log = readFileSync(sudoLog, 'utf8')
expect(log).not.toContain('daemon-reload')
expect(log).not.toContain('enable')
expect(res.stdout).not.toContain('Service: installed')
})
// ══════════════════════════════════════════════════════════════════════════
// mcp.httpPort alignment (2526a0b, task #1077)
//
// A binary install seeds config.json from the shipped example, which carries
// mcp.httpPort 3006 while the API port is aligned to this install's PORT. The
// two listeners then have nothing to do with each other, so on a host that
// already owns 3006 the service installs and dies on EADDRINUSE — and the
// installer's remedy names the wrong port.
//
// The rule: a FRESH config gets mcp.httpPort = MCP_PORT, and MCP_PORT defaults
// to PORT + 1 (production's 3105/3106). An EXISTING config.json is preserved
// verbatim, which is what keeps the migration path (config on 3106, API on
// 3105) safe.
// ══════════════════════════════════════════════════════════════════════════
/** The mcp.httpPort alignment line in seed_files(), as it reads in install.sh. */
const HTTPPORT_ALIGNMENT = ` align_config_port "\${INSTALL_DIR}/\${dest}" '"httpPort"' "\$MCP_PORT"`
/** The MCP_PORT == PORT guard in main(), as it reads in install.sh. */
const COLLISION_GUARD = ` if [ "\$MCP_PORT" = "\$PORT" ]; then
error "MCP_PORT (\${MCP_PORT}) must differ from PORT (\${PORT})"
fi`
test('an empty MCP_PORT seeds mcp.httpPort = PORT + 1', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
// The default, spelled the way app.env spells it: MCP_PORT is absent, so
// resolve_mcp_port() must derive the value from PORT alone.
const appEnv = join(deployDir, 'app.env')
expect(readFileSync(appEnv, 'utf8')).not.toMatch(/^MCP_PORT=.+$/m)
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, res.stderr + res.stdout).toBe(0)
const cfg = seededConfig(ROOT)
// server.port was already aligned before 2526a0b; mcp.httpPort is new.
expect(cfg.server.port).toBe(FIXTURE_PORT)
expect(cfg.mcp.httpPort).toBe(FIXTURE_PORT + 1)
// The bug this fixes, asserted negatively: the example's 3006 must not
// survive, or the seed binds whatever the host happens to own.
expect(cfg.mcp.httpPort).not.toBe(EXAMPLE_HTTP_PORT)
// And the two listeners differ by construction, so they cannot collide.
expect(cfg.mcp.httpPort).not.toBe(cfg.server.port)
}, 60_000)
test('an explicit MCP_PORT is used verbatim', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`,
appEnv: { MCP_PORT: '4321' }
})
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, res.stderr + res.stdout).toBe(0)
const cfg = seededConfig(ROOT)
// Verbatim: not PORT+1, not the example's value, not clamped to PORT.
expect(cfg.mcp.httpPort).toBe(4321)
expect(cfg.mcp.httpPort).not.toBe(FIXTURE_PORT + 1)
expect(cfg.mcp.httpPort).not.toBe(EXAMPLE_HTTP_PORT)
// The API port is still this install's own — the override moves one listener.
expect(cfg.server.port).toBe(FIXTURE_PORT)
}, 60_000)
test('MCP_PORT == PORT aborts before the payload is fetched', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`,
appEnv: { MCP_PORT: String(FIXTURE_PORT) }
})
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, res.stderr + res.stdout).toBe(1)
// Both ports are named, so the operator knows which value to change.
expect(res.stderr).toContain('MCP_PORT')
expect(res.stderr).toContain('must differ')
expect(res.stderr).toContain(String(FIXTURE_PORT))
// "Before the payload is fetched" is asserted by the absence of the fetch:
// binary_install_payload() is what creates INSTALL_DIR, so its not existing
// means the download never started. The release WAS available, so this is
// the guard's ordering and not an unrelated failure.
expect(existsSync(join(ROOT, 'opt'))).toBe(false)
expect(res.stdout).not.toContain('Installed')
}, 60_000)
test('an EXISTING config.json is never re-seeded: a hand-edited httpPort survives --force', () => {
// The case that must not regress. 2526a0b's alignment runs ONLY on the
// seeding path, and seed_files() skips a config.json that already exists —
// which is what keeps an installed host's own mcp.httpPort (production's
// deliberate 3106) and the config-on-3106/API-on-3105 migration path safe
// from a --force re-run that would otherwise stamp PORT+1 over it.
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
const args = ['--version', 'v0.8.0', '--force', '--no-service']
const first = runBinaryInstall(deployDir, stubsDir, sudoLog, args)
expect(first.status, first.stderr + first.stdout).toBe(0)
// Run 1 did seed, and aligned — otherwise this test proves nothing.
expect(seededConfig(ROOT).mcp.httpPort).toBe(FIXTURE_PORT + 1)
// Hand-edit to the migration layout: API on 3105, MCP left on 3006.
const configPath = join(ROOT, 'opt', APP, 'config.json')
const edited = JSON.parse(readFileSync(configPath, 'utf8'))
edited.server.port = 3105
edited.mcp.httpPort = 3106
edited.handEdited = true
writeFileSync(configPath, `${JSON.stringify(edited, null, 2)}\n`)
const second = runBinaryInstall(deployDir, stubsDir, sudoLog, args)
expect(second.status, second.stderr + second.stdout).toBe(0)
const cfg = seededConfig(ROOT)
// Verbatim: every hand-edited value, including the ones alignment would touch.
// Asserted BEFORE the log line below, so a regression fails on the file's
// content rather than on a message that a future log edit could change.
expect(cfg.server.port).toBe(3105)
expect(cfg.mcp.httpPort).toBe(3106)
expect(cfg.handEdited).toBe(true)
// Explicitly NOT re-aligned to this install's ports.
expect(cfg.mcp.httpPort).not.toBe(FIXTURE_PORT + 1)
expect(cfg.server.port).not.toBe(FIXTURE_PORT)
// And the operator is told the file was kept, not left to infer it.
expect(second.stdout).toContain('Preserved existing config.json')
}, 60_000)
// ── non-vacuity: each guard above, with the guard removed ──────────────────
//
// Each of these runs the SAME install twice in one test: once against
// install.sh as shipped, once against a mutated COPY inside the fixture
// (never the repo's file). The pair is the evidence that the assertion above
// is load-bearing — that it fails when the behaviour it guards is gone,
// rather than passing for an unrelated reason. A green run of the tests
// above is not that evidence; this is.
test('removing the httpPort alignment is detected (non-vacuity)', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
// Two independent scratch trees, one release: real vs mutated.
const realRoot = join(ROOT, 'real')
const mutRoot = join(ROOT, 'mut')
mkdirSync(realRoot, { recursive: true })
mkdirSync(mutRoot, { recursive: true })
const real = seedBinaryTree(realRoot, { releasesBase: `file://${RELEASES}` })
const mut = seedBinaryTree(mutRoot, { releasesBase: `file://${RELEASES}` })
// The mutation: the mcp alignment line is dropped, keeping the server.port
// one. This is 2526a0b's predecessor — the state the change fixed.
mutateInstallScript(mut.deployDir, `${HTTPPORT_ALIGNMENT}\n`, '')
const args = ['--version', 'v0.8.0', '--no-service']
expect(runBinaryInstall(real.deployDir, real.stubsDir, real.sudoLog, args).status).toBe(0)
const mutRes = runBinaryInstall(mut.deployDir, mut.stubsDir, mut.sudoLog, args)
expect(mutRes.status, mutRes.stderr + mutRes.stdout).toBe(0)
// Shipped: aligned. Mutated: the example's 3006 survives — so the assertion
// `httpPort === PORT + 1` in the test above has something to fail on.
expect(seededConfig(realRoot).mcp.httpPort).toBe(FIXTURE_PORT + 1)
const mutated = seededConfig(mutRoot)
expect(mutated.mcp.httpPort).toBe(EXAMPLE_HTTP_PORT)
// The mutation removes ONE listener's alignment, not both: server.port is
// still aligned, so the pair isolates httpPort rather than the whole file.
expect(mutated.server.port).toBe(FIXTURE_PORT)
}, 90_000)
test('removing the existing-config guard is detected (non-vacuity)', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const realRoot = join(ROOT, 'real')
const mutRoot = join(ROOT, 'mut')
mkdirSync(realRoot, { recursive: true })
mkdirSync(mutRoot, { recursive: true })
const real = seedBinaryTree(realRoot, { releasesBase: `file://${RELEASES}` })
const mut = seedBinaryTree(mutRoot, { releasesBase: `file://${RELEASES}` })
// The mutation: seed_files()'s skip is removed (the guard that keeps an
// existing dest), so the copy+align path runs over an operator's hand-edited
// config on every re-run. `if false` is the shape of a deleted guard.
mutateInstallScript(
mut.deployDir,
` if [ -e "\${INSTALL_DIR}/\${dest}" ]; then`,
` if false; then`
)
const args = ['--version', 'v0.8.0', '--force', '--no-service']
for (const [root, tree] of [
[realRoot, real],
[mutRoot, mut]
] as const) {
expect(runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, args).status).toBe(0)
const configPath = join(root, 'opt', APP, 'config.json')
const edited = JSON.parse(readFileSync(configPath, 'utf8'))
edited.server.port = 3105
edited.mcp.httpPort = 3106
writeFileSync(configPath, `${JSON.stringify(edited, null, 2)}\n`)
}
// Re-run over the hand-edited config. Real: preserved. Mutated: overwritten.
expect(runBinaryInstall(real.deployDir, real.stubsDir, real.sudoLog, args).status).toBe(0)
expect(runBinaryInstall(mut.deployDir, mut.stubsDir, mut.sudoLog, args).status).toBe(0)
expect(seededConfig(realRoot).mcp.httpPort).toBe(3106)
// So `httpPort === 3106` in the test above fails on this tree — the
// assertion is guarded by the skip, not by the fixture being a fresh install.
const mutated = seededConfig(mutRoot)
expect(mutated.mcp.httpPort).toBe(FIXTURE_PORT + 1)
expect(mutated.server.port).toBe(FIXTURE_PORT)
}, 90_000)
test('removing the MCP_PORT == PORT guard is detected (non-vacuity)', () => {
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const realRoot = join(ROOT, 'real')
const mutRoot = join(ROOT, 'mut')
mkdirSync(realRoot, { recursive: true })
mkdirSync(mutRoot, { recursive: true })
const real = seedBinaryTree(realRoot, {
releasesBase: `file://${RELEASES}`,
appEnv: { MCP_PORT: String(FIXTURE_PORT) }
})
const mut = seedBinaryTree(mutRoot, {
releasesBase: `file://${RELEASES}`,
appEnv: { MCP_PORT: String(FIXTURE_PORT) }
})
// The mutation: the collision check is removed from main(), so both
// listeners are seeded with the SAME port and the install proceeds.
mutateInstallScript(mut.deployDir, `${COLLISION_GUARD}\n`, '')
const args = ['--version', 'v0.8.0', '--no-service']
const realRes = runBinaryInstall(real.deployDir, real.stubsDir, real.sudoLog, args)
const mutRes = runBinaryInstall(mut.deployDir, mut.stubsDir, mut.sudoLog, args)
// Shipped: aborted, nothing installed. Mutated: installed a config whose two
// listeners collide — the exact outcome the guard exists to prevent, and
// the state `status === 1` in the test above is asserted against.
expect(realRes.status).toBe(1)
expect(existsSync(join(realRoot, 'opt'))).toBe(false)
expect(mutRes.status, mutRes.stderr + mutRes.stdout).toBe(0)
expect(existsSync(join(mutRoot, 'opt'))).toBe(true)
const mutated = seededConfig(mutRoot)
expect(mutated.mcp.httpPort).toBe(mutated.server.port)
}, 90_000)
// ══════════════════════════════════════════════════════════════════════════
// CONFIG_LINK_NAME — the R1 config link (install.sh: setup_config_link)
//
// ADR addendum #1122 R1 makes DATA_DIR the app's own config home, while the
// unit's WorkingDirectory is fixed at INSTALL_DIR and synaptomind resolves
// its config cwd-relative (src/config.ts:267 join(process.cwd(),
// 'config.json')). CONFIG_LINK_NAME bridges the two:
// ${INSTALL_DIR}/<name> -> ${DATA_DIR}/<name>, created by the installer AFTER
// the seeding passes.
//
// The properties below are what make that link safe to create on EVERY run of
// an installer that may be re-run by an operator at any time:
//
// created — the whole feature; without it the app reads its defaults.
// seeded once — the config lands in DATA_DIR (not the payload tree, which an
// update swaps), and an operator's own DATA_DIR config is never
// re-seeded over.
// refreshed — a link naming some OTHER tree is re-pointed. A symlink is a
// pointer, not data, so re-pointing it loses nothing.
// never — a REAL FILE at the link path is left byte-for-byte alone and
// replaced reported with the move that finishes R1 by hand. Losing an
// operator's config is the one unrecoverable outcome here.
// no-op — an app.env that does not set the key behaves exactly as
// before, byte for byte (the empty default is the only app that
// must keep working unchanged).
//
// The link is asserted through a REAL install, not by calling setup_config_link
// in isolation: the ordering claim (the link is created after seeding, so no
// `cp` is ever handed a symlink to write through) is a property of main()'s
// sequence and is invisible from the function alone.
// ══════════════════════════════════════════════════════════════════════════
/**
* The app.env shape synaptomind actually ships after #1131: the config is NOT
* seeded into the payload tree, it is seeded into DATA_DIR and reached through
* the link.
*/
function configLinkTree(root: string, releases: string, appEnv: Record<string, string> = {}) {
seedRelease(releases, 'v0.8.0', stubPayload('v0.8.0'))
return seedBinaryTree(root, {
releasesBase: `file://${releases}`,
appEnv: {
CONFIG_LINK_NAME: 'config.json',
SEED_FILES: '.env.example:.env',
SEED_FILES_DATA: 'config.json.example:config.json',
...appEnv
}
})
}
/** 'missing' | 'symlink' | 'file' | 'dir' — lstat, so a link is never followed. */
function pathType(path: string): string {
try {
const st = lstatSync(path)
return st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'dir' : 'file'
} catch {
return 'missing'
}
}
test('a fresh install seeds the config into DATA_DIR and links INSTALL_DIR at it', () => {
const tree = configLinkTree(ROOT, RELEASES)
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, res.stdout + res.stderr).toBe(0)
const link = join(ROOT, 'opt', APP, 'config.json')
const target = join(ROOT, 'data', 'config.json')
// A LINK, and pointing at DATA_DIR — not a second copy in the payload tree,
// which is the tree an update swaps out from under the operator.
expect(pathType(link)).toBe('symlink')
expect(readlinkSync(link)).toBe(target)
expect(pathType(target)).toBe('file')
expect(res.stdout).toContain(`Linked ${link} -> ${target}`)
// The seeded copy is the file the app READS, so it carries this install's
// ports — an unaligned one leaves the app on the example's 3005/3006.
const cfg = JSON.parse(readFileSync(target, 'utf8'))
expect(cfg.server.port).toBe(FIXTURE_PORT)
expect(cfg.mcp.httpPort).toBe(FIXTURE_PORT + 1)
}, 60_000)
test("the config is seeded ONCE: a re-install preserves the operator's DATA_DIR config", () => {
const tree = configLinkTree(ROOT, RELEASES)
const target = join(ROOT, 'data', 'config.json')
const args = ['--version', 'v0.8.0', '--force', '--no-service']
const first = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, args)
expect(first.status, first.stdout + first.stderr).toBe(0)
// Run 1 really seeded — otherwise "preserved" below would also hold on a
// tree where nothing was ever written.
expect(pathType(target)).toBe('file')
expect(JSON.parse(readFileSync(target, 'utf8')).server.port).toBe(FIXTURE_PORT)
// The operator edits the config the app reads — the migration layout.
const edited = JSON.parse(readFileSync(target, 'utf8'))
edited.server.port = 3105
edited.mcp.httpPort = 3106
edited.handEdited = true
writeFileSync(target, `${JSON.stringify(edited, null, 2)}\n`)
const second = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, args)
expect(second.status, second.stdout + second.stderr).toBe(0)
expect(second.stdout).toContain('Preserved existing config.json')
const after = JSON.parse(readFileSync(target, 'utf8'))
expect(after.handEdited).toBe(true)
expect(after.server.port).toBe(3105)
expect(after.mcp.httpPort).toBe(3106)
// And the link is idempotent: re-pointing a link that already names DATA_DIR
// would be churn, so the run says "Preserved" and the link is untouched.
const link = join(ROOT, 'opt', APP, 'config.json')
expect(second.stdout).toContain(`Preserved ${link} -> ${target}`)
expect(readlinkSync(link)).toBe(target)
}, 90_000)
test('a link naming another tree is REFRESHED, and the tree it named keeps its contents', () => {
const tree = configLinkTree(ROOT, RELEASES)
const installDir = join(ROOT, 'opt', APP)
const link = join(installDir, 'config.json')
const stale = join(ROOT, 'stale', 'config.json')
mkdirSync(join(ROOT, 'stale'), { recursive: true })
mkdirSync(installDir, { recursive: true })
// The state a host is in when the data dir moved: a link left over from the
// previous layout, pointing at a tree that still exists.
writeFileSync(stale, '{ "where": "stale" }\n')
symlinkSync(stale, link)
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, res.stdout + res.stderr).toBe(0)
const target = join(ROOT, 'data', 'config.json')
expect(readlinkSync(link)).toBe(target)
expect(res.stdout).toContain(`Linked ${link} -> ${target}`)
// A symlink is a POINTER: re-pointing it must not have consumed what it
// named, which is the whole difference between a link and a file.
expect(readFileSync(stale, 'utf8')).toBe('{ "where": "stale" }\n')
expect(pathType(target)).toBe('file')
}, 60_000)
test('a REAL FILE at the link path is never replaced — it is reported, with the move to make', () => {
const tree = configLinkTree(ROOT, RELEASES)
const installDir = join(ROOT, 'opt', APP)
const link = join(installDir, 'config.json')
const target = join(ROOT, 'data', 'config.json')
mkdirSync(installDir, { recursive: true })
// An operator's hand-edited config already in the payload tree: the exact
// state R1 exists to migrate away from, and the one a deploy script must not
// resolve by unlinking it.
const handEdited = '{ "operator": "hand-edited", "port": 3105 }\n'
writeFileSync(link, handEdited)
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
// The refusal is a WARNING, not an abort: everything else this install owes
// the operator — the DATA_DIR seed included — still happens.
expect(res.status, res.stdout + res.stderr).toBe(0)
expect(pathType(link), 'the hand-edited file was replaced by something else').toBe('file')
expect(readFileSync(link, 'utf8')).toBe(handEdited)
// Reported, and the report names the move that finishes R1 — a warning that
// only says "left alone" leaves the operator with nothing to do.
expect(res.stderr).toContain(`${link} is a real file, not a link`)
expect(res.stderr).toContain(target)
expect(res.stderr).toMatch(/move it there/)
// And the link was not created behind the operator's back either.
expect(res.stdout).not.toContain(`Linked ${link}`)
expect(pathType(target)).toBe('file')
}, 60_000)
test('an app.env that does not set CONFIG_LINK_NAME is a true no-op', () => {
// The default is empty, so this is the shape of every app that has not opted
// in — including the vendored copies in ziptask and subagentix. Nothing may
// be created, and nothing may be said about a link.
const tree = configLinkTree(ROOT, RELEASES, { CONFIG_LINK_NAME: '' })
const installDir = join(ROOT, 'opt', APP)
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, res.stdout + res.stderr).toBe(0)
const link = join(installDir, 'config.json')
expect(pathType(link)).toBe('missing')
// Not one word about a link. SEED_FILES_DATA legitimately names
// config.json on stdout, so the claim under test is scoped to the LINK.
expect(res.stdout).not.toContain(`Linked ${link}`)
expect(res.stdout).not.toContain(`Preserved ${link}`)
expect(res.stderr).not.toContain('is a real file, not a link')
// SEED_FILES_DATA still seeds into DATA_DIR: the two keys are independent,
// and a framework that tied them would make the no-op unobservable.
expect(pathType(join(ROOT, 'data', 'config.json'))).toBe('file')
}, 60_000)
test('removing the empty-value early return is detected (non-vacuity)', () => {
// The pair for the no-op test above: the shipped tree is silent, and the
// mutant — the same run with `[ -n "${CONFIG_LINK_NAME:-}" ] || return 0`
// deleted — is not. Without this the "no-op" assertion could be passing
// because the function never runs on this path at all, which is a different
// claim from "the empty default changes nothing".
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const realRoot = join(ROOT, 'real')
const mutRoot = join(ROOT, 'mut')
mkdirSync(realRoot, { recursive: true })
mkdirSync(mutRoot, { recursive: true })
const appEnv = {
CONFIG_LINK_NAME: '',
SEED_FILES: '.env.example:.env',
SEED_FILES_DATA: 'config.json.example:config.json'
}
const real = seedBinaryTree(realRoot, { releasesBase: `file://${RELEASES}`, appEnv })
const mut = seedBinaryTree(mutRoot, { releasesBase: `file://${RELEASES}`, appEnv })
mutateInstallScript(mut.deployDir, ` [ -n "\${CONFIG_LINK_NAME:-}" ] || return 0\n`, '')
const args = ['--version', 'v0.8.0', '--no-service']
const realRes = runBinaryInstall(real.deployDir, real.stubsDir, real.sudoLog, args)
const mutRes = runBinaryInstall(mut.deployDir, mut.stubsDir, mut.sudoLog, args)
expect(realRes.status, realRes.stdout + realRes.stderr).toBe(0)
expect(mutRes.status, mutRes.stdout + mutRes.stderr).toBe(0)
// Shipped: silent about the LINK, and INSTALL_DIR has no config.json entry
// at all. SEED_FILES_DATA legitimately names config.json on stdout, so the
// claim is scoped to the link and to the warning the mutant produces.
const realLink = join(realRoot, 'opt', APP, 'config.json')
expect(pathType(realLink)).toBe('missing')
expect(realRes.stdout).not.toContain(`Linked ${realLink}`)
expect(`${realRes.stdout}${realRes.stderr}`).not.toContain('is a real file, not a link')
// Mutated: with the guard gone the empty name resolves to the DIRECTORIES, so
// `${INSTALL_DIR}/` itself is reported as "a real file" — which is exactly the
// noise the no-op assertion above would have caught.
expect(mutRes.stderr).toContain('is a real file, not a link')
}, 90_000)
test('CONFIG_LINK_NAME set with an empty DATA_DIR, or naming a path, is refused', () => {
// Both refusals are loud and named, because the alternative is a link that
// points back into the tree the link exists to leave (empty DATA_DIR), or a
// name like "../../etc/x" that writes outside INSTALL_DIR.
//
// The empty-DATA_DIR case drops SEED_FILES_DATA on purpose: with it set,
// seed_files_data() refuses first (install.sh runs the seeding passes before
// setup_config_link), which is the same verdict from an EARLIER guard. The
// claim under test is that setup_config_link refuses on its own account, so
// the fixture must not give an earlier function the reason to abort.
const cases: [string, Record<string, string>][] = [
['empty DATA_DIR', { DATA_DIR: '', CONFIG_LINK_NAME: 'config.json', SEED_FILES_DATA: '' }],
['a name with a separator', { CONFIG_LINK_NAME: 'etc/config.json' }],
['a relative traversal', { CONFIG_LINK_NAME: '../../config.json' }],
['a bare dot', { CONFIG_LINK_NAME: '.' }]
]
cases.forEach(([label, appEnv], i) => {
const root = join(ROOT, `refuse-${i}`)
mkdirSync(root, { recursive: true })
const tree = configLinkTree(root, RELEASES, appEnv)
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, `${label} must abort the install:\n${res.stdout}${res.stderr}`).toBe(1)
expect(res.stderr).toContain('CONFIG_LINK_NAME')
expect(res.stdout).not.toContain('Done.')
})
}, 120_000)
// install.sh's OWN health gate — start_and_verify (task #1087)
//
// Task #1083 covered wait_health's failure path, but every install.sh test
// that installs a service stubs a HEALTHY /health, and every test that
// doesn't is `--no-service` — so start_and_verify returned at install.sh:495
// (or :496, with no unit installed) and the block at 498-501, the variable it
// sets and the `error` that reads it at 609-611, had no test at all. An
// install whose service came up unhealthy was
// untested end to end: nothing proved the run exits non-zero, and nothing
// proved it stops short of the success marker.
//
// Reaching it needs a fixture where a unit CAN be written and a start CAN be
// issued, and neither may touch the host. This host is the reason that is not
// a formality: /etc/systemd/system/synaptomind.service EXISTS here, owned by
// root, and the production service runs as the same unprivileged user as this
// suite (AGENTS.md §8 — a real `sudo systemctl` is one SIGTERM from taking the
// live service down). So the boundary is enforced three ways, and the third
// is asserted rather than assumed:
// 1. UNIT_FILE redirects the unit into the fixture's own root.
// 2. A sudo stub stands in for sudo on PATH and refuses every absolute path
// outside the fixture root and $TMPDIR, so the REAL sudo never runs
// (a PATH stub does not cover sudo's own secure PATH — it is not reached
// at all, because the stub answers the call first).
// 3. `sudo systemctl …` execs into THIS fixture's systemctl stub, which
// logs and exits 0. The priv log therefore carries two lines per call —
// one from the sudo stub, one from the systemctl stub — and the second
// is only there if the stub, not systemd, handled it.
// ══════════════════════════════════════════════════════════════════════════
/**
* The one privileged call every service install makes, as the log records it.
*
* `restart`, not `start` (task #1103): `start` on an already-active unit is a
* no-op, so an install over a live service left the old process serving. The
* boundary test below asserts this exact call, so it moves with the fix.
*/
const systemctlStartLine = 'systemctl restart synaptomind'
/** The lines of the privileged log, one per run_root/systemctl invocation. */
function privCalls(sudoLog: string): string[] {
const raw = readFileSync(sudoLog, 'utf8').trim()
if (!raw) return []
// Each stub logs its own $0, so a line opens with the stub's PATH. Reduce it
// to the command name: what is under assertion is WHICH command ran, not
// where the stub that recorded it happens to live.
return raw.split('\n').map(l => l.replace(/^\S*\/(sudo|systemctl)(?=\s|$)/, '$1'))
}
/**
* Every absolute path any privileged call named, as an ARGUMENT — the leading
* command name is dropped, because that is the stub's own path and asserting
* on it would say nothing about what the call touched.
*/
function privPaths(sudoLog: string): string[] {
return privCalls(sudoLog)
.flatMap(line => line.split(' ').slice(1))
.filter(arg => arg.startsWith('/'))
}
/**
* wait_health's /health contract check, exactly as it reads in lib/common.sh.
*
* The gate the install's own start_and_verify depends on, quoted here so the
* non-vacuity test below can weaken it and prove the assertion went with it.
* Spelled one source line per entry, so it can be diffed against common.sh by
* eye; mutateCommonScript throws if the text is ever absent, so a reformat
* cannot quietly turn the proof into a no-op.
*/
const CONTRACT_CHECK = [
' if [ -n "$status" ] && [ "$status_ok" = true ] \\',
' && { [ "$version_check" != true ] || [ -n "$version" ]; }; then'
].join('\n')
/** The /health samples the stub actually served, in order. */
function healthSamples(log: string): string[] {
if (!existsSync(log)) return []
const raw = readFileSync(log, 'utf8').trim()
return raw ? raw.split('\n') : []
}
/**
* A binary install that installs a real (fixture-local) unit and polls a
* /health that answers `body`. The only thing a caller varies is the payload
* the endpoint reports.
*/
function healthGateTree(root: string, releases: string, body: string, healthLog: string) {
seedRelease(releases, 'v0.8.0', stubPayload('v0.8.0'))
const unitFile = join(root, 'unit', `${APP}.service`)
mkdirSync(join(root, 'unit'), { recursive: true })
const tree = seedBinaryTree(root, {
releasesBase: `file://${releases}`,
appEnv: { UNIT_FILE: unitFile, APP_DESC: 'Synaptomind — thought-graph engine' },
// The unit write goes through write_file_atomically → run_root, so the
// sudo stub has to execute for the service path to be exercised at all.
execSudo: true
})
stubHealth(tree.stubsDir, body, healthLog)
return { ...tree, unitFile }
}
test('a /health that passes lets the install finish and report Done', () => {
// The control, and it is not optional. Without it, "exits non-zero" in the
// test below could be produced by ANY earlier failure in the run — a
// refused unit write, an unseeded payload — and would still be green. This
// run is byte-identical in setup and passes, so the non-zero below is the
// gate and nothing else.
const tree = healthGateTree(ROOT, RELEASES, healthyBody('0.8.0'), join(ROOT, 'health.log'))
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0'
])
expect(res.status, res.stdout + res.stderr).toBe(0)
// The gate really ran and really passed: the endpoint was polled, and the
// version it reported is the one install.sh was expecting.
expect(healthSamples(join(ROOT, 'health.log'))).toHaveLength(1)
expect(res.stdout).toContain('Done.')
expect(res.stderr).not.toContain('did not pass the health check')
// The service was installed, not skipped — otherwise the gate was skipped
// with it and this control would prove nothing.
expect(res.stdout).toContain(`Service: ${tree.unitFile}`)
expect(existsSync(tree.unitFile)).toBe(true)
}, 60_000)
test('a /health that reports failed: the install exits non-zero and never says Done', () => {
// The gap. install.sh:498-501 records HEALTH_OK=false; :609-611 turns that
// into `error`, i.e. exit 1, before the success marker is reached.
//
// A body a STATUS-ONLY read would have accepted is used on purpose: it
// answers, it is well-formed JSON, it even carries the right version, and
// only the contract (status ∈ {ok,degraded} AND a version) rejects it. The
// companion non-vacuity test below reverts the gate to a status-only read
// and shows this same install reporting success.
const healthLog = join(ROOT, 'health.log')
const body = JSON.stringify({
status: 'failed',
version: '0.8.0',
checks: { database: 'ok', embedder: 'ok' }
})
const tree = healthGateTree(ROOT, RELEASES, body, healthLog)
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0'
])
// Non-zero, and for the named reason — not some unrelated failure earlier in
// the run. The healthy control above runs the same tree shape and exits 0.
expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(1)
expect(res.stderr).toContain('installed but the service did not pass the health check')
// The success marker is the thing the gate withholds.
expect(res.stdout).not.toContain('Done.')
// Characterising, because "prints no success summary" is narrower in fact
// than in the phrasing: print_summary runs at install.sh:608 BEFORE the gate
// is read at :609, so the "=== … installed ===" block is still emitted. What
// the gate withholds is the completion marker, not the operator's summary of
// what got installed. Pinned so the ordering is a stated fact about this
// script instead of something a reader has to infer — and so moving
// print_summary after the gate surfaces here as a deliberate diff.
expect(res.stdout).toContain(`=== ${APP} installed ===`)
// And the failure is attributed to the CONTRACT, with the reason in the
// message, rather than a bare timeout an operator cannot act on.
expect(res.stderr).toContain('not with a synaptomind /health payload')
// The endpoint was reached: a status-only read needs a non-empty status, so
// a run that never polled anything would satisfy the mutation too.
expect(healthSamples(healthLog)).toHaveLength(1)
expect(healthSamples(healthLog)[0]).toContain(body)
// The payload is still fully installed — the gate reports an install that did
// not come up healthy, it does not pretend the install itself was refused.
expect(existsSync(join(ROOT, 'opt', APP, APP))).toBe(true)
}, 60_000)
test('a /health reporting a different version fails the gate by naming both versions', () => {
// The other arm the contract check exists for, and the one a status-only read
// also waves through: status ok, a version present — just not the payload
// this run installed.
const tree = healthGateTree(ROOT, RELEASES, healthyBody('0.7.9'), join(ROOT, 'health.log'))
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0'
])
expect(res.status, res.stdout + res.stderr).toBe(1)
expect(res.stderr).toContain('reports version 0.7.9, expected 0.8.0')
expect(res.stdout).not.toContain('Done.')
}, 60_000)
test('reaching the gate writes a unit and issues a start only inside the fixture', () => {
// The boundary, asserted rather than assumed — this host has a real
// /etc/systemd/system/synaptomind.service, so "the fixture does not touch
// it" is a claim that has to be checked, not a comment.
const tree = healthGateTree(ROOT, RELEASES, healthyBody('0.8.0'), join(ROOT, 'health.log'))
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0'
])
expect(res.status, res.stdout + res.stderr).toBe(0)
const calls = privCalls(tree.sudoLog)
// `sudo systemctl start` was issued…
expect(calls).toContain(`sudo ${systemctlStartLine}`)
// …and the SECOND line is the systemctl stub's own. It is only reachable by
// exec'ing the stub, so its presence is the proof that no real systemctl
// handled the start. A refused call would have produced only the first line.
expect(calls).toContain(systemctlStartLine)
// The unit was written, and at the fixture's path: the write path is
// exercised for real, so this is a sandbox, not a bypass.
expect(existsSync(tree.unitFile)).toBe(true)
expect(readFileSync(tree.unitFile, 'utf8')).toContain(
`Environment=LD_LIBRARY_PATH=${join(ROOT, 'opt', APP)}/lib`
)
// Every path any privileged call named is inside the fixture root, or the
// mktemp -d render directory install.sh stages the unit through. Nothing
// else: /etc/systemd/system is the path this assertion exists to exclude,
// and a stray absolute path is the way that would be reached.
// mktemp -d renders the unit to $TMPDIR/tmp.XXXXXXXXXX/ before it is
// staged, so the render directory itself and the file inside it are both
// legitimate. The root comes from `tmpdir()`, not from a hardcoded /tmp:
// `mktemp` honours $TMPDIR, and a CI runner (or any run with TMPDIR set)
// renders somewhere else, which a literal /tmp prefix would reject.
const renderRoot = join(tmpdir(), 'tmp.')
for (const p of privPaths(tree.sudoLog)) {
expect(
p.startsWith(`${ROOT}/`) || p.startsWith(renderRoot),
`privileged call touched a path outside the fixture: ${p}`
).toBe(true)
}
expect(readFileSync(tree.sudoLog, 'utf8')).not.toContain('/etc/systemd')
}, 60_000)
test('reverting the gate to a status-only read makes this install report success (non-vacuity)', () => {
// The proof that the assertions above are load-bearing. A green run of them
// is not that evidence; this is: with the contract check weakened to "any
// non-empty status", the SAME install — same payload, same fixture, same
// `status: failed` body — stops being a failure. So the exit code, the
// stderr message and the absent `Done.` in the test above are all decided by
// the gate, and each would be reported as a failure by a reviewer running
// this tree.
//
// Two independent scratch trees against one release, as the other
// non-vacuity tests here do: real vs mutated.
const realRoot = join(ROOT, 'real')
const mutRoot = join(ROOT, 'mut')
mkdirSync(realRoot, { recursive: true })
mkdirSync(mutRoot, { recursive: true })
seedRelease(RELEASES, 'v0.8.0', stubPayload('v0.8.0'))
const body = JSON.stringify({
status: 'failed',
version: '0.8.0',
checks: { database: 'ok', embedder: 'ok' }
})
const real = healthGateTree(realRoot, RELEASES, body, join(realRoot, 'health.log'))
const mut = healthGateTree(mutRoot, RELEASES, body, join(mutRoot, 'health.log'))
// The mutation: wait_health's contract check, reduced to a status-only read —
// a body counts as this app's /health if it carries any `status` field at
// all, with no value constraint and no `version` requirement. This is the
// check the comment above the helper calls out as load-bearing ("Without the
// version arm … anything else on the port passed as 'Service is healthy'").
//
// The target is spelled as it reads in common.sh, one source line per array
// entry, because it is copied from that file: a template literal would have
// to escape every `$`, and the escapes are what make a mutation target
// unverifiable by eye against the file it claims to quote.
mutateCommonScript(mut.deployDir, CONTRACT_CHECK, ' if [ -n "$status" ]; then')
const args = ['--version', 'v0.8.0']
const realRes = runBinaryInstall(real.deployDir, real.stubsDir, real.sudoLog, args)
const mutRes = runBinaryInstall(mut.deployDir, mut.stubsDir, mut.sudoLog, args)
// Shipped: the gate refuses a `status: failed` payload.
expect(realRes.status, realRes.stdout + realRes.stderr).toBe(1)
expect(realRes.stdout).not.toContain('Done.')
// Mutated: the exact opposite of every assertion in the test above — exit 0,
// the success marker printed, and no gate error. The version arm is also
// gone, so the same tree would wave through a stranger on the port; that is
// the point of the mutation, not a second thing to fix here.
expect(mutRes.status, mutRes.stdout + mutRes.stderr).toBe(0)
expect(mutRes.stdout).toContain('Done.')
expect(mutRes.stderr).not.toContain('did not pass the health check')
// Both trees polled the same single sample, so the pair differs only in the
// gate and not in how much of the health window each run saw.
expect(healthSamples(join(realRoot, 'health.log'))).toHaveLength(1)
expect(healthSamples(join(mutRoot, 'health.log'))).toHaveLength(1)
}, 90_000)
// ══════════════════════════════════════════════════════════════════════════
// task #1103 — install.sh --force over an ALREADY RUNNING service
//
// The real 0.9.0 cutover on 2026-10-01: install.sh reported
// `Installed /opt/synaptomind/synaptomind (0.9.0)`, rendered the new unit,
// and then failed its own health gate with `/health reports version 0.8.0,
// expected 0.9.0` — MainPID unchanged, NRestarts=0. start_and_verify issued
// `systemctl start`, and `start` on an already-active unit is a no-op, so the
// OLD process went on serving while the new payload and unit sat on disk
// unused. The gate was RIGHT. The install only took effect once an operator
// ran `systemctl restart` by hand.
//
// Why this block needs a STATEFUL systemctl and not the logging stub: `start`
// and `restart` both exit 0 against a logging stub, so the old code would
// have been indistinguishable from the fix. statefulSystemctlStub models the
// one property under test — a no-op `start` leaves the running payload in
// place — and stubServingHealth answers /health with the version that payload
// reports. So every assertion below is on OBSERVED behaviour: what the
// endpoint served, whether the gate passed, and which verb ran.
// ══════════════════════════════════════════════════════════════════════════
const RESTART_CALL = ' if run_root systemctl restart "$APP_NAME"; then'
/**
* A two-install tree: 0.8.0 first (a fresh install, which brings the unit UP),
* then 0.9.0 with --force over the service that first install left running —
* the production sequence. The unit lives inside the fixture root and the
* systemctl stub stands in for systemd, so nothing privileged and nothing
* outside the fixture is touched.
*/
function reinstallTree(
root: string,
releases: string,
opts: { mutate?: (deployDir: string) => void } = {}
) {
seedRelease(releases, 'v0.8.0', stubPayload('v0.8.0'))
seedRelease(releases, 'v0.9.0', stubPayload('v0.9.0'))
const unitFile = join(root, 'unit', `${APP}.service`)
mkdirSync(join(root, 'unit'), { recursive: true })
const healthLog = join(root, 'health.log')
const tree = seedBinaryTree(root, {
releasesBase: `file://${releases}`,
appEnv: { UNIT_FILE: unitFile, APP_DESC: 'Synaptomind — thought-graph engine' },
// The unit write goes through run_root, so the sudo stub must execute for
// the service path to be exercised at all.
execSudo: true,
unitState: true
})
stubServingHealth(tree.stubsDir, tree.stateDir, healthLog)
opts.mutate?.(tree.deployDir)
return { ...tree, unitFile, healthLog }
}
test('a --force re-install over a RUNNING service brings the new payload up', () => {
const tree = reinstallTree(ROOT, RELEASES)
const healthLog = tree.healthLog
// Run 1: the fresh install. The unit is not active yet, so this is also the
// FIRST-INSTALL case: whatever verb the fix uses has to start it here.
const first = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0'
])
expect(first.status, first.stdout + first.stderr).toBe(0)
expect(servingVersion(tree.stateDir), 'the first install must have started the unit').toBe(
'0.8.0'
)
// Run 1's own polls saw 0.8.0 — so the samples asserted below can only be 0.9.0
// if run 2 really did put the new payload on the port.
const afterFirst = healthSamples(healthLog).length
expect(afterFirst).toBeGreaterThan(0)
for (const s of healthSamples(healthLog)) expect(s).toContain(' 0.8.0')
// Run 2: the payload and the unit are replaced under a unit that is already
// active. This is where `start` used to no-op.
const second = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.9.0',
'--force'
])
// The observable the defect denied: the SERVING payload is the new one.
expect(servingVersion(tree.stateDir)).toBe('0.9.0')
// And the endpoint agreed — read from what it actually answered, not from
// what the payload on disk says. Only run 2's samples, sliced by the count
// taken above, and EVERY one of them: the first poll is the one that would
// have caught the old process.
const all = healthSamples(healthLog)
expect(all.length, 'run 2 must have polled /health').toBeGreaterThan(afterFirst)
for (const s of all.slice(afterFirst)) expect(s).toContain(' 0.9.0')
// Which is why the gate passed and the run reported success — the thing the
// 0.9.0 cutover could not do.
expect(second.status, second.stdout + second.stderr).toBe(0)
expect(second.stdout).toContain('Done.')
// The unit was replaced, not skipped: a `start`-only fix would pass the
// assertions above with a STALE unit on disk.
expect(existsSync(tree.unitFile)).toBe(true)
expect(privCalls(tree.sudoLog)).toContain(`sudo ${systemctlStartLine}`)
}, 90_000)
test('reverting restart to start leaves the OLD payload serving (non-vacuity)', () => {
// The proof the test above is load-bearing: with the shipped verb reverted to
// the `systemctl start` of the 0.9.0 cutover — the SAME two-install sequence,
// same payloads, same fixture — the second run no-ops and reproduces the
// production failure exactly, gate message included. A green run of the test
// above is not that evidence; this is.
const realRoot = join(ROOT, 'real')
const mutRoot = join(ROOT, 'mut')
mkdirSync(realRoot, { recursive: true })
mkdirSync(mutRoot, { recursive: true })
const real = reinstallTree(realRoot, RELEASES)
const mut = reinstallTree(mutRoot, RELEASES, {
// The mutation is the historical code, not a weakened gate: the health
// check is untouched, so the only variable is the verb that puts the
// service on the new payload.
mutate: deployDir =>
mutateInstallScript(
deployDir,
RESTART_CALL,
' if run_root systemctl start "$APP_NAME"; then'
)
})
const first = ['--version', 'v0.8.0']
expect(runBinaryInstall(real.deployDir, real.stubsDir, real.sudoLog, first).status).toBe(0)
expect(runBinaryInstall(mut.deployDir, mut.stubsDir, mut.sudoLog, first).status).toBe(0)
// Both trees really did have a RUNNING service before the re-install. Without
// this the mutated run could "pass" for the wrong reason — by never having
// been in the state the defect needs.
expect(servingVersion(real.stateDir)).toBe('0.8.0')
expect(servingVersion(mut.stateDir)).toBe('0.8.0')
const second = ['--version', 'v0.9.0', '--force']
const realRes = runBinaryInstall(real.deployDir, real.stubsDir, real.sudoLog, second)
const mutRes = runBinaryInstall(mut.deployDir, mut.stubsDir, mut.sudoLog, second)
// Mutated: the old process survived, and the install failed its own gate with
// the message the real cutover produced. The payload on disk IS 0.9.0 — this
// is not a failed download being mistaken for a restart problem.
expect(servingVersion(mut.stateDir), 'the old process must still be serving').toBe('0.8.0')
expect(mutRes.status, mutRes.stdout + mutRes.stderr).toBe(1)
expect(mutRes.stderr).toContain('reports version 0.8.0, expected 0.9.0')
expect(mutRes.stdout).not.toContain('Done.')
// The no-op is observed in the stub's own event log, not inferred from the
// absence of a restart: a stub that recorded nothing would make this vacuous.
expect(unitEvents(mut.stateDir)).toContain('start NO-OP already active, still serving 0.8.0')
// And the mutated endpoint kept answering with the OLD version, which is the
// mechanism the gate caught: every sample of run 2 reports 0.8.0 while the
// run expected 0.9.0.
const mutSamples = healthSamples(mut.healthLog)
expect(mutSamples.length).toBeGreaterThan(0)
for (const s of mutSamples) expect(s).toContain(' 0.8.0')
expect(
readFileSync(join(mutRoot, 'opt', APP, APP), 'utf8'),
'the payload on disk was replaced anyway'
).toContain('synaptomind v0.9.0')
// Shipped: the opposite on every one of those points.
expect(servingVersion(real.stateDir)).toBe('0.9.0')
expect(realRes.status, realRes.stdout + realRes.stderr).toBe(0)
expect(realRes.stdout).toContain('Done.')
expect(unitEvents(real.stateDir)).toContain('restart launched 0.9.0')
}, 120_000)
test('the FIRST install still comes up: the unit is not active yet', () => {
// The constraint that rules `try-restart` out. A first install has no running
// unit, so a verb that no-ops on an inactive unit leaves the service DOWN and
// the gate timing out on a service that was never asked to start. Asserted
// against the shipped code, with the state proving the unit came up from
// nothing.
const tree = reinstallTree(ROOT, RELEASES)
expect(existsSync(join(tree.stateDir, 'active')), 'precondition: nothing is active').toBe(false)
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0'
])
expect(res.status, res.stdout + res.stderr).toBe(0)
expect(res.stdout).toContain('Done.')
expect(servingVersion(tree.stateDir)).toBe('0.8.0')
expect(unitEvents(tree.stateDir)).toEqual(['restart launched 0.8.0'])
}, 60_000)
test('try-restart would leave a FIRST install down — and is not what ships (non-vacuity)', () => {
// Why the alternatives lose, demonstrated rather than asserted in a comment.
// `try-restart` replaces the payload on a running unit just as well as
// `restart` does, so a test that only covered the re-install could not tell
// them apart; on a FIRST install it is inert and the service never comes up.
const tree = reinstallTree(ROOT, RELEASES, {
mutate: deployDir =>
mutateInstallScript(
deployDir,
RESTART_CALL,
' if run_root systemctl try-restart "$APP_NAME"; then'
)
})
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0'
])
// The payload and the unit are on disk and complete — the ONLY thing missing
// is a running process, which is the whole point.
expect(existsSync(join(ROOT, 'opt', APP, APP))).toBe(true)
expect(existsSync(tree.unitFile)).toBe(true)
// Nothing was ever launched, so nothing serves and the gate times out.
expect(servingVersion(tree.stateDir)).toBe('')
expect(unitEvents(tree.stateDir)).toContain('try-restart NO-OP unit inactive, nothing started')
expect(res.status, res.stdout + res.stderr).toBe(1)
expect(res.stderr).toContain('did not pass the health check')
expect(res.stdout).not.toContain('Done.')
}, 60_000)
test('a version mismatch still fails the run loudly after the fix', () => {
// The gate is not what changed, and this pins that. A /health that answers a
// version this install did NOT put there must still fail the run, even though
// the restart succeeded and the summary was printed.
const tree = healthGateTree(ROOT, RELEASES, healthyBody('0.7.9'), join(ROOT, 'health.log'))
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0'
])
expect(res.status, res.stdout + res.stderr).toBe(1)
expect(res.stderr).toContain('reports version 0.7.9, expected 0.8.0')
expect(res.stdout).not.toContain('Done.')
// Characterising, and unchanged by this task: print_summary still runs BEFORE
// the gate is read (task #1087 pinned it), so the operator sees the restart
// line, then the === … installed === block, then the failure. Nothing about
// that ordering moved — pinned here so a future reorder surfaces as a diff.
expect(res.stdout).toContain(`=== ${APP} installed ===`)
expect(res.stdout.indexOf(`=== ${APP} installed ===`)).toBeLessThan(
res.stdout.indexOf('Done.') === -1 ? res.stdout.length : res.stdout.indexOf('Done.')
)
}, 60_000)
test('--no-service still reaches neither systemctl nor sudo', () => {
// Re-pinned because the fix added a verb to this path's neighbourhood: the
// early return has to stay above it, or a --no-service install would restart
// a service the operator told it not to touch.
const tree = reinstallTree(ROOT, RELEASES)
const res = runBinaryInstall(tree.deployDir, tree.stubsDir, tree.sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(res.status, res.stdout + res.stderr).toBe(0)
expect(readFileSync(tree.sudoLog, 'utf8')).toBe('')
expect(unitEvents(tree.stateDir)).toEqual([])
expect(servingVersion(tree.stateDir)).toBe('')
}, 60_000)
})
// ════════════════════════════════════════════════════════════════════════════
// The real, 50 MB release artifact produced by scripts/build-tarball.ts.
// Skipped when dist/ is absent (it is gitignored) so the suite stays green on
// a fresh clone; the ADR's §3.2 layout assertion lives here.
// ════════════════════════════════════════════════════════════════════════════
describe.skipIf(!existsSync(REAL_TARBALL))('install.sh — the real published tarball', () => {
// These tests copy and extract the REAL ~50 MB release artifact, twice per
// run. Bun's default 5 s per-test budget is not enough headroom on a loaded
// host or a CI runner, which made the suite intermittently red with
// "this test timed out" instead of a real assertion failure. Each test below
// therefore sets an explicit budget rather than raising the file default.
let ROOT = ''
let RELEASES = ''
beforeEach(() => {
ROOT = mkTempTree('synapto-real-')
RELEASES = join(ROOT, 'releases')
mkdirSync(join(RELEASES, 'v0.8.0'), { recursive: true })
cpSync(REAL_TARBALL, join(RELEASES, 'v0.8.0', 'synaptomind-v0.8.0-linux-x86_64.tar.gz'))
})
afterEach(() => {
rmSync(ROOT, { recursive: true, force: true })
})
test('installs the real payload and the binary reports v0.8.0 with no bun', () => {
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
const res = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
const installDir = join(ROOT, 'opt', APP)
expect(res.status, res.stderr + res.stdout).toBe(0)
for (const rel of [APP, 'vec0.so', 'lib/libonnxruntime.so.1']) {
expect(existsSync(join(installDir, rel)), rel).toBe(true)
}
// The version check in the installer already ran the binary; assert the
// contract directly under `env -i` so no bun on PATH can answer for it.
const ver = spawnSync('env', ['-i', join(installDir, APP), '--version'], { encoding: 'utf8' })
expect(ver.stdout.trim(), ver.stderr).toBe('synaptomind v0.8.0')
expect(tempLeftovers(installDir)).toEqual([])
}, 60_000)
test('a second run reports "already installed" instead of aborting with --force', () => {
// Regression: ver_cmp "v0.8.0" "0.8.0" answers "newer", so an up-to-date
// binary install used to fail with "use --force to override" (ADR §2.6).
const { deployDir, stubsDir, sudoLog } = seedBinaryTree(ROOT, {
releasesBase: `file://${RELEASES}`
})
const first = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(first.status, first.stderr + first.stdout).toBe(0)
const second = runBinaryInstall(deployDir, stubsDir, sudoLog, [
'--version',
'v0.8.0',
'--no-service'
])
expect(second.status, second.stderr + second.stdout).toBe(0)
expect(second.stdout).toContain('up to date')
expect(second.stderr).not.toContain('--force')
}, 60_000)
})