backup-docker-to-local 1.0.0__tar.gz → 1.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {backup_docker_to_local-1.0.0/src/backup_docker_to_local.egg-info → backup_docker_to_local-1.1.1}/PKG-INFO +1 -1
  2. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/pyproject.toml +1 -1
  3. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1/src/backup_docker_to_local.egg-info}/PKG-INFO +1 -1
  4. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/backup/app.py +35 -19
  5. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/backup/cli.py +3 -19
  6. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/backup/compose.py +3 -1
  7. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/backup/db.py +26 -9
  8. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/backup/docker.py +3 -1
  9. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/backup/volume.py +6 -2
  10. backup_docker_to_local-1.1.1/src/baudolo/restore/__init__.py +1 -0
  11. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/restore/__main__.py +3 -1
  12. backup_docker_to_local-1.1.1/src/baudolo/restore/db/__init__.py +1 -0
  13. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/restore/db/mariadb.py +22 -4
  14. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/restore/files.py +3 -1
  15. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/seed/__main__.py +29 -11
  16. backup_docker_to_local-1.0.0/src/baudolo/restore/__init__.py +0 -1
  17. backup_docker_to_local-1.0.0/src/baudolo/restore/db/__init__.py +0 -1
  18. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/LICENSE +0 -0
  19. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/README.md +0 -0
  20. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/setup.cfg +0 -0
  21. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/backup_docker_to_local.egg-info/SOURCES.txt +0 -0
  22. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/backup_docker_to_local.egg-info/dependency_links.txt +0 -0
  23. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/backup_docker_to_local.egg-info/entry_points.txt +0 -0
  24. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/backup_docker_to_local.egg-info/requires.txt +0 -0
  25. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/backup_docker_to_local.egg-info/top_level.txt +0 -0
  26. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/__init__.py +0 -0
  27. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/backup/__init__.py +0 -0
  28. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/backup/__main__.py +0 -0
  29. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/backup/shell.py +0 -0
  30. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/restore/db/postgres.py +0 -0
  31. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/restore/paths.py +0 -0
  32. {backup_docker_to_local-1.0.0 → backup_docker_to_local-1.1.1}/src/baudolo/restore/run.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backup-docker-to-local
3
- Version: 1.0.0
3
+ Version: 1.1.1
4
4
  Summary: Backup Docker volumes to local with rsync and optional DB dumps.
5
5
  Author: Kevin Veen-Birkenbach
6
6
  License: AGPL-3.0-or-later
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "backup-docker-to-local"
7
- version = "1.0.0"
7
+ version = "1.1.1"
8
8
  description = "Backup Docker volumes to local with rsync and optional DB dumps."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backup-docker-to-local
3
- Version: 1.0.0
3
+ Version: 1.1.1
4
4
  Summary: Backup Docker volumes to local with rsync and optional DB dumps.
5
5
  Author: Kevin Veen-Birkenbach
6
6
  License: AGPL-3.0-or-later
@@ -51,7 +51,9 @@ def is_image_ignored(container: str, images_no_backup_required: list[str]) -> bo
51
51
  return any(pat in img for pat in images_no_backup_required)
52
52
 
53
53
 
54
- def volume_is_fully_ignored(containers: list[str], images_no_backup_required: list[str]) -> bool:
54
+ def volume_is_fully_ignored(
55
+ containers: list[str], images_no_backup_required: list[str]
56
+ ) -> bool:
55
57
  """
56
58
  Skip file backup only if all containers linked to the volume are ignored.
57
59
  """
@@ -70,28 +72,27 @@ def requires_stop(containers: list[str], images_no_stop_required: list[str]) ->
70
72
  return True
71
73
  return False
72
74
 
73
-
74
75
  def backup_mariadb_or_postgres(
75
76
  *,
76
77
  container: str,
77
78
  volume_dir: str,
78
79
  databases_df: "pandas.DataFrame",
79
80
  database_containers: list[str],
80
- ) -> bool:
81
+ ) -> tuple[bool, bool]:
81
82
  """
82
- Returns True if the container is a DB container we handled.
83
+ Returns (is_db_container, dumped_any)
83
84
  """
84
85
  for img in ["mariadb", "postgres"]:
85
86
  if has_image(container, img):
86
- backup_database(
87
+ dumped = backup_database(
87
88
  container=container,
88
89
  volume_dir=volume_dir,
89
90
  db_type=img,
90
91
  databases_df=databases_df,
91
92
  database_containers=database_containers,
92
93
  )
93
- return True
94
- return False
94
+ return True, dumped
95
+ return False, False
95
96
 
96
97
 
97
98
  def _backup_dumps_for_volume(
@@ -100,21 +101,26 @@ def _backup_dumps_for_volume(
100
101
  vol_dir: str,
101
102
  databases_df: "pandas.DataFrame",
102
103
  database_containers: list[str],
103
- ) -> bool:
104
+ ) -> tuple[bool, bool]:
104
105
  """
105
- Create DB dumps for any mariadb/postgres containers attached to this volume.
106
- Returns True if at least one dump was produced.
106
+ Returns (found_db_container, dumped_any)
107
107
  """
108
+ found_db = False
108
109
  dumped_any = False
110
+
109
111
  for c in containers:
110
- if backup_mariadb_or_postgres(
112
+ is_db, dumped = backup_mariadb_or_postgres(
111
113
  container=c,
112
114
  volume_dir=vol_dir,
113
115
  databases_df=databases_df,
114
116
  database_containers=database_containers,
115
- ):
117
+ )
118
+ if is_db:
119
+ found_db = True
120
+ if dumped:
116
121
  dumped_any = True
117
- return dumped_any
122
+
123
+ return found_db, dumped_any
118
124
 
119
125
 
120
126
  def main() -> int:
@@ -135,18 +141,26 @@ def main() -> int:
135
141
  containers = containers_using_volume(volume_name)
136
142
 
137
143
  vol_dir = create_volume_directory(version_dir, volume_name)
138
-
139
- # Old behavior: DB dumps are additional to file backups.
140
- _backup_dumps_for_volume(
144
+
145
+ found_db, dumped_any = _backup_dumps_for_volume(
141
146
  containers=containers,
142
147
  vol_dir=vol_dir,
143
148
  databases_df=databases_df,
144
149
  database_containers=args.database_containers,
145
150
  )
146
151
 
147
- # dump-only: skip ALL file rsync backups
152
+ # dump-only logic:
148
153
  if args.dump_only:
149
- continue
154
+ if found_db and not dumped_any:
155
+ print(
156
+ f"WARNING: dump-only requested but no DB dump was produced for DB volume '{volume_name}'. Falling back to file backup.",
157
+ flush=True,
158
+ )
159
+ # continue to file backup below
160
+ else:
161
+ # keep old behavior: skip file backups
162
+ continue
163
+
150
164
 
151
165
  # skip file backup if all linked containers are ignored
152
166
  if volume_is_fully_ignored(containers, args.images_no_backup_required):
@@ -178,6 +192,8 @@ def main() -> int:
178
192
  print("Finished volume backups.", flush=True)
179
193
 
180
194
  print("Handling Docker Compose services...", flush=True)
181
- handle_docker_compose_services(args.compose_dir, args.docker_compose_hard_restart_required)
195
+ handle_docker_compose_services(
196
+ args.compose_dir, args.docker_compose_hard_restart_required
197
+ )
182
198
 
183
199
  return 0
@@ -2,22 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import os
5
- from pathlib import Path
6
-
7
-
8
- def _default_repo_name() -> str:
9
- """
10
- Derive the repository name from the folder that contains `src/`.
11
-
12
- Expected layout:
13
- <repo-root>/src/baudolo/backup/cli.py
14
-
15
- => parents[0]=backup, [1]=baudolo, [2]=src, [3]=repo-root
16
- """
17
- try:
18
- return Path(__file__).resolve().parents[3].name
19
- except Exception:
20
- return "backup-docker-to-local"
21
5
 
22
6
 
23
7
  def parse_args() -> argparse.Namespace:
@@ -41,7 +25,7 @@ def parse_args() -> argparse.Namespace:
41
25
 
42
26
  p.add_argument(
43
27
  "--repo-name",
44
- default=_default_repo_name(),
28
+ default="backup-docker-to-local",
45
29
  help="Backup repo folder name under <backups-dir>/<machine-id>/ (default: git repo folder name)",
46
30
  )
47
31
  p.add_argument(
@@ -51,8 +35,8 @@ def parse_args() -> argparse.Namespace:
51
35
  )
52
36
  p.add_argument(
53
37
  "--backups-dir",
54
- default="/Backups",
55
- help="Backup root directory (default: /Backups)",
38
+ default="/var/lib/backup/",
39
+ help="Backup root directory (default: /var/lib/backup/)",
56
40
  )
57
41
 
58
42
  p.add_argument(
@@ -10,7 +10,9 @@ def hard_restart_docker_services(dir_path: str) -> None:
10
10
  subprocess.run(["docker-compose", "up", "-d"], cwd=dir_path, check=True)
11
11
 
12
12
 
13
- def handle_docker_compose_services(parent_directory: str, hard_restart_required: list[str]) -> None:
13
+ def handle_docker_compose_services(
14
+ parent_directory: str, hard_restart_required: list[str]
15
+ ) -> None:
14
16
  for entry in os.scandir(parent_directory):
15
17
  if not entry.is_dir():
16
18
  continue
@@ -3,11 +3,13 @@ from __future__ import annotations
3
3
  import os
4
4
  import pathlib
5
5
  import re
6
-
6
+ import logging
7
7
  import pandas
8
8
 
9
9
  from .shell import BackupException, execute_shell_command
10
10
 
11
+ log = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def get_instance(container: str, database_containers: list[str]) -> str:
13
15
  if container in database_containers:
@@ -30,19 +32,25 @@ def backup_database(
30
32
  db_type: str,
31
33
  databases_df: "pandas.DataFrame",
32
34
  database_containers: list[str],
33
- ) -> None:
35
+ ) -> bool:
36
+ """
37
+ Returns True if at least one dump file was produced, else False.
38
+ """
34
39
  instance_name = get_instance(container, database_containers)
35
40
  entries = databases_df.loc[databases_df["instance"] == instance_name]
36
41
  if entries.empty:
37
- raise BackupException(f"No entry found for instance '{instance_name}'")
42
+ log.warning("No entry found for instance '%s' (skipping DB dump)", instance_name)
43
+ return False
38
44
 
39
45
  out_dir = os.path.join(volume_dir, "sql")
40
46
  pathlib.Path(out_dir).mkdir(parents=True, exist_ok=True)
41
47
 
42
- for row in entries.iloc:
43
- db_name = row["database"]
44
- user = row["username"]
45
- password = row["password"]
48
+ produced = False
49
+
50
+ for row in entries.itertuples(index=False):
51
+ db_name = row.database
52
+ user = row.username
53
+ password = row.password
46
54
 
47
55
  dump_file = os.path.join(out_dir, f"{db_name}.backup.sql")
48
56
 
@@ -52,13 +60,15 @@ def backup_database(
52
60
  f"-u {user} -p{password} {db_name} > {dump_file}"
53
61
  )
54
62
  execute_shell_command(cmd)
63
+ produced = True
55
64
  continue
56
65
 
57
66
  if db_type == "postgres":
58
67
  cluster_file = os.path.join(out_dir, f"{instance_name}.cluster.backup.sql")
68
+
59
69
  if not db_name:
60
70
  fallback_pg_dumpall(container, user, password, cluster_file)
61
- return
71
+ return True
62
72
 
63
73
  try:
64
74
  cmd = (
@@ -66,8 +76,15 @@ def backup_database(
66
76
  f"pg_dump -U {user} -d {db_name} -h localhost > {dump_file}"
67
77
  )
68
78
  execute_shell_command(cmd)
79
+ produced = True
69
80
  except BackupException as e:
70
81
  print(f"pg_dump failed: {e}", flush=True)
71
- print(f"Falling back to pg_dumpall for instance '{instance_name}'", flush=True)
82
+ print(
83
+ f"Falling back to pg_dumpall for instance '{instance_name}'",
84
+ flush=True,
85
+ )
72
86
  fallback_pg_dumpall(container, user, password, cluster_file)
87
+ produced = True
73
88
  continue
89
+
90
+ return produced
@@ -37,7 +37,9 @@ def change_containers_status(containers: list[str], status: str) -> None:
37
37
  def docker_volume_exists(volume: str) -> bool:
38
38
  # Avoid throwing exceptions for exists checks.
39
39
  try:
40
- execute_shell_command(f"docker volume inspect {volume} >/dev/null 2>&1 && echo OK")
40
+ execute_shell_command(
41
+ f"docker volume inspect {volume} >/dev/null 2>&1 && echo OK"
42
+ )
41
43
  return True
42
44
  except Exception:
43
45
  return False
@@ -13,7 +13,9 @@ def get_storage_path(volume_name: str) -> str:
13
13
  return f"{path}/"
14
14
 
15
15
 
16
- def get_last_backup_dir(versions_dir: str, volume_name: str, current_backup_dir: str) -> str | None:
16
+ def get_last_backup_dir(
17
+ versions_dir: str, volume_name: str, current_backup_dir: str
18
+ ) -> str | None:
17
19
  versions = sorted(os.listdir(versions_dir), reverse=True)
18
20
  for version in versions:
19
21
  candidate = os.path.join(versions_dir, version, volume_name, "files", "")
@@ -37,6 +39,8 @@ def backup_volume(versions_dir: str, volume_name: str, volume_dir: str) -> None:
37
39
  execute_shell_command(cmd)
38
40
  except BackupException as e:
39
41
  if "file has vanished" in str(e):
40
- print("Warning: Some files vanished before transfer. Continuing.", flush=True)
42
+ print(
43
+ "Warning: Some files vanished before transfer. Continuing.", flush=True
44
+ )
41
45
  else:
42
46
  raise
@@ -0,0 +1 @@
1
+ __all__ = ["main"]
@@ -66,7 +66,9 @@ def main(argv: list[str] | None = None) -> int:
66
66
  # ------------------------------------------------------------------
67
67
  # mariadb
68
68
  # ------------------------------------------------------------------
69
- p_mdb = sub.add_parser("mariadb", help="Restore a single MariaDB/MySQL-compatible dump")
69
+ p_mdb = sub.add_parser(
70
+ "mariadb", help="Restore a single MariaDB/MySQL-compatible dump"
71
+ )
70
72
  _add_common_backup_args(p_mdb)
71
73
  p_mdb.add_argument("--container", required=True)
72
74
  p_mdb.add_argument("--db-name", required=True)
@@ -0,0 +1 @@
1
+ """Database restore handlers (Postgres, MariaDB/MySQL)."""
@@ -23,7 +23,9 @@ exit 42
23
23
  raise RuntimeError("empty client detection output")
24
24
  return out
25
25
  except Exception as e:
26
- print("ERROR: neither 'mariadb' nor 'mysql' found in container.", file=sys.stderr)
26
+ print(
27
+ "ERROR: neither 'mariadb' nor 'mysql' found in container.", file=sys.stderr
28
+ )
27
29
  raise e
28
30
 
29
31
 
@@ -47,7 +49,14 @@ def restore_mariadb_sql(
47
49
  # MariaDB 11 images may not contain the mysql binary at all.
48
50
  docker_exec(
49
51
  container,
50
- [client, "-u", user, f"--password={password}", "-e", "SET FOREIGN_KEY_CHECKS=0;"],
52
+ [
53
+ client,
54
+ "-u",
55
+ user,
56
+ f"--password={password}",
57
+ "-e",
58
+ "SET FOREIGN_KEY_CHECKS=0;",
59
+ ],
51
60
  )
52
61
 
53
62
  result = docker_exec(
@@ -80,10 +89,19 @@ def restore_mariadb_sql(
80
89
 
81
90
  docker_exec(
82
91
  container,
83
- [client, "-u", user, f"--password={password}", "-e", "SET FOREIGN_KEY_CHECKS=1;"],
92
+ [
93
+ client,
94
+ "-u",
95
+ user,
96
+ f"--password={password}",
97
+ "-e",
98
+ "SET FOREIGN_KEY_CHECKS=1;",
99
+ ],
84
100
  )
85
101
 
86
102
  with open(sql_path, "rb") as f:
87
- docker_exec(container, [client, "-u", user, f"--password={password}", db_name], stdin=f)
103
+ docker_exec(
104
+ container, [client, "-u", user, f"--password={password}", db_name], stdin=f
105
+ )
88
106
 
89
107
  print(f"MariaDB/MySQL restore complete for db '{db_name}'.")
@@ -6,7 +6,9 @@ import sys
6
6
  from .run import run, docker_volume_exists
7
7
 
8
8
 
9
- def restore_volume_files(volume_name: str, backup_files_dir: str, *, rsync_image: str) -> int:
9
+ def restore_volume_files(
10
+ volume_name: str, backup_files_dir: str, *, rsync_image: str
11
+ ) -> int:
10
12
  if not os.path.isdir(backup_files_dir):
11
13
  print(f"ERROR: backup files dir not found: {backup_files_dir}", file=sys.stderr)
12
14
  return 2
@@ -2,21 +2,24 @@ import pandas as pd
2
2
  import argparse
3
3
  import os
4
4
 
5
+
5
6
  def check_and_add_entry(file_path, instance, database, username, password):
6
7
  # Check if the file exists and is not empty
7
8
  if os.path.exists(file_path) and os.path.getsize(file_path) > 0:
8
9
  # Read the existing CSV file with header
9
- df = pd.read_csv(file_path, sep=';')
10
+ df = pd.read_csv(file_path, sep=";")
10
11
  else:
11
12
  # Create a new DataFrame with columns if file does not exist
12
- df = pd.DataFrame(columns=['instance', 'database', 'username', 'password'])
13
+ df = pd.DataFrame(columns=["instance", "database", "username", "password"])
13
14
 
14
15
  # Check if the entry exists and remove it
15
16
  mask = (
16
- (df['instance'] == instance) &
17
- ((df['database'] == database) |
18
- (((df['database'].isna()) | (df['database'] == '')) & (database == ''))) &
19
- (df['username'] == username)
17
+ (df["instance"] == instance)
18
+ & (
19
+ (df["database"] == database)
20
+ | (((df["database"].isna()) | (df["database"] == "")) & (database == ""))
21
+ )
22
+ & (df["username"] == username)
20
23
  )
21
24
 
22
25
  if not df[mask].empty:
@@ -26,25 +29,40 @@ def check_and_add_entry(file_path, instance, database, username, password):
26
29
  print("Adding new entry.")
27
30
 
28
31
  # Create a new DataFrame for the new entry
29
- new_entry = pd.DataFrame([{'instance': instance, 'database': database, 'username': username, 'password': password}])
32
+ new_entry = pd.DataFrame(
33
+ [
34
+ {
35
+ "instance": instance,
36
+ "database": database,
37
+ "username": username,
38
+ "password": password,
39
+ }
40
+ ]
41
+ )
30
42
 
31
43
  # Add (or replace) the entry using concat
32
44
  df = pd.concat([df, new_entry], ignore_index=True)
33
45
 
34
46
  # Save the updated CSV file
35
- df.to_csv(file_path, sep=';', index=False)
47
+ df.to_csv(file_path, sep=";", index=False)
48
+
36
49
 
37
50
  def main():
38
- parser = argparse.ArgumentParser(description="Check and replace (or add) a database entry in a CSV file.")
51
+ parser = argparse.ArgumentParser(
52
+ description="Check and replace (or add) a database entry in a CSV file."
53
+ )
39
54
  parser.add_argument("file_path", help="Path to the CSV file")
40
55
  parser.add_argument("instance", help="Database instance")
41
56
  parser.add_argument("database", help="Database name")
42
57
  parser.add_argument("username", help="Username")
43
- parser.add_argument("password", nargs='?', default="", help="Password (optional)")
58
+ parser.add_argument("password", nargs="?", default="", help="Password (optional)")
44
59
 
45
60
  args = parser.parse_args()
46
61
 
47
- check_and_add_entry(args.file_path, args.instance, args.database, args.username, args.password)
62
+ check_and_add_entry(
63
+ args.file_path, args.instance, args.database, args.username, args.password
64
+ )
65
+
48
66
 
49
67
  if __name__ == "__main__":
50
68
  main()
@@ -1 +0,0 @@
1
- __all__ = ["main"]
@@ -1 +0,0 @@
1
- """Database restore handlers (Postgres, MariaDB/MySQL)."""