| @@ -0,0 +1,341 @@ |
| 1 | +import type { SiteConfig } from '@ts-cloud/core' |
| 2 | +import { expect, test } from 'bun:test' |
| 3 | +import assert from 'node:assert/strict' |
| 4 | +import { constants } from 'node:fs' |
| 5 | +import { chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, symlink, utimes, writeFile } from 'node:fs/promises' |
| 6 | +import { tmpdir } from 'node:os' |
| 7 | +import { dirname, join, resolve } from 'node:path' |
| 8 | +import { buildSiteServicesScript } from '../../src/drivers/shared/app-services' |
| 9 | +import { buildSiteDeployScript } from '../../src/drivers/shared/deploy-script' |
| 10 | +import { buildPromoteStagedRelease, buildPruneReleases, buildResetReleaseDir, buildStrandedReleaseTrap, releasePaths } from '../../src/drivers/shared/releases' |
| 11 | + |
| 12 | + |
| 13 | + |
| 14 | +test('Stacks background units use the explicitly selected release-local Bun', () => { |
| 15 | + const site: SiteConfig = { |
| 16 | + root: '.', |
| 17 | + backgroundBun: 'pantry/bun.sh/v1.4.4/bin/bun', |
| 18 | + scheduler: { memoryHigh: '1G', memoryMax: '1536M' }, |
| 19 | + queues: [{ queue: 'reports', tries: 5, stopWaitSecs: 120 }], |
| 20 | + } |
| 21 | + const script = buildSiteServicesScript({ slug: 'farm', siteName: 'main', appBase: '/srv/farm', releaseId: 'release-a', site }).join('\n') |
| 22 | + expect(script).toContain('ExecStart=/srv/farm/releases/release-a/pantry/bun.sh/v1.4.4/bin/bun node_modules/@stacksjs/ts-cloud/dist/bin/stacks-scheduler.js') |
| 23 | + expect(script).toContain('ExecStart=/srv/farm/releases/release-a/pantry/bun.sh/v1.4.4/bin/bun /srv/farm/releases/release-a/node_modules/@stacksjs/ts-cloud/dist/bin/stacks-queue-worker.js --queue=reports') |
| 24 | + expect(script).toContain('Environment="TS_CLOUD_BACKGROUND_BUN=pantry/bun.sh/v1.4.4/bin/bun"') |
| 25 | + expect(script).toContain('Environment="PATH=/srv/farm/releases/release-a/pantry/bun.sh/v1.4.4/bin:/usr/local/bin:/usr/bin:/bin"') |
| 26 | + expect(script).toContain('MemoryHigh=1G') |
| 27 | + expect(script).toContain('MemoryMax=1536M') |
| 28 | + expect(script).toContain('TimeoutStopSec=120') |
| 29 | + expect(script).not.toContain('ExecStart=/usr/local/bin/bun ') |
| 30 | + expect(script).not.toContain('/bin/sh -lc') |
| 31 | +}) |
| 32 | + |
| 33 | + |
| 34 | + |
| 35 | + |
| 36 | +for (const variant of ['next-id', 'same-id'] as const) for (const service of ['scheduler', 'queue-worker'] as const) test.skipIf(process.platform === 'win32')(`selected ${service} Bun retains its admitted release during ${variant} deployment`, async () => { |
| 37 | + const root = await realpath(await mkdtemp(join(tmpdir(), 'ts-cloud-background-runtime-'))) |
| 38 | + const releaseA = join(root, 'releases', 'a'), releaseB = join(root, 'releases', 'b') |
| 39 | + const relative = 'private-runtime/bin/bun' |
| 40 | + const runtimeA = join(releaseA, relative), runtimeB = join(releaseB, relative) |
| 41 | + const current = join(root, 'current'), outside = join(root, 'outside') |
| 42 | + let running: ReturnType<typeof Bun.spawn> | undefined |
| 43 | + let timer: ReturnType<typeof setTimeout> | undefined |
| 44 | + let stderrTask = Promise.resolve('') |
| 45 | + let stdout = '', stderr = '', completed = false |
| 46 | + let settledExitCode: number | null = null |
| 47 | + let deadlineFired = false, cleanupKill = false |
| 48 | + try { |
| 49 | + for (const path of [dirname(runtimeA), dirname(runtimeB), outside]) await mkdir(path, { recursive: true }) |
| 50 | + await copyFile(process.execPath, runtimeA, constants.COPYFILE_EXCL) |
| 51 | + await chmod(runtimeA, 0o700) |
| 52 | + await copyFile(process.execPath, runtimeB, constants.COPYFILE_EXCL) |
| 53 | + await chmod(runtimeB, 0o700) |
| 54 | + await writeFile(join(releaseA, 'generation'), 'a') |
| 55 | + await writeFile(join(releaseB, 'generation'), 'b') |
| 56 | + await writeFile(join(releaseA, 'bunfig.toml'), 'preload=[]\n[install]\nauto="disable"\n') |
| 57 | + await writeFile(join(releaseA, 'package.json'), '{"type":"module","private":true}') |
| 58 | + await symlink(releaseA, current) |
| 59 | + |
| 60 | + const entry = join(releaseA, `node_modules/@stacksjs/ts-cloud/dist/bin/stacks-${service}.js`) |
| 61 | + await mkdir(dirname(entry), { recursive: true }) |
| 62 | + const built = await Bun.build({ entrypoints: [resolve(import.meta.dir, `../../bin/stacks-${service}.ts`)], target: 'bun' }) |
| 63 | + assert(built.success, built.logs.map(String).join('\n')) |
| 64 | + assert(built.outputs[0]) |
| 65 | + await writeFile(entry, await built.outputs[0].text()) |
| 66 | + const action = join(releaseA, service === 'scheduler' ? 'node_modules/@stacksjs/actions/dist/schedule/run.js' : 'node_modules/@stacksjs/actions/dist/queue/work.js') |
| 67 | + await mkdir(dirname(action), { recursive: true }) |
| 68 | + const drainSetup = variant === 'same-id' |
| 69 | + ? `const keepAlive = setInterval(() => {}, 100); const drained = new Promise(resolve => process.once('SIGTERM', () => { clearInterval(keepAlive); resolve() }))` |
| 70 | + : `const drained = new Response(Bun.stdin).text()` |
| 71 | + await writeFile(action, `import { realpathSync } from 'node:fs' |
| 72 | +${drainSetup} |
| 73 | +const parent = { pid: process.pid, executable: realpathSync(process.execPath), version: Bun.version, cwd: realpathSync(process.cwd()), args: process.argv.slice(2) } |
| 74 | +console.log(JSON.stringify({ phase: 'ready', parent })) |
| 75 | +await drained |
| 76 | +const generation = await Bun.file('generation').text() |
| 77 | +if (generation !== 'a') throw new Error('Admitted worker lost its generation: ' + generation) |
| 78 | +const child = Bun.spawn(['bun', '--eval', 'import { realpathSync } from "node:fs";console.log(JSON.stringify({executable:realpathSync(process.execPath),version:Bun.version,cwd:process.cwd()}))'], { cwd: ${JSON.stringify(outside)}, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) |
| 79 | +const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]) |
| 80 | +if (exitCode !== 0 || stderr) throw new Error('Finite child failed: ' + stderr) |
| 81 | +console.log(JSON.stringify({ phase: 'done', parent, generation, child: JSON.parse(stdout) })) |
| 82 | +`) |
| 83 | + const site: SiteConfig = { root: '.', backgroundBun: relative, ...(service === 'scheduler' ? { scheduler: true } : { queues: [{ queue: 'reports,urgent', sleep: 1, tries: 5, timeout: 90 }] }) } |
| 84 | + const script = buildSiteServicesScript({ slug: 'test', siteName: 'app', appBase: root, releaseId: 'a', site }).join('\n') |
| 85 | + const command = script.split('\n').find(line => line.startsWith('ExecStart=')) |
| 86 | + assert(command) |
| 87 | + const env = Object.fromEntries(script.split('\n').filter(line => line.startsWith('Environment="')).map(line => { |
| 88 | + const value = line.slice('Environment="'.length, -1), at = value.indexOf('=') |
| 89 | + return [value.slice(0, at), value.slice(at + 1)] |
| 90 | + })) |
| 91 | + const child = running = Bun.spawn(command.slice('ExecStart='.length).split(' '), { cwd: current, env: { ...env, TMPDIR: root }, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }) |
| 92 | + const workerExited = child.exited.then(code => { settledExitCode = code; return code }) |
| 93 | + stderrTask = new Response(child.stderr).text() |
| 94 | + timer = setTimeout(() => { if (settledExitCode === null && child.exitCode === null) { deadlineFired = true; child.kill('SIGKILL') } }, 15000) |
| 95 | + const reader = child.stdout.getReader(), decoder = new TextDecoder() |
| 96 | + try { |
| 97 | + while (!stdout.includes('\n')) { |
| 98 | + const chunk = await reader.read() |
| 99 | + assert(!chunk.done, 'Native scheduler entry exited before its finite action became ready') |
| 100 | + stdout += decoder.decode(chunk.value, { stream: true }) |
| 101 | + assert(stdout.length <= 16384) |
| 102 | + } |
| 103 | + const ready = JSON.parse(stdout.slice(0, stdout.indexOf('\n'))) |
| 104 | + assert.equal(ready.phase, 'ready') |
| 105 | + assert.equal(ready.parent.pid, child.pid) |
| 106 | + assert.deepEqual(ready.parent.args, service === 'scheduler' ? [] : ['--queue=reports,urgent', '--sleep=1', '--tries=5', '--timeout=90']) |
| 107 | + assert.equal(ready.parent.executable, runtimeA) |
| 108 | + assert.equal(ready.parent.cwd, releaseA) |
| 109 | + if (variant === 'next-id') { |
| 110 | + const next = join(root, 'next') |
| 111 | + await symlink(releaseB, next) |
| 112 | + await rename(next, current) |
| 113 | + |
| 114 | + |
| 115 | + const trap = Bun.spawn(['/usr/bin/env', 'bun', '--eval', 'import { realpathSync } from "node:fs"; console.log(JSON.stringify({executable:realpathSync(process.execPath),version:Bun.version}))'], { cwd: outside, env: { PATH: dirname(join(current, relative)) }, stdout: 'pipe', stderr: 'pipe' }) |
| 116 | + const [trapCode, trapOut, trapErr] = await Promise.all([trap.exited, new Response(trap.stdout).text(), new Response(trap.stderr).text()]) |
| 117 | + assert.equal(trapCode, 0) |
| 118 | + assert.equal(JSON.parse(trapOut).executable, runtimeB) |
| 119 | + assert.equal(JSON.parse(trapOut).version, Bun.version) |
| 120 | + assert.equal(trapErr, '') |
| 121 | + assert(await Bun.file(runtimeA).exists(), 'The admitted worker retains its release until it drains') |
| 122 | + child.stdin.write('continue\n'); child.stdin.end() |
| 123 | + } |
| 124 | + else { |
| 125 | + const paths = releasePaths(root, 'a') |
| 126 | + const drain = [`kill -TERM ${child.pid}`, `for i in $(seq 1 500); do if ! kill -0 ${child.pid} 2>/dev/null; then break; fi; sleep 0.01; done; ! kill -0 ${child.pid} 2>/dev/null`] |
| 127 | + const artifact = buildSiteDeployScript({ slug: 'test', siteName: 'app', appDir: root, releaseId: 'a', artifactFetch: [], execStart: runtimeA, envEntries: {}, beforeResetCommands: drain }) |
| 128 | + const marker = artifact.indexOf(drain[0]) |
| 129 | + const reset = buildResetReleaseDir(paths) |
| 130 | + const resetAt = artifact.indexOf(reset[0]) |
| 131 | + assert(marker > 0, 'Same-ID replacement must emit the drain phase') |
| 132 | + assert(resetAt > marker) |
| 133 | + const guardEnd = artifact.indexOf('fi', marker) |
| 134 | + const trap = artifact.indexOf(buildStrandedReleaseTrap(paths)) |
| 135 | + assert(guardEnd > marker && guardEnd < resetAt && trap > 0) |
| 136 | + assert(artifact[marker - 1].startsWith('if [ -e ')) |
| 137 | + const before = artifact.filter((_, at) => at === 0 || at === trap || (at >= marker - 1 && at <= guardEnd)) |
| 138 | + const program = [...before, ...reset, `cp -R ${releaseB}/. "$TS_CLOUD_STAGED"`, ...buildPromoteStagedRelease(paths)].join('\n') |
| 139 | + const replacing = Bun.spawn(['/bin/bash', '-c', program], { cwd: root, env: { PATH: '/usr/bin:/bin' }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) |
| 140 | + const replaceTimer = setTimeout(() => { if (replacing.exitCode === null) replacing.kill('SIGKILL') }, 6000) |
| 141 | + let replaceCode: number, replaceOut: string, replaceErr: string |
| 142 | + try { [replaceCode, replaceOut, replaceErr] = await Promise.all([replacing.exited, new Response(replacing.stdout).text(), new Response(replacing.stderr).text()]) } |
| 143 | + finally { clearTimeout(replaceTimer); if (replacing.exitCode === null) { replacing.kill('SIGKILL'); await replacing.exited } } |
| 144 | + assert.equal(replaceCode, 0, `${replaceOut}\n${replaceErr}`) |
| 145 | + assert.equal(await workerExited, 0) |
| 146 | + } |
| 147 | + for (;;) { |
| 148 | + const chunk = await reader.read() |
| 149 | + if (chunk.done) break |
| 150 | + stdout += decoder.decode(chunk.value, { stream: true }) |
| 151 | + assert(stdout.length <= 16384) |
| 152 | + } |
| 153 | + stdout += decoder.decode() |
| 154 | + const code = settledExitCode = await workerExited |
| 155 | + stderr = await stderrTask |
| 156 | + assert.equal(code, 0, `${stdout}\n${stderr}`) |
| 157 | + assert.equal(stderr, '') |
| 158 | + const done = JSON.parse(stdout.trim().split('\n').at(-1)!) |
| 159 | + assert.equal(done.phase, 'done') |
| 160 | + assert.equal(done.parent.executable, runtimeA) |
| 161 | + assert.equal(done.child.executable, runtimeA) |
| 162 | + assert.equal(done.child.version, done.parent.version) |
| 163 | + assert.equal(done.child.cwd, outside) |
| 164 | + assert.equal(done.generation, 'a') |
| 165 | + if (variant === 'next-id') { |
| 166 | + await utimes(releaseA, new Date('2000-01-01'), new Date('2000-01-01')) |
| 167 | + await utimes(releaseB, new Date('2026-01-01'), new Date('2026-01-01')) |
| 168 | + const pruning = Bun.spawn(['/bin/bash', '-euo', 'pipefail', '-c', buildPruneReleases(releasePaths(root, 'b'), 1).join('\n')], { cwd: root, env: { PATH: '/usr/bin:/bin' }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) |
| 169 | + const pruneTimer = setTimeout(() => { if (pruning.exitCode === null) pruning.kill('SIGKILL') }, 6000) |
| 170 | + let pruneCode: number, pruneOut: string, pruneErr: string |
| 171 | + try { [pruneCode, pruneOut, pruneErr] = await Promise.all([pruning.exited, new Response(pruning.stdout).text(), new Response(pruning.stderr).text()]) } |
| 172 | + finally { clearTimeout(pruneTimer); if (pruning.exitCode === null) { pruning.kill('SIGKILL'); await pruning.exited } } |
| 173 | + assert.equal(pruneCode, 0, `${pruneOut}\n${pruneErr}`) |
| 174 | + assert.equal(pruneErr, '') |
| 175 | + assert.equal(await Bun.file(runtimeA).exists(), false, 'The original keep1 policy still retires the drained release') |
| 176 | + assert.equal(await Bun.file(runtimeB).exists(), true) |
| 177 | + assert.equal(await realpath(current), releaseB) |
| 178 | + console.log(JSON.stringify({ oracle: 'native-background-prune-after-drain', service, workerExit: code, oldRuntimeRemoved: true, currentRuntimePreserved: true, keepReleases: 1 })) |
| 179 | + } |
| 180 | + else { |
| 181 | + assert.equal(await realpath(current), releaseA) |
| 182 | + assert.equal(await Bun.file(join(releaseA, 'generation')).text(), 'b') |
| 183 | + console.log(JSON.stringify({ oracle: 'native-background-same-id-replacement-after-drain', service, workerExit: code, admittedGeneration: done.generation, replacementGeneration: 'b' })) |
| 184 | + } |
| 185 | + completed = true |
| 186 | + } |
| 187 | + finally { await reader.cancel().catch(() => {}) } |
| 188 | + } |
| 189 | + finally { |
| 190 | + if (timer) clearTimeout(timer) |
| 191 | + if (running?.exitCode === null && settledExitCode === null) { cleanupKill = true; running.kill('SIGKILL') } |
| 192 | + if (running) settledExitCode = await running.exited |
| 193 | + stderr = await stderrTask |
| 194 | + const artifacts = await Promise.allSettled([ |
| 195 | + writeFile(join(root, 'original.stdout'), stdout), |
| 196 | + writeFile(join(root, 'original.stderr'), stderr), |
| 197 | + writeFile(join(root, 'lifecycle.json'), JSON.stringify({ settledExitCode, exitProperty: running?.exitCode ?? null, signal: running?.signalCode ?? null, deadlineFired, cleanupKill, completed })), |
| 198 | + ]) |
| 199 | + if (completed && artifacts.every(result => result.status === 'fulfilled')) await rm(root, { recursive: true }) |
| 200 | + else console.error('BACKGROUND_RUNTIME_FIXTURE_RETAINED', root) |
| 201 | + } |
| 202 | +}, 30000) |
| 203 | + |
| 204 | + |
| 205 | +for (const service of ['scheduler', 'queue-worker'] as const) test.skipIf(process.platform === 'win32')(`selected ${service} runtime is admitted before application work`, async () => { |
| 206 | + const root = await realpath(await mkdtemp(join(tmpdir(), 'ts-cloud-background-admission-'))) |
| 207 | + const release = join(root, 'releases', 'a'), selected = 'runtime/bin/bun' |
| 208 | + const runtime = join(release, selected), alias = join(release, 'alias/bin/bun'), escaped = join(release, 'escaped/bin/bun') |
| 209 | + const marker = join(root, 'action-ran') |
| 210 | + const observations: object[] = [] |
| 211 | + let child: ReturnType<typeof Bun.spawn> | undefined, timer: ReturnType<typeof setTimeout> | undefined |
| 212 | + let settledExitCode: number | null = null, deadlineFired = false, cleanupKill = false, completed = false |
| 213 | + try { |
| 214 | + for (const path of [dirname(runtime), dirname(alias), dirname(escaped)]) await mkdir(path, { recursive: true }) |
| 215 | + await copyFile(process.execPath, runtime, constants.COPYFILE_EXCL) |
| 216 | + await chmod(runtime, 0o700) |
| 217 | + await symlink(runtime, alias) |
| 218 | + await symlink(process.execPath, escaped) |
| 219 | + await writeFile(join(release, 'bunfig.toml'), 'preload=[]\n[install]\nauto="disable"\n') |
| 220 | + await writeFile(join(release, 'package.json'), '{"type":"module","private":true}') |
| 221 | + const entry = join(release, `node_modules/@stacksjs/ts-cloud/dist/bin/stacks-${service}.js`) |
| 222 | + await mkdir(dirname(entry), { recursive: true }) |
| 223 | + const built = await Bun.build({ entrypoints: [resolve(import.meta.dir, `../../bin/stacks-${service}.ts`)], target: 'bun' }) |
| 224 | + assert(built.success, built.logs.map(String).join('\n')) |
| 225 | + assert(built.outputs[0]) |
| 226 | + await writeFile(entry, await built.outputs[0].text()) |
| 227 | + const action = join(release, service === 'scheduler' ? 'node_modules/@stacksjs/actions/dist/schedule/run.js' : 'node_modules/@stacksjs/actions/dist/queue/work.js') |
| 228 | + await mkdir(dirname(action), { recursive: true }) |
| 229 | + await writeFile(action, `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'invoked'); console.log('APP_WORK_INVOKED')`) |
| 230 | + const cases = [ |
| 231 | + { name: 'selected', executable: runtime, selected, path: dirname(runtime), allowed: true }, |
| 232 | + { name: 'contained-alias', executable: alias, selected: 'alias/bin/bun', path: dirname(alias), allowed: true }, |
| 233 | + { name: 'missing-selected-runtime', executable: runtime, selected: 'missing/bin/bun', path: dirname(runtime), allowed: false }, |
| 234 | + { name: 'different-running-executable', executable: process.execPath, selected, path: dirname(runtime), allowed: false }, |
| 235 | + { name: 'external-symlink', executable: escaped, selected: 'escaped/bin/bun', path: dirname(escaped), allowed: false }, |
| 236 | + { name: 'unsafe-selection', executable: runtime, selected: '../outside/bun', path: dirname(runtime), allowed: false }, |
| 237 | + { name: 'wrong-launch-path', executable: runtime, selected, path: '/usr/bin', allowed: false }, |
| 238 | + ] |
| 239 | + for (const scenario of cases) { |
| 240 | + await rm(marker, { force: true }) |
| 241 | + settledExitCode = null |
| 242 | + deadlineFired = false |
| 243 | + const running = child = Bun.spawn([scenario.executable, '--no-install', '--no-env-file', entry], { cwd: release, env: { APP_ENV: 'test', NODE_ENV: 'test', TS_CLOUD_BACKGROUND_BUN: scenario.selected, PATH: `${scenario.path}:/usr/bin:/bin` }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) |
| 244 | + const exited = running.exited.then(code => { settledExitCode = code; return code }) |
| 245 | + timer = setTimeout(() => { if (settledExitCode === null) { deadlineFired = true; running.kill('SIGKILL') } }, 6000) |
| 246 | + const [code, stdout, stderr] = await Promise.all([exited, new Response(running.stdout).text(), new Response(running.stderr).text()]) |
| 247 | + clearTimeout(timer) |
| 248 | + timer = undefined |
| 249 | + const invoked = await readFile(marker, 'utf8').catch(() => null) |
| 250 | + observations.push({ scenario: scenario.name, pid: running.pid, code, exitProperty: running.exitCode, signal: running.signalCode, deadlineFired, invoked, stdout, stderr }) |
| 251 | + assert(stdout.length <= 16384 && stderr.length <= 16384) |
| 252 | + assert.equal(deadlineFired, false, scenario.name) |
| 253 | + assert.equal(code, scenario.allowed ? 0 : 1, `${scenario.name}: ${stdout}\n${stderr}`) |
| 254 | + assert.equal(invoked, scenario.allowed ? 'invoked' : null, scenario.name) |
| 255 | + assert.equal(stdout.includes('APP_WORK_INVOKED'), scenario.allowed, scenario.name) |
| 256 | + if (scenario.allowed) assert.equal(stderr, '') |
| 257 | + } |
| 258 | + completed = true |
| 259 | + } |
| 260 | + finally { |
| 261 | + if (timer) clearTimeout(timer) |
| 262 | + if (child && settledExitCode === null) { cleanupKill = true; child.kill('SIGKILL') } |
| 263 | + if (child) settledExitCode = await child.exited |
| 264 | + const artifacts = await Promise.allSettled([ |
| 265 | + writeFile(join(root, 'observations.json'), JSON.stringify(observations, null, 2)), |
| 266 | + writeFile(join(root, 'lifecycle.json'), JSON.stringify({ settledExitCode, exitProperty: child?.exitCode ?? null, signal: child?.signalCode ?? null, deadlineFired, cleanupKill, completed })), |
| 267 | + ]) |
| 268 | + if (completed && artifacts.every(result => result.status === 'fulfilled')) await rm(root, { recursive: true }) |
| 269 | + else console.error('BACKGROUND_RUNTIME_ADMISSION_RETAINED', root) |
| 270 | + } |
| 271 | +}, 60000) |
| 272 | + |
| 273 | + |
| 274 | +test('selected background queue options cannot introduce unit arguments or directives', () => { |
| 275 | + const invalid: unknown[] = [ |
| 276 | + { queue: 'reports --timeout=0' }, |
| 277 | + { queue: 'reports\nExecStart=/bad' }, |
| 278 | + { queue: 'reports%n' }, |
| 279 | + { queue: '$QUEUE' }, |
| 280 | + { queue: 'reports\\urgent' }, |
| 281 | + { sleep: '1 --tries=0' }, |
| 282 | + { tries: '3\nUser=root' }, |
| 283 | + { timeout: Number.NaN }, |
| 284 | + { timeout: Number.POSITIVE_INFINITY }, |
| 285 | + { processes: Number.NaN }, |
| 286 | + { stopWaitSecs: '90\nExecStart=/bad' }, |
| 287 | + ] |
| 288 | + for (const worker of invalid) expect(() => buildSiteServicesScript({ |
| 289 | + slug: 'test', siteName: 'app', appBase: '/srv/test', releaseId: 'a', |
| 290 | + site: { root: '.', backgroundBun: 'runtime/bin/bun', queues: [worker as NonNullable<SiteConfig['queues']>[number]] }, |
| 291 | + })).toThrow('Stacks background queue options') |
| 292 | +}) |
| 293 | + |
| 294 | +test('release-local runtime selection refuses unsafe paths and missing release identity', () => { |
| 295 | + for (const value of ['', '/usr/bin/bun', '../bin/bun', 'runtime/../bin/bun', 'runtime/bin/bun --eval bad', 'runtime/%n/bin/bun', 'runtime/$USER/bin/bun', 'runtime/bin/node']) { |
| 296 | + expect(() => buildSiteServicesScript({ slug: 'test', siteName: 'app', appBase: '/srv/test', releaseId: 'a', site: { root: '.', scheduler: true, backgroundBun: value } })).toThrow('backgroundBun') |
| 297 | + } |
| 298 | + for (const releaseId of [undefined, '../other', 'a/b', '%n', '$RELEASE', 'a b']) { |
| 299 | + expect(() => buildSiteServicesScript({ slug: 'test', siteName: 'app', appBase: '/srv/test', releaseId, site: { root: '.', scheduler: true, backgroundBun: 'runtime/bin/bun' } })).toThrow('releaseId') |
| 300 | + } |
| 301 | +}) |
| 302 | + |
| 303 | + |
| 304 | +test.skipIf(process.platform === 'win32')('failed release reconciliation preserves old files before the actual prune phase', async () => { |
| 305 | + const root = await realpath(await mkdtemp(join(tmpdir(), 'ts-cloud-reconcile-prune-'))) |
| 306 | + const old = join(root, 'releases', 'a'), next = join(root, 'releases', 'b') |
| 307 | + const observations: object[] = [] |
| 308 | + let completed = false |
| 309 | + try { |
| 310 | + for (const directory of [old, next]) await mkdir(directory, { recursive: true }) |
| 311 | + await writeFile(join(old, 'asset'), 'old') |
| 312 | + await writeFile(join(next, 'asset'), 'new') |
| 313 | + await symlink(next, join(root, 'current')) |
| 314 | + await utimes(old, new Date('2000-01-01'), new Date('2000-01-01')) |
| 315 | + await utimes(next, new Date('2026-01-01'), new Date('2026-01-01')) |
| 316 | + for (const command of ['false', 'true']) { |
| 317 | + const artifact = buildSiteDeployScript({ slug: 'test', siteName: 'app', appDir: root, releaseId: 'b', artifactFetch: [], execStart: '/usr/local/bin/bun server.ts', envEntries: {}, keepReleases: 1, postActivateCommands: [command] }) |
| 318 | + const start = artifact.indexOf(command) |
| 319 | + assert(start > 0) |
| 320 | + const pruning = buildPruneReleases(releasePaths(root, 'b'), 1) |
| 321 | + assert.deepEqual(artifact.slice(start + 1, start + 1 + pruning.length), pruning) |
| 322 | + const phase = [artifact[0], ...artifact.slice(start, start + 1 + pruning.length)].join('\n') |
| 323 | + const child = Bun.spawn(['/bin/bash', '-c', phase], { cwd: root, env: { PATH: '/usr/bin:/bin' }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) |
| 324 | + const timeout = setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL') }, 6000) |
| 325 | + let code: number, stdout: string, stderr: string |
| 326 | + try { [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]) } |
| 327 | + finally { clearTimeout(timeout); if (child.exitCode === null) { child.kill('SIGKILL'); await child.exited } } |
| 328 | + observations.push({ command, code, signal: child.signalCode, stdout, stderr, oldAssetRemains: await Bun.file(join(old, 'asset')).exists(), nextAssetRemains: await Bun.file(join(next, 'asset')).exists() }) |
| 329 | + assert.equal(code, command === 'false' ? 1 : 0, `${stdout}\n${stderr}`) |
| 330 | + assert.equal(await Bun.file(join(old, 'asset')).exists(), command === 'false') |
| 331 | + assert.equal(await Bun.file(join(next, 'asset')).exists(), true) |
| 332 | + assert.equal(await realpath(join(root, 'current')), next) |
| 333 | + } |
| 334 | + completed = true |
| 335 | + } |
| 336 | + finally { |
| 337 | + const written = await writeFile(join(root, 'observations.json'), JSON.stringify(observations, null, 2)).then(() => true, () => false) |
| 338 | + if (completed && written) await rm(root, { recursive: true }) |
| 339 | + else console.error('BACKGROUND_RECONCILIATION_PRUNE_RETAINED', root) |
| 340 | + } |
| 341 | +}, 20000) |