Skip to content

Commit ccac475

Browse files
improvement(setup): make k8s mode end somewhere usable, and show install progress
Two things made k8s mode the least satisfying path. The services are ClusterIP, so a successful install left nothing on :3000 — 'Sim is ready' was true about the cluster and useless to the user, who had to notice and run a port-forward by hand. Compose opens a browser and dev offers to start the server; k8s now offers the forward the same way and runs it in the foreground so Ctrl-C ends it. Realtime gets its own forward (kubectl takes one resource per invocation) or the editor socket fails; it is a child in the same process group, so the terminal's Ctrl-C reaches it, and it is killed explicitly when the app forward exits. 'helm --wait' then blocked for minutes with a single static spinner, so a slow image pull looked identical to a wedged install. Run helm asynchronously and poll the cluster, so the spinner reports '3/3 pods ready · 1 starting'. CronJob-owned pods are excluded: the chart schedules a lot of them (36 on a running cluster here) and they finish as Completed, which would swamp the count and make readiness jitter for reasons unrelated to the install. Restarting pods are surfaced too — a cold cluster restarts realtime while Postgres comes up, and a silent spinner made that look like nothing was happening.
1 parent a28f49f commit ccac475

1 file changed

Lines changed: 142 additions & 8 deletions

File tree

scripts/setup/modes/k8s.ts

Lines changed: 142 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { spawnSync } from 'node:child_process'
1+
import { spawn, spawnSync } from 'node:child_process'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import type { Detection } from '../detect.ts'
44
import { ensureDocker } from '../docker.ts'
@@ -8,6 +8,7 @@ import { waitFor } from '../probes.ts'
88
import * as p from '../prompter.ts'
99
import { glyph, theme } from '../theme.ts'
1010

11+
const APP_URL = 'http://localhost:3000'
1112
const RELEASE = 'sim-dev'
1213
const NAMESPACE = 'sim-dev'
1314
const LOCAL_CONTEXT_PREFIXES = ['kind-', 'docker-desktop', 'minikube', 'orbstack']
@@ -179,6 +180,102 @@ async function ensureLocalContext(detection: Detection): Promise<string> {
179180
return 'kind-sim'
180181
}
181182

183+
interface PodProgress {
184+
ready: number
185+
total: number
186+
detail: string
187+
}
188+
189+
/**
190+
* One-line summary of what the cluster is doing, for the install spinner. Only
191+
* long-running workloads count — the chart's CronJobs spawn short-lived pods
192+
* that finish as Completed, and counting those makes "ready" jitter downward
193+
* for reasons that have nothing to do with the install.
194+
*/
195+
function podProgress(context: string): PodProgress | null {
196+
const result = spawnSync(
197+
'kubectl',
198+
[
199+
'get',
200+
'pods',
201+
'--context',
202+
context,
203+
'-n',
204+
NAMESPACE,
205+
'-o',
206+
'jsonpath={range .items[*]}{.status.phase}{"\\t"}{.metadata.ownerReferences[0].kind}{"\\t"}{range .status.containerStatuses[*]}{.ready},{.state.waiting.reason}{" "}{end}{"\\n"}{end}',
207+
],
208+
{ encoding: 'utf8' }
209+
)
210+
if (result.status !== 0) return null
211+
const rows = result.stdout.split('\n').filter(Boolean)
212+
if (rows.length === 0) return null
213+
214+
let ready = 0
215+
let total = 0
216+
let pulling = 0
217+
let crashing = 0
218+
for (const row of rows) {
219+
const [, ownerKind = '', containers = ''] = row.split('\t')
220+
if (ownerKind === 'Job') continue
221+
total++
222+
if (containers.includes('true,')) ready++
223+
if (containers.includes('ContainerCreating') || containers.includes('PodInitializing'))
224+
pulling++
225+
if (containers.includes('CrashLoopBackOff') || containers.includes('ImagePullBackOff'))
226+
crashing++
227+
}
228+
if (total === 0) return null
229+
const notes: string[] = []
230+
if (pulling > 0) notes.push(`${pulling} starting`)
231+
// Restarts while Postgres comes up are normal on a cold cluster; say so rather
232+
// than let a silent spinner imply nothing is happening.
233+
if (crashing > 0) notes.push(`${crashing} restarting`)
234+
return { ready, total, detail: notes.join(' · ') }
235+
}
236+
237+
/**
238+
* `helm --wait` blocks for minutes with no output, so a slow image pull is
239+
* indistinguishable from a wedged install — the reason the old spinner was
240+
* unsatisfying. Run helm asynchronously and poll the cluster so the spinner
241+
* reports what is actually happening.
242+
*/
243+
async function helmInstall(
244+
args: string[],
245+
input: string,
246+
context: string,
247+
spin: ReturnType<typeof p.spinner>
248+
): Promise<void> {
249+
const child = spawn('helm', args, { cwd: ROOT, stdio: ['pipe', 'pipe', 'pipe'] })
250+
child.stdin.write(input)
251+
child.stdin.end()
252+
253+
let stderr = ''
254+
let stdout = ''
255+
child.stdout.on('data', (chunk) => {
256+
stdout += chunk
257+
})
258+
child.stderr.on('data', (chunk) => {
259+
stderr += chunk
260+
})
261+
262+
const ticker = setInterval(() => {
263+
const progress = podProgress(context)
264+
if (!progress) return
265+
const suffix = progress.detail ? ` · ${progress.detail}` : ''
266+
spin.message(`${progress.ready}/${progress.total} pods ready${suffix}`)
267+
}, 3000)
268+
269+
const code = await new Promise<number>((resolve) => {
270+
child.once('close', (status) => resolve(status ?? 1))
271+
})
272+
clearInterval(ticker)
273+
274+
if (code !== 0) {
275+
throw new Error(`helm upgrade --install failed: ${stderr.trim() || stdout.trim()}`)
276+
}
277+
}
278+
182279
function existingReleaseSecrets(context: string): Record<string, string> | null {
183280
const scope = ['--kube-context', context, '-n', NAMESPACE]
184281
const status = spawnSync('helm', ['status', RELEASE, ...scope], { stdio: 'ignore' })
@@ -239,8 +336,7 @@ export async function runK8sMode(detection: Detection): Promise<void> {
239336
const spin = p.spinner()
240337
spin.start('helm upgrade --install (first run pulls images — this can take several minutes)…')
241338
try {
242-
run(
243-
'helm',
339+
await helmInstall(
244340
[
245341
'upgrade',
246342
'--install',
@@ -259,8 +355,9 @@ export async function runK8sMode(detection: Detection): Promise<void> {
259355
'--timeout',
260356
'15m',
261357
],
262-
'helm upgrade --install failed',
263-
secretValues(secrets)
358+
secretValues(secrets),
359+
context,
360+
spin
264361
)
265362
} catch (error) {
266363
spin.stop(`${glyph.fail} helm install failed`)
@@ -289,10 +386,47 @@ export async function runK8sMode(detection: Detection): Promise<void> {
289386

290387
p.note(
291388
[
292-
`kubectl --context ${shq(context)} -n ${NAMESPACE} port-forward svc/${RELEASE}-app 3000:3000`,
293-
`kubectl --context ${shq(context)} -n ${NAMESPACE} get pods`,
294-
`helm uninstall ${RELEASE} --kube-context ${shq(context)} -n ${NAMESPACE} # tear down`,
389+
`open ${APP_URL} (needs the port-forward below)`,
390+
`pods: kubectl --context ${shq(context)} -n ${NAMESPACE} get pods`,
391+
`app logs: kubectl --context ${shq(context)} -n ${NAMESPACE} logs deploy/${RELEASE}-app --tail 50`,
392+
`forward: kubectl --context ${shq(context)} -n ${NAMESPACE} port-forward svc/${RELEASE}-app 3000:3000`,
393+
`tear down: helm uninstall ${RELEASE} --kube-context ${shq(context)} -n ${NAMESPACE}`,
295394
].join('\n'),
296395
'Reach your cluster'
297396
)
397+
398+
await offerPortForward(context)
399+
}
400+
401+
/**
402+
* The services are ClusterIP, so a healthy release is still unreachable from the
403+
* host — "Sim is ready" with nothing on :3000 is the least satisfying way to end
404+
* a setup. Offer the forward the same way dev mode offers to start the server,
405+
* and run it in the foreground so Ctrl-C ends it.
406+
*
407+
* Realtime needs its own forward (one resource per invocation) or the editor's
408+
* socket fails; it runs as a child in this process group, so the terminal's
409+
* Ctrl-C reaches it too, and it is killed explicitly once the app forward exits.
410+
*/
411+
async function offerPortForward(context: string): Promise<void> {
412+
const forward = await p.confirm({
413+
message: `Port-forward now so you can open ${APP_URL}?`,
414+
initialValue: true,
415+
})
416+
if (!forward) {
417+
p.log.info(theme.muted('Skipped — run the forward command above when you want to reach it.'))
418+
return
419+
}
420+
421+
const scope = ['--context', context, '-n', NAMESPACE]
422+
const realtime = spawn(
423+
'kubectl',
424+
[...scope, 'port-forward', `svc/${RELEASE}-realtime`, '3002:3002'],
425+
{ stdio: 'ignore' }
426+
)
427+
p.log.step(`Forwarding ${APP_URL} (app) and :3002 (realtime) — Ctrl-C to stop`)
428+
spawnSync('kubectl', [...scope, 'port-forward', `svc/${RELEASE}-app`, '3000:3000'], {
429+
stdio: 'inherit',
430+
})
431+
realtime.kill()
298432
}

0 commit comments

Comments
 (0)