mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-20 21:22:54 +00:00
refactor(databases): state the databases.csv contract once
The schema lived three times in this repo alone: the seed and the backup each spelled out the column list and the semicolon, and _validate_database_value existed twice under one name with different strictness - the seed checked the character set, the backup only checked for empty. A hand-edited file therefore bypassed the only real validation on its way into a dump command. baudolo.databases now holds the columns, the delimiter, the cluster marker, the validator and a read_rows() for consumers that do not want pandas. Values come back verbatim: a password may begin or end with a space, so stripping belongs to the caller that compares, never to the reader. DatabasesCsvError subclasses ValueError, so callers that predate the module keep catching what they caught. The backup's call sites switch over in the next commit, which rewrites them anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ import sys
|
||||
import pandas
|
||||
from pandas.errors import EmptyDataError
|
||||
|
||||
from baudolo.databases import COLUMNS, DELIMITER
|
||||
|
||||
from .db import backup_database
|
||||
from .docker import has_tool, image_id
|
||||
|
||||
@@ -81,7 +83,7 @@ def _empty_databases_df() -> pandas.DataFrame:
|
||||
This allows the backup to continue without DB dumps when the CSV is missing
|
||||
or empty (pandas EmptyDataError).
|
||||
"""
|
||||
return pandas.DataFrame(columns=["instance", "database", "username", "password"])
|
||||
return pandas.DataFrame(columns=list(COLUMNS))
|
||||
|
||||
|
||||
def load_databases_df(csv_path: str) -> pandas.DataFrame:
|
||||
@@ -93,7 +95,9 @@ def load_databases_df(csv_path: str) -> pandas.DataFrame:
|
||||
- Valid CSV -> return dataframe
|
||||
"""
|
||||
try:
|
||||
return pandas.read_csv(csv_path, sep=";", keep_default_na=False, dtype=str)
|
||||
return pandas.read_csv(
|
||||
csv_path, sep=DELIMITER, keep_default_na=False, dtype=str
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
f"WARNING: databases.csv not found: {csv_path}. Continuing without database dumps.",
|
||||
|
||||
102
src/baudolo/databases.py
Normal file
102
src/baudolo/databases.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""The databases.csv contract: its columns, its delimiter, and what a row means.
|
||||
|
||||
``baudolo-seed`` writes the file, the backup reads it to learn which dumps to
|
||||
take, and a restore consumer reads it again to replay them. Stating the schema
|
||||
once keeps a column or a convention added here from being invisible to the
|
||||
other two.
|
||||
|
||||
Field values are handed back exactly as they stand in the file. A password may
|
||||
legitimately begin or end with a space, so stripping belongs to the caller that
|
||||
compares, never to the reader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import re
|
||||
from typing import NamedTuple
|
||||
|
||||
COLUMNS = ("instance", "database", "username", "password")
|
||||
DELIMITER = ";"
|
||||
CLUSTER_ROW = "*"
|
||||
|
||||
_NAME_RE = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_-]*$")
|
||||
|
||||
|
||||
class DatabasesCsvError(ValueError):
|
||||
"""A row does not match the contract."""
|
||||
|
||||
|
||||
class Row(NamedTuple):
|
||||
"""One databases.csv row, verbatim.
|
||||
|
||||
``database`` holds :data:`CLUSTER_ROW` when the whole instance is dumped.
|
||||
"""
|
||||
|
||||
instance: str
|
||||
database: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@property
|
||||
def is_cluster(self) -> bool:
|
||||
return self.database.strip() == CLUSTER_ROW
|
||||
|
||||
|
||||
def validate_database(value: str | None, *, instance: str) -> str:
|
||||
"""The database column of one row, or raise.
|
||||
|
||||
The name reaches a shell as part of the dump command, so it is checked
|
||||
where it is read as well as where it is written: a file edited by hand
|
||||
never passed the seed.
|
||||
|
||||
Args:
|
||||
value: the raw column.
|
||||
instance: named in the error, so a bad row can be found.
|
||||
|
||||
Raises:
|
||||
DatabasesCsvError: the column is empty, literally ``nan``, or holds
|
||||
anything but letters, numbers, ``_`` and ``-``.
|
||||
"""
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
raise DatabasesCsvError(
|
||||
f"Invalid databases.csv entry for instance '{instance}': column "
|
||||
f"'database' must be '{CLUSTER_ROW}' or a concrete database name "
|
||||
"(not empty)."
|
||||
)
|
||||
if text == CLUSTER_ROW:
|
||||
return CLUSTER_ROW
|
||||
if text.lower() == "nan":
|
||||
raise DatabasesCsvError(
|
||||
f"Invalid databases.csv entry for instance '{instance}': "
|
||||
"database must not be 'nan'."
|
||||
)
|
||||
if not _NAME_RE.match(text):
|
||||
raise DatabasesCsvError(
|
||||
f"Invalid databases.csv entry for instance '{instance}': invalid "
|
||||
f"database name '{text}'. Allowed: letters, numbers, '_' and '-'."
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def read_rows(csv_path: str) -> list[Row]:
|
||||
"""Every row of the file in file order, header skipped, blank rows dropped.
|
||||
|
||||
Raises:
|
||||
DatabasesCsvError: a row holds fewer columns than :data:`COLUMNS`.
|
||||
"""
|
||||
rows: list[Row] = []
|
||||
with open(csv_path, newline="", encoding="utf-8") as handle:
|
||||
reader = csv.reader(handle, delimiter=DELIMITER)
|
||||
next(reader, None)
|
||||
for raw in reader:
|
||||
if not any(field.strip() for field in raw):
|
||||
continue
|
||||
if len(raw) < len(COLUMNS):
|
||||
raise DatabasesCsvError(
|
||||
f"{csv_path} has a row with {len(raw)} column(s), "
|
||||
f"expected {len(COLUMNS)}"
|
||||
)
|
||||
rows.append(Row(*raw[: len(COLUMNS)]))
|
||||
return rows
|
||||
@@ -2,38 +2,16 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pandas as pd
|
||||
from pandas.errors import EmptyDataError
|
||||
|
||||
DB_NAME_RE = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_-]*$")
|
||||
|
||||
|
||||
def _validate_database_value(value: str | None, *, instance: str) -> str:
|
||||
v = (value or "").strip()
|
||||
if v == "":
|
||||
raise ValueError(
|
||||
f"Invalid databases.csv entry for instance '{instance}': "
|
||||
"column 'database' must be '*' or a concrete database name (not empty)."
|
||||
)
|
||||
if v == "*":
|
||||
return "*"
|
||||
if v.lower() == "nan":
|
||||
raise ValueError(
|
||||
f"Invalid databases.csv entry for instance '{instance}': database must not be 'nan'."
|
||||
)
|
||||
if not DB_NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
f"Invalid databases.csv entry for instance '{instance}': "
|
||||
f"invalid database name '{v}'. Allowed: letters, numbers, '_' and '-'."
|
||||
)
|
||||
return v
|
||||
from baudolo.databases import COLUMNS, DELIMITER, validate_database
|
||||
|
||||
|
||||
def _empty_df() -> pd.DataFrame:
|
||||
return pd.DataFrame(columns=["instance", "database", "username", "password"])
|
||||
return pd.DataFrame(columns=list(COLUMNS))
|
||||
|
||||
|
||||
def check_and_add_entry(
|
||||
@@ -50,13 +28,13 @@ def check_and_add_entry(
|
||||
- database MUST be set
|
||||
- database MUST be '*' or a valid database name
|
||||
"""
|
||||
database = _validate_database_value(database, instance=instance)
|
||||
database = validate_database(database, instance=instance)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
df = pd.read_csv(
|
||||
file_path,
|
||||
sep=";",
|
||||
sep=DELIMITER,
|
||||
dtype=str,
|
||||
keep_default_na=False,
|
||||
)
|
||||
@@ -77,11 +55,11 @@ def check_and_add_entry(
|
||||
print("Adding new entry.")
|
||||
new_entry = pd.DataFrame(
|
||||
[[instance, database, username, password]],
|
||||
columns=["instance", "database", "username", "password"],
|
||||
columns=list(COLUMNS),
|
||||
)
|
||||
df = pd.concat([df, new_entry], ignore_index=True)
|
||||
|
||||
df.to_csv(file_path, sep=";", index=False)
|
||||
df.to_csv(file_path, sep=DELIMITER, index=False)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
Reference in New Issue
Block a user