Files
docker-volume-backup/src/baudolo/restore/__main__.py
T
kevinveenbirkenbachandClaude Opus 5.5 328520169b fix(security): keep database passwords out of every argv
MariaDB dumps and restores read the password from MYSQL_PWD, and docker exec forwards env variables by name only, so no process listing shows a password. baudolo-restore gains --db-password-stdin; --db-password still works but warns.

🎯 Fix: 96 %
💥 Break: 3 %
💸 Cost: –
⏱️ Time: –
🛡️ Security: ~100 %
🏆 Score: 93
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:37:04 +00:00

210 lines
6.8 KiB
Python

from __future__ import annotations
import argparse
import sys
from .db.cluster import restore_cluster_sql
from .db.mariadb import restore_mariadb_sql
from .db.postgres import restore_postgres_sql
from .files import restore_volume_files
from .paths import BackupPaths
def _password(parser: argparse.ArgumentParser, args: argparse.Namespace) -> str:
"""
Args:
parser: reports a password given both ways or not at all.
args: the parsed engine subcommand.
Returns:
The database password, from stdin or from the deprecated argument.
"""
if args.db_password_stdin == (args.db_password is not None):
parser.error(
"give the password either via --db-password-stdin or --db-password"
)
if args.db_password_stdin:
return sys.stdin.read().removesuffix("\n")
print(
"WARNING: --db-password is visible in the process list; "
"use --db-password-stdin",
file=sys.stderr,
)
return args.db_password
def _add_common_backup_args(p: argparse.ArgumentParser) -> None:
p.add_argument("volume_name", help="Docker volume name (target volume)")
p.add_argument("backup_hash", help="Hashed machine id")
p.add_argument("version", help="Backup version directory name")
p.add_argument(
"--backups-dir",
default="/Backups",
help="Backup root directory (default: /Backups)",
)
p.add_argument(
"--repo-name",
required=True,
help="Backup repo folder name under <backups-dir>/<hash>/",
)
def _add_common_engine_args(p: argparse.ArgumentParser) -> None:
p.add_argument("--container", required=True)
p.add_argument(
"--db-password",
default=None,
help="Deprecated: visible in the process list; use --db-password-stdin",
)
p.add_argument(
"--db-password-stdin",
action="store_true",
help="Read the database password from stdin",
)
p.add_argument("--empty", action="store_true")
p.add_argument(
"--no-version-check",
action="store_true",
help=(
"Replay even if the dump comes from a newer engine than the target. "
"With --empty this can leave an emptied database behind."
),
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="baudolo-restore",
description="Restore docker volume files and DB dumps.",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_files = sub.add_parser("files", help="Restore files into a docker volume")
_add_common_backup_args(p_files)
p_files.add_argument(
"--source-volume",
default=None,
help=(
"Volume name used as backup source path key. "
"Defaults to <volume_name> (target volume). "
"Use this when restoring from one volume backup into a different target volume."
),
)
p_pg = sub.add_parser("postgres", help="Restore a single PostgreSQL database dump")
_add_common_backup_args(p_pg)
_add_common_engine_args(p_pg)
p_pg.add_argument("--db-name", required=True)
p_pg.add_argument("--db-user", default=None, help="Defaults to db-name if omitted")
p_cluster = sub.add_parser(
"cluster", help="Restore a full PostgreSQL cluster dump (pg_dumpall)"
)
_add_common_backup_args(p_cluster)
_add_common_engine_args(p_cluster)
p_cluster.add_argument(
"--instance",
required=True,
help="Instance the dump was taken from; names <instance>.cluster.backup.sql",
)
p_cluster.add_argument(
"--db-user",
required=True,
help="Superuser of the instance; the dump creates roles and databases",
)
p_mdb = sub.add_parser(
"mariadb", help="Restore a single MariaDB/MySQL-compatible dump"
)
_add_common_backup_args(p_mdb)
_add_common_engine_args(p_mdb)
p_mdb.add_argument("--db-name", required=True)
p_mdb.add_argument("--db-user", default=None, help="Defaults to db-name if omitted")
args = parser.parse_args(argv)
password = None if args.cmd == "files" else _password(parser, args)
try:
if args.cmd == "files":
source_volume = args.source_volume or args.volume_name
bp_files = BackupPaths(
source_volume,
args.backup_hash,
args.version,
repo_name=args.repo_name,
backups_dir=args.backups_dir,
)
return restore_volume_files(
args.volume_name,
bp_files.files_dir(),
)
if args.cmd == "postgres":
user = args.db_user or args.db_name
restore_postgres_sql(
container=args.container,
db_name=args.db_name,
user=user,
password=password,
sql_path=BackupPaths(
args.volume_name,
args.backup_hash,
args.version,
repo_name=args.repo_name,
backups_dir=args.backups_dir,
).sql_file(args.db_name),
empty=args.empty,
check_version=not args.no_version_check,
)
return 0
if args.cmd == "cluster":
restore_cluster_sql(
container=args.container,
user=args.db_user,
password=password,
sql_path=BackupPaths(
args.volume_name,
args.backup_hash,
args.version,
repo_name=args.repo_name,
backups_dir=args.backups_dir,
).cluster_file(args.instance),
empty=args.empty,
check_version=not args.no_version_check,
)
return 0
if args.cmd == "mariadb":
user = args.db_user or args.db_name
restore_mariadb_sql(
container=args.container,
db_name=args.db_name,
user=user,
password=password,
sql_path=BackupPaths(
args.volume_name,
args.backup_hash,
args.version,
repo_name=args.repo_name,
backups_dir=args.backups_dir,
).sql_file(args.db_name),
empty=args.empty,
check_version=not args.no_version_check,
)
return 0
parser.error("Unhandled command")
return 2 # noqa: TRY300 - the try wraps the whole dispatch on purpose
except Exception as e: # noqa: BLE001 - CLI boundary: any failure becomes exit 1
print(f"ERROR: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())