ReviewOS

also looking at this

stacks/ts-cloud

fix: honor release-local background runtimes

#198
Merged chris wants to merge codex/release-local-background-runtime into main
13 files +724 -35

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/config.mdmodified+54-0
Changes to docs/config.md
@@ -448,6 +448,60 @@ sharedPaths: [{ path: 'database/app.sqlite', target: '/var/www/acme-app/shared/d
448448Each site installs under its own base, so a plain string would give each of them
449449a database of its own. `seed: false` marks the sites that do not own the file.
450450
451### Background services and release-local Bun
452
453Stacks server sites can select a Bun executable shipped inside each release for
454their scheduler and queue workers:
455
456```typescript
457sites: {
458 main: {
459 root: '.',
460 start: 'bun server.ts',
461 port: 3000,
462 backgroundBun: 'pantry/bun.com/v1.4.4/bin/bun',
463 scheduler: true,
464 queues: [{ queue: 'reports,emails', tries: 3, stopWaitSecs: 120 }],
465 },
466}
467```
468
469Install the selected executable into the release during your normal artifact
470build or `preStart` commands. This option does not install Bun, infer a runtime
471from `start`, or change the main service's start command. Omission retains the
472host Bun for background services. Laravel commands and arbitrary daemon commands
473retain their existing runtime behavior.
474
475The native deploy supplies its release ID. Selected scheduler and queue units
476use that physical `releases/<id>` directory for WorkingDirectory, ExecStart and
477child PATH from process launch. Switching `current` cannot redirect a running
478worker's later Bun children. Bun's default child environment is
479[captured at worker launch](https://bun.com/reference/bun/Spawn/BaseOptions).
480
481The selected path must be relative, contain only ASCII letters, digits, `_`,
482`.`, `/` and `-`, and end in `bun`. Empty, `.` and `..` segments are refused. Release-directory paths use the
483same ASCII characters.
484A symlink may resolve inside the same physical release, including a Pantry
485`.bin/bun` alias. Before loading application work, the native entry verifies the
486selected executable, its release containment, the running executable and the
487leading PATH entry. A missing or mismatched selection fails that process;
488there is no global-runtime retry.
489
490Selected Stacks queue names accept ASCII letters, digits and `_ . : , { } / @ + -`
491without spaces. Tuning values must be finite numbers. Whitespace, directives,
492systemd expansion characters and command fragments are refused before unit
493creation.
494
495For non-PHP server sites, background services reconcile after the new release's
496activation and any health gate, before old releases are pruned. Removing the
497last declared service still reconciles the empty desired set and stops its old managed units. When a deploy
498replaces an existing release ID, managed background work stops before that
499directory is reset. Stopped background services restart only after activation;
500a failed replacement can leave them stopped until a recovery deploy. Systemd's
501configured stop timeout bounds worker draining. The configured release
502retention count still applies after reconciliation. A refused stop or disable stops the deploy
503before unit-file removal and this pruning step.
504
451505### Liveness probe
452506
453507Every server-app site with a `port` gets a systemd timer,
packages/core/src/types.tsmodified+7-0
Changes to packages/core/src/types.ts
@@ -1351,6 +1351,13 @@ export interface SiteConfig {
13511351 */
13521352 start?: string
13531353
1354 /**
1355 * Stacks scheduler and queue Bun executable, relative to the active release.
1356 * Example: pantry/bun.sh/v1.4.4/bin/bun. No global fallback when selected.
1357 * Omit to retain the host runtime. This does not change the main start command.
1358 */
1359 backgroundBun?: string
1360
13541361 /**
13551362 * Port the SSR app listens on. Required when `start` is set.
13561363 * Two SSR sites on the same EC2 instance must use different ports.
packages/ts-cloud/bin/stacks-queue-worker.tsmodified+2-0
Changes to packages/ts-cloud/bin/stacks-queue-worker.ts
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
22import { resolve } from 'node:path'
33import process from 'node:process'
44import { pathToFileURL } from 'node:url'
5import { validateBackgroundBunRuntime } from '../src/drivers/shared/background-bun'
56
67process.env.APP_ENV ||= 'production'
78process.env.NODE_ENV ||= 'production'
@@ -18,6 +19,7 @@ process.env.NODE_ENV ||= 'production'
1819 * `--tries` and `--timeout` from argv itself, so the flags pass through as is.
1920 */
2021async function main(): Promise<void> {
22 validateBackgroundBunRuntime()
2123 const candidates = [
2224 'storage/framework/core/actions/src/queue/work.ts',
2325 'node_modules/@stacksjs/actions/src/queue/work.ts',
packages/ts-cloud/bin/stacks-scheduler.tsmodified+2-0
Changes to packages/ts-cloud/bin/stacks-scheduler.ts
@@ -2,11 +2,13 @@ import { existsSync } from 'node:fs'
22import { resolve } from 'node:path'
33import process from 'node:process'
44import { pathToFileURL } from 'node:url'
5import { validateBackgroundBunRuntime } from '../src/drivers/shared/background-bun'
56
67process.env.APP_ENV ||= 'production'
78process.env.NODE_ENV ||= 'production'
89
910async function main(): Promise<void> {
11 validateBackgroundBunRuntime()
1012 const candidates = [
1113 'storage/framework/core/actions/src/schedule/run.ts',
1214 'node_modules/@stacksjs/actions/src/schedule/run.ts',
packages/ts-cloud/src/drivers/shared/app-frameworks.tsmodified+36-8
Changes to packages/ts-cloud/src/drivers/shared/app-frameworks.ts
@@ -12,6 +12,7 @@
1212 */
1313import type { QueueWorkerConfig, SiteConfig } from '@ts-cloud/core'
1414import { PANTRY_PROJECT_DIR } from './package-manager'
15import { releaseLocalBunPath } from './background-bun'
1516
1617export type FrameworkId = 'stacks' | 'laravel'
1718
@@ -32,6 +33,8 @@ export function resolveSiteFramework(site: Pick<SiteConfig, 'framework' | 'type'
3233export interface FrameworkExecContext {
3334 /** The active-release directory (`/var/www/<site>/current`). */
3435 current: string
36 /** Explicit release-relative Bun for Stacks scheduler and queue services. */
37 backgroundBun?: string
3538}
3639
3740export interface AppFrameworkDriver {
@@ -45,6 +48,8 @@ export interface AppFrameworkDriver {
4548 wrapExec: (command: string) => string
4649 /** Static `Environment=` vars for a unit (systemd inherits no shell env). */
4750 readonly execEnv?: Record<string, string>
51 /** Runtime environment for generated scheduler and queue units. */
52 executionEnvironment?: (ctx: FrameworkExecContext) => Record<string, string>
4853 /**
4954 * How the app scheduler runs:
5055 * - `'cron'` → `schedule:run` is one-shot; run it every minute via cron (Laravel).
@@ -67,6 +72,24 @@ const PANTRY_ENV_EVAL = `eval "$(cd ${PANTRY_PROJECT_DIR} && pantry env 2>/dev/n
6772
6873/** The ts-cloud-installed bun (see ubuntu-bootstrap) + dedicated runtime entries. */
6974const BUN_BIN = '/usr/local/bin/bun'
75const STACKS_EXEC_ENV: Record<string, string> = {
76 PATH: '/usr/local/bin:/usr/bin:/bin',
77 BUN_INSTALL: '/root/.bun',
78 APP_ENV: 'production',
79 NODE_ENV: 'production',
80}
81
82function backgroundBun(ctx: FrameworkExecContext): string {
83 if (ctx.backgroundBun === undefined) return BUN_BIN
84 return releaseLocalBunPath(ctx.current, ctx.backgroundBun)
85}
86function validateSelectedQueueOptions(worker: QueueWorkerConfig): void {
87 const queue = worker.queue ?? 'default'
88 const numbers = [worker.sleep, worker.tries, worker.timeout, worker.processes, worker.stopWaitSecs]
89 if (typeof queue !== 'string' || !/^[a-zA-Z0-9_.:,{}/@+-]+$/.test(queue || 'default')
90 || numbers.some(value => value !== undefined && (typeof value !== 'number' || !Number.isFinite(value))))
91 throw new Error('Stacks background queue options require safe queue names and finite numeric values')
92}
7093const STACKS_SCHEDULER = 'node_modules/@stacksjs/ts-cloud/dist/bin/stacks-scheduler.js'
7194// Not `storage/framework/core/buddy/src/cli.ts queue:work`: that path exists
7295// only in the framework's monorepo, never in an app installed from npm, so
@@ -83,25 +106,30 @@ const STACKS_QUEUE_WORKER = 'node_modules/@stacksjs/ts-cloud/dist/bin/stacks-que
83106export const stacksDriver: AppFrameworkDriver = {
84107 id: 'stacks',
85108 wrapExec: (command) => command,
86 execEnv: {
87 PATH: '/usr/local/bin:/usr/bin:/bin',
88 BUN_INSTALL: '/root/.bun',
89 APP_ENV: 'production',
90 NODE_ENV: 'production',
109 execEnv: STACKS_EXEC_ENV,
110 executionEnvironment: (ctx) => {
111 const executable = backgroundBun(ctx)
112 return ctx.backgroundBun === undefined ? STACKS_EXEC_ENV : {
113 ...STACKS_EXEC_ENV,
114 PATH: `${executable.slice(0, executable.lastIndexOf('/'))}:${STACKS_EXEC_ENV.PATH}`,
115 TS_CLOUD_BACKGROUND_BUN: ctx.backgroundBun,
116 }
91117 },
92118 // The scheduler action stays alive through its in-process timers. Launch it
93119 // through ts-cloud's dedicated production entry so Buddy's full command
94120 // dispatcher does not remain resident while waiting on a child process.
95121 schedulerMode: 'daemon',
96 schedulerCommand: () => `${BUN_BIN} ${STACKS_SCHEDULER}`,
97 queueWorkerCommand: (worker, { current }) => {
122 schedulerCommand: (ctx) => `${backgroundBun(ctx)} ${STACKS_SCHEDULER}`,
123 queueWorkerCommand: (worker, ctx) => {
124 if (ctx.backgroundBun !== undefined) validateSelectedQueueOptions(worker)
125 const { current } = ctx
98126 const flags = [
99127 `--queue=${worker.queue || 'default'}`,
100128 `--sleep=${worker.sleep ?? 3}`,
101129 `--tries=${worker.tries ?? 3}`,
102130 `--timeout=${worker.timeout ?? 60}`,
103131 ]
104 return `${BUN_BIN} ${current}/${STACKS_QUEUE_WORKER} ${flags.join(' ')}`
132 return `${backgroundBun(ctx)} ${current}/${STACKS_QUEUE_WORKER} ${flags.join(' ')}`
105133 },
106134}
107135
packages/ts-cloud/src/drivers/shared/app-services.tsmodified+29-11
Changes to packages/ts-cloud/src/drivers/shared/app-services.ts
@@ -13,9 +13,10 @@
1313 * <slug>-<site>-daemon-<slug-of-name>.service
1414 * Scheduler cron lives at /etc/cron.d/<slug>-<site>-scheduler.
1515 *
16 * ExecStart commands target `<base>/current/...` (the active-release symlink),
17 * so workers/daemons always run the live code; `queue:restart` (run by the
18 * deploy's $RESTART_QUEUES macro) cycles them onto the new release.
16 * Default services target `<base>/current/...`. An explicitly selected Stacks
17 * background runtime binds scheduler and queue units to `<base>/releases/<id>`
18 * from process startup, including their child PATH. Reconciliation restarts
19 * these units for each promoted release; arbitrary daemons retain their defaults.
1920 */
2021import type { SiteConfig } from '@ts-cloud/core'
2122import { buildClearAutoMemoryHigh } from './deploy-script'
@@ -29,6 +30,8 @@ export interface SiteServicesOptions {
2930 phpVersion?: string
3031 /** Site base dir. @default `/var/www/<siteName>` */
3132 appBase?: string
33 /** Physical release ID, required when selecting a release-local Stacks background runtime. */
34 releaseId?: string
3235}
3336
3437/** Escape a string for safe use inside a POSIX/ERE regex character context. */
@@ -51,6 +54,15 @@ function slugify(value: string): string {
5154 )
5255}
5356
57function managedUnitsLoop(slug: string, siteName: string): string {
58 return `for unit in $(ls /etc/systemd/system/ 2>/dev/null | grep -E '^${reEscape(slug)}-${reEscape(siteName)}-((queue|daemon)-.*|scheduler)\\.service$' || true); do`
59}
60
61/** Stop existing managed background work before its release directory can be replaced. */
62export function buildStopSiteServicesScript(options: Pick<SiteServicesOptions, 'slug' | 'siteName'>): string[] {
63 return [managedUnitsLoop(options.slug, options.siteName), ' systemctl stop "$unit"', 'done']
64}
65
5466export function queueUnitName(slug: string, siteName: string, index: number): string {
5567 return `${slug}-${siteName}-queue-${index}`
5668}
@@ -131,7 +143,12 @@ export function buildSiteServicesScript(options: SiteServicesOptions): string[]
131143 const base = options.appBase ?? `/var/www/${siteName}`
132144 const current = `${base}/current`
133145 const driver = getAppFrameworkDriver(resolveSiteFramework(site))
134 const ctx = { current }
146 const selectedRuntime = driver.id === 'stacks' && site.backgroundBun !== undefined
147 if (selectedRuntime && (!options.releaseId || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(options.releaseId)))
148 throw new Error('Stacks backgroundBun requires a safe physical releaseId')
149 const backgroundDirectory = selectedRuntime ? `${base}/releases/${options.releaseId}` : current
150 const ctx = { current: backgroundDirectory, backgroundBun: site.backgroundBun }
151 const executionEnvironment = driver.executionEnvironment?.(ctx) ?? driver.execEnv
135152
136153 const out: string[] = []
137154 const desiredUnits: string[] = []
@@ -140,6 +157,7 @@ export function buildSiteServicesScript(options: SiteServicesOptions): string[]
140157 // the worker command; the driver's exec wrapper supplies its runtime env.
141158 const queues = site.queues || []
142159 queues.forEach((worker, qIndex) => {
160 const execStart = driver.wrapExec(driver.queueWorkerCommand(worker, ctx))
143161 const processes = Math.max(1, worker.processes ?? 1)
144162 for (let p = 0; p < processes; p++) {
145163 const name = queueUnitName(slug, siteName, desiredUnits.length)
@@ -149,9 +167,9 @@ export function buildSiteServicesScript(options: SiteServicesOptions): string[]
149167 name,
150168 systemdUnit({
151169 description: `${siteName} queue worker ${qIndex}.${p} (managed by ts-cloud)`,
152 workingDir: current,
153 execStart: driver.wrapExec(driver.queueWorkerCommand(worker, ctx)),
154 environment: driver.execEnv,
170 workingDir: backgroundDirectory,
171 execStart,
172 environment: executionEnvironment,
155173 stopWaitSecs: worker.stopWaitSecs ?? 90,
156174 }),
157175 ),
@@ -197,9 +215,9 @@ export function buildSiteServicesScript(options: SiteServicesOptions): string[]
197215 schedulerUnit,
198216 systemdUnit({
199217 description: `${siteName} scheduler (managed by ts-cloud)`,
200 workingDir: current,
218 workingDir: backgroundDirectory,
201219 execStart: driver.wrapExec(driver.schedulerCommand(ctx)),
202 environment: driver.execEnv,
220 environment: executionEnvironment,
203221 memoryHigh: limits.memoryHigh,
204222 memoryMax: limits.memoryMax,
205223 }),
@@ -223,10 +241,10 @@ export function buildSiteServicesScript(options: SiteServicesOptions): string[]
223241 // Escape regex metachars in slug/siteName and anchor to `.service` so a
224242 // sibling site whose name is a prefix (e.g. `app` vs `app-admin`) or a slug
225243 // with a `.`/`+` doesn't over-match and prune another site's units.
226 `for unit in $(ls /etc/systemd/system/ 2>/dev/null | grep -E '^${reEscape(slug)}-${reEscape(siteName)}-((queue|daemon)-.*|scheduler)\\.service$' || true); do`,
244 managedUnitsLoop(slug, siteName),
227245 ' case " $TS_CLOUD_DESIRED " in',
228246 ' *" $unit "*) ;;',
229 ' *) systemctl stop "$unit" 2>/dev/null || true; systemctl disable "$unit" 2>/dev/null || true; rm -f "/etc/systemd/system/$unit" ;;',
247 ' *) systemctl stop "$unit"; systemctl disable "$unit"; rm -f "/etc/systemd/system/$unit" ;;',
230248 ' esac',
231249 'done',
232250 'systemctl daemon-reload',
packages/ts-cloud/src/drivers/shared/background-bun.tsadded+36-0
Changes to packages/ts-cloud/src/drivers/shared/background-bun.ts
@@ -0,0 +1,36 @@
1import { accessSync, constants, realpathSync, statSync } from 'node:fs'
2import { basename, delimiter, isAbsolute, relative, resolve, sep } from 'node:path'
3import process from 'node:process'
4
5/** The opt-in is a release-relative executable, never a command or shell fragment. */
6function validateBackgroundBun(value: string): void {
7 const segments = value.split('/')
8 if (!/^[a-zA-Z0-9_./-]+$/.test(value) || value.startsWith('/')
9 || segments.some(segment => !segment || segment === '.' || segment === '..') || segments.at(-1) !== 'bun')
10 throw new Error('Stacks backgroundBun must select a safe release-relative Bun executable')
11}
12
13export function releaseLocalBunPath(directory: string, selected: string): string {
14 validateBackgroundBun(selected)
15 if (!/^\/[a-zA-Z0-9_./-]+$/.test(directory) || directory.split('/').includes('..'))
16 throw new Error('Stacks backgroundBun requires a safe absolute release directory')
17 return `${directory}/${selected}`
18}
19
20/** Reject a missing, external or mismatched runtime before importing application work. */
21export function validateBackgroundBunRuntime(): void {
22 const selected = process.env.TS_CLOUD_BACKGROUND_BUN
23 if (selected === undefined) return
24 validateBackgroundBun(selected)
25 const release = realpathSync(process.cwd())
26 const executable = realpathSync(resolve(release, selected))
27 const within = relative(release, executable)
28 if (!within || isAbsolute(within) || within === '..' || within.startsWith(`..${sep}`)
29 || basename(executable) !== 'bun' || !statSync(executable).isFile()
30 || realpathSync(process.execPath) !== executable)
31 throw new Error('The selected background Bun must be the running executable inside this release')
32 accessSync(executable, constants.X_OK)
33 const firstPath = process.env.PATH?.split(delimiter)[0]
34 if (!firstPath || realpathSync(resolve(firstPath, 'bun')) !== executable)
35 throw new Error('The selected background Bun must lead the service launch PATH')
36}
packages/ts-cloud/src/drivers/shared/compute-deploy.tsmodified+9-7
Changes to packages/ts-cloud/src/drivers/shared/compute-deploy.ts
@@ -7,7 +7,7 @@ import { buildMailEnv, hasManagementDashboardSite, resolveAppDatabase, resolveMa
77import { describeCredentialReach, formatCredentialReach, unrelatedReachCount } from '../../deploy/attach-credentials'
88import { buildManagementDashboardArtifact, ensureManagementDashboard, managementDashboardSiteNames } from '../../deploy/management-dashboard'
99import { isPhpSite, resolveSiteKind, siteInstallBase } from '../../deploy/site-target'
10import { buildSiteServicesScript, siteHasServices } from './app-services'
10import { buildSiteServicesScript, buildStopSiteServicesScript, siteHasServices } from './app-services'
1111import { buildSslScript, resolveSslProvider } from './certbot'
1212import { buildComputeStorageSiteScript } from './compute-storage'
1313import { buildDatabaseSetupScript, buildManagedDbEnv } from './db-provision'
@@ -251,6 +251,10 @@ export async function deploySiteRelease(
251251 // WorkingDirectory, rpx static root, ownership guard, deploy history) derives
252252 // from it so a shared box never collides two projects on /var/www/<name>.
253253 const appBase = siteInstallBase(slug, siteName)
254 // Background services must stop/drain on their existing release before its
255 // files can be pruned, including the selected private runtime and job assets.
256 // An empty desired set must still stop and remove units from earlier releases.
257 const servicesScript = buildSiteServicesScript({ slug, siteName, site, appBase, releaseId: sha })
254258 const baseScript =
255259 kind === 'server-static'
256260 ? buildStaticSiteDeployScript({
@@ -260,6 +264,8 @@ export async function deploySiteRelease(
260264 artifactFetch,
261265 releaseId: sha,
262266 preStartCommands: site.preStart,
267 postActivateCommands: servicesScript,
268 beforeResetCommands: buildStopSiteServicesScript({ slug, siteName }),
263269 })
264270 : buildSiteDeployScript({
265271 siteName,
@@ -280,6 +286,8 @@ export async function deploySiteRelease(
280286 envEntries: envWithServices,
281287 port: site.port,
282288 preStartCommands: site.preStart,
289 postActivateCommands: servicesScript,
290 beforeResetCommands: buildStopSiteServicesScript({ slug, siteName }),
283291 sharedPaths: site.sharedPaths,
284292 // Ported sites cut over with SO_REUSEPORT overlap + health gate by
285293 // default; `zeroDowntime: false` opts back into stop/start. Portless
@@ -321,11 +329,6 @@ export async function deploySiteRelease(
321329 : []
322330 const staticSsl = wantsNginxStatic ? buildSslScript(site) : []
323331
324 // Reconcile background services (scheduler / queue workers / daemons) for
325 // non-PHP sites too — not just Laravel. The framework driver (app-services)
326 // renders the right commands for Stacks (bun) vs Laravel, so a Stacks
327 // server-app can run its scheduler + workers the same way.
328 const servicesScript = siteHasServices(site) ? buildSiteServicesScript({ slug, siteName, site, appBase }) : []
329332
330333 const remoteScript = [
331334 ...buildDeployHistoryHeader(appBase, {
@@ -340,7 +343,6 @@ export async function deploySiteRelease(
340343 ...baseScript,
341344 ...staticVhost,
342345 ...staticSsl,
343 ...servicesScript,
344346 // Refreshes the cleanup timer, then cleans once now (#195).
345347 ...hostCleanup,
346348 ]
packages/ts-cloud/src/drivers/shared/deploy-script.tsmodified+28-8
Changes to packages/ts-cloud/src/drivers/shared/deploy-script.ts
@@ -615,6 +615,12 @@ export function buildZeroDowntimeCutover(options: ZeroDowntimeCutoverOptions): s
615615 ]
616616}
617617
618function buildBeforeReleaseReset(paths: ReleasePaths, commands: string[]): string[] {
619 if (!commands.length) return []
620 const target = quoteShellArg(paths.release)
621 return [`if [ -e ${target} ] || [ -L ${target} ]; then`, ...commands, 'fi']
622}
623
618624export interface BuildSiteDeployScriptOptions {
619625 siteName: string
620626 slug: string
@@ -637,6 +643,10 @@ export interface BuildSiteDeployScriptOptions {
637643 * `node_modules`.
638644 */
639645 preStartCommands?: string[]
646 /** Reconcile release-bound work after activation/health gates, before old releases are pruned. */
647 postActivateCommands?: string[]
648 /** Stop release-bound work before an existing target directory can be replaced. */
649 beforeResetCommands?: string[]
640650 /**
641651 * Extra paths kept in `shared/` and symlinked into each release, so they
642652 * survive a deploy. `.env` is always shared; anything the app WRITES and must
@@ -765,6 +775,8 @@ export function buildSiteDeployScript(options: BuildSiteDeployScriptOptions): st
765775 port,
766776 keepReleases = DEFAULT_KEEP_RELEASES,
767777 preStartCommands = [],
778 postActivateCommands = [],
779 beforeResetCommands = [],
768780 healthCheckPath,
769781 healthGateSeconds = 5,
770782 liveness,
@@ -830,16 +842,16 @@ export function buildSiteDeployScript(options: BuildSiteDeployScriptOptions): st
830842 // buildDeployLock): a second deploy racing the first is how a release dir
831843 // gets `rm -rf`'d while it is still being extracted into.
832844 ...buildDeployLock(paths),
833 // A failed deploy must not strand its half-built release dir: rollback
834 // picks the newest non-current dir and would activate this never-activated
835 // (broken) release. On any failure before activation, remove it.
836 buildStrandedReleaseTrap(paths),
837845 ...artifactFetch,
838846 ...buildEnsureReleaseLayout(paths, sharedPaths),
839847 // Unpack this deploy into its own release dir. When that id is the one
840848 // being served — the same commit deployed twice, a retry after an
841849 // interrupted run — it is staged beside the live tree and swapped in
842850 // below, rather than deleted out from under the running service.
851 ...buildBeforeReleaseReset(paths, beforeResetCommands),
852 // A refused drain must not give cleanup ownership of an existing target.
853 // After the drain succeeds, remove any newly half-built non-current release.
854 buildStrandedReleaseTrap(paths),
843855 ...buildResetReleaseDir(paths),
844856 `tar xzf ${tarball} -C "$TS_CLOUD_STAGED"`,
845857 // Drop the staged tarball once extracted — don't leave a world-readable
@@ -914,6 +926,7 @@ export function buildSiteDeployScript(options: BuildSiteDeployScriptOptions): st
914926 : [...buildClearAutoMemoryHigh(`${unitBase}@.service`), ...buildCommitmentReport()]),
915927 ...buildSliceReconcile(instance),
916928 ...buildZeroDowntimeCutover({ paths, unitBase, releaseId, port, healthCheckPath: gatePath, healthGateSeconds }),
929 ...postActivateCommands,
917930 ...buildPruneReleases(paths, keepReleases),
918931 // The deploy gate proves this release came up. Nothing after today
919932 // proves it is still answering, which is what the timer below is for.
@@ -981,6 +994,7 @@ export function buildSiteDeployScript(options: BuildSiteDeployScriptOptions): st
981994 // failure puts `current` back and restarts the previous release.
982995 `for TS_CLOUD_I in $(seq 1 ${Math.max(1, healthGateSeconds)}); do sleep 1; systemctl is-active --quiet ${serviceName} || { echo "[ts-cloud] release ${releaseId} of ${serviceName} did not stay up — restoring the previous release" >&2; journalctl -u ${serviceName} -n 50 --no-pager >&2 || true; if [ -n "$TS_CLOUD_PREV_CURRENT" ] && [ -d "$TS_CLOUD_PREV_CURRENT" ] && [ "$(readlink -f "$TS_CLOUD_PREV_CURRENT")" != "$(readlink -f ${paths.release})" ]; then ln -sfn "$TS_CLOUD_PREV_CURRENT" ${paths.current}.tmp && mv -Tf ${paths.current}.tmp ${paths.current}; systemctl restart ${serviceName} || true; fi; exit 1; }; done`,
983996 ...buildRecordPreviousRelease(paths),
997 ...postActivateCommands,
984998 ...buildPruneReleases(paths, keepReleases),
985999 // Only a ported service can be probed over HTTP. A worker or scheduler has
9861000 // no port to ask, so it gets no timer rather than a check that would call
@@ -1015,6 +1029,10 @@ export interface BuildStaticSiteDeployScriptOptions {
10151029 * tarball ships source rather than a pre-built site.
10161030 */
10171031 preStartCommands?: string[]
1032 /** Reconcile release-bound work after activation/health gates, before old releases are pruned. */
1033 postActivateCommands?: string[]
1034 /** Stop release-bound work before an existing target directory can be replaced. */
1035 beforeResetCommands?: string[]
10181036}
10191037
10201038/**
@@ -1026,7 +1044,7 @@ export interface BuildStaticSiteDeployScriptOptions {
10261044 * tlsx), which ts-cloud points at the symlink. Old releases are pruned.
10271045 */
10281046export function buildStaticSiteDeployScript(options: BuildStaticSiteDeployScriptOptions): string[] {
1029 const { siteName, artifactFetch, releaseId, keepReleases = DEFAULT_KEEP_RELEASES, preStartCommands = [] } = options
1047 const { siteName, artifactFetch, releaseId, keepReleases = DEFAULT_KEEP_RELEASES, preStartCommands = [], postActivateCommands = [], beforeResetCommands = [] } = options
10301048 const base = options.appDir ?? `/var/www/${siteName}`
10311049 const paths = releasePaths(base, releaseId)
10321050 const tarball = releaseTarballTmpPath(options.slug, siteName, releaseId)
@@ -1036,13 +1054,14 @@ export function buildStaticSiteDeployScript(options: BuildStaticSiteDeployScript
10361054 return [
10371055 'set -euo pipefail',
10381056 ...buildDeployLock(paths),
1039 // Same stranded-release guard as buildSiteDeployScript: never let a failed
1040 // deploy leave a release rollback could activate.
1041 buildStrandedReleaseTrap(paths),
10421057 ...artifactFetch,
10431058 ...buildEnsureReleaseLayout(paths, []),
10441059 // Same staging rule as buildSiteDeployScript: never delete the tree the
10451060 // docroot currently points at.
1061 ...buildBeforeReleaseReset(paths, beforeResetCommands),
1062 // A refused drain must not give cleanup ownership of an existing target.
1063 // After the drain succeeds, remove any newly half-built non-current release.
1064 buildStrandedReleaseTrap(paths),
10461065 ...buildResetReleaseDir(paths),
10471066 `tar xzf ${tarball} -C "$TS_CLOUD_STAGED"`,
10481067 ...buildPromoteStagedRelease(paths),
@@ -1051,6 +1070,7 @@ export function buildStaticSiteDeployScript(options: BuildStaticSiteDeployScript
10511070 ...preStart,
10521071 // Promote atomically — the docroot (`current`) is never empty during the swap.
10531072 ...buildActivateRelease(paths),
1073 ...postActivateCommands,
10541074 ...buildPruneReleases(paths, keepReleases),
10551075 ]
10561076}
packages/ts-cloud/test/drivers/app-services-lifecycle.test.tsadded+104-0
Changes to packages/ts-cloud/test/drivers/app-services-lifecycle.test.ts
@@ -0,0 +1,104 @@
1import { expect, test } from 'bun:test'
2import assert from 'node:assert/strict'
3import { chmod, mkdir, mkdtemp, readFile, realpath, rm, symlink, utimes, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { buildSiteServicesScript } from '../../src/drivers/shared/app-services'
7import { buildSiteDeployScript, buildStaticSiteDeployScript } from '../../src/drivers/shared/deploy-script'
8import { buildPruneReleases, buildResetReleaseDir, buildStrandedReleaseTrap, releasePaths } from '../../src/drivers/shared/releases'
9
10// The external systemctl port refuses or accepts each lifecycle operation. Actual generated
11// shell and filesystem effects must preserve the old unit/release on refusal.
12for (const fault of ['stop', 'disable'] as const) test.skipIf(process.platform === 'win32')(`a refused stale-service ${fault} prevents unit removal and release pruning`, async () => {
13 const root = await realpath(await mkdtemp(join(tmpdir(), 'ts-cloud-stop-refusal-')))
14 const units = join(root, 'units'), cron = join(root, 'cron'), bin = join(root, 'bin')
15 const old = join(root, 'releases', 'a'), current = join(root, 'releases', 'b')
16 const unit = join(units, 'fixture-app-scheduler.service')
17 const sibling = join(units, 'fixture-app-extra-scheduler.service')
18 const operationCode = join(root, 'operation-code'), trace = join(root, 'manager-trace')
19 const observations: object[] = []
20 let completed = false
21 try {
22 for (const directory of [units, cron, bin, old, current]) await mkdir(directory, { recursive: true })
23 await writeFile(unit, 'managed unit')
24 await writeFile(sibling, 'unrelated site unit')
25 await writeFile(join(old, 'asset'), 'old')
26 await writeFile(join(current, 'asset'), 'current')
27 await symlink(current, join(root, 'current'))
28 await utimes(old, new Date('2000-01-01'), new Date('2000-01-01'))
29 await utimes(current, new Date('2026-01-01'), new Date('2026-01-01'))
30 await writeFile(join(bin, 'systemctl'), '#!/bin/bash\nprintf "%s\\n" "$*" >> "$MANAGER_TRACE"\nif [ "$1" = "$FAIL_ACTION" ]; then exit "$(cat "$OPERATION_CODE")"; fi\nexit 0\n')
31 await chmod(join(bin, 'systemctl'), 0o700)
32 const generated = ['set -euo pipefail', ...buildSiteServicesScript({ slug: 'fixture', siteName: 'app', appBase: root, site: { root: '.', scheduler: false, queues: [], daemons: [] } }), ...buildPruneReleases(releasePaths(root, 'b'), 1)].join('\n')
33 // Map only the provider's system directories into the owned fixture.
34 const program = generated.replaceAll('/etc/systemd/system/', `${units}/`).replaceAll('/etc/cron.d/', `${cron}/`)
35 assert(!program.includes('/etc/'))
36 for (const code of [23, 0]) {
37 await writeFile(operationCode, String(code))
38 const child = Bun.spawn(['/bin/bash', '-c', program], { cwd: root, env: { PATH: `${bin}:/usr/bin:/bin`, OPERATION_CODE: operationCode, MANAGER_TRACE: trace, FAIL_ACTION: fault }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
39 const timer = setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL') }, 6000)
40 let exitCode: number, stdout: string, stderr: string
41 try { [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]) }
42 finally { clearTimeout(timer); if (child.exitCode === null) { child.kill('SIGKILL'); await child.exited } }
43 observations.push({ fault, operationCode: code, exitCode, exitProperty: child.exitCode, signal: child.signalCode, stdout, stderr, unitRemains: await Bun.file(unit).exists(), oldAssetRemains: await Bun.file(join(old, 'asset')).exists() })
44 expect(exitCode).toBe(code)
45 expect(await Bun.file(unit).exists()).toBe(code !== 0)
46 expect(await Bun.file(join(old, 'asset')).exists()).toBe(code !== 0)
47 expect(await readFile(sibling, 'utf8')).toBe('unrelated site unit')
48 expect(await Bun.file(join(current, 'asset')).exists()).toBe(true)
49 expect((await readFile(trace, 'utf8')).includes('stop fixture-app-extra-scheduler.service')).toBe(false)
50 }
51 completed = true
52 }
53 finally {
54 const written = await writeFile(join(root, 'observations.json'), JSON.stringify(observations, null, 2)).then(() => true, () => false)
55 if (completed && written) await rm(root, { recursive: true })
56 else console.error('BACKGROUND_STOP_REFUSAL_RETAINED', root)
57 }
58}, 20000)
59
60for (const kind of ['app', 'static'] as const) test.skipIf(process.platform === 'win32')(`reset drain and real EXIT trap preserve ownership for ${kind}`, async () => {
61 const root = await realpath(await mkdtemp(join(tmpdir(), 'ts-cloud-reset-drain-')))
62 const active = join(root, 'releases', 'a'), target = join(root, 'releases', 'b')
63 const observations: object[] = []
64 let completed = false
65 try {
66 await mkdir(active, { recursive: true })
67 await writeFile(join(active, 'asset'), 'active')
68 await symlink(active, join(root, 'current'))
69 for (const [index, callback] of ['false', 'false', 'true', 'true'].entries()) {
70 if (index === 1) await writeFile(join(target, 'asset'), 'existing target')
71 const options = { slug: 'fixture', siteName: 'app', appDir: root, releaseId: 'b', artifactFetch: [], beforeResetCommands: [callback] }
72 const artifact = kind === 'app' ? buildSiteDeployScript({ ...options, execStart: '/usr/local/bin/bun server.ts', envEntries: {} }) : buildStaticSiteDeployScript(options)
73 const hook = artifact.indexOf(callback)
74 const resetting = buildResetReleaseDir(releasePaths(root, 'b'))
75 const reset = artifact.indexOf(resetting[0])
76 assert(hook > 0 && reset > hook)
77 const guardEnd = artifact.indexOf('fi', hook)
78 const trap = artifact.indexOf(buildStrandedReleaseTrap(releasePaths(root, 'b')))
79 assert(trap > 0 && guardEnd > hook && guardEnd < reset)
80 assert(artifact[hook - 1].startsWith('if [ -e '))
81 // Execute the actual production trap in its actual order, including
82 // failure before reset and failure after this deploy owns a new target.
83 const phase = artifact.filter((_, at) => at === 0 || at === trap || (at >= hook - 1 && at <= guardEnd) || (at >= reset && at < reset + resetting.length))
84 if (index === 3) phase.push('false')
85 const child = Bun.spawn(['/bin/bash', '-c', phase.join('\n')], { cwd: root, env: { PATH: '/usr/bin:/bin' }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
86 const timer = setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL') }, 6000)
87 let code: number, stdout: string, stderr: string
88 try { [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]) }
89 finally { clearTimeout(timer); if (child.exitCode === null) { child.kill('SIGKILL'); await child.exited } }
90 observations.push({ index, callback, code, signal: child.signalCode, stdout, stderr, targetAssetRemains: await Bun.file(join(target, 'asset')).exists() })
91 expect(code).toBe(index === 1 || index === 3 ? 1 : 0)
92 expect(await readFile(join(active, 'asset'), 'utf8')).toBe('active')
93 expect(await Bun.file(join(target, 'asset')).exists()).toBe(index === 1)
94 expect(await realpath(join(root, 'current'))).toBe(active)
95 expect(await realpath(target).catch(() => null)).toBe(index === 3 ? null : target)
96 }
97 completed = true
98 }
99 finally {
100 const written = await writeFile(join(root, 'observations.json'), JSON.stringify(observations, null, 2)).then(() => true, () => false)
101 if (completed && written) await rm(root, { recursive: true })
102 else console.error('BACKGROUND_RESET_DRAIN_RETAINED', root)
103 }
104}, 30000)
packages/ts-cloud/test/drivers/app-services-runtime.test.tsadded+341-0
Changes to packages/ts-cloud/test/drivers/app-services-runtime.test.ts
@@ -0,0 +1,341 @@
1import type { SiteConfig } from '@ts-cloud/core'
2import { expect, test } from 'bun:test'
3import assert from 'node:assert/strict'
4import { constants } from 'node:fs'
5import { chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, symlink, utimes, writeFile } from 'node:fs/promises'
6import { tmpdir } from 'node:os'
7import { dirname, join, resolve } from 'node:path'
8import { buildSiteServicesScript } from '../../src/drivers/shared/app-services'
9import { buildSiteDeployScript } from '../../src/drivers/shared/deploy-script'
10import { buildPromoteStagedRelease, buildPruneReleases, buildResetReleaseDir, buildStrandedReleaseTrap, releasePaths } from '../../src/drivers/shared/releases'
11
12// Generated units are the deployment boundary; a separate real-process case
13// must qualify executable resolution and child PATH across release switching.
14test('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// The action is deliberately finite. It probes the actual native entry and a
35// real Bun child, without running an application job or installed service.
36for (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}
73const parent = { pid: process.pid, executable: realpathSync(process.execPath), version: Bun.version, cwd: realpathSync(process.cwd()), args: process.argv.slice(2) }
74console.log(JSON.stringify({ phase: 'ready', parent }))
75await drained
76const generation = await Bun.file('generation').text()
77if (generation !== 'a') throw new Error('Admitted worker lost its generation: ' + generation)
78const 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' })
79const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()])
80if (exitCode !== 0 || stderr) throw new Error('Finite child failed: ' + stderr)
81console.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 // Independent positive control: current now selects the replacement's
114 // actual Bun, while the generated service must retain its physical release.
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
205for (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
274test('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
294test('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
304test.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)
packages/ts-cloud/test/drivers/compute-deploy.test.tsmodified+55-1
Changes to packages/ts-cloud/test/drivers/compute-deploy.test.ts
@@ -1,5 +1,5 @@
11import { afterEach, describe, expect, it, mock } from 'bun:test'
2import type { CloudConfig, CloudDriver } from '@ts-cloud/core'
2import type { CloudConfig, CloudDriver, SiteConfig } from '@ts-cloud/core'
33import { deriveManagementDashboardPort } from '@ts-cloud/core'
44import {
55 buildManagementDashboardServiceReconciliationScript,
@@ -1305,3 +1305,57 @@ describe('deployAllComputeSites attach-mode credential reach', () => {
13051305 expect(warnings.join('\n')).not.toContain('credential')
13061306 })
13071307})
1308
1309
1310describe('compute release background lifecycle', () => {
1311 for (const kind of ['app', 'static'] as const) for (const selected of [false, true]) it(`${kind} background services drain before pruning with ${selected ? 'selected' : 'default'} Bun`, async () => {
1312 const driver = createMockDriver()
1313 const site: SiteConfig = { root: '.', deploy: 'server', scheduler: true, queues: [{ queue: 'reports' }], ...(kind === 'app' ? { start: 'bun server.ts', port: 3000 } : {}), ...(selected ? { backgroundBun: 'runtime/bin/bun' } : {}) }
1314 const result = await deploySiteRelease(driver, { config, environment: 'production', siteName: 'background', site, slug: 'my-app', sha: 'abc123', runtime: 'bun', localTarballPath: '/tmp/fixture-release.tar.gz' })
1315 expect(result.success).toBe(true)
1316 const commands: string[] = (driver.runRemoteDeploy as ReturnType<typeof mock>).mock.calls[0][0].commands
1317 const prune = commands.findIndex(line => line.startsWith('TS_CLOUD_CURRENT=$(readlink -f '))
1318 for (const unit of ['queue-0', 'scheduler']) {
1319 const command = `systemctl restart my-app-background-${unit}.service`
1320 const restart = commands.indexOf(command)
1321 expect(restart).toBeGreaterThan(-1)
1322 expect(prune).toBeGreaterThan(restart)
1323 expect(commands.filter(line => line === command)).toHaveLength(1)
1324 }
1325 expect(commands.join('\n')).toContain(selected ? 'ExecStart=/var/www/my-app-background/releases/abc123/runtime/bin/bun' : 'ExecStart=/usr/local/bin/bun')
1326 })
1327})
1328
1329
1330describe('removing all managed background services', () => {
1331 for (const kind of ['app', 'static'] as const) for (const keepRuntimeOption of [false, true]) it(`${kind} removes stale units with runtime option ${keepRuntimeOption}`, async () => {
1332 const driver = createMockDriver()
1333 const site: SiteConfig = { root: '.', deploy: 'server', scheduler: false, queues: [], daemons: [], ...(kind === 'app' ? { start: 'bun server.ts', port: 3000 } : {}), ...(keepRuntimeOption ? { backgroundBun: 'runtime/bin/bun' } : {}) }
1334 const result = await deploySiteRelease(driver, { config, environment: 'production', siteName: 'background', site, slug: 'my-app', sha: 'abc123', runtime: 'bun', localTarballPath: '/tmp/fixture-release.tar.gz' })
1335 expect(result.success).toBe(true)
1336 const commands: string[] = (driver.runRemoteDeploy as ReturnType<typeof mock>).mock.calls[0][0].commands
1337 const stop = commands.findIndex(line => line.includes('systemctl stop "$unit"') && line.includes('rm -f'))
1338 const prune = commands.findIndex(line => line.startsWith('TS_CLOUD_CURRENT=$(readlink -f '))
1339 expect(stop).toBeGreaterThan(-1)
1340 expect(prune).toBeGreaterThan(stop)
1341 expect(commands).toContain('TS_CLOUD_DESIRED=""')
1342 expect(commands.some(line => line.startsWith('cat > /etc/systemd/system/my-app-background-scheduler.service '))).toBe(false)
1343 })
1344})
1345
1346
1347describe('background drain before a same-ID directory replacement', () => {
1348 for (const kind of ['app', 'static'] as const) for (const selected of [false, true]) it(`${kind} drains existing units before reset with runtime option ${selected}`, async () => {
1349 const driver = createMockDriver()
1350 const site: SiteConfig = { root: '.', deploy: 'server', scheduler: true, ...(kind === 'app' ? { start: 'bun server.ts', port: 3000 } : {}), ...(selected ? { backgroundBun: 'runtime/bin/bun' } : {}) }
1351 const result = await deploySiteRelease(driver, { config, environment: 'production', siteName: 'background', site, slug: 'my-app', sha: 'abc123', runtime: 'bun', localTarballPath: '/tmp/fixture-release.tar.gz' })
1352 expect(result.success).toBe(true)
1353 const commands: string[] = (driver.runRemoteDeploy as ReturnType<typeof mock>).mock.calls[0][0].commands
1354 const stop = commands.findIndex(line => line.includes('systemctl stop "$unit"'))
1355 const reset = commands.indexOf('rm -rf /var/www/my-app-background/releases/abc123.incoming')
1356 const guard = commands.findIndex(line => line.startsWith("if [ -e '/var/www/my-app-background/releases/abc123' ]"))
1357 expect(guard).toBeGreaterThan(-1)
1358 expect(stop).toBeGreaterThan(guard)
1359 expect(reset).toBeGreaterThan(stop)
1360 })
1361})
packages/ts-cloud/test/drivers/deploy-script.test.tsmodified+21-0
Changes to packages/ts-cloud/test/drivers/deploy-script.test.ts
@@ -834,3 +834,24 @@ describe('stx image and bundle caches shared across releases', () => {
834834 expect(joined).toContain('Environment=STX_BUNDLE_CACHE_DIR=/var/www/web/shared/stx-bundles')
835835 })
836836})
837
838
839describe('release resources reconcile before old releases are pruned', () => {
840 const hook = 'systemctl restart example-background.service'
841 const base = { siteName: 'app', slug: 'example', artifactFetch: [], releaseId: 'b', appDir: '/srv/example', keepReleases: 1, postActivateCommands: [hook] }
842 const scripts = [
843 buildSiteDeployScript({ ...base, execStart: '/usr/local/bin/bun server.ts', envEntries: {} }),
844 buildSiteDeployScript({ ...base, execStart: '/usr/local/bin/bun server.ts', envEntries: {}, port: 3000 }),
845 buildStaticSiteDeployScript(base),
846 ]
847 for (const [index, script] of scripts.entries()) it(`reconciles after activation and before pruning for release shape ${index}`, () => {
848 const activate = script.findIndex(line => line.includes('mv -Tf /srv/example/current.tmp /srv/example/current'))
849 const reconcile = script.indexOf(hook)
850 const prune = script.findIndex(line => line.startsWith('TS_CLOUD_CURRENT=$(readlink -f '))
851 expect(activate).toBeGreaterThan(-1)
852 expect(reconcile).toBeGreaterThan(activate)
853 expect(prune).toBeGreaterThan(reconcile)
854 expect(script.filter(line => line === hook)).toHaveLength(1)
855 expect(script.join('\n')).toContain('tail -n +2')
856 })
857})