|
1 | 1 | 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' |
3 | 10 | import { ensureDocker } from './docker' |
4 | 11 | import { generateSecret } from './env-files' |
5 | 12 | import { SetupError } from './errors' |
@@ -36,6 +43,8 @@ function dockerOutput(args: string[]): string | null { |
36 | 43 | interface ManagedContainer { |
37 | 44 | running: boolean |
38 | 45 | dsn: string |
| 46 | + password: string |
| 47 | + binding: PortBinding |
39 | 48 | } |
40 | 49 |
|
41 | 50 | /** |
@@ -63,21 +72,16 @@ function inspectManagedContainer(): ManagedContainer | null { |
63 | 72 | ?.slice('POSTGRES_PASSWORD='.length) |
64 | 73 | if (!password) return null |
65 | 74 |
|
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 |
75 | 77 |
|
76 | 78 | return { |
77 | 79 | running: running === 'true', |
78 | 80 | // Read back from the container env verbatim, so it may be a password the |
79 | 81 | // 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, |
81 | 85 | } |
82 | 86 | } |
83 | 87 |
|
@@ -249,6 +253,14 @@ async function startManagedContainer(detection: Detection): Promise<string> { |
249 | 253 | const password = volumeInitialized() |
250 | 254 | ? await resolveExistingVolume() |
251 | 255 | : 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> { |
252 | 264 | // A user-supplied password can contain @ : / # — raw interpolation would |
253 | 265 | // re-parse the DSN into a different host and fail a password that is correct. |
254 | 266 | const dsn = buildDsn(password, hostPort) |
@@ -309,13 +321,41 @@ async function startManagedContainer(detection: Detection): Promise<string> { |
309 | 321 | return dsn |
310 | 322 | } |
311 | 323 |
|
| 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 | + |
312 | 347 | /** |
313 | 348 | * The mode-B database ladder: reuse a working DSN, offer (never silently adopt) |
314 | 349 | * a Postgres already on 5432, start/reuse the wizard-managed pgvector |
315 | 350 | * container, or take an external DSN. Adopting an existing database is always |
316 | 351 | * an explicit choice — migrations run against whatever is chosen here. |
317 | 352 | */ |
318 | 353 | 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 | + |
319 | 359 | if (existingDsn && (await probeWithSpinner(existingDsn, 'Testing existing DATABASE_URL…'))) { |
320 | 360 | return existingDsn |
321 | 361 | } |
|
0 commit comments