1"""Deciding whether to manage Postgres, and producing a URL when we do.
2
3This runs from `setup()`, which happens *before* `plain.runtime.settings` is
4configured — so we cannot read `settings.POSTGRES_URL` here to see whether the
5user already configured one. Rather than guess, we sidestep the question with
6precedence:
7
8 PLAIN_POSTGRES_URL (env) > POSTGRES_URL (settings.py) > DATABASE_URL (env)
9
10We inject `DATABASE_URL`, the *lowest* precedence source. So a user who sets
11`POSTGRES_URL` in settings.py silently wins over us, with no detection needed
12and no way for us to point their app at the wrong database. Injecting the
13highest-precedence variable — as the prototype did — is what made that possible.
14
15The asymmetry is deliberate, and it's why the two variables aren't
16interchangeable here: **`PLAIN_POSTGRES_URL` is what a person sets** — explicit,
17and it beats everything including us — while **`DATABASE_URL` is what we set**,
18because it defers to everything. We tell users about the first and quietly use
19the second. Either one being present means someone has already chosen, and we
20do nothing at all.
21
22The same property makes subprocesses cheap: `plain dev` spawns `plain postgres
23sync`, which inherits our `DATABASE_URL`, sees a URL already configured, and
24skips all of this. Only the outermost process does any real work.
25"""
26
27from __future__ import annotations
28
29import os
30import sys
31from enum import Enum, auto
32from pathlib import Path
33
34import click
35
36from ..state import checkout_id, checkout_state_path
37from .backends import (
38 LOCAL_PORT,
39 Server,
40 connect_local_server,
41 docker_available,
42 local_available,
43 local_rejected_us,
44 port_is_open,
45 start_docker_server,
46)
47from .cluster import Cluster
48from .identity import (
49 DEV_PASSWORD,
50 DEV_USER,
51 PostgresConfig,
52 cluster_name,
53 project_identity,
54 resolve_database_name,
55 volume_name,
56)
57
58# One cluster serves every worktree's connection pool plus xdist workers.
59# Postgres' default of 100 is not enough; this is cheap to raise.
60MAX_CONNECTIONS = 500
61
62# Commands we won't *start a server* for. They still get a URL — see
63# `ensure_postgres`; that's a separate question and a much cheaper one.
64#
65# A name list, not per-command metadata, because this runs before
66# `plain.runtime.setup()` — the click registry that could carry such metadata
67# doesn't exist yet, and building it is exactly the work this precedes.
68#
69# Anything not listed — including custom app commands like `create-user` — is
70# assumed to want a running server, because being wrong in that direction just
71# means a container we didn't strictly need, while being wrong the other way
72# means a command failing to connect for no visible reason.
73COMMANDS_THAT_DONT_START_A_SERVER = {
74 "agent",
75 "assets",
76 "changelog",
77 "code",
78 "contrib",
79 "create",
80 # `plain db` manages the server explicitly — the subcommands that need one
81 # open it themselves, and the ones that stop or merely report on it must
82 # not start it as a side effect of `setup()`. Without this, `plain db
83 # server stop` starts the container before stopping it, and the next `plain
84 # db server list` silently starts it again.
85 "db",
86 "docs",
87 "fix",
88 "install",
89 "scan",
90 "settings",
91 "tailwind",
92 "tunnel",
93 "upgrade",
94 "urls",
95 "utils",
96 "vendor",
97}
98
99
100# The variable we write a resolved URL into. Named here rather than spelled out
101# at each call site: it is deliberately the lowest-precedence source, and that
102# choice has to move as one piece if it ever moves at all.
103INJECTED_URL_ENV_VAR = "DATABASE_URL"
104
105
106def url_already_configured() -> bool:
107 """Did the user already point us at a database?
108
109 Env-only by necessity (settings aren't loaded yet) but *sufficient*, because
110 we inject the lowest-precedence variable — a settings.py URL beats ours
111 without us having to detect it. See the module docstring.
112 """
113 return bool(
114 os.environ.get(INJECTED_URL_ENV_VAR) or os.environ.get("PLAIN_POSTGRES_URL")
115 )
116
117
118def command_may_start_server() -> bool:
119 """Should we go as far as *starting* a server for this command?
120
121 Only consults the top-level command, so `plain run test` isn't mistaken for
122 `plain test`.
123 """
124 argv = [a for a in sys.argv[1:] if not a.startswith("-")]
125 if not argv:
126 return False # bare `plain` / `plain --help`
127 return argv[0] not in COMMANDS_THAT_DONT_START_A_SERVER
128
129
130def cache_path(project_root: Path) -> Path:
131 return checkout_state_path(project_root) / "postgres-url"
132
133
134class CachedURL(Enum):
135 """What we learned about the URL we cached last time."""
136
137 MISSING = auto() # nothing cached yet
138 UNREACHABLE = auto() # cached, but no server is answering
139 NO_DATABASE = auto() # server is up, but our database is gone
140 OK = auto()
141
142
143def read_cached_url(project_root: Path) -> tuple[CachedURL, str | None]:
144 """Check the cached URL by actually connecting to it.
145
146 Verified with a real connection rather than a Docker call — ~6ms versus
147 ~80ms — which is what makes it affordable on every command. That's more
148 than a bare TCP probe would cost, and worth it: the probe can only tell us
149 something is listening, while a connection distinguishes "the server is
150 gone" from "the server is fine but our database was dropped". The second
151 case is repairable without Docker at all, and repairing it beats handing
152 the app a URL that will hang for the pool's full timeout.
153 """
154 cache = cache_path(project_root)
155 if not cache.exists():
156 return CachedURL.MISSING, None
157
158 url = cache.read_text().strip()
159 if not url:
160 return CachedURL.MISSING, None
161
162 port = _port_from_url(url)
163 if port is None or not port_is_open(port):
164 # Cheap pre-check so a dead server costs 0.2ms, not a connect timeout.
165 return CachedURL.UNREACHABLE, url
166
167 import psycopg
168
169 try:
170 psycopg.connect(url, connect_timeout=2).close()
171 except psycopg.errors.InvalidCatalogName:
172 return CachedURL.NO_DATABASE, url
173 except psycopg.OperationalError:
174 return CachedURL.UNREACHABLE, url
175
176 return CachedURL.OK, url
177
178
179def write_cached_url(project_root: Path, *, url: str) -> None:
180 cache = cache_path(project_root)
181 if cache.exists() and cache.read_text() == url:
182 return # Repair is an event; the common case shouldn't touch disk.
183 cache.parent.mkdir(parents=True, exist_ok=True)
184 cache.write_text(url)
185
186
187def clear_cached_url(project_root: Path) -> None:
188 """Drop the cache — for when the database it names no longer exists."""
189 cache_path(project_root).unlink(missing_ok=True)
190
191
192def _port_from_url(url: str) -> int | None:
193 from urllib.parse import urlsplit
194
195 try:
196 return urlsplit(url).port
197 except ValueError:
198 return None
199
200
201def start_server(project_root: Path, *, config: PostgresConfig) -> Server | None:
202 """Bring up (or find) the project's Postgres server.
203
204 `config.backend` is the *preference* — usually `"auto"` — so the selection
205 order here is what turns it into an answer. Returns `None` when no backend
206 is available, having already explained why.
207 """
208 if config.backend == "off":
209 return None
210
211 if config.backend in ("auto", "docker") and docker_available():
212 return start_docker_server(
213 container=cluster_name(project_root),
214 volume=volume_name(project_root),
215 image=config.image,
216 max_connections=MAX_CONNECTIONS,
217 )
218
219 if config.backend in ("auto", "local") and local_available():
220 return connect_local_server()
221
222 _explain_no_backend(config.backend)
223 return None
224
225
226def _explain_no_backend(backend: str) -> None:
227 if local_rejected_us():
228 # The most common workstation case, and the most confusing one: you do
229 # have Postgres, so "no Postgres available" reads as a lie. Homebrew and
230 # Postgres.app both create a superuser named after your OS account and
231 # no `postgres` role, which is exactly what we tried to log in as.
232 click.secho(
233 f"A Postgres is listening on 127.0.0.1:{LOCAL_PORT}, but it rejected "
234 f"the {DEV_USER!r} login we use.",
235 fg="yellow",
236 err=True,
237 )
238 click.echo(
239 " Homebrew and Postgres.app name the superuser after your account "
240 "rather than 'postgres'. Point us at it directly:\n"
241 f" export PLAIN_POSTGRES_URL=postgres://[email protected]:{LOCAL_PORT}/<database>\n"
242 " Or start Docker and we'll run our own, isolated from it.",
243 err=True,
244 )
245 return
246
247 if backend == "docker":
248 message = "Docker isn't available, and postgres backend is pinned to 'docker'."
249 elif backend == "local":
250 message = (
251 f"No Postgres listening on 127.0.0.1:{LOCAL_PORT}, and postgres "
252 "backend is pinned to 'local'."
253 )
254 else:
255 message = (
256 "No Postgres available: Docker isn't running and nothing is "
257 f"listening on 127.0.0.1:{LOCAL_PORT}."
258 )
259 click.secho(
260 f"{message}\nSet PLAIN_POSTGRES_URL to use your own database, or start Docker.",
261 fg="yellow",
262 err=True,
263 )
264
265
266def ensure_database(cluster: Cluster, *, project_root: Path, db_name: str) -> None:
267 """Make sure this checkout's database exists, forking from main if we can.
268
269 A new worktree starting empty is the actual pain point in parallel dev —
270 you have to re-seed it every time. So the default is a copy of the project's
271 main database, which carries its data. `plain db create` opts out.
272 """
273 if cluster.database_exists(db_name):
274 return
275
276 project_name, _ = project_identity(project_root)
277 fork_source = project_name if db_name != project_name else None
278
279 # Two processes can reach this at once — several worktrees starting
280 # together, or `plain test` and `plain shell` side by side. Both see the
281 # database missing, both create it, and one loses. Losing that race means
282 # the database now exists, which is all we wanted, so treat it as success
283 # rather than crashing the command.
284 from psycopg import errors
285
286 try:
287 if fork_source and cluster.database_exists(fork_source):
288 mechanism = cluster.fork_database(fork_source, db_name)
289 click.secho(
290 f"Created database {db_name!r} from {fork_source!r} ({mechanism}).",
291 fg="green",
292 err=True,
293 )
294 else:
295 cluster.create_database(db_name)
296 mechanism = "empty"
297 click.secho(f"Created database {db_name!r}.", fg="green", err=True)
298 except (errors.DuplicateDatabase, errors.UniqueViolation):
299 return
300
301 cluster.record_created(
302 db_name,
303 checkout=checkout_id(project_root),
304 created_via=mechanism,
305 project_root=project_root,
306 )
307
308
309def open_cluster(project_root: Path, *, create: bool = True) -> Cluster | None:
310 """The project's cluster, starting a server if needed and allowed."""
311 config = PostgresConfig.load(project_root)
312 if config.backend == "off":
313 return None
314
315 if not create:
316 # Reachable server only — callers here are advisory (the guard, the
317 # branch check) and must never start Docker as a side effect.
318 status, cached = read_cached_url(project_root)
319 if cached is None or status is CachedURL.UNREACHABLE:
320 return None
321 return Cluster(_server_from_url(cached))
322
323 server = start_server(project_root, config=config)
324 return Cluster(server) if server else None
325
326
327def _server_from_url(url: str) -> Server:
328 from urllib.parse import urlsplit
329
330 parts = urlsplit(url)
331 return Server(
332 host=parts.hostname or "127.0.0.1",
333 port=parts.port or 5432,
334 user=parts.username or DEV_USER,
335 password=parts.password or DEV_PASSWORD,
336 backend="cached",
337 )
338
339
340def ensure_postgres(project_root: Path) -> str | None:
341 """Resolve a database URL for this checkout and inject it. Returns the URL.
342
343 Returns `None` — changing nothing — when the user configured their own
344 database, when managed Postgres is switched off, or when this command
345 doesn't need a database and nothing is running yet.
346 """
347 if url_already_configured():
348 return None
349
350 config = PostgresConfig.load(project_root)
351 if config.backend == "off":
352 return None
353
354 status, cached = read_cached_url(project_root)
355
356 # Fast path: the database we used last time is still there. No Docker.
357 if status is CachedURL.OK and cached:
358 _inject(cached)
359 return cached
360
361 # The server is fine but our database went away — someone dropped it, or
362 # another checkout cleaned up. Rebuild it in place; still no Docker needed.
363 if status is CachedURL.NO_DATABASE and cached:
364 cluster = Cluster(_server_from_url(cached))
365 db_name = resolve_database_name(project_root)
366 ensure_database(cluster, project_root=project_root, db_name=db_name)
367 # Rebuild the URL from the resolved name rather than reusing the cached
368 # one: the cache may predate a pointer change or a renamed worktree, so
369 # it can name a database we didn't just create. Rewrite the cache too.
370 url = cluster.url(db_name)
371 _inject(url)
372 write_cached_url(project_root, url=url)
373 return url
374
375 if not command_may_start_server():
376 # We won't start a server for this command — but that's a question about
377 # side effects, not about which database this checkout owns. `POSTGRES_URL`
378 # is a required setting, so refusing to answer here left the app unable to
379 # configure at all: with the server stopped, `plain docs` and `plain fix`
380 # failed outright, and with no cache yet *every* `plain db` command failed
381 # — including the ones whose whole job is to fix that.
382 #
383 # So always answer. The URL names this checkout's database correctly, and
384 # nothing connects unless the command connects. `plain db` goes on to open
385 # the cluster itself, which starts the server and rewrites the cache.
386 url = cached or _unstarted_url(project_root)
387 _inject(url)
388 return url
389
390 server = start_server(project_root, config=config)
391 if server is None:
392 return None
393
394 cluster = Cluster(server)
395 db_name = resolve_database_name(project_root)
396 ensure_database(cluster, project_root=project_root, db_name=db_name)
397
398 url = cluster.url(db_name)
399 _inject(url)
400 write_cached_url(project_root, url=url)
401 return url
402
403
404def _unstarted_url(project_root: Path) -> str:
405 """A URL for this checkout when we haven't started a server to ask.
406
407 The database name is exact — it's derived, not looked up. The host and port
408 are a guess at the conventional local Postgres, because a stopped container
409 publishes no port and finding out would mean starting it, which is the one
410 thing this path exists to avoid.
411
412 So this is honest rather than authoritative: right for the local backend,
413 and for Docker it names the right database on a port that isn't listening.
414 That is exactly the contract every command on this path already had with a
415 stale cache, and it can't reach another project's data — the name is
416 namespaced per project and per checkout.
417
418 Deliberately not cached: the cache is for URLs we've confirmed.
419 """
420 return connect_local_server().url(resolve_database_name(project_root))
421
422
423def _inject(url: str) -> None:
424 # The lowest-precedence source, so a settings.py POSTGRES_URL still wins.
425 # Subprocesses inherit it and skip resolution entirely.
426 os.environ[INJECTED_URL_ENV_VAR] = url
427
428
429def is_managed(project_root: Path) -> bool:
430 """Is the database currently in use one that we produced?
431
432 Answered by comparing the active URL against what we cached, rather than a
433 sentinel environment variable. A `PLAIN_`-prefixed sentinel would be read as
434 a misspelled setting by the `settings.unused_env_vars` preflight check, and
435 putting a permanent warning in every managed project to answer a question we
436 can already answer from a file isn't a trade worth making.
437
438 Only a database we created can match, so a bring-your-own URL never does —
439 which is what the guard and the branch check rely on before acting.
440 """
441 active = os.environ.get(INJECTED_URL_ENV_VAR)
442 if not active:
443 return False
444
445 cache = cache_path(project_root)
446 return cache.exists() and cache.read_text().strip() == active