Skip to content

Commit 3cd543d

Browse files
committed
feat(deploy): forgiving + configurable readiness timeouts (SSH 3m→8m, bun 5m→12m via TS_CLOUD_SSH_WAIT_SECS/TS_CLOUD_BOOT_WAIT_SECS) with a heartbeat; fail fast (not hang) on prod confirm in a non-interactive shell
1 parent f46d316 commit 3cd543d

1 file changed

Lines changed: 80 additions & 25 deletions

File tree

  • storage/framework/core/buddy/src/commands

storage/framework/core/buddy/src/commands/deploy.ts

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -548,11 +548,63 @@ function resolveProvider(tsCloudConfig: any): string {
548548
|| 'aws'
549549
}
550550

551+
/** Parse a positive-integer seconds env var, falling back to a default. */
552+
function readWaitSecs(name: string, defaultSecs: number): number {
553+
const secs = process.env[name] ? Number.parseInt(process.env[name] as string, 10) : Number.NaN
554+
return Number.isFinite(secs) && secs > 0 ? secs : defaultSecs
555+
}
556+
557+
/** Human-friendly duration, e.g. `8m` or `1m30s`. */
558+
function fmtDuration(secs: number): string {
559+
const m = Math.floor(secs / 60)
560+
const s = secs % 60
561+
return m ? (s ? `${m}m${s}s` : `${m}m`) : `${s}s`
562+
}
563+
564+
/**
565+
* Poll `check()` until it stops throwing or the timeout elapses, emitting a
566+
* heartbeat every ~30s so a multi-minute wait never looks frozen (and, when
567+
* backgrounded, so the caller can see it is still alive).
568+
*/
569+
async function pollUntil(opts: {
570+
label: string
571+
timeoutSecs: number
572+
intervalMs?: number
573+
check: () => void
574+
timeoutMessage: (elapsedSecs: number) => string
575+
}): Promise<void> {
576+
log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`)
577+
const started = Date.now()
578+
const deadline = started + opts.timeoutSecs * 1000
579+
let lastHeartbeat = 0
580+
for (;;) {
581+
try {
582+
opts.check()
583+
return
584+
}
585+
catch {
586+
const elapsedSecs = Math.floor((Date.now() - started) / 1000)
587+
if (Date.now() > deadline)
588+
throw new Error(opts.timeoutMessage(elapsedSecs))
589+
if (elapsedSecs - lastHeartbeat >= 30) {
590+
log.info(` … still waiting (${elapsedSecs}s elapsed)`)
591+
lastHeartbeat = elapsedSecs
592+
}
593+
await new Promise(r => setTimeout(r, opts.intervalMs ?? 5000))
594+
}
595+
}
596+
}
597+
551598
/**
552599
* Wait until cloud-init finishes on the freshly provisioned host and the bun
553600
* runtime is on PATH. Cloud-init runs asynchronously after the server reports
554601
* "running", so deploying immediately would race the bun install and the
555602
* systemd unit's ExecStart (`/usr/local/bin/bun …`) would not exist yet.
603+
*
604+
* Timeouts are generous — cold Hetzner boots plus cloud-init installing
605+
* bun/caddy can take several minutes — and overridable per environment:
606+
* TS_CLOUD_SSH_WAIT_SECS (default 480 = 8m) — SSH reachability
607+
* TS_CLOUD_BOOT_WAIT_SECS (default 720 = 12m) — cloud-init + bun on PATH
556608
*/
557609
async function waitForRemoteReady(ip: string, verbose: boolean): Promise<void> {
558610
const { execSync } = await import('node:child_process')
@@ -570,19 +622,15 @@ async function waitForRemoteReady(ip: string, verbose: boolean): Promise<void> {
570622
})
571623

572624
// 1) Wait for SSH to accept connections (server may still be booting).
573-
log.info('Waiting for SSH to come up...')
574-
const sshDeadline = Date.now() + 3 * 60_000
575-
for (;;) {
576-
try {
577-
run('true')
578-
break
579-
}
580-
catch {
581-
if (Date.now() > sshDeadline)
582-
throw new Error(`SSH did not become reachable on ${ip} within 3 minutes`)
583-
await new Promise(r => setTimeout(r, 5000))
584-
}
585-
}
625+
const sshWaitSecs = readWaitSecs('TS_CLOUD_SSH_WAIT_SECS', 8 * 60)
626+
await pollUntil({
627+
label: 'Waiting for SSH to come up',
628+
timeoutSecs: sshWaitSecs,
629+
check: () => { run('true') },
630+
timeoutMessage: elapsed =>
631+
`SSH did not become reachable on ${ip} within ${fmtDuration(sshWaitSecs)} (waited ${elapsed}s). `
632+
+ `The box may still be booting — raise TS_CLOUD_SSH_WAIT_SECS and retry.`,
633+
})
586634
log.success('SSH is up')
587635

588636
// 2) Block on cloud-init, then confirm bun landed on PATH.
@@ -594,18 +642,16 @@ async function waitForRemoteReady(ip: string, verbose: boolean): Promise<void> {
594642
log.debug('cloud-init status --wait returned non-zero (continuing):', err)
595643
}
596644

597-
const bunDeadline = Date.now() + 5 * 60_000
598-
for (;;) {
599-
try {
600-
run('test -x /usr/local/bin/bun')
601-
break
602-
}
603-
catch {
604-
if (Date.now() > bunDeadline)
605-
throw new Error('bun runtime did not appear at /usr/local/bin/bun within 5 minutes (cloud-init may have failed — check /var/log/cloud-init-output.log)')
606-
await new Promise(r => setTimeout(r, 5000))
607-
}
608-
}
645+
const bootWaitSecs = readWaitSecs('TS_CLOUD_BOOT_WAIT_SECS', 12 * 60)
646+
await pollUntil({
647+
label: 'Waiting for the bun runtime',
648+
timeoutSecs: bootWaitSecs,
649+
check: () => { run('test -x /usr/local/bin/bun') },
650+
timeoutMessage: elapsed =>
651+
`bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(bootWaitSecs)} (waited ${elapsed}s). `
652+
+ `cloud-init may have failed — SSH in and check /var/log/cloud-init-output.log; `
653+
+ `raise TS_CLOUD_BOOT_WAIT_SECS for slow regions.`,
654+
})
609655
log.success('Server is ready (bun installed)')
610656
}
611657

@@ -1059,6 +1105,15 @@ export function deploy(buddy: CLI): void {
10591105
}
10601106

10611107
async function confirmProductionDeployment() {
1108+
// In a non-interactive shell (CI, a background job, piped stdin) there is no
1109+
// one to answer the prompt — `prompts.confirm` would hang forever. Fail fast
1110+
// with a clear instruction instead of stalling the pipeline.
1111+
if (!process.stdin.isTTY) {
1112+
log.error('Refusing to deploy to production from a non-interactive shell without confirmation.')
1113+
log.info(' ➡️ Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`')
1114+
process.exit(ExitCode.InvalidArgument)
1115+
}
1116+
10621117
const confirmed = await prompts.confirm({
10631118
message: 'Are you sure you want to deploy to production?',
10641119
initial: true,

0 commit comments

Comments
 (0)