v0.154.0
  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