v0.166.0
  1"""What `plain db clean` may drop, and why it leaves the rest.
  2
  3Dropping a database can't be undone, and `plain db clean` drops databases
  4nobody named. So what it may drop is decided in one function of facts,
  5`plan_clean`, which touches no database and no disk, and everything it is
  6given is listed with the reason: what would be dropped, and what is left.
  7
  8**A development database** is dropped only when all of this is so:
  9
 10- It isn't the project's main database, or this checkout's.
 11- No checkout of the project is configured to use it. This goes by what each
 12  checkout uses now, not by who the database's metadata says made it: a
 13  worktree that was moved, or pointed at it with `plain db use`, uses it
 14  whatever the metadata says.
 15- Its metadata names the checkout that made it, and that checkout is gone.
 16- Nothing is connected to it.
 17
 18**When a checkout is gone.** Its directory is missing, and:
 19
 20- in a git repository, git lists no worktree that holds it. A worktree git
 21  still lists whose directory is missing may be on a volume that isn't
 22  mounted, so its database is left until `git worktree prune` says
 23  otherwise.
 24- outside git, the directory that held it is still there. When that is
 25  missing too, nothing can be told from the path.
 26
 27**A test database** is dropped only when it carries the record of the run
 28that made it and that run is proved dead. **A template**, which a
 29database's test runs clone, is dropped only when its record says it is of
 30no use now: the schema of this checkout's database has changed since it
 31was built, the database it was built for is gone from the server, or the
 32run building it died before it was done. `plain.postgres.testing.leftovers`
 33has both rules, and this module only reports its verdicts.
 34
 35Outside a git repository the only checkout known is the one the command
 36runs from, so "no checkout is configured to use it" can only be checked for
 37that one.
 38"""
 39
 40from dataclasses import dataclass
 41from pathlib import Path, PurePath
 42from typing import TYPE_CHECKING
 43
 44from .identity import _run_git, resolve_database_name
 45
 46if TYPE_CHECKING:
 47    from plain.postgres.testing.leftovers import LeftoverVerdict
 48
 49    from .cluster import Cluster, DevDatabase
 50
 51
 52@dataclass(frozen=True)
 53class Checkout:
 54    """A checkout of the project: a git worktree's copy of the project root."""
 55
 56    path: str
 57    # The worktree it is in, as git lists it. Outside git, the same as `path`.
 58    worktree: str
 59    # Its directory is there. A worktree git lists can be missing: removed
 60    # by hand and not pruned, or on a volume that isn't mounted.
 61    exists: bool
 62    # The database it is configured to use. `None` when it is missing.
 63    database: str | None
 64
 65
 66@dataclass(frozen=True)
 67class OwnerPath:
 68    """What is on disk where a database's metadata says its checkout was."""
 69
 70    exists: bool
 71    parent_exists: bool
 72
 73
 74@dataclass(frozen=True)
 75class CleanFacts:
 76    """Everything `plan_clean` goes by, about one database."""
 77
 78    name: str
 79    size_bytes: int
 80    # The checkout its metadata names, and what is there now.
 81    recorded_checkout: str | None
 82    owner_path: OwnerPath | None
 83    connections: int
 84    is_test: bool
 85    # For a test database: whether its run is proved dead, and the reason.
 86    # Both from `plain.postgres.testing.leftovers.judge_leftover`. For a
 87    # template (a test database with `is_template` too): whether it is of
 88    # no use now, and why, from `judge_template`.
 89    run_is_dead: bool = False
 90    run_verdict: str = ""
 91    is_template: bool = False
 92    # For a template: whether the database it was built for is gone from
 93    # the server. Dropping it then needs the same fact.
 94    tested_database_is_gone: bool = False
 95
 96
 97@dataclass(frozen=True)
 98class CleanItem:
 99    name: str
100    size_bytes: int
101    owner: str
102    reason: str
103    is_test: bool
104    is_template: bool = False
105    tested_database_is_gone: bool = False
106
107
108@dataclass(frozen=True)
109class CleanPlan:
110    drop: tuple[CleanItem, ...]
111    leave: tuple[CleanItem, ...]
112
113
114def plan_clean(
115    databases: list[CleanFacts],
116    *,
117    project_main: str,
118    current: str,
119    checkouts: list[Checkout],
120    in_git: bool,
121) -> CleanPlan:
122    """Sort every database into what to drop and what to leave, with why."""
123    drop = []
124    leave = []
125    for facts in databases:
126        dropping, reason = _judge(
127            facts,
128            project_main=project_main,
129            current=current,
130            checkouts=checkouts,
131            in_git=in_git,
132        )
133        item = CleanItem(
134            name=facts.name,
135            size_bytes=facts.size_bytes,
136            owner=_owner(facts),
137            reason=reason,
138            is_test=facts.is_test,
139            is_template=facts.is_template,
140            tested_database_is_gone=facts.tested_database_is_gone,
141        )
142        (drop if dropping else leave).append(item)
143    return CleanPlan(drop=tuple(drop), leave=tuple(leave))
144
145
146def _owner(facts: CleanFacts) -> str:
147    if facts.is_template:
148        return "(test template)"
149    if facts.is_test:
150        return "(test database)"
151    return facts.recorded_checkout or "(no recorded owner)"
152
153
154def _judge(
155    facts: CleanFacts,
156    *,
157    project_main: str,
158    current: str,
159    checkouts: list[Checkout],
160    in_git: bool,
161) -> tuple[bool, str]:
162    if facts.name == project_main:
163        return False, "the project's main database, which every checkout forks from"
164    if facts.name == current:
165        return False, "this checkout's database"
166
167    for checkout in checkouts:
168        if checkout.database == facts.name:
169            return False, f"the checkout at {checkout.path} is configured to use it"
170
171    if facts.is_test:
172        return facts.run_is_dead, facts.run_verdict
173
174    if not facts.recorded_checkout or facts.owner_path is None:
175        return False, "no record of which checkout made it"
176    if facts.owner_path.exists:
177        return False, "its checkout is there"
178
179    if in_git:
180        holder = _worktree_holding(facts.recorded_checkout, checkouts=checkouts)
181        if holder is not None:
182            return False, (
183                f"git lists a worktree at {holder.worktree}, though its directory"
184                " is missing (a volume that isn't mounted, or a worktree"
185                " removed by hand: `git worktree prune` settles it)"
186            )
187    elif not facts.owner_path.parent_exists:
188        return False, (
189            "its checkout's directory is missing and so is the directory"
190            " that held it, so whether the checkout is gone can't be told"
191        )
192
193    if facts.connections:
194        count = facts.connections
195        open_to_it = "1 connection is" if count == 1 else f"{count} connections are"
196        return False, f"{open_to_it} open to it, though its checkout is gone"
197
198    return True, "its checkout is gone"
199
200
201def _worktree_holding(path: str, *, checkouts: list[Checkout]) -> Checkout | None:
202    """The checkout whose worktree git lists and `path` is in, if its
203    directory is missing."""
204    for checkout in checkouts:
205        if checkout.exists:
206            continue
207        if PurePath(path).is_relative_to(PurePath(checkout.worktree)):
208            return checkout
209    return None
210
211
212# ---------------------------------------------------------------------------
213# Reading the facts
214# ---------------------------------------------------------------------------
215
216
217def look_at_owner_path(recorded_checkout: str | None) -> OwnerPath | None:
218    if not recorded_checkout:
219        return None
220    path = Path(recorded_checkout)
221    return OwnerPath(exists=path.exists(), parent_exists=path.parent.exists())
222
223
224def project_checkouts(project_root: Path) -> tuple[list[Checkout], bool]:
225    """
226    The project's checkouts, and whether the project is in a git
227    repository.
228
229    In git, the repository's worktrees are the checkouts: for each one git
230    lists, the directory in it where this project's root is. Outside git,
231    the only checkout known is `project_root`.
232    """
233    toplevel = _run_git(["rev-parse", "--show-toplevel"], project_root)
234    listing = _run_git(["worktree", "list", "--porcelain"], project_root)
235    if toplevel is None or listing is None:
236        return [_checkout_at(project_root, worktree=project_root)], False
237
238    # Where the project's root is inside a worktree: `example`, or nowhere
239    # deeper than the worktree itself.
240    inside = project_root.resolve().relative_to(Path(toplevel).resolve())
241
242    checkouts = []
243    for line in listing.splitlines():
244        if line.startswith("worktree "):
245            worktree = Path(line.removeprefix("worktree "))
246            checkouts.append(_checkout_at(worktree / inside, worktree=worktree))
247    return checkouts, True
248
249
250def _checkout_at(root: Path, *, worktree: Path) -> Checkout:
251    # Resolved the way a checkout's path is when it is recorded, so the two
252    # compare. For a missing directory that settles what of it is there.
253    if not root.is_dir():
254        return Checkout(
255            path=str(root.resolve()),
256            worktree=str(worktree.resolve()),
257            exists=False,
258            database=None,
259        )
260    return Checkout(
261        path=str(root.resolve()),
262        worktree=str(worktree.resolve()),
263        exists=True,
264        database=resolve_database_name(root.resolve()),
265    )
266
267
268def read_clean_facts(
269    cluster: Cluster,
270    *,
271    project_name: str,
272    current: str,
273    current_schema: str | None,
274) -> list[CleanFacts]:
275    """The facts about every database of the project. Reads, and changes
276    nothing: a test database's run lock is asked for and given straight back.
277
278    `current_schema` is the digest of the schema this checkout's database,
279    `current`, builds its test databases with now, which is what tells its
280    template from a stale one. `None` when it couldn't be worked out, and
281    then the template is left.
282    """
283    from plain.postgres.testing.leftovers import (
284        judge_leftover,
285        judge_template,
286        look_at_leftover,
287        look_at_template,
288        maintenance_connection,
289    )
290
291    databases = cluster.list_databases(project_name)
292    names = {database.name for database in databases}
293    facts = []
294    with maintenance_connection(cluster.config) as maintenance:
295        for database in databases:
296            if database.template_record is not None:
297                template = database.template_record
298                gone = template.database not in names
299                facts.append(
300                    _test_database_facts(
301                        database,
302                        verdict=judge_template(
303                            look_at_template(
304                                maintenance,
305                                cluster.config,
306                                name=database.name,
307                                record=template,
308                            ),
309                            current_schema=current_schema
310                            if template.database == current
311                            else None,
312                            tested_database_is_gone=gone,
313                        ),
314                        is_template=True,
315                        tested_database_is_gone=gone,
316                    )
317                )
318            elif database.is_test:
319                facts.append(
320                    _test_database_facts(
321                        database,
322                        verdict=judge_leftover(
323                            look_at_leftover(
324                                maintenance,
325                                cluster.config,
326                                name=database.name,
327                                record=database.run_record,
328                            ),
329                            tested_database=_tested_database(database),
330                        ),
331                    )
332                )
333            else:
334                facts.append(
335                    CleanFacts(
336                        name=database.name,
337                        size_bytes=database.size_bytes,
338                        recorded_checkout=database.checkout,
339                        owner_path=look_at_owner_path(database.checkout),
340                        connections=cluster.connection_count(database.name),
341                        is_test=False,
342                    )
343                )
344    return facts
345
346
347def _tested_database(database: DevDatabase) -> str:
348    return database.run_record.database if database.run_record else ""
349
350
351def _test_database_facts(
352    database: DevDatabase,
353    *,
354    verdict: LeftoverVerdict,
355    is_template: bool = False,
356    tested_database_is_gone: bool = False,
357) -> CleanFacts:
358    reason = verdict.reason
359    if database.run_record is None and database.template_record is None:
360        reason += f" (if it is debris: plain db drop {database.name})"
361    return CleanFacts(
362        name=database.name,
363        size_bytes=database.size_bytes,
364        recorded_checkout=None,
365        owner_path=None,
366        connections=0,
367        is_test=True,
368        run_is_dead=verdict.drop,
369        run_verdict=reason,
370        is_template=is_template,
371        tested_database_is_gone=tested_database_is_gone,
372    )