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.
- {cli_backup-0.1.0/src/cli_backup.egg-info → cli_backup-0.2.0}/PKG-INFO +12 -4
- {cli_backup-0.1.0 → cli_backup-0.2.0}/README.md +10 -2
- {cli_backup-0.1.0 → cli_backup-0.2.0}/pyproject.toml +2 -2
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/scheduled.py +77 -13
- {cli_backup-0.1.0 → cli_backup-0.2.0/src/cli_backup.egg-info}/PKG-INFO +12 -4
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup.egg-info/SOURCES.txt +2 -0
- cli_backup-0.2.0/tests/test_cross_project_restore.py +57 -0
- cli_backup-0.2.0/tests/test_postgresql.py +51 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/LICENSE +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/setup.cfg +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/__init__.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/cli.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/config.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/cpu_priority.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/files.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/mysql.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/redis_backup.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/rehearsal.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup/storage.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup.egg-info/dependency_links.txt +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup.egg-info/entry_points.txt +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup.egg-info/requires.txt +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/src/cli_backup.egg-info/top_level.txt +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_cli.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_config.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_cpu_priority.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_files.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_mysql.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_redis.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_rehearsal.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_scheduled.py +0 -0
- {cli_backup-0.1.0 → cli_backup-0.2.0}/tests/test_sqlite_podman.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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,
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
593
|
-
raise ValueError('
|
|
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
|
-
|
|
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':
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|