Skip to content

Commit 1a4bfe4

Browse files
fix(setup,compose): bundle Redis, fix socket reconnect, and harden the setup wizard (#5964)
* feat(compose,setup): bundle Redis, always configure it, fix lifecycle detection Compose shipped no redis service at all — REDIS_URL was ${REDIS_URL:-} in both app and realtime, so every self-hosted stack ran without it. Storage silently falls back to PostgreSQL, but the pub/sub channels (live Chat task-status, table events) have no fallback, so live updates never arrived. - compose (prod + local): add a redis:7-alpine service with a healthcheck, default REDIS_URL to redis://redis:6379, and make app/realtime depend on it being healthy. Not published to the host — only the containers need it, and binding 6379 would collide with a local Redis. An external REDIS_URL in root .env still overrides. Deliberately not written into root .env: doctor pings REDIS_URL from the host, and a compose-internal hostname would fail that probe the same way DATABASE_URL would. - dev mode: configure Redis in quick too. Quick uses a new non-interactive ensureRedis (adopt whatever answers, else start the managed container, warn only if Docker is unavailable); custom keeps the ladder, with corrected copy — the old prompt claimed Redis was only for multi-replica. - lifecycle: detect compose stacks via 'docker compose ls' instead of probing '-f <file> ps' in the working directory. Compose derives the project name from the directory it was started in, so the old probe found a stack only when run from the checkout that launched it (a globally linked sim never could) and listed the same stack once per candidate file. compose ls reports the real project and its config file, so one stack yields one install from anywhere; non-Sim projects are filtered by compose filename. Every compose op now runs in that stack's directory. - lifecycle: distinguish 'Docker unreachable' from 'nothing installed'. With the daemon down, status reported containers as 'absent' and suggested re-running setup; it now says Docker is down and marks state unknown. * fix(setup): don't start managed Postgres with a password the volume will ignore POSTGRES_PASSWORD only applies when initdb runs on an empty data directory. The sim-postgres-data volume outlives its container (sim down keeps it, docker rm keeps it, and the wizard's own recreate path keeps it), and inspectManagedContainer recovers the password from the *container*, not the volume — so once the container is gone the password is unrecoverable. Setup then generated a fresh password and ran against the initialized volume. Postgres kept its original password and rejected every connection with 'password authentication failed for user postgres', which surfaced as a misleading 'container did not become healthy'. Detect an already-bootstrapped volume (PG_VERSION present) before choosing a password, and ask: supply the existing password, or delete the volume and start fresh (double-confirmed, since that destroys data). Refusing both fails with the exact docker volume rm command instead of looping. * improvement(setup): default to Docker Compose and sharpen the run-mode copy Compose was listed first but only preselected when Docker happened to be running — with Docker stopped the cursor sat on 'Local dev', steering people toward a source checkout when they wanted to run Sim. Compose mode calls ensureDocker(true), which offers to start Docker Desktop, so a stopped daemon is no reason to change the default. Also tightens the hints to say what each mode is for: run bundled Sim (fastest way to start), work on Sim itself, test a production-style k8s deploy. * fix(compose): point the browser socket at :3002 so it stops reconnecting The stack publishes the app on 3000 and realtime on 3002 with no reverse proxy between them, but NEXT_PUBLIC_SOCKET_URL defaulted to empty — which tells the browser client to use the page origin. :3000/socket.io answers 308 (a Next redirect), not a Socket.IO handshake, so the client failed and retried forever. Default it to http://localhost:3002; a proxied deployment overrides it (or sets it empty to use the page origin). Also give COPILOT_API_KEY and SIM_AGENT_API_URL empty defaults so every compose command stops printing 'variable is not set' warnings. The app already falls back to the prod copilot backend when SIM_AGENT_API_URL is blank. * feat(setup): pass SIM_AGENT_API_URL through, and warn on a half-set mothership Sim devs testing against a non-prod mothership export SIM_CLI_AUTH_ORIGIN so the Chat key is minted there, but nothing carried the matching backend URL into the install — the app kept defaulting to prod copilot, which rejects a staging key with 'Invalid API key'. Persist SIM_AGENT_API_URL when it is exported, so later docker compose up / dev runs stay on that backend instead of reverting to prod once the shell is gone: SIM_CLI_AUTH_ORIGIN=https://www.staging.sim.ai \ SIM_AGENT_API_URL=https://www.staging.copilot.sim.ai \ bun run setup Setting only the auth origin is the trap, so that combination warns. Neither set is the self-hoster default and stays silent — no prompts, no flags. * fix(setup): survive a vanished port owner, and stop flagging our own containers Two failures from one compose re-run: - 'Kill it for me' crashed setup with 'kill() failed: ESRCH: No such process'. The owner list is an lsof snapshot, so the process can exit before the signal lands — which is the outcome we wanted, not an error. ESRCH now counts as freed, EPERM warns that it must be stopped by hand, and anything else warns; the loop re-probes either way instead of aborting a setup that had already written .env. - Compose mode demanded 3000/3002 be free even when this stack was the one holding them, so re-running setup against a running install reported its own realtime container as a blocker and offered to kill Docker's listener. 'docker compose up -d' reconciles its own containers, so skip the check when the project already has some. A foreign process is still caught, and a foreign container still surfaces as a bind error from compose. * fix(csp,setup): permit the socket origin the client actually uses; encode DSN passwords Review round on #5964: - The socket reconnect was a CSP bug, not a URL bug. getSocketUrl() already falls back to localhost:3002 for a localhost page, but generateRuntimeCSP gated that same fallback on isDev — and compose runs NODE_ENV=production, so connect-src omitted ws://localhost:3002 and the browser blocked the handshake. Key the fallback on the app URL being localhost instead, mirroring getSocketUrl. Revert the compose NEXT_PUBLIC_SOCKET_URL default: an explicit value suppresses the page-origin fallback that reverse-proxied self-hosts depend on, and ':-' treats empty as unset so the documented escape hatch could not work either. LOCALHOST_HOSTNAMES is duplicated locally because csp.ts is loaded by next.config.ts before @/ aliases resolve. - Percent-encode the password when building the Postgres DSN. A user-supplied password containing @ : / # does not merely re-parse to the wrong host — it fails to parse as a URL at all, so a correct password surfaced as a connection failure. - Tell 'Postgres rejected this password' apart from 'Postgres never started'. On the keep-the-volume path a wrong password left a healthy server and the old generic 'container did not become healthy' error, which is the confusion this change set exists to remove. Adds a CSP regression test for the unset-socket-URL production case; verified it fails against the previous condition. * 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. * fix(setup): identify Sim compose projects by content, not filename Cursor (High): composeInstalls treated any project whose config basename was docker-compose.prod.yml or docker-compose.local.yml as a Sim install. Those names are common, and sim reset runs 'compose down -v' — so a stranger's stack could have had its volumes destroyed. I introduced that reach. The previous ROOT-scoped '-f' probe was implicitly safe because it could only ever see the project in this checkout; switching to a global 'compose ls' to find stacks started elsewhere means projects must be identified by content instead. Read the config file Docker recorded and require a Sim marker (the published app image, or the app Dockerfile this repo builds), so both the prod and local variants match while an unrelated file with the same name does not. An unreadable or since-deleted file is left unmanaged rather than assumed ours. Verified against a decoy nginx compose file using our exact filename: ignored, while both real Sim compose files still match. * fix(setup): scope the compose port skip to published ports; print both k8s forwards Review round on #5964: - ensureComposePortsFree skipped conflict handling whenever the project had any container running, so leftover db/redis (which publish neither app port) waved through a foreign process on :3000 — it then surfaced as a raw compose bind error instead of the prompt. Read the host ports the project actually publishes and skip only those; the remaining ports still get the full check. Reading from the containers rather than the file matters because what counts is what is bound right now. - The post-install note and the skip path documented only the app forward, while offerPortForward runs two. Skipping the prompt or copying the printed command left the editor's socket dead — the exact failure this change set exists to fix. Both commands now come from one forwardCommands() helper, so what is printed and what is run cannot drift. * fix(setup): one source for the k8s forwards, and surface a dead realtime forward Third round on the same theme, so fix it at the root rather than at another call site. - lifecycle's k8sReachHints (used by sim start/restart) still restated an app-only forward, recreating the dead editor socket the setup path had just been fixed for. forwardCommands is now exported and consumed there, so every place that tells a user how to reach a ClusterIP release derives it from one definition. - The realtime forward was spawned with stdio ignored and never checked, so a busy :3002 or a missing service killed it silently while the app forward kept running — indistinguishable from success until the editor won't connect. Keep its stderr, warn on an exit we did not ask for, and stay quiet on the intentional kill. * fix(setup): pin the compose project on every lifecycle op composeInstalls records the real project name from 'compose ls' and status and the destructive confirms print it, but every op ran 'compose -f <file>' with only cwd set — so Compose re-derived the project from that directory. The derived name is frequently not the recorded one: a directory is lowercased and stripped of dots (Sim.Demo_Test derives simdemo_test), and an explicit -p or COMPOSE_PROJECT_NAME at creation diverges outright. stop/down/reset could therefore act on a different project than the one named in the confirm, and reset runs 'down -v'. Route every op through composeArgs(), which pins '-p <recorded project>'. cwd stays, since the file's own relative paths still resolve against it. Verified with a stack started as -p pinned-name from a directory deriving simdemo_test: the old form found 0 of its containers, the pinned form finds them. * fix(setup): warn on both halves of a mothership mismatch mothershipOverride warned only when SIM_CLI_AUTH_ORIGIN was set without SIM_AGENT_API_URL, while its own copy said to set both or neither. The reverse is the same failure mirrored: with only SIM_AGENT_API_URL set, the Chat key is still minted against the default prod auth origin and then validated against the override, which rejects it — silently, which is exactly what this helper exists to prevent. Warn on either asymmetry, and read the default origin from one constant shared with the handoff so the message can't claim an origin the code no longer uses. * fix(setup): warn about a half-set mothership before minting the key mothershipOverride ran two steps after promptCopilotKey, so a half-set override minted a key against one environment, stored it, and only then warned that the other environment would reject it. Worse on a re-run: promptCopilotKey offers to keep an existing COPILOT_API_KEY and defaults to yes, so the bad key survives. Move the override ahead of the key prompt in both compose and dev, so the warning arrives while it can still change the outcome — the user can abort and set the missing half before anything is minted. Nothing in the override depends on the key, so the order is free.
1 parent 7f4cc38 commit 1a4bfe4

13 files changed

Lines changed: 736 additions & 74 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* The bundled docker-compose stack runs NODE_ENV=production, serves the app from
5+
* localhost, and leaves NEXT_PUBLIC_SOCKET_URL unset. getSocketUrl() falls back
6+
* to localhost:3002 for a localhost page regardless of NODE_ENV, so the CSP has
7+
* to permit that origin or the browser blocks the handshake and Socket.IO
8+
* retries forever. Its own file because vi.mock is hoisted per-module and the
9+
* sibling suite needs NEXT_PUBLIC_SOCKET_URL set.
10+
*/
11+
import { createEnvMock } from '@sim/testing'
12+
import { describe, expect, it, vi } from 'vitest'
13+
14+
vi.mock('@/lib/core/config/env', () =>
15+
createEnvMock({
16+
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
17+
NEXT_PUBLIC_SOCKET_URL: undefined,
18+
})
19+
)
20+
21+
vi.mock('@/lib/core/config/env-flags', () => ({
22+
isDev: false,
23+
isHosted: false,
24+
isReactGrabEnabled: false,
25+
}))
26+
27+
import { generateRuntimeCSP } from './csp'
28+
29+
describe('generateRuntimeCSP — socket fallback on a localhost origin', () => {
30+
it('permits the default socket origin when NEXT_PUBLIC_SOCKET_URL is unset', () => {
31+
const csp = generateRuntimeCSP()
32+
33+
expect(csp).toContain('http://localhost:3002')
34+
expect(csp).toContain('ws://localhost:3002')
35+
})
36+
37+
it('keeps the socket sources inside connect-src', () => {
38+
const connectSrc = generateRuntimeCSP()
39+
.split(';')
40+
.map((directive) => directive.trim())
41+
.find((directive) => directive.startsWith('connect-src'))
42+
43+
expect(connectSrc).toBeDefined()
44+
expect(connectSrc).toContain('ws://localhost:3002')
45+
})
46+
})

apps/sim/lib/core/security/csp.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@ function toWebSocketUrl(httpUrl: string): string {
1616
return httpUrl.replace('http://', 'ws://').replace('https://', 'wss://')
1717
}
1818

19+
/**
20+
* Kept in sync with LOCALHOST_HOSTNAMES in ../utils/urls by hand: this module is
21+
* loaded by next.config.ts before `@/` aliases resolve, so it cannot import from
22+
* there (see the note above).
23+
*/
24+
const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
25+
26+
/** Mirrors getSocketUrl's localhost check — those origins fall back to DEFAULT_SOCKET_URL. */
27+
function isLocalhostUrl(url: string): boolean {
28+
if (!url) return false
29+
try {
30+
return LOCALHOST_HOSTNAMES.has(new URL(url).hostname)
31+
} catch {
32+
return false
33+
}
34+
}
35+
1936
function getHostnameFromUrl(url: string | undefined): string[] {
2037
if (!url) return []
2138
try {
@@ -208,7 +225,15 @@ export function buildCSPString(directives: CSPDirectives): string {
208225
export function generateRuntimeCSP(): string {
209226
const appUrl = getEnv('NEXT_PUBLIC_APP_URL') || ''
210227

211-
const socketUrl = getEnv('NEXT_PUBLIC_SOCKET_URL') || (isDev ? DEFAULT_SOCKET_URL : '')
228+
// Must permit whatever getSocketUrl() actually connects to, or the browser
229+
// blocks the handshake and Socket.IO retries forever. That helper falls back
230+
// to DEFAULT_SOCKET_URL whenever the page is served from localhost — which
231+
// includes a production build (docker compose sets NODE_ENV=production), so
232+
// keying this on isDev alone left the bundled stack with a CSP that forbade
233+
// its own realtime port. A non-localhost origin still resolves to the page
234+
// origin, which appUrl already covers, so nothing is loosened there.
235+
const socketUrl =
236+
getEnv('NEXT_PUBLIC_SOCKET_URL') || (isDev || isLocalhostUrl(appUrl) ? DEFAULT_SOCKET_URL : '')
212237
const socketWsUrl = socketUrl ? toWebSocketUrl(socketUrl) : ''
213238
const ollamaUrl = getEnv('OLLAMA_URL') || (isDev ? DEFAULT_OLLAMA_URL : '')
214239

docker-compose.local.yml

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,17 @@ services:
2121
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-dev-encryption-key-at-least-32-chars}
2222
- API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-}
2323
- INTERNAL_API_SECRET=${INTERNAL_API_SECRET:-dev-internal-api-secret-min-32-chars}
24-
- REDIS_URL=${REDIS_URL:-}
25-
- COPILOT_API_KEY=${COPILOT_API_KEY}
26-
- SIM_AGENT_API_URL=${SIM_AGENT_API_URL}
24+
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
25+
- COPILOT_API_KEY=${COPILOT_API_KEY:-}
26+
- SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-}
2727
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
2828
- SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002}
2929
- NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-}
3030
depends_on:
3131
db:
3232
condition: service_healthy
33+
redis:
34+
condition: service_healthy
3335
migrations:
3436
condition: service_completed_successfully
3537
realtime:
@@ -55,10 +57,12 @@ services:
5557
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}
5658
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-dev-secret-at-least-32-characters-long}
5759
- INTERNAL_API_SECRET=${INTERNAL_API_SECRET:-dev-internal-api-secret-min-32-chars}
58-
- REDIS_URL=${REDIS_URL:-}
60+
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
5961
depends_on:
6062
db:
6163
condition: service_healthy
64+
redis:
65+
condition: service_healthy
6266
restart: unless-stopped
6367
ports:
6468
- '3002:3002'
@@ -86,6 +90,20 @@ services:
8690
command: ['bun', 'run', 'db:migrate']
8791
restart: 'no'
8892

93+
# Backs pub/sub (live Chat task-status and table events) and the shared caches.
94+
# The app falls back to PostgreSQL for storage when REDIS_URL is unset, but the
95+
# pub/sub channels have no fallback — without Redis, live status never streams.
96+
# Not published to the host: only the app and realtime containers need it, and
97+
# binding 6379 would collide with a Redis already running locally.
98+
redis:
99+
image: redis:7-alpine
100+
restart: always
101+
healthcheck:
102+
test: ['CMD', 'redis-cli', 'ping']
103+
interval: 5s
104+
timeout: 5s
105+
retries: 5
106+
89107
db:
90108
image: pgvector/pgvector:pg17
91109
restart: always

docker-compose.prod.yml

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,23 @@ services:
3030
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
3131
- API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-}
3232
- INTERNAL_API_SECRET=${INTERNAL_API_SECRET}
33-
- REDIS_URL=${REDIS_URL:-}
34-
- COPILOT_API_KEY=${COPILOT_API_KEY}
35-
- SIM_AGENT_API_URL=${SIM_AGENT_API_URL}
33+
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
34+
- COPILOT_API_KEY=${COPILOT_API_KEY:-}
35+
- SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-}
3636
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
3737
- SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002}
38-
# NEXT_PUBLIC_SOCKET_URL is read by the browser. Leaving it unset lets the
39-
# client default to the page's own origin (assumes the reverse proxy routes
40-
# /socket.io). Set it explicitly only when the realtime service is on a
41-
# different host:port from the app (e.g. wss://socket.example.com).
38+
# NEXT_PUBLIC_SOCKET_URL is read by the browser. Leave it unset for this
39+
# stack: the client already falls back to localhost:3002 for a localhost
40+
# page, and a proxied deployment needs the page-origin fallback that an
41+
# explicit value would suppress. Set it only when realtime is on a
42+
# different host:port (e.g. wss://socket.example.com).
4243
- NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-}
4344
- ADMISSION_GATE_MAX_INFLIGHT=${ADMISSION_GATE_MAX_INFLIGHT:-500}
4445
depends_on:
4546
db:
4647
condition: service_healthy
48+
redis:
49+
condition: service_healthy
4750
migrations:
4851
condition: service_completed_successfully
4952
realtime:
@@ -74,10 +77,12 @@ services:
7477
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}
7578
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
7679
- INTERNAL_API_SECRET=${INTERNAL_API_SECRET}
77-
- REDIS_URL=${REDIS_URL:-}
80+
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
7881
depends_on:
7982
db:
8083
condition: service_healthy
84+
redis:
85+
condition: service_healthy
8186
healthcheck:
8287
test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:3002/health']
8388
interval: 90s
@@ -96,6 +101,20 @@ services:
96101
command: ['bun', 'run', 'db:migrate']
97102
restart: 'no'
98103

104+
# Backs pub/sub (live Chat task-status and table events) and the shared caches.
105+
# The app falls back to PostgreSQL for storage when REDIS_URL is unset, but the
106+
# pub/sub channels have no fallback — without Redis, live status never streams.
107+
# Not published to the host: only the app and realtime containers need it, and
108+
# binding 6379 would collide with a Redis already running locally.
109+
redis:
110+
image: redis:7-alpine
111+
restart: unless-stopped
112+
healthcheck:
113+
test: ['CMD', 'redis-cli', 'ping']
114+
interval: 5s
115+
timeout: 5s
116+
retries: 5
117+
99118
db:
100119
image: pgvector/pgvector:pg17
101120
restart: unless-stopped

scripts/setup/db.ts

Lines changed: 129 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ import { glyph, theme } from './theme.ts'
99

1010
const DEFAULT_DSN = 'postgresql://postgres:postgres@localhost:5432/simstudio'
1111

12+
/** Postgres' wire message when the password is wrong — a live server, not a dead one. */
13+
const AUTH_FAILURE = /password authentication failed/i
14+
15+
/**
16+
* Percent-encodes the password so characters that are structural in a URL
17+
* (`@`, `:`, `/`, `#`, `?`) can't re-parse the DSN into a different host — which
18+
* would fail a password that is actually correct.
19+
*/
20+
function buildDsn(password: string, hostPort: string | number): string {
21+
return `postgresql://postgres:${encodeURIComponent(password)}@localhost:${hostPort}/simstudio`
22+
}
23+
1224
export function docker(args: string[]): void {
1325
const result = spawnSync('docker', args, { encoding: 'utf8' })
1426
if (result.status !== 0) {
@@ -63,7 +75,9 @@ function inspectManagedContainer(): ManagedContainer | null {
6375

6476
return {
6577
running: running === 'true',
66-
dsn: `postgresql://postgres:${password}@localhost:${hostPort}/simstudio`,
78+
// Read back from the container env verbatim, so it may be a password the
79+
// user supplied for an existing volume — encode it like any other.
80+
dsn: buildDsn(password, hostPort),
6781
}
6882
}
6983

@@ -122,6 +136,81 @@ async function promptExternalDsn(): Promise<string> {
122136
}
123137
}
124138

139+
const DB_VOLUME = 'sim-postgres-data'
140+
141+
/** True once initdb has run in the volume — PG_VERSION only exists after bootstrap. */
142+
function volumeInitialized(): boolean {
143+
if (spawnSync('docker', ['volume', 'inspect', DB_VOLUME], { stdio: 'ignore' }).status !== 0) {
144+
return false
145+
}
146+
// Read the marker from inside the volume; the image is already local, so this
147+
// costs nothing extra and beats assuming "volume exists" means "bootstrapped"
148+
// (a failed first run leaves an empty volume behind).
149+
return (
150+
spawnSync(
151+
'docker',
152+
[
153+
'run',
154+
'--rm',
155+
'-v',
156+
`${DB_VOLUME}:/pgdata`,
157+
'--entrypoint',
158+
'test',
159+
'pgvector/pgvector:pg17',
160+
'-f',
161+
'/pgdata/PG_VERSION',
162+
],
163+
{ stdio: 'ignore' }
164+
).status === 0
165+
)
166+
}
167+
168+
/**
169+
* The volume already holds a cluster whose password we cannot read back. Either
170+
* the user supplies it, or the data goes — silently generating a new password
171+
* would produce a container that never authenticates.
172+
*/
173+
async function resolveExistingVolume(): Promise<string> {
174+
p.log.warn(
175+
`The ${DB_VOLUME} volume already contains a database, but its password is not recoverable — Postgres ignores POSTGRES_PASSWORD on an existing data directory.`
176+
)
177+
const choice = await p.select({
178+
message: 'How should the wizard proceed?',
179+
options: [
180+
{
181+
value: 'password',
182+
label: 'Keep the data — I have its password',
183+
hint: 'from a previous .env, or your notes',
184+
},
185+
{
186+
value: 'wipe',
187+
label: 'Delete the old data and start fresh',
188+
hint: `removes the ${DB_VOLUME} volume — this cannot be undone`,
189+
},
190+
],
191+
initialValue: 'password',
192+
})
193+
if (choice === 'password') {
194+
return p.password({
195+
message: `Password for the existing ${DB_VOLUME} database`,
196+
validate: (value) => (value ? undefined : 'required'),
197+
})
198+
}
199+
const sure = await p.confirm({
200+
message: theme.error(`Permanently delete the ${DB_VOLUME} volume and all its data?`),
201+
initialValue: false,
202+
})
203+
if (!sure) {
204+
throw new SetupError('kept the existing database volume, so setup cannot continue.', [
205+
're-run and supply the password, or remove it yourself:',
206+
theme.command(`docker volume rm ${DB_VOLUME}`),
207+
])
208+
}
209+
docker(['volume', 'rm', DB_VOLUME])
210+
p.log.step(`Removed ${DB_VOLUME}`)
211+
return generateSecret().slice(0, 24)
212+
}
213+
125214
/**
126215
* Provisions the managed container, reconciling with one that already exists
127216
* rather than colliding on the name. Recreating is always an explicit choice —
@@ -142,15 +231,27 @@ async function startManagedContainer(detection: Detection): Promise<string> {
142231
throw new SetupError(`the existing ${DB_CONTAINER} container is not usable.`, [
143232
`inspect: ${theme.command(`docker logs ${DB_CONTAINER}`)}`,
144233
`remove it: ${theme.command(`docker rm -f ${DB_CONTAINER}`)}`,
145-
`start clean: ${theme.command('docker volume rm sim-postgres-data')} drops its data too`,
234+
`start clean: ${theme.command(`docker volume rm ${DB_VOLUME}`)} drops its data too`,
146235
])
147236
}
148237
docker(['rm', '-f', DB_CONTAINER])
149238
}
150239

151-
const password = generateSecret().slice(0, 24)
152240
const hostPort = detection.postgresPortOpen ? 5433 : 5432
153-
const dsn = `postgresql://postgres:${password}@localhost:${hostPort}/simstudio`
241+
// POSTGRES_PASSWORD only applies when initdb runs on an empty data directory.
242+
// The volume outlives the container (sim down keeps it, so does `docker rm`),
243+
// so once the container is gone the password it was created with is
244+
// unrecoverable — inspectManagedContainer reads it from the container, not the
245+
// volume. Running with a freshly generated password against an initialized
246+
// volume starts a healthy Postgres that rejects every connection with
247+
// "password authentication failed", which surfaces as a bogus "container did
248+
// not become healthy". Ask instead of guessing.
249+
const password = volumeInitialized()
250+
? await resolveExistingVolume()
251+
: generateSecret().slice(0, 24)
252+
// A user-supplied password can contain @ : / # — raw interpolation would
253+
// re-parse the DSN into a different host and fail a password that is correct.
254+
const dsn = buildDsn(password, hostPort)
154255
docker([
155256
'run',
156257
'-d',
@@ -159,7 +260,7 @@ async function startManagedContainer(detection: Detection): Promise<string> {
159260
'--label',
160261
'managed-by=sim-setup',
161262
'-v',
162-
'sim-postgres-data:/var/lib/postgresql/data',
263+
`${DB_VOLUME}:/var/lib/postgresql/data`,
163264
'-e',
164265
`POSTGRES_PASSWORD=${password}`,
165266
'-e',
@@ -170,9 +271,31 @@ async function startManagedContainer(detection: Detection): Promise<string> {
170271
])
171272
const spin = p.spinner()
172273
spin.start(`Starting ${DB_CONTAINER} container on :${hostPort}…`)
173-
const healthy = await waitFor(async () => (await pgProbe(dsn)).ok, 45_000, 1500)
274+
let lastError = ''
275+
const healthy = await waitFor(
276+
async () => {
277+
const probe = await pgProbe(dsn)
278+
if (!probe.ok) lastError = probe.error ?? ''
279+
return probe.ok
280+
},
281+
45_000,
282+
1500
283+
)
174284
if (!healthy) {
175285
spin.stop(`${glyph.fail} container did not become healthy`)
286+
// Postgres running and refusing the password is a different failure from
287+
// Postgres never starting, and it is the likely one on the keep-the-volume
288+
// path. Reporting it as "did not become healthy" is the exact confusion
289+
// this whole change set exists to remove.
290+
if (AUTH_FAILURE.test(lastError)) {
291+
throw new SetupError(
292+
`Postgres started, but rejected that password for the existing ${DB_VOLUME} volume.`,
293+
[
294+
're-run and enter the password the volume was created with',
295+
`or discard the old data: ${theme.command(`docker rm -f ${DB_CONTAINER} && docker volume rm ${DB_VOLUME}`)}`,
296+
]
297+
)
298+
}
176299
const logs = spawnSync('docker', ['logs', '--tail', '20', DB_CONTAINER], { encoding: 'utf8' })
177300
throw new SetupError(
178301
`the Postgres container failed to start. Last logs:\n${logs.stdout}${logs.stderr}`,

0 commit comments

Comments
 (0)