Skip to content

Commit 41abe4a

Browse files
committed
fix(setup): rebind containers an earlier sim-setup published on every interface, and stop a dev reset that failed to tear down
1 parent 529d1a2 commit 41abe4a

4 files changed

Lines changed: 136 additions & 18 deletions

File tree

‎packages/sim-setup/src/db.ts‎

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { spawnSync } from 'node:child_process'
2-
import { DB_CONTAINER, type Detection, LOOPBACK_HOST } from './detect'
2+
import {
3+
DB_CONTAINER,
4+
type Detection,
5+
LOOPBACK_HOST,
6+
type PortBinding,
7+
publishedBinding,
8+
targetsLocalPort,
9+
} from './detect'
310
import { ensureDocker } from './docker'
411
import { generateSecret } from './env-files'
512
import { SetupError } from './errors'
@@ -36,6 +43,8 @@ function dockerOutput(args: string[]): string | null {
3643
interface ManagedContainer {
3744
running: boolean
3845
dsn: string
46+
password: string
47+
binding: PortBinding
3948
}
4049

4150
/**
@@ -63,21 +72,16 @@ function inspectManagedContainer(): ManagedContainer | null {
6372
?.slice('POSTGRES_PASSWORD='.length)
6473
if (!password) return null
6574

66-
// `docker port` only reports a published port while the container runs; the
67-
// static config carries it either way.
68-
const hostPort = dockerOutput([
69-
'inspect',
70-
DB_CONTAINER,
71-
'--format',
72-
'{{(index .HostConfig.PortBindings "5432/tcp" 0).HostPort}}',
73-
])
74-
if (!hostPort) return null
75+
const binding = publishedBinding(DB_CONTAINER, '5432/tcp')
76+
if (!binding) return null
7577

7678
return {
7779
running: running === 'true',
7880
// Read back from the container env verbatim, so it may be a password the
7981
// user supplied for an existing volume — encode it like any other.
80-
dsn: buildDsn(password, hostPort),
82+
dsn: buildDsn(password, binding.hostPort),
83+
password,
84+
binding,
8185
}
8286
}
8387

@@ -249,6 +253,14 @@ async function startManagedContainer(detection: Detection): Promise<string> {
249253
const password = volumeInitialized()
250254
? await resolveExistingVolume()
251255
: generateSecret().slice(0, 24)
256+
return launchManagedContainer(password, hostPort)
257+
}
258+
259+
/** Runs the managed container on `LOOPBACK_HOST` and waits until it accepts the password. */
260+
async function launchManagedContainer(
261+
password: string,
262+
hostPort: string | number
263+
): Promise<string> {
252264
// A user-supplied password can contain @ : / # — raw interpolation would
253265
// re-parse the DSN into a different host and fail a password that is correct.
254266
const dsn = buildDsn(password, hostPort)
@@ -309,13 +321,41 @@ async function startManagedContainer(detection: Detection): Promise<string> {
309321
return dsn
310322
}
311323

324+
/**
325+
* Offers to re-publish a managed container that an earlier release bound to
326+
* every interface, keeping its password, port, and data volume. Returns the new
327+
* DSN when `existingDsn` pointed at that container (or nothing was configured),
328+
* so the env files move to the loopback address with it.
329+
*/
330+
async function rebindExposedContainer(existingDsn?: string): Promise<string | null> {
331+
const container = inspectManagedContainer()
332+
if (!container || container.binding.hostIp === LOOPBACK_HOST) return null
333+
const { hostPort } = container.binding
334+
const rebind = await p.confirm({
335+
message: `${DB_CONTAINER} publishes Postgres on every network interface (set up by an earlier sim-setup). Recreate it on ${LOOPBACK_HOST} only? Its data and password are kept.`,
336+
initialValue: true,
337+
})
338+
if (!rebind) {
339+
p.log.warn(`${DB_CONTAINER} stays reachable from your network on :${hostPort}.`)
340+
return null
341+
}
342+
docker(['rm', '-f', DB_CONTAINER])
343+
const dsn = await launchManagedContainer(container.password, hostPort)
344+
return !existingDsn || targetsLocalPort(existingDsn, hostPort) ? dsn : null
345+
}
346+
312347
/**
313348
* The mode-B database ladder: reuse a working DSN, offer (never silently adopt)
314349
* a Postgres already on 5432, start/reuse the wizard-managed pgvector
315350
* container, or take an external DSN. Adopting an existing database is always
316351
* an explicit choice — migrations run against whatever is chosen here.
317352
*/
318353
export async function resolveDatabase(detection: Detection, existingDsn?: string): Promise<string> {
354+
if (detection.dbContainer?.managed) {
355+
const rebound = await rebindExposedContainer(existingDsn)
356+
if (rebound) return rebound
357+
}
358+
319359
if (existingDsn && (await probeWithSpinner(existingDsn, 'Testing existing DATABASE_URL…'))) {
320360
return existingDsn
321361
}

‎packages/sim-setup/src/detect.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,43 @@ export const REDIS_CONTAINER = 'sim-redis'
1616
*/
1717
export const LOOPBACK_HOST = '127.0.0.1'
1818

19+
export interface PortBinding {
20+
/** Empty when Docker publishes on every interface. */
21+
hostIp: string
22+
hostPort: string
23+
}
24+
25+
/**
26+
* The host side of a container's published port, read from its static config so
27+
* a stopped container answers too. Containers from releases before
28+
* `LOOPBACK_HOST` was enforced report an empty or wildcard `hostIp`.
29+
*/
30+
export function publishedBinding(container: string, containerPort: string): PortBinding | null {
31+
const out = commandOutput('docker', [
32+
'inspect',
33+
container,
34+
'--format',
35+
`{{json (index .HostConfig.PortBindings "${containerPort}")}}`,
36+
])
37+
if (!out) return null
38+
try {
39+
const [first] = JSON.parse(out) as { HostIp?: string; HostPort?: string }[]
40+
return first?.HostPort ? { hostIp: first.HostIp ?? '', hostPort: first.HostPort } : null
41+
} catch {
42+
return null
43+
}
44+
}
45+
46+
/** Whether `url` addresses this machine on `port`, so it reaches a container published there. */
47+
export function targetsLocalPort(url: string, port: string): boolean {
48+
try {
49+
const { hostname, port: urlPort } = new URL(url)
50+
return ['localhost', LOOPBACK_HOST, '[::1]'].includes(hostname) && urlPort === port
51+
} catch {
52+
return false
53+
}
54+
}
55+
1956
const SHELL_LLM_KEYS = [
2057
'OPENAI_API_KEY',
2158
'ANTHROPIC_API_KEY',

‎packages/sim-setup/src/lifecycle.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -608,8 +608,17 @@ async function reset(install: Install | null): Promise<void> {
608608
p.log.step('Containers and volumes removed')
609609
} else if (install?.kind === 'dev') {
610610
const names = managedNames(install)
611-
if (names.length) spawnSync('docker', ['rm', '-f', ...names], { cwd: ROOT, stdio: 'ignore' })
612-
spawnSync('docker', ['volume', 'rm', POSTGRES_VOLUME], { cwd: ROOT, stdio: 'ignore' })
611+
if (names.length) dockerRun(['rm', '-f', ...names], 'docker rm failed')
612+
// A volume that never existed is already reset; only one that survives is a failure.
613+
const removal = spawnSync('docker', ['volume', 'rm', POSTGRES_VOLUME], { encoding: 'utf8' })
614+
if (
615+
removal.status !== 0 &&
616+
spawnSync('docker', ['volume', 'inspect', POSTGRES_VOLUME], { stdio: 'ignore' }).status === 0
617+
) {
618+
throw new SetupError(
619+
`could not remove the ${POSTGRES_VOLUME} volume: ${removal.stderr.trim()}`
620+
)
621+
}
613622
p.log.step('Managed containers and Postgres volume removed')
614623
} else if (install?.kind === 'k8s') {
615624
const uninstall = spawnSync(

‎packages/sim-setup/src/redis.ts‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { spawnSync } from 'node:child_process'
22
import { docker } from './db'
3-
import { type Detection, LOOPBACK_HOST, REDIS_CONTAINER } from './detect'
3+
import {
4+
type Detection,
5+
LOOPBACK_HOST,
6+
publishedBinding,
7+
REDIS_CONTAINER,
8+
targetsLocalPort,
9+
} from './detect'
410
import { ensureDocker } from './docker'
511
import { SetupError } from './errors'
612
import { redisPing, waitFor } from './probes'
@@ -29,8 +35,27 @@ async function pingWithSpinner(url: string, label: string): Promise<boolean> {
2935
return ping.ok
3036
}
3137

32-
async function startManagedRedis(detection: Detection): Promise<string> {
33-
const hostPort = detection.redisPortOpen ? 6380 : 6379
38+
function defaultRedisPort(detection: Detection): number {
39+
return detection.redisPortOpen ? 6380 : 6379
40+
}
41+
42+
/**
43+
* Recreates a managed container that an earlier release published on every
44+
* interface, on the same port. It holds no data, so this needs no prompt.
45+
* Returns the new URL when `existing` pointed at it (or nothing was configured).
46+
*/
47+
async function rebindExposedRedis(detection: Detection, existing?: string): Promise<string | null> {
48+
if (!detection.redisContainer?.managed) return null
49+
const binding = publishedBinding(REDIS_CONTAINER, '6379/tcp')
50+
if (!binding || binding.hostIp === LOOPBACK_HOST) return null
51+
p.log.step(
52+
`Recreating ${REDIS_CONTAINER} on ${LOOPBACK_HOST}: an earlier sim-setup published it on every network interface`
53+
)
54+
const url = await startManagedRedis(binding.hostPort)
55+
return !existing || targetsLocalPort(existing, binding.hostPort) ? url : null
56+
}
57+
58+
async function startManagedRedis(hostPort: number | string): Promise<string> {
3459
const url = `redis://${LOOPBACK_HOST}:${hostPort}`
3560
// A prior sim-redis (unhealthy, or bound to a stale port) would collide on the
3661
// name. Unlike Postgres this container has no data volume, so removing it
@@ -88,6 +113,8 @@ async function promptRedisUrl(existing?: string): Promise<string> {
88113
* failing the whole setup.
89114
*/
90115
export async function ensureRedis(detection: Detection, existing?: string): Promise<string | null> {
116+
const rebound = await rebindExposedRedis(detection, existing)
117+
if (rebound) return rebound
91118
if (existing && (await redisPing(existing)).ok) return existing
92119
if (detection.redisPortOpen && (await redisPing(LOCAL_URL)).ok) {
93120
p.log.step(`Using the Redis already on :6379`)
@@ -107,14 +134,17 @@ export async function ensureRedis(detection: Detection, existing?: string): Prom
107134
)
108135
return null
109136
}
110-
return startManagedRedis(detection)
137+
return startManagedRedis(defaultRedisPort(detection))
111138
}
112139

113140
/**
114141
* Redis ladder, mirroring the Postgres one: reuse what's running (with
115142
* consent), restart/start a wizard-managed container, or take a URL.
116143
*/
117144
export async function resolveRedis(detection: Detection, existing?: string): Promise<string> {
145+
const rebound = await rebindExposedRedis(detection, existing)
146+
if (rebound) return rebound
147+
118148
// A configured REDIS_URL wins over the port scan: it may point at a non-default
119149
// port, or at a host that isn't this machine at all.
120150
if (
@@ -166,5 +196,7 @@ export async function resolveRedis(detection: Detection, existing?: string): Pro
166196
p.log.warn('Docker is not available, so the wizard cannot manage a Redis container for you.')
167197
}
168198
const choice = await p.select({ message: 'Where should Redis live?', options })
169-
return choice === 'container' ? startManagedRedis(detection) : promptRedisUrl(existing)
199+
return choice === 'container'
200+
? startManagedRedis(defaultRedisPort(detection))
201+
: promptRedisUrl(existing)
170202
}

0 commit comments

Comments
 (0)