cli-backup 0.1.0__tar.gz → 0.2.0__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 (33) hide show
  1. {cli_backup-0.1.0/src/cli_backup.egg-info → cli_backup-0.2.0}/PKG-INFO +12 -4
  2. {cli_backup-0.1.0 → cli_backup-0.2.0}/README.md +10 -2
  3. {cli_backup-0.1.0 → cli_backup-0.2.0}/pyproject.toml +2 -2
  4. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/scheduled.py +77 -13
  5. {cli_backup-0.1.0 → cli_backup-0.2.0/src/cli_backup.egg-info}/PKG-INFO +12 -4
  6. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup.egg-info/SOURCES.txt +2 -0
  7. cli_backup-0.2.0/tests/test_cross_project_restore.py +57 -0
  8. cli_backup-0.2.0/tests/test_postgresql.py +51 -0
  9. {cli_backup-0.1.0 → cli_backup-0.2.0}/LICENSE +0 -0
  10. {cli_backup-0.1.0 → cli_backup-0.2.0}/setup.cfg +0 -0
  11. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/__init__.py +0 -0
  12. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/cli.py +0 -0
  13. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/config.py +0 -0
  14. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/cpu_priority.py +0 -0
  15. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/files.py +0 -0
  16. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/mysql.py +0 -0
  17. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/redis_backup.py +0 -0
  18. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/rehearsal.py +0 -0
  19. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/storage.py +0 -0
  20. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup.egg-info/dependency_links.txt +0 -0
  21. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup.egg-info/entry_points.txt +0 -0
  22. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup.egg-info/requires.txt +0 -0
  23. {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup.egg-info/top_level.txt +0 -0
  24. {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_cli.py +0 -0
  25. {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_config.py +0 -0
  26. {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_cpu_priority.py +0 -0
  27. {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_files.py +0 -0
  28. {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_mysql.py +0 -0
  29. {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_redis.py +0 -0
  30. {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_rehearsal.py +0 -0
  31. {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_scheduled.py +0 -0
  32. {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_sqlite_podman.py +0 -0
  33. {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_storage.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-backup
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Host backup CLI for database snapshots and directory archives
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/XakkiDev/cli-backup
7
7
  Project-URL: Issues, https://github.com/XakkiDev/cli-backup/issues
8
- Keywords: backup,mysql,mariadb,sqlite,s3
8
+ Keywords: backup,mysql,mariadb,postgresql,sqlite,s3
9
9
  Requires-Python: >=3.11
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
@@ -24,10 +24,10 @@ A Python command-line tool for full database backups and directory archives, sto
24
24
 
25
25
  | Interface | Sources | Features |
26
26
  | --- | --- | --- |
27
- | `scheduled` and `share.Makefile` | MariaDB, MySQL, SQLite | Date-based daily, weekly and monthly snapshots; Docker or Podman containers, TCP, or SQLite online backup |
27
+ | `scheduled` and `share.Makefile` | MariaDB, MySQL, PostgreSQL, SQLite | Date-based daily, weekly and monthly snapshots; Docker or Podman containers, TCP, or SQLite online backup |
28
28
  | Generic commands | MariaDB/MySQL, Redis, directories | Full dumps, Redis RDB snapshots, gzip tar archives and exclusion patterns |
29
29
 
30
- The CLI runs on the host, supports rootless Docker and Podman for scheduled database backups, and does not require a separate backup service in a project's Compose file. Scheduled Compose targets are discovered from container labels. PostgreSQL, MongoDB and incremental backups are not implemented.
30
+ The CLI runs on the host, supports rootless Docker and Podman for scheduled database backups, and does not require a separate backup service in a project's Compose file. Scheduled Compose targets are discovered from container labels. MongoDB and incremental backups are not implemented.
31
31
 
32
32
  **Protect backup data and credentials.** Backups are not encrypted by this tool. Use private local paths, protected environment files and encrypted transport. Backups are not a substitute for an authorized recovery exercise. This CLI does not create buckets, change lifecycle policies, or schedule jobs.
33
33
 
@@ -63,6 +63,8 @@ Set `CLI_BACKUP_PROJECT_NAME` if the storage namespace should differ from the Co
63
63
 
64
64
  Container mode defaults to root, using `MARIADB_ROOT_PASSWORD` and `MARIADB_DATABASE` from the container; MySQL equivalents are also supported. Set a user and password together to override root. `CLI_BACKUP_DB_NAME` accepts comma-separated database names; each database produces a separate file. Credentials must be supplied in the environment or a protected project file, never as command-line arguments.
65
65
 
66
+ For PostgreSQL, set `CLI_BACKUP_DB_SERVICE_NAME=postgresql`. Container mode defaults to `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` from the database container. The container must accept TCP connections on `127.0.0.1:5432` (or the configured port). Host mode requires `pg_dump` and explicit connection settings. Dumps include one database and use PostgreSQL's consistent snapshot; cluster-wide roles and tablespaces are not included. Restore and restore rehearsal are not implemented for PostgreSQL; verify the checksum and perform recovery manually only with authorization.
67
+
66
68
  For SQLite, set `CLI_BACKUP_PROJECT_NAME`, `CLI_BACKUP_DB_SERVICE_NAME=sqlite`, and an absolute `CLI_BACKUP_DB_PATH`. Do not set container, host or Compose variables. The SQLite online backup API includes committed WAL transactions without stopping the writer. Automatic SQLite restoration is not implemented.
67
69
 
68
70
  ```sh
@@ -74,6 +76,12 @@ make cli-backup@verify path=daily where=local file=2026-01-01.example.sql.gz
74
76
 
75
77
  Additional targets `cli-backup@restore-test` and `cli-backup@restore` exist for MariaDB/MySQL. A live restore is destructive, requires an explicit file and interactive confirmation, and must target the correct stopped-writer environment. A restore rehearsal creates a disposable isolated container and should run only with appropriate authorization. For an unavailable source container, set `CLI_BACKUP_TEST_MYSQL_IMAGE` to an appropriate disposable test image before a rehearsal. Use `db=<name>` to filter when needed. Source-independent `list` and `verify` work with a stopped database container.
76
78
 
79
+ To restore a backup from another project into this project's selected MariaDB/MySQL database, explicitly specify the source project and database. Stop writers and run interactively only with authorization; the confirmation names both source and destination. The destination database must already exist. This example reads from S3 without a persistent intermediary dump file:
80
+
81
+ ```sh
82
+ make cli-backup@restore where=s3 path=daily file=2026-01-01.production.sql.gz source_project=production source_db=production db=development
83
+ ```
84
+
77
85
  ### Storage and schedule
78
86
 
79
87
  Set `CLI_BACKUP_LOCAL_ROOT` for local storage and/or the S3 variables `CLI_BACKUP_S3_BUCKET`, `CLI_BACKUP_S3_ENDPOINT`, `CLI_BACKUP_S3_REGION`, `CLI_BACKUP_S3_KEY`, `CLI_BACKUP_S3_SECRET`, and optionally `CLI_BACKUP_S3_PREFIX`. Project environment values override global environment values; explicit scheduled CLI options override project values. `--project-env` accepts literal assignments and does not execute shell code. Period keys are relative to the optional S3 prefix:
@@ -8,10 +8,10 @@ A Python command-line tool for full database backups and directory archives, sto
8
8
 
9
9
  | Interface | Sources | Features |
10
10
  | --- | --- | --- |
11
- | `scheduled` and `share.Makefile` | MariaDB, MySQL, SQLite | Date-based daily, weekly and monthly snapshots; Docker or Podman containers, TCP, or SQLite online backup |
11
+ | `scheduled` and `share.Makefile` | MariaDB, MySQL, PostgreSQL, SQLite | Date-based daily, weekly and monthly snapshots; Docker or Podman containers, TCP, or SQLite online backup |
12
12
  | Generic commands | MariaDB/MySQL, Redis, directories | Full dumps, Redis RDB snapshots, gzip tar archives and exclusion patterns |
13
13
 
14
- The CLI runs on the host, supports rootless Docker and Podman for scheduled database backups, and does not require a separate backup service in a project's Compose file. Scheduled Compose targets are discovered from container labels. PostgreSQL, MongoDB and incremental backups are not implemented.
14
+ The CLI runs on the host, supports rootless Docker and Podman for scheduled database backups, and does not require a separate backup service in a project's Compose file. Scheduled Compose targets are discovered from container labels. MongoDB and incremental backups are not implemented.
15
15
 
16
16
  **Protect backup data and credentials.** Backups are not encrypted by this tool. Use private local paths, protected environment files and encrypted transport. Backups are not a substitute for an authorized recovery exercise. This CLI does not create buckets, change lifecycle policies, or schedule jobs.
17
17
 
@@ -47,6 +47,8 @@ Set `CLI_BACKUP_PROJECT_NAME` if the storage namespace should differ from the Co
47
47
 
48
48
  Container mode defaults to root, using `MARIADB_ROOT_PASSWORD` and `MARIADB_DATABASE` from the container; MySQL equivalents are also supported. Set a user and password together to override root. `CLI_BACKUP_DB_NAME` accepts comma-separated database names; each database produces a separate file. Credentials must be supplied in the environment or a protected project file, never as command-line arguments.
49
49
 
50
+ For PostgreSQL, set `CLI_BACKUP_DB_SERVICE_NAME=postgresql`. Container mode defaults to `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` from the database container. The container must accept TCP connections on `127.0.0.1:5432` (or the configured port). Host mode requires `pg_dump` and explicit connection settings. Dumps include one database and use PostgreSQL's consistent snapshot; cluster-wide roles and tablespaces are not included. Restore and restore rehearsal are not implemented for PostgreSQL; verify the checksum and perform recovery manually only with authorization.
51
+
50
52
  For SQLite, set `CLI_BACKUP_PROJECT_NAME`, `CLI_BACKUP_DB_SERVICE_NAME=sqlite`, and an absolute `CLI_BACKUP_DB_PATH`. Do not set container, host or Compose variables. The SQLite online backup API includes committed WAL transactions without stopping the writer. Automatic SQLite restoration is not implemented.
51
53
 
52
54
  ```sh
@@ -58,6 +60,12 @@ make cli-backup@verify path=daily where=local file=2026-01-01.example.sql.gz
58
60
 
59
61
  Additional targets `cli-backup@restore-test` and `cli-backup@restore` exist for MariaDB/MySQL. A live restore is destructive, requires an explicit file and interactive confirmation, and must target the correct stopped-writer environment. A restore rehearsal creates a disposable isolated container and should run only with appropriate authorization. For an unavailable source container, set `CLI_BACKUP_TEST_MYSQL_IMAGE` to an appropriate disposable test image before a rehearsal. Use `db=<name>` to filter when needed. Source-independent `list` and `verify` work with a stopped database container.
60
62
 
63
+ To restore a backup from another project into this project's selected MariaDB/MySQL database, explicitly specify the source project and database. Stop writers and run interactively only with authorization; the confirmation names both source and destination. The destination database must already exist. This example reads from S3 without a persistent intermediary dump file:
64
+
65
+ ```sh
66
+ make cli-backup@restore where=s3 path=daily file=2026-01-01.production.sql.gz source_project=production source_db=production db=development
67
+ ```
68
+
61
69
  ### Storage and schedule
62
70
 
63
71
  Set `CLI_BACKUP_LOCAL_ROOT` for local storage and/or the S3 variables `CLI_BACKUP_S3_BUCKET`, `CLI_BACKUP_S3_ENDPOINT`, `CLI_BACKUP_S3_REGION`, `CLI_BACKUP_S3_KEY`, `CLI_BACKUP_S3_SECRET`, and optionally `CLI_BACKUP_S3_PREFIX`. Project environment values override global environment values; explicit scheduled CLI options override project values. `--project-env` accepts literal assignments and does not execute shell code. Period keys are relative to the optional S3 prefix:
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cli-backup"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Host backup CLI for database snapshots and directory archives"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = ["boto3==1.43.127"]
11
11
  license = "MIT"
12
12
  readme = "README.md"
13
- keywords = ["backup", "mysql", "mariadb", "sqlite", "s3"]
13
+ keywords = ["backup", "mysql", "mariadb", "postgresql", "sqlite", "s3"]
14
14
 
15
15
  [project.urls]
16
16
  Homepage = "https://github.com/XakkiDev/cli-backup"
@@ -83,8 +83,8 @@ class Settings:
83
83
  if runtime not in ('docker', 'podman'):
84
84
  raise ValueError('CLI_BACKUP_CONTAINER_RUNTIME must be docker or podman')
85
85
  service = env.get('CLI_BACKUP_DB_SERVICE_NAME', 'mariadb').lower()
86
- if service not in ('mariadb', 'mysql', 'sqlite'):
87
- raise ValueError('unsupported CLI_BACKUP_DB_SERVICE_NAME; supported: mariadb, mysql, sqlite')
86
+ if service not in ('mariadb', 'mysql', 'sqlite', 'postgresql'):
87
+ raise ValueError('unsupported CLI_BACKUP_DB_SERVICE_NAME; supported: mariadb, mysql, sqlite, postgresql')
88
88
  if service == 'sqlite':
89
89
  if not db_path or not Path(db_path).is_absolute() or container or host or compose:
90
90
  raise ValueError('SQLite requires an absolute CLI_BACKUP_DB_PATH and no container or host')
@@ -97,7 +97,7 @@ class Settings:
97
97
  raise ValueError('invalid CLI_BACKUP_DB_CONTAINER')
98
98
  if host and (host.startswith('-') or not re.fullmatch(r'[A-Za-z0-9_.:-]+', host)):
99
99
  raise ValueError('invalid CLI_BACKUP_DB_HOST')
100
- port = int(env.get('CLI_BACKUP_DB_PORT', '3306'))
100
+ port = int(env.get('CLI_BACKUP_DB_PORT', '5432' if service == 'postgresql' else '3306'))
101
101
  if not 1 <= port <= 65535:
102
102
  raise ValueError('invalid CLI_BACKUP_DB_PORT')
103
103
  names = env.get('CLI_BACKUP_DB_NAME', '')
@@ -160,11 +160,33 @@ def container_info(settings: Settings) -> tuple[str, dict[str, str], str]:
160
160
  return info[0]['Id'], environment, info[0]['Config']['Image']
161
161
 
162
162
 
163
- def connection(settings: Settings) -> tuple[MySQLConfig | None, list[str], str]:
163
+ @dataclass(frozen=True)
164
+ class PostgreSQLConfig:
165
+ database: str
166
+ user: str
167
+ password: str
168
+ host: str
169
+ port: int
170
+ target: str
171
+ runtime: str
172
+ cpu_nice: int
173
+
174
+
175
+ def connection(settings: Settings) -> tuple[MySQLConfig | PostgreSQLConfig | None, list[str], str]:
164
176
  if settings.service == 'sqlite':
165
177
  names = databases(settings.names or Path(settings.db_path).stem)
166
178
  return None, names, ''
167
179
  target, env, image = container_info(settings) if settings.container else ('', {}, '')
180
+ if settings.service == 'postgresql':
181
+ names = databases(settings.names or env.get('POSTGRES_DB') or env.get('POSTGRES_USER') or '')
182
+ user = settings.user or env.get('POSTGRES_USER') or 'postgres'
183
+ password = settings.password or env.get('POSTGRES_PASSWORD') or ''
184
+ if not user or user.startswith('-'):
185
+ raise ValueError('invalid PostgreSQL user')
186
+ if not password:
187
+ raise ValueError('database password is missing; set CLI_BACKUP_DB_USER and CLI_BACKUP_DB_PASSWORD')
188
+ return PostgreSQLConfig(names[0], user, password, settings.host or '127.0.0.1',
189
+ settings.port, target, settings.container_runtime, settings.cpu_nice), names, image
168
190
  names = databases(settings.names or env.get('MARIADB_DATABASE') or env.get('MYSQL_DATABASE') or '')
169
191
  user = settings.user or 'root'
170
192
  password = settings.password or env.get('MARIADB_ROOT_PASSWORD') or env.get('MYSQL_ROOT_PASSWORD') or ''
@@ -220,7 +242,35 @@ def capture_gzip(config: MySQLConfig, path: Path) -> None:
220
242
  raise
221
243
 
222
244
 
223
- def capture_source(settings: Settings, cfg: MySQLConfig | None, name: str, artifact: Path, tmp: Path) -> str | None:
245
+ def capture_postgresql(config: PostgreSQLConfig, name: str, path: Path) -> None:
246
+ prefix = [config.runtime, 'exec', '-i', '-e', 'PGPASSWORD', config.target] if config.target else []
247
+ nice = ['nice', '-n', str(config.cpu_nice)] if config.target and config.cpu_nice else []
248
+ command = prefix + nice + ['pg_dump', '--no-password', '--host', config.host,
249
+ '--port', str(config.port), '--username', config.user, '--dbname', name]
250
+ process = subprocess.Popen(command, env=dict(os.environ, PGPASSWORD=config.password),
251
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
252
+ assert process.stdout is not None
253
+ try:
254
+ size = 0
255
+ with gzip.open(path, 'xb') as output:
256
+ while chunk := process.stdout.read(1024 * 1024):
257
+ output.write(chunk)
258
+ size += len(chunk)
259
+ process.stdout.close()
260
+ if process.wait() != 0 or not size:
261
+ raise ValueError('PostgreSQL dump failed or is empty')
262
+ except BaseException:
263
+ process.kill()
264
+ process.wait()
265
+ raise
266
+
267
+
268
+ def capture_source(settings: Settings, cfg: MySQLConfig | PostgreSQLConfig | None, name: str, artifact: Path, tmp: Path) -> str | None:
269
+ if settings.service == 'postgresql':
270
+ if not isinstance(cfg, PostgreSQLConfig):
271
+ raise ValueError('missing PostgreSQL connection')
272
+ capture_postgresql(cfg, name, artifact)
273
+ return None
224
274
  if settings.service != 'sqlite':
225
275
  if cfg is None:
226
276
  raise ValueError('missing database connection')
@@ -508,6 +558,8 @@ def main(argv=None) -> int:
508
558
  parser.add_argument('--storage', choices=('local', 's3'), default='local')
509
559
  parser.add_argument('--file')
510
560
  parser.add_argument('--database')
561
+ parser.add_argument('--source-project-name')
562
+ parser.add_argument('--source-database')
511
563
  parser.add_argument('--project-env', type=Path)
512
564
  for option in ('project-name', 'compose-project-name', 'db-container', 'db-service-name',
513
565
  'db-host', 'db-port', 'db-name', 'db-user', 'db-path', 'local-root', 's3-bucket', 'cpu-nice', 'container-runtime'):
@@ -523,6 +575,11 @@ def main(argv=None) -> int:
523
575
  if value is not None:
524
576
  environment['CLI_BACKUP_' + option.upper()] = value
525
577
  settings = Settings.from_env(environment)
578
+ if (args.source_project_name or args.source_database) and args.action != 'restore':
579
+ raise ValueError('source project and source database are only supported for restore')
580
+ source_project = validate_project(args.source_project_name) if args.source_project_name else settings.project
581
+ if args.source_database and len(databases(args.source_database)) != 1:
582
+ raise ValueError('source database must be a single database name')
526
583
  if args.action == 'config-check':
527
584
  print(f'Configuration valid for project {settings.project}')
528
585
  return 0
@@ -556,9 +613,10 @@ def main(argv=None) -> int:
556
613
  chosen = dict(stores(settings, args.period)).get(args.storage)
557
614
  if chosen is None:
558
615
  raise ValueError('storage is not configured')
559
- items = list_store(chosen, settings.project, settings.service)
560
- if args.database:
561
- items = [m for m in items if m.artifact.endswith(backup_suffix(settings, args.database))]
616
+ items = list_store(chosen, source_project, settings.service)
617
+ source_database = args.source_database or (args.database if source_project == settings.project else None)
618
+ if source_database:
619
+ items = [m for m in items if m.artifact.endswith(backup_suffix(settings, source_database))]
562
620
  if args.action == 'list':
563
621
  for item in items:
564
622
  print(f'{item.artifact}\t{item.timestamp}\t{item.size}\t{item.sha256}')
@@ -582,15 +640,17 @@ def main(argv=None) -> int:
582
640
  if not match:
583
641
  raise ValueError('invalid scheduled backup filename')
584
642
  selected = match.group(1)
585
- if names and selected not in names:
643
+ if source_project == settings.project and not args.source_database and names and selected not in names:
586
644
  raise ValueError('selected database is not configured')
645
+ if args.source_database and selected != args.source_database:
646
+ raise ValueError('backup database does not match the selected source database')
587
647
  artifact = tmp / manifest.artifact
588
648
  get(chosen, manifest, artifact)
589
649
  if args.action == 'verify':
590
650
  print('SHA-256 verified')
591
651
  return 0
592
- if settings.service == 'sqlite':
593
- raise ValueError('SQLite restoration is manual; use the verified backup file')
652
+ if settings.service in ('sqlite', 'postgresql'):
653
+ raise ValueError(f'{settings.service} restoration is manual; use the verified backup file')
594
654
  plain = tmp / 'restore.sql'
595
655
  with gzip.open(artifact, 'rb') as source, plain.open('xb') as output:
596
656
  shutil.copyfileobj(source, output, 1024 * 1024)
@@ -605,9 +665,13 @@ def main(argv=None) -> int:
605
665
  else:
606
666
  if not args.file:
607
667
  raise ValueError('--file is required for live restore')
608
- if not sys.stdin.isatty() or input(f'Restore {selected} in {settings.project}? Type yes: ') != 'yes':
668
+ target = args.database or (names[0] if len(names) == 1 else selected)
669
+ if target not in names:
670
+ raise ValueError('restore target database is not configured')
671
+ prompt = f'Restore {source_project}/{selected} into {settings.project}/{target}? Type yes: '
672
+ if not sys.stdin.isatty() or input(prompt) != 'yes':
609
673
  raise ValueError('restore not confirmed')
610
- database = MySQLConfig(**{**vars(cfg), 'database': selected})
674
+ database = MySQLConfig(**{**vars(cfg), 'database': target})
611
675
  with plain.open('rb') as source:
612
676
  restore_mysql(database, source)
613
677
  return 0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-backup
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Host backup CLI for database snapshots and directory archives
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/XakkiDev/cli-backup
7
7
  Project-URL: Issues, https://github.com/XakkiDev/cli-backup/issues
8
- Keywords: backup,mysql,mariadb,sqlite,s3
8
+ Keywords: backup,mysql,mariadb,postgresql,sqlite,s3
9
9
  Requires-Python: >=3.11
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
@@ -24,10 +24,10 @@ A Python command-line tool for full database backups and directory archives, sto
24
24
 
25
25
  | Interface | Sources | Features |
26
26
  | --- | --- | --- |
27
- | `scheduled` and `share.Makefile` | MariaDB, MySQL, SQLite | Date-based daily, weekly and monthly snapshots; Docker or Podman containers, TCP, or SQLite online backup |
27
+ | `scheduled` and `share.Makefile` | MariaDB, MySQL, PostgreSQL, SQLite | Date-based daily, weekly and monthly snapshots; Docker or Podman containers, TCP, or SQLite online backup |
28
28
  | Generic commands | MariaDB/MySQL, Redis, directories | Full dumps, Redis RDB snapshots, gzip tar archives and exclusion patterns |
29
29
 
30
- The CLI runs on the host, supports rootless Docker and Podman for scheduled database backups, and does not require a separate backup service in a project's Compose file. Scheduled Compose targets are discovered from container labels. PostgreSQL, MongoDB and incremental backups are not implemented.
30
+ The CLI runs on the host, supports rootless Docker and Podman for scheduled database backups, and does not require a separate backup service in a project's Compose file. Scheduled Compose targets are discovered from container labels. MongoDB and incremental backups are not implemented.
31
31
 
32
32
  **Protect backup data and credentials.** Backups are not encrypted by this tool. Use private local paths, protected environment files and encrypted transport. Backups are not a substitute for an authorized recovery exercise. This CLI does not create buckets, change lifecycle policies, or schedule jobs.
33
33
 
@@ -63,6 +63,8 @@ Set `CLI_BACKUP_PROJECT_NAME` if the storage namespace should differ from the Co
63
63
 
64
64
  Container mode defaults to root, using `MARIADB_ROOT_PASSWORD` and `MARIADB_DATABASE` from the container; MySQL equivalents are also supported. Set a user and password together to override root. `CLI_BACKUP_DB_NAME` accepts comma-separated database names; each database produces a separate file. Credentials must be supplied in the environment or a protected project file, never as command-line arguments.
65
65
 
66
+ For PostgreSQL, set `CLI_BACKUP_DB_SERVICE_NAME=postgresql`. Container mode defaults to `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` from the database container. The container must accept TCP connections on `127.0.0.1:5432` (or the configured port). Host mode requires `pg_dump` and explicit connection settings. Dumps include one database and use PostgreSQL's consistent snapshot; cluster-wide roles and tablespaces are not included. Restore and restore rehearsal are not implemented for PostgreSQL; verify the checksum and perform recovery manually only with authorization.
67
+
66
68
  For SQLite, set `CLI_BACKUP_PROJECT_NAME`, `CLI_BACKUP_DB_SERVICE_NAME=sqlite`, and an absolute `CLI_BACKUP_DB_PATH`. Do not set container, host or Compose variables. The SQLite online backup API includes committed WAL transactions without stopping the writer. Automatic SQLite restoration is not implemented.
67
69
 
68
70
  ```sh
@@ -74,6 +76,12 @@ make cli-backup@verify path=daily where=local file=2026-01-01.example.sql.gz
74
76
 
75
77
  Additional targets `cli-backup@restore-test` and `cli-backup@restore` exist for MariaDB/MySQL. A live restore is destructive, requires an explicit file and interactive confirmation, and must target the correct stopped-writer environment. A restore rehearsal creates a disposable isolated container and should run only with appropriate authorization. For an unavailable source container, set `CLI_BACKUP_TEST_MYSQL_IMAGE` to an appropriate disposable test image before a rehearsal. Use `db=<name>` to filter when needed. Source-independent `list` and `verify` work with a stopped database container.
76
78
 
79
+ To restore a backup from another project into this project's selected MariaDB/MySQL database, explicitly specify the source project and database. Stop writers and run interactively only with authorization; the confirmation names both source and destination. The destination database must already exist. This example reads from S3 without a persistent intermediary dump file:
80
+
81
+ ```sh
82
+ make cli-backup@restore where=s3 path=daily file=2026-01-01.production.sql.gz source_project=production source_db=production db=development
83
+ ```
84
+
77
85
  ### Storage and schedule
78
86
 
79
87
  Set `CLI_BACKUP_LOCAL_ROOT` for local storage and/or the S3 variables `CLI_BACKUP_S3_BUCKET`, `CLI_BACKUP_S3_ENDPOINT`, `CLI_BACKUP_S3_REGION`, `CLI_BACKUP_S3_KEY`, `CLI_BACKUP_S3_SECRET`, and optionally `CLI_BACKUP_S3_PREFIX`. Project environment values override global environment values; explicit scheduled CLI options override project values. `--project-env` accepts literal assignments and does not execute shell code. Period keys are relative to the optional S3 prefix:
@@ -20,8 +20,10 @@ src/cli_backup.egg-info/top_level.txt
20
20
  tests/test_cli.py
21
21
  tests/test_config.py
22
22
  tests/test_cpu_priority.py
23
+ tests/test_cross_project_restore.py
23
24
  tests/test_files.py
24
25
  tests/test_mysql.py
26
+ tests/test_postgresql.py
25
27
  tests/test_redis.py
26
28
  tests/test_rehearsal.py
27
29
  tests/test_scheduled.py
@@ -0,0 +1,57 @@
1
+ import contextlib
2
+ import gzip
3
+ import io
4
+ import os
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ from cli_backup.mysql import MySQLConfig
11
+ from cli_backup.scheduled import main
12
+ from cli_backup.storage import LocalStorage, make_manifest
13
+
14
+
15
+ class CrossProjectRestoreTests(unittest.TestCase):
16
+ def test_source_project_and_target_database_are_distinct(self):
17
+ with tempfile.TemporaryDirectory() as tmp:
18
+ root = Path(tmp)
19
+ store = LocalStorage(root / 'daily')
20
+ artifact = root / 'dump.gz'
21
+ with gzip.open(artifact, 'wb') as stream:
22
+ stream.write(b'SELECT 1;')
23
+ filename = '2026-11-11.student.sql.gz'
24
+ store.put(artifact, make_manifest(artifact, 'sa-prod', 'mariadb', filename, '2026-11-11T00:00:00+00:00'))
25
+ cfg = MySQLConfig('exec', 'student_dev', 'root', 'secret', service='container')
26
+ env = {'CLI_BACKUP_PROJECT_NAME': 'sa-dev', 'CLI_BACKUP_DB_CONTAINER': 'mariadb',
27
+ 'CLI_BACKUP_LOCAL_ROOT': str(root)}
28
+ captured = []
29
+ def fake_restore(database, stream):
30
+ captured.append((database.database, stream.read()))
31
+ args = ['restore', '--storage', 's3', '--file', filename,
32
+ '--source-project-name', 'sa-prod', '--source-database', 'student']
33
+ with patch.dict(os.environ, env, clear=True), \
34
+ patch('cli_backup.scheduled.connection', return_value=(cfg, ['student_dev'], 'image')), \
35
+ patch('cli_backup.scheduled.stores', return_value=[('s3', store)]), \
36
+ patch('cli_backup.scheduled.restore_mysql', side_effect=fake_restore), \
37
+ patch('sys.stdin.isatty', return_value=True), patch('builtins.input', return_value='yes') as prompt:
38
+ self.assertEqual(main(args), 0)
39
+ self.assertEqual(captured, [('student_dev', b'SELECT 1;')])
40
+ self.assertIn('sa-prod/student into sa-dev/student_dev', prompt.call_args.args[0])
41
+
42
+ def test_confirmation_is_required_even_for_cross_project_restore(self):
43
+ with tempfile.TemporaryDirectory() as tmp:
44
+ root = Path(tmp); store = LocalStorage(root / 'daily'); artifact = root / 'dump.gz'
45
+ with gzip.open(artifact, 'wb') as stream: stream.write(b'SELECT 1;')
46
+ filename = '2026-11-11.student.sql.gz'
47
+ store.put(artifact, make_manifest(artifact, 'sa-prod', 'mariadb', filename, '2026-11-11T00:00:00+00:00'))
48
+ cfg = MySQLConfig('exec', 'student_dev', 'root', 'secret', service='container')
49
+ env = {'CLI_BACKUP_PROJECT_NAME': 'sa-dev', 'CLI_BACKUP_DB_CONTAINER': 'mariadb', 'CLI_BACKUP_LOCAL_ROOT': str(root)}
50
+ with patch.dict(os.environ, env, clear=True), patch('cli_backup.scheduled.connection', return_value=(cfg, ['student_dev'], 'image')), \
51
+ patch('cli_backup.scheduled.stores', return_value=[('s3', store)]), patch('cli_backup.scheduled.restore_mysql') as restore, \
52
+ patch('sys.stdin.isatty', return_value=False), contextlib.redirect_stderr(io.StringIO()):
53
+ self.assertEqual(main(['restore', '--storage', 's3', '--file', filename, '--source-project-name', 'sa-prod']), 2)
54
+ restore.assert_not_called()
55
+
56
+ if __name__ == '__main__':
57
+ unittest.main()
@@ -0,0 +1,51 @@
1
+ import gzip
2
+ import io
3
+ import subprocess
4
+ import tempfile
5
+ import unittest
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ from cli_backup.scheduled import Settings, PostgreSQLConfig, backup_database, connection, capture_postgresql
11
+
12
+
13
+ class PostgreSQLBackupTests(unittest.TestCase):
14
+ def settings(self, root):
15
+ return Settings.from_env({'CLI_BACKUP_COMPOSE_PROJECT_NAME': 'a24-service',
16
+ 'CLI_BACKUP_DB_CONTAINER': 'postgres', 'CLI_BACKUP_DB_SERVICE_NAME': 'postgresql',
17
+ 'CLI_BACKUP_LOCAL_ROOT': str(root), 'CLI_BACKUP_PERIOD_MONTHLY': 'false'})
18
+
19
+ def test_container_credentials_are_discovered_without_exposing_password(self):
20
+ with tempfile.TemporaryDirectory() as root:
21
+ settings = self.settings(root)
22
+ self.assertEqual(settings.port, 5432)
23
+ with patch('cli_backup.scheduled.container_info', return_value=('db123', {
24
+ 'POSTGRES_DB': 'a24', 'POSTGRES_USER': 'dbuser', 'POSTGRES_PASSWORD': 'secret'}, 'pg:16')):
25
+ cfg, names, _ = connection(settings)
26
+ self.assertEqual(names, ['a24'])
27
+ fake = unittest.mock.Mock()
28
+ fake.stdout = io.BytesIO(b'-- valid dump\n')
29
+ fake.wait.return_value = 0
30
+ with patch('cli_backup.scheduled.subprocess.Popen', return_value=fake) as popen:
31
+ backup_database(settings, cfg, 'a24', Path(root), datetime.fromisoformat('2026-11-11T03:00:00+00:00'))
32
+ args, kwargs = popen.call_args
33
+ self.assertNotIn('secret', args[0])
34
+ self.assertEqual(kwargs['env']['PGPASSWORD'], 'secret')
35
+ self.assertIn('pg_dump', args[0])
36
+ path = Path(root) / 'daily/a24-service/postgresql/2026-11-11.a24.sql.gz'
37
+ with gzip.open(path, 'rb') as dump:
38
+ self.assertEqual(dump.read(), b'-- valid dump\n')
39
+ self.assertTrue(Path(str(path) + '.json').is_file())
40
+
41
+ def test_failed_dump_is_not_accepted(self):
42
+ cfg = PostgreSQLConfig('db', 'user', 'secret', 'localhost', 5432, '', 'docker', 10)
43
+ proc = unittest.mock.Mock()
44
+ proc.stdout = io.BytesIO(b'partial')
45
+ proc.wait.return_value = 1
46
+ with tempfile.TemporaryDirectory() as root, patch('cli_backup.scheduled.subprocess.Popen', return_value=proc):
47
+ with self.assertRaisesRegex(ValueError, 'failed'):
48
+ capture_postgresql(cfg, 'db', Path(root) / 'out.gz')
49
+
50
+ if __name__ == '__main__':
51
+ unittest.main()
File without changes
File without changes
File without changes