Skip to content

Commit 08d13fa

Browse files
fix(setup): reuse an existing managed Postgres container instead of colliding
A running sim-postgres fell through to `docker run --name sim-postgres` and died on the name conflict; a stopped one failed with "no DATABASE_URL to reach it" because the generated password only lived in the env files a fresh clone lacks. Both facts are recoverable from Docker: the ladder now reads the published port and password back via `docker inspect` and reuses the container (starting it if stopped). A container that won't answer prompts before recreating, and never drops the data volume silently.
1 parent 43de45d commit 08d13fa

1 file changed

Lines changed: 96 additions & 22 deletions

File tree

scripts/setup/db.ts

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,71 @@ export function docker(args: string[]): void {
1616
}
1717
}
1818

19+
function dockerOutput(args: string[]): string | null {
20+
const result = spawnSync('docker', args, { encoding: 'utf8' })
21+
return result.status === 0 ? result.stdout.trim() : null
22+
}
23+
24+
interface ManagedContainer {
25+
running: boolean
26+
dsn: string
27+
}
28+
29+
/**
30+
* Recovers everything needed to reach an existing managed container from Docker
31+
* itself, so re-running the wizard is idempotent.
32+
*
33+
* Both facts used to be unrecoverable: the password is generated at creation and
34+
* only lived in the env files the run wrote, and the host port varies (5433 when
35+
* 5432 is taken). Reading them back turns "a container already exists" from a
36+
* fatal name collision into a reuse.
37+
*/
38+
function inspectManagedContainer(): ManagedContainer | null {
39+
const running = dockerOutput(['inspect', DB_CONTAINER, '--format', '{{.State.Running}}'])
40+
if (running === null) return null
41+
42+
const env = dockerOutput([
43+
'inspect',
44+
DB_CONTAINER,
45+
'--format',
46+
'{{range .Config.Env}}{{println .}}{{end}}',
47+
])
48+
const password = env
49+
?.split('\n')
50+
.find((line) => line.startsWith('POSTGRES_PASSWORD='))
51+
?.slice('POSTGRES_PASSWORD='.length)
52+
if (!password) return null
53+
54+
// `docker port` only reports a published port while the container runs; the
55+
// static config carries it either way.
56+
const hostPort = dockerOutput([
57+
'inspect',
58+
DB_CONTAINER,
59+
'--format',
60+
'{{(index .HostConfig.PortBindings "5432/tcp" 0).HostPort}}',
61+
])
62+
if (!hostPort) return null
63+
64+
return {
65+
running: running === 'true',
66+
dsn: `postgresql://postgres:${password}@localhost:${hostPort}/simstudio`,
67+
}
68+
}
69+
70+
/** Starts the container if needed and returns its DSN, or null if it won't answer. */
71+
async function reuseManagedContainer(container: ManagedContainer): Promise<string | null> {
72+
if (!container.running) docker(['start', DB_CONTAINER])
73+
const spin = p.spinner()
74+
spin.start(`Reusing existing ${DB_CONTAINER} container…`)
75+
const healthy = await waitFor(async () => (await pgProbe(container.dsn)).ok, 30_000, 1500)
76+
spin.stop(
77+
healthy
78+
? `Postgres running in ${DB_CONTAINER} on :${new URL(container.dsn).port}`
79+
: `${glyph.warn} ${DB_CONTAINER} exists but is not answering`
80+
)
81+
return healthy ? container.dsn : null
82+
}
83+
1984
async function probeWithSpinner(dsn: string, label: string): Promise<boolean> {
2085
const spin = p.spinner()
2186
spin.start(label)
@@ -57,7 +122,32 @@ async function promptExternalDsn(): Promise<string> {
57122
}
58123
}
59124

125+
/**
126+
* Provisions the managed container, reconciling with one that already exists
127+
* rather than colliding on the name. Recreating is always an explicit choice —
128+
* the data volume outlives the container, so a silent recreate would quietly
129+
* re-point setup at data the user may not expect.
130+
*/
60131
async function startManagedContainer(detection: Detection): Promise<string> {
132+
const existing = inspectManagedContainer()
133+
if (existing) {
134+
const reused = await reuseManagedContainer(existing)
135+
if (reused) return reused
136+
137+
const recreate = await p.confirm({
138+
message: `${DB_CONTAINER} exists but is not answering. Remove and recreate it? Its data volume is kept.`,
139+
initialValue: true,
140+
})
141+
if (!recreate) {
142+
throw new SetupError(`the existing ${DB_CONTAINER} container is not usable.`, [
143+
`inspect: ${theme.command(`docker logs ${DB_CONTAINER}`)}`,
144+
`remove it: ${theme.command(`docker rm -f ${DB_CONTAINER}`)}`,
145+
`start clean: ${theme.command('docker volume rm sim-postgres-data')} drops its data too`,
146+
])
147+
}
148+
docker(['rm', '-f', DB_CONTAINER])
149+
}
150+
61151
const password = generateSecret().slice(0, 24)
62152
const hostPort = detection.postgresPortOpen ? 5433 : 5432
63153
const dsn = `postgresql://postgres:${password}@localhost:${hostPort}/simstudio`
@@ -96,26 +186,6 @@ async function startManagedContainer(detection: Detection): Promise<string> {
96186
return dsn
97187
}
98188

99-
async function restartManagedContainer(existingDsn: string | undefined): Promise<string> {
100-
if (!existingDsn) {
101-
throw new SetupError(
102-
`found a stopped ${DB_CONTAINER} container but no DATABASE_URL to reach it (the generated password lived in your env files).`,
103-
[
104-
`remove it: ${theme.command(`docker rm ${DB_CONTAINER}`)}`,
105-
`also drop its data volume if you don't need it: ${theme.command('docker volume rm sim-postgres-data')}`,
106-
'then re-run the wizard to provision a fresh one',
107-
]
108-
)
109-
}
110-
docker(['start', DB_CONTAINER])
111-
const spin = p.spinner()
112-
spin.start(`Starting existing ${DB_CONTAINER} container…`)
113-
const healthy = await waitFor(async () => (await pgProbe(existingDsn)).ok, 30_000, 1500)
114-
spin.stop(healthy ? `${DB_CONTAINER} running` : `${glyph.fail} ${DB_CONTAINER} did not come up`)
115-
if (!healthy) throw new Error(`${DB_CONTAINER} started but is not answering on ${existingDsn}`)
116-
return existingDsn
117-
}
118-
119189
/**
120190
* The mode-B database ladder: reuse a working DSN, offer (never silently adopt)
121191
* a Postgres already on 5432, start/reuse the wizard-managed pgvector
@@ -127,8 +197,12 @@ export async function resolveDatabase(detection: Detection, existingDsn?: string
127197
return existingDsn
128198
}
129199

130-
if (detection.dbContainer?.managed && detection.dbContainer.state === 'stopped') {
131-
return restartManagedContainer(existingDsn)
200+
// Any managed container, running or stopped — a running one used to fall
201+
// through to `docker run` and die on the name collision.
202+
if (detection.dbContainer?.managed) {
203+
const existing = inspectManagedContainer()
204+
const reused = existing && (await reuseManagedContainer(existing))
205+
if (reused) return reused
132206
}
133207

134208
if (

0 commit comments

Comments
 (0)