feat(backup): stricter databases.csv semantics + atomic SQL dumps
- read databases.csv with stable types (dtype=str, keep_default_na=False) - validate database field: require '*' or concrete name (no empty/NaN) - support Postgres cluster dumps via '*' entries (pg_dumpall) - write SQL dumps atomically to avoid partial/empty files - early-skip fully ignored volumes before creating backup directories - update seed CLI to enforce new contract and update by (instance,database) - adjust tests: sql dir naming + add E2E coverage for early-skip and '*' seeding
This commit is contained in:
@@ -1,67 +1,106 @@
|
||||
import pandas as pd
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import pandas as pd
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def check_and_add_entry(file_path, instance, database, username, password):
|
||||
# Check if the file exists and is not empty
|
||||
if os.path.exists(file_path) and os.path.getsize(file_path) > 0:
|
||||
# Read the existing CSV file with header
|
||||
df = pd.read_csv(file_path, sep=";")
|
||||
else:
|
||||
# Create a new DataFrame with columns if file does not exist
|
||||
df = pd.DataFrame(columns=["instance", "database", "username", "password"])
|
||||
DB_NAME_RE = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_-]*$")
|
||||
|
||||
# Check if the entry exists and remove it
|
||||
mask = (
|
||||
(df["instance"] == instance)
|
||||
& (
|
||||
(df["database"] == database)
|
||||
| (((df["database"].isna()) | (df["database"] == "")) & (database == ""))
|
||||
def _validate_database_value(value: Optional[str], *, 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)."
|
||||
)
|
||||
& (df["username"] == username)
|
||||
)
|
||||
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
|
||||
|
||||
if not df[mask].empty:
|
||||
print("Replacing existing entry.")
|
||||
df = df[~mask]
|
||||
def check_and_add_entry(
|
||||
file_path: str,
|
||||
instance: str,
|
||||
database: Optional[str],
|
||||
username: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Add or update an entry in databases.csv.
|
||||
|
||||
The function enforces strict validation:
|
||||
- database MUST be set
|
||||
- database MUST be '*' or a valid database name
|
||||
"""
|
||||
database = _validate_database_value(database, instance=instance)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
df = pd.read_csv(
|
||||
file_path,
|
||||
sep=";",
|
||||
dtype=str,
|
||||
keep_default_na=False,
|
||||
)
|
||||
else:
|
||||
df = pd.DataFrame(
|
||||
columns=["instance", "database", "username", "password"]
|
||||
)
|
||||
|
||||
mask = (df["instance"] == instance) & (df["database"] == database)
|
||||
|
||||
if mask.any():
|
||||
print("Updating existing entry.")
|
||||
df.loc[mask, ["username", "password"]] = [username, password]
|
||||
else:
|
||||
print("Adding new entry.")
|
||||
new_entry = pd.DataFrame(
|
||||
[[instance, database, username, password]],
|
||||
columns=["instance", "database", "username", "password"],
|
||||
)
|
||||
df = pd.concat([df, new_entry], ignore_index=True)
|
||||
|
||||
# Create a new DataFrame for the new entry
|
||||
new_entry = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"instance": instance,
|
||||
"database": database,
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
# Add (or replace) the entry using concat
|
||||
df = pd.concat([df, new_entry], ignore_index=True)
|
||||
|
||||
# Save the updated CSV file
|
||||
df.to_csv(file_path, sep=";", index=False)
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check and replace (or add) a database entry in a CSV file."
|
||||
description="Seed or update databases.csv for backup configuration."
|
||||
)
|
||||
parser.add_argument("file_path", help="Path to the CSV file")
|
||||
parser.add_argument("instance", help="Database instance")
|
||||
parser.add_argument("database", help="Database name")
|
||||
parser.add_argument("username", help="Username")
|
||||
parser.add_argument("password", nargs="?", default="", help="Password (optional)")
|
||||
parser.add_argument("file", help="Path to databases.csv")
|
||||
parser.add_argument("instance", help="Instance name (e.g. bigbluebutton)")
|
||||
parser.add_argument(
|
||||
"database",
|
||||
help="Database name or '*' to dump all databases",
|
||||
)
|
||||
parser.add_argument("username", help="Database username")
|
||||
parser.add_argument("password", help="Database password")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
check_and_add_entry(
|
||||
args.file_path, args.instance, args.database, args.username, args.password
|
||||
)
|
||||
try:
|
||||
check_and_add_entry(
|
||||
file_path=args.file,
|
||||
instance=args.instance,
|
||||
database=args.database,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user