cli-backup 0.1.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 (31) hide show
  1. cli_backup-0.1.0/LICENSE +21 -0
  2. cli_backup-0.1.0/PKG-INFO +119 -0
  3. cli_backup-0.1.0/README.md +103 -0
  4. cli_backup-0.1.0/pyproject.toml +23 -0
  5. cli_backup-0.1.0/setup.cfg +4 -0
  6. cli_backup-0.1.0/src/cli_backup/__init__.py +1 -0
  7. cli_backup-0.1.0/src/cli_backup/cli.py +209 -0
  8. cli_backup-0.1.0/src/cli_backup/config.py +68 -0
  9. cli_backup-0.1.0/src/cli_backup/cpu_priority.py +21 -0
  10. cli_backup-0.1.0/src/cli_backup/files.py +216 -0
  11. cli_backup-0.1.0/src/cli_backup/mysql.py +109 -0
  12. cli_backup-0.1.0/src/cli_backup/redis_backup.py +24 -0
  13. cli_backup-0.1.0/src/cli_backup/rehearsal.py +79 -0
  14. cli_backup-0.1.0/src/cli_backup/scheduled.py +616 -0
  15. cli_backup-0.1.0/src/cli_backup/storage.py +386 -0
  16. cli_backup-0.1.0/src/cli_backup.egg-info/PKG-INFO +119 -0
  17. cli_backup-0.1.0/src/cli_backup.egg-info/SOURCES.txt +29 -0
  18. cli_backup-0.1.0/src/cli_backup.egg-info/dependency_links.txt +1 -0
  19. cli_backup-0.1.0/src/cli_backup.egg-info/entry_points.txt +2 -0
  20. cli_backup-0.1.0/src/cli_backup.egg-info/requires.txt +4 -0
  21. cli_backup-0.1.0/src/cli_backup.egg-info/top_level.txt +1 -0
  22. cli_backup-0.1.0/tests/test_cli.py +221 -0
  23. cli_backup-0.1.0/tests/test_config.py +16 -0
  24. cli_backup-0.1.0/tests/test_cpu_priority.py +50 -0
  25. cli_backup-0.1.0/tests/test_files.py +227 -0
  26. cli_backup-0.1.0/tests/test_mysql.py +25 -0
  27. cli_backup-0.1.0/tests/test_redis.py +30 -0
  28. cli_backup-0.1.0/tests/test_rehearsal.py +44 -0
  29. cli_backup-0.1.0/tests/test_scheduled.py +323 -0
  30. cli_backup-0.1.0/tests/test_sqlite_podman.py +76 -0
  31. cli_backup-0.1.0/tests/test_storage.py +219 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cli-backup contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: cli-backup
3
+ Version: 0.1.0
4
+ Summary: Host backup CLI for database snapshots and directory archives
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/XakkiDev/cli-backup
7
+ Project-URL: Issues, https://github.com/XakkiDev/cli-backup/issues
8
+ Keywords: backup,mysql,mariadb,sqlite,s3
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: boto3==1.43.127
13
+ Provides-Extra: dev
14
+ Requires-Dist: ruff==0.6.9; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # cli-backup
18
+
19
+ A Python command-line tool for full database backups and directory archives, stored locally, in S3-compatible object storage, or both. Use it directly or include the shared Makefile in multiple projects on the same host.
20
+
21
+ [![CI](https://github.com/XakkiDev/cli-backup/actions/workflows/ci.yml/badge.svg)](https://github.com/XakkiDev/cli-backup/actions/workflows/ci.yml) · [MIT license](LICENSE)
22
+
23
+ ## Capabilities
24
+
25
+ | Interface | Sources | Features |
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 |
28
+ | Generic commands | MariaDB/MySQL, Redis, directories | Full dumps, Redis RDB snapshots, gzip tar archives and exclusion patterns |
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.
31
+
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
+
34
+ ## Installation
35
+
36
+ Python 3.11+ on a POSIX host is required. Client binaries are required for direct database or Redis connections. The selected Docker/Podman runtime is required for container sources.
37
+
38
+ ```sh
39
+ git clone https://github.com/XakkiDev/cli-backup.git
40
+ cd cli-backup
41
+ make venv install
42
+ .venv/bin/cli-backup --help
43
+ .venv/bin/cli-backup scheduled --help
44
+ ```
45
+
46
+ For a user-wide executable, `pipx install .` installs from the checkout. `share.Makefile` is supplied in the repository checkout rather than the Python wheel: keep or download a trusted checkout to use the shared Make targets. On systems without `ensurepip`, install the matching Python venv package first.
47
+
48
+ The Composer package `xakki/cli-backup` distributes the source tree and agent skills for Composer-based projects. Composer does not install Python dependencies or provide a ready-to-run CLI: after `composer require xakki/cli-backup`, install the Python project from `vendor/xakki/cli-backup` with `pip` or `pipx` and include `vendor/xakki/cli-backup/share.Makefile` when needed. The `extra.skills.source` metadata identifies the agent skills for compatible Composer skill managers.
49
+
50
+
51
+ ## Scheduled database backups
52
+
53
+ Include `share.Makefile` from your checkout. For a Compose service:
54
+
55
+ ```make
56
+ CLI_BACKUP_COMPOSE_PROJECT_NAME := example
57
+ CLI_BACKUP_DB_CONTAINER := mariadb
58
+ include /path/to/cli-backup/share.Makefile
59
+ export CLI_BACKUP_COMPOSE_PROJECT_NAME CLI_BACKUP_DB_CONTAINER
60
+ ```
61
+
62
+ Set `CLI_BACKUP_PROJECT_NAME` if the storage namespace should differ from the Compose project. For a standalone container, set `CLI_BACKUP_PROJECT_NAME` and `CLI_BACKUP_DB_CONTAINER` to its name or ID. Set `CLI_BACKUP_CONTAINER_RUNTIME=podman` to use local rootless Podman; Docker is the default and uses the selected Docker context. For TCP use `CLI_BACKUP_PROJECT_NAME`, `CLI_BACKUP_DB_HOST`, optionally `CLI_BACKUP_DB_PORT`, `CLI_BACKUP_DB_NAME`, `CLI_BACKUP_DB_USER` and `CLI_BACKUP_DB_PASSWORD`.
63
+
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
+
66
+ 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
+
68
+ ```sh
69
+ make cli-backup@config-check
70
+ make cli-backup@backup
71
+ make cli-backup@list path=daily where=local
72
+ make cli-backup@verify path=daily where=local file=2026-01-01.example.sql.gz
73
+ ```
74
+
75
+ 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
+
77
+ ### Storage and schedule
78
+
79
+ 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:
80
+
81
+ ```text
82
+ daily/<project>/<source>/YYYY-MM-DD.<database>.sql.gz
83
+ weekly/<project>/<source>/YYYY-MM-DD.<database>.sql.gz
84
+ monthly/<project>/<source>/YYYY-MM-DD.<database>.sql.gz
85
+ .state/<project>/<source>/... # schedule metadata
86
+ ```
87
+
88
+ SQLite uses `.sqlite.gz`. `CLI_BACKUP_PERIOD_DAILY` and `CLI_BACKUP_PERIOD_MONTHLY` default to `true`; `CLI_BACKUP_PERIOD_WEEKLY` defaults to `false`. With daily enabled, beginning on Wednesday or the third day of the month, the latest eligible daily copy from the preceding week or month is copied to the relevant period if missing. When both select the same Sunday, weekly chooses an earlier snapshot. With daily disabled, a new weekly or monthly snapshot is created on or after the threshold if the current period is missing. Run the command from an external scheduler, normally daily, so missed attempts can be retried. A completed period may not create a new snapshot on a repeated invocation.
89
+
90
+ `CLI_BACKUP_LOCAL_DAYS` sets local retention, with minimum retention for period selection and idempotency. The object storage service controls S3 retention. Required destinations are attempted independently, and errors cause a nonzero exit. S3 publishing reads and verifies the complete uploaded object before publishing its manifest. The default CPU scheduling priority is `nice 10` for the CLI and container dump client; configure `CLI_BACKUP_CPU_NICE` from 0 to 19. This does not limit the database server's CPU or I/O.
91
+
92
+ ## Generic CLI
93
+
94
+ Run `cli-backup --help` for global options and commands. Generic CLI options precede the subcommand. Its configuration uses `CLI_BACKUP_PROJECT` and `CLI_BACKUP_DB_MODE` (`compose`, `container`, or `tcp`), which are distinct from the scheduled interface's project and connection variables.
95
+
96
+ ```sh
97
+ cli-backup backup mysql
98
+ cli-backup backup redis
99
+ cli-backup backup files --directory /srv/example --exclude '*.tmp' --exclude 'cache/*'
100
+ cli-backup list files --storage local
101
+ cli-backup verify files --storage local --file NAME
102
+ ```
103
+
104
+ Directory archives are full scans, not point-in-time filesystem snapshots. Exclusion patterns use Python `fnmatch` relative to the source; `*` can match `/`. Symlinks are archived without following them, hard links are archived separately, and unsafe escaping links or special files must be excluded. Extraction requires a new absolute destination and rejects unsafe paths; permissions are restored without setuid/setgid bits, and ownership is not restored. See `cli-backup restore --help` before recovery.
105
+
106
+ MariaDB/MySQL dumps use `--single-transaction`; coordinate schema changes and nontransactional tables. Object-count comparisons do not prove row-level equivalence. The local and S3 files are sensitive; ensure sufficient free temporary space and protect the destination.
107
+
108
+ ## Development and AI agent skills
109
+
110
+ ```sh
111
+ make venv install
112
+ make test lint
113
+ ```
114
+
115
+ CI runs unit tests on Python 3.11–3.13, lint, build and installed-wheel smoke tests. `make test-integration` requires explicitly selected disposable images and may perform database restoration; run it only when authorized. Project-portable skills for agents are in [`.agents/skills/`](.agents/skills/): `cli-backup-setup` covers integration and `cli-backup-develop` covers implementation and testing. They do not authorize storage administration, schedule changes, or restoration.
116
+
117
+ ## License
118
+
119
+ [MIT](LICENSE)
@@ -0,0 +1,103 @@
1
+ # cli-backup
2
+
3
+ A Python command-line tool for full database backups and directory archives, stored locally, in S3-compatible object storage, or both. Use it directly or include the shared Makefile in multiple projects on the same host.
4
+
5
+ [![CI](https://github.com/XakkiDev/cli-backup/actions/workflows/ci.yml/badge.svg)](https://github.com/XakkiDev/cli-backup/actions/workflows/ci.yml) · [MIT license](LICENSE)
6
+
7
+ ## Capabilities
8
+
9
+ | Interface | Sources | Features |
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 |
12
+ | Generic commands | MariaDB/MySQL, Redis, directories | Full dumps, Redis RDB snapshots, gzip tar archives and exclusion patterns |
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.
15
+
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
+
18
+ ## Installation
19
+
20
+ Python 3.11+ on a POSIX host is required. Client binaries are required for direct database or Redis connections. The selected Docker/Podman runtime is required for container sources.
21
+
22
+ ```sh
23
+ git clone https://github.com/XakkiDev/cli-backup.git
24
+ cd cli-backup
25
+ make venv install
26
+ .venv/bin/cli-backup --help
27
+ .venv/bin/cli-backup scheduled --help
28
+ ```
29
+
30
+ For a user-wide executable, `pipx install .` installs from the checkout. `share.Makefile` is supplied in the repository checkout rather than the Python wheel: keep or download a trusted checkout to use the shared Make targets. On systems without `ensurepip`, install the matching Python venv package first.
31
+
32
+ The Composer package `xakki/cli-backup` distributes the source tree and agent skills for Composer-based projects. Composer does not install Python dependencies or provide a ready-to-run CLI: after `composer require xakki/cli-backup`, install the Python project from `vendor/xakki/cli-backup` with `pip` or `pipx` and include `vendor/xakki/cli-backup/share.Makefile` when needed. The `extra.skills.source` metadata identifies the agent skills for compatible Composer skill managers.
33
+
34
+
35
+ ## Scheduled database backups
36
+
37
+ Include `share.Makefile` from your checkout. For a Compose service:
38
+
39
+ ```make
40
+ CLI_BACKUP_COMPOSE_PROJECT_NAME := example
41
+ CLI_BACKUP_DB_CONTAINER := mariadb
42
+ include /path/to/cli-backup/share.Makefile
43
+ export CLI_BACKUP_COMPOSE_PROJECT_NAME CLI_BACKUP_DB_CONTAINER
44
+ ```
45
+
46
+ Set `CLI_BACKUP_PROJECT_NAME` if the storage namespace should differ from the Compose project. For a standalone container, set `CLI_BACKUP_PROJECT_NAME` and `CLI_BACKUP_DB_CONTAINER` to its name or ID. Set `CLI_BACKUP_CONTAINER_RUNTIME=podman` to use local rootless Podman; Docker is the default and uses the selected Docker context. For TCP use `CLI_BACKUP_PROJECT_NAME`, `CLI_BACKUP_DB_HOST`, optionally `CLI_BACKUP_DB_PORT`, `CLI_BACKUP_DB_NAME`, `CLI_BACKUP_DB_USER` and `CLI_BACKUP_DB_PASSWORD`.
47
+
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
+
50
+ 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
+
52
+ ```sh
53
+ make cli-backup@config-check
54
+ make cli-backup@backup
55
+ make cli-backup@list path=daily where=local
56
+ make cli-backup@verify path=daily where=local file=2026-01-01.example.sql.gz
57
+ ```
58
+
59
+ 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
+
61
+ ### Storage and schedule
62
+
63
+ 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:
64
+
65
+ ```text
66
+ daily/<project>/<source>/YYYY-MM-DD.<database>.sql.gz
67
+ weekly/<project>/<source>/YYYY-MM-DD.<database>.sql.gz
68
+ monthly/<project>/<source>/YYYY-MM-DD.<database>.sql.gz
69
+ .state/<project>/<source>/... # schedule metadata
70
+ ```
71
+
72
+ SQLite uses `.sqlite.gz`. `CLI_BACKUP_PERIOD_DAILY` and `CLI_BACKUP_PERIOD_MONTHLY` default to `true`; `CLI_BACKUP_PERIOD_WEEKLY` defaults to `false`. With daily enabled, beginning on Wednesday or the third day of the month, the latest eligible daily copy from the preceding week or month is copied to the relevant period if missing. When both select the same Sunday, weekly chooses an earlier snapshot. With daily disabled, a new weekly or monthly snapshot is created on or after the threshold if the current period is missing. Run the command from an external scheduler, normally daily, so missed attempts can be retried. A completed period may not create a new snapshot on a repeated invocation.
73
+
74
+ `CLI_BACKUP_LOCAL_DAYS` sets local retention, with minimum retention for period selection and idempotency. The object storage service controls S3 retention. Required destinations are attempted independently, and errors cause a nonzero exit. S3 publishing reads and verifies the complete uploaded object before publishing its manifest. The default CPU scheduling priority is `nice 10` for the CLI and container dump client; configure `CLI_BACKUP_CPU_NICE` from 0 to 19. This does not limit the database server's CPU or I/O.
75
+
76
+ ## Generic CLI
77
+
78
+ Run `cli-backup --help` for global options and commands. Generic CLI options precede the subcommand. Its configuration uses `CLI_BACKUP_PROJECT` and `CLI_BACKUP_DB_MODE` (`compose`, `container`, or `tcp`), which are distinct from the scheduled interface's project and connection variables.
79
+
80
+ ```sh
81
+ cli-backup backup mysql
82
+ cli-backup backup redis
83
+ cli-backup backup files --directory /srv/example --exclude '*.tmp' --exclude 'cache/*'
84
+ cli-backup list files --storage local
85
+ cli-backup verify files --storage local --file NAME
86
+ ```
87
+
88
+ Directory archives are full scans, not point-in-time filesystem snapshots. Exclusion patterns use Python `fnmatch` relative to the source; `*` can match `/`. Symlinks are archived without following them, hard links are archived separately, and unsafe escaping links or special files must be excluded. Extraction requires a new absolute destination and rejects unsafe paths; permissions are restored without setuid/setgid bits, and ownership is not restored. See `cli-backup restore --help` before recovery.
89
+
90
+ MariaDB/MySQL dumps use `--single-transaction`; coordinate schema changes and nontransactional tables. Object-count comparisons do not prove row-level equivalence. The local and S3 files are sensitive; ensure sufficient free temporary space and protect the destination.
91
+
92
+ ## Development and AI agent skills
93
+
94
+ ```sh
95
+ make venv install
96
+ make test lint
97
+ ```
98
+
99
+ CI runs unit tests on Python 3.11–3.13, lint, build and installed-wheel smoke tests. `make test-integration` requires explicitly selected disposable images and may perform database restoration; run it only when authorized. Project-portable skills for agents are in [`.agents/skills/`](.agents/skills/): `cli-backup-setup` covers integration and `cli-backup-develop` covers implementation and testing. They do not authorize storage administration, schedule changes, or restoration.
100
+
101
+ ## License
102
+
103
+ [MIT](LICENSE)
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cli-backup"
7
+ version = "0.1.0"
8
+ description = "Host backup CLI for database snapshots and directory archives"
9
+ requires-python = ">=3.11"
10
+ dependencies = ["boto3==1.43.127"]
11
+ license = "MIT"
12
+ readme = "README.md"
13
+ keywords = ["backup", "mysql", "mariadb", "sqlite", "s3"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/XakkiDev/cli-backup"
17
+ Issues = "https://github.com/XakkiDev/cli-backup/issues"
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["ruff==0.6.9"]
21
+
22
+ [project.scripts]
23
+ cli-backup = "cli_backup.cli:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Backup CLI."""
@@ -0,0 +1,209 @@
1
+ """Command-line entry point for backup and recovery."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ from .config import load_config, read_project_env, resolve
14
+ from .cpu_priority import lower_cpu_priority, validate_cpu_nice
15
+ from .files import capture_directory, extract_archive
16
+ from .mysql import MySQLConfig, capture_mysql, count_mysql_objects, restore_mysql
17
+ from .redis_backup import capture_redis
18
+ from .rehearsal import rehearse_mysql, rehearse_redis
19
+ from .storage import LocalStorage, S3Storage, local_get, make_manifest, prune_local, s3_get, s3_list, write_private_copy
20
+
21
+
22
+ def parser_build() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(prog='cli-backup')
24
+ parser.add_argument('--project-env', type=Path)
25
+ parser.add_argument('--project')
26
+ parser.add_argument('--local-root')
27
+ parser.add_argument('--temp-root')
28
+ parser.add_argument('--s3-bucket')
29
+ parser.add_argument('--s3-endpoint')
30
+ parser.add_argument('--s3-region')
31
+ parser.add_argument('--s3-prefix')
32
+ parser.add_argument('--compose-env-file', action='append', default=[])
33
+ for option in ('db-mode', 'db-name', 'db-user', 'db-host', 'db-port', 'db-service',
34
+ 'db-network', 'db-image', 'db-client', 'compose-dir', 'redis-host',
35
+ 'redis-port', 'redis-timeout', 'local-days', 'cpu-nice', 'test-mysql-image', 'test-redis-image', 'test-redis-server', 'restore-timeout'):
36
+ parser.add_argument('--' + option)
37
+ sub = parser.add_subparsers(dest='command', required=True)
38
+ sub.add_parser('scheduled', help='scheduled database backups; run cli-backup scheduled --help')
39
+ sub.add_parser('config-check')
40
+ publish = sub.add_parser('publish-local')
41
+ publish.add_argument('source', choices=['files', 'mysql', 'redis'])
42
+ publish.add_argument('--file', required=True)
43
+ backup = sub.add_parser('backup')
44
+ backup.add_argument('source', choices=['files', 'mysql', 'redis'])
45
+ backup.add_argument('--directory', type=Path)
46
+ backup.add_argument('--exclude', action='append', default=[])
47
+ for name in ('list', 'verify', 'restore', 'restore-test', 'download'):
48
+ item = sub.add_parser(name)
49
+ item.add_argument('source', choices=['files', 'mysql', 'redis'])
50
+ item.add_argument('--storage', choices=['local', 's3'], default='local')
51
+ if name != 'list':
52
+ item.add_argument('--file', required=(name != 'restore-test'))
53
+ if name == 'download':
54
+ item.add_argument('--destination', type=Path, required=True)
55
+ if name == 'restore':
56
+ item.add_argument('--destination', type=Path)
57
+ item.add_argument('--target', help='explicit database name')
58
+ return parser
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ argv = sys.argv[1:] if argv is None else argv
63
+ if argv and argv[0] == 'scheduled':
64
+ from .scheduled import main as scheduled_main
65
+ return scheduled_main(argv[1:])
66
+ args = parser_build().parse_args(argv)
67
+ try:
68
+ project_env = read_project_env(args.project_env) if args.project_env else {}
69
+ settings = vars(args)
70
+ config = load_config(os.environ, project_env, settings)
71
+ def setting(name: str, default: str = '') -> str:
72
+ return resolve(name, settings.get(name), project_env, os.environ) or default
73
+ cpu_nice = validate_cpu_nice(setting('cpu_nice', '10'))
74
+ if args.command == 'backup':
75
+ lower_cpu_priority(cpu_nice)
76
+ if args.command == 'config-check':
77
+ print(f'Configuration valid for project {config.project}')
78
+ return 0
79
+ stores = {}
80
+ if config.local_root:
81
+ stores['local'] = LocalStorage(Path(config.local_root).expanduser())
82
+ if config.s3_bucket and (args.command in ('backup', 'publish-local') or args.storage == 's3'):
83
+ def s3_factory():
84
+ import boto3
85
+ from botocore.config import Config
86
+ client = boto3.client('s3', endpoint_url=setting('s3_endpoint') or None,
87
+ region_name=setting('s3_region') or None, config=Config(s3={'addressing_style': 'path'}),
88
+ aws_access_key_id=setting('s3_key') or None,
89
+ aws_secret_access_key=setting('s3_secret') or None)
90
+ return S3Storage(client, config.s3_bucket, setting('s3_prefix'))
91
+ stores['s3'] = s3_factory
92
+ def mysql_config(database: str | None = None) -> MySQLConfig:
93
+ return MySQLConfig(mode=setting('db_mode', 'compose'), database=database or setting('db_name'),
94
+ user=setting('db_user'), password=setting('db_password'), host=setting('db_host', '127.0.0.1'),
95
+ port=int(setting('db_port', '3306')), service=setting('db_service', 'mariadb'),
96
+ network=setting('db_network'), image=setting('db_image'), compose_dir=setting('compose_dir', '.'),
97
+ client=setting('db_client', 'mariadb'), compose_env_files=tuple(args.compose_env_file), cpu_nice=cpu_nice)
98
+ if args.command == 'publish-local':
99
+ if 'local' not in stores or 's3' not in stores:
100
+ raise ValueError('local and S3 storage are required')
101
+ local = stores['local']
102
+ manifest = next((item for item in local.list(config.project, args.source) if item.artifact == args.file), None)
103
+ if manifest is None or not local.verify(manifest):
104
+ raise ValueError('local backup missing or checksum mismatch')
105
+ remote = stores['s3']()
106
+ if not remote.verify(manifest):
107
+ raise ValueError('remote backup checksum mismatch')
108
+ remote.client.put_object(Bucket=remote.bucket, Key=remote.key(manifest) + '.json',
109
+ Body=manifest.encode(), IfNoneMatch='*')
110
+ print(f's3: {manifest.artifact}')
111
+ return 0
112
+ temp_root = setting('temp_root')
113
+ with tempfile.TemporaryDirectory(prefix='cli-backup-', dir=str(Path(temp_root).expanduser().absolute()) if temp_root else None) as tmp:
114
+ artifact = Path(tmp) / 'artifact'
115
+ if args.command == 'backup':
116
+ retention = setting('local_days')
117
+ if retention and int(retention) < 1:
118
+ raise ValueError('local retention days must be positive')
119
+ object_counts = None
120
+ if args.source == 'files':
121
+ if not args.directory:
122
+ raise ValueError('--directory is required for file backup')
123
+ capture_directory(args.directory, args.exclude, artifact)
124
+ elif args.source == 'mysql':
125
+ database = mysql_config()
126
+ object_counts = count_mysql_objects(database)
127
+ with artifact.open('xb') as output:
128
+ capture_mysql(database, output)
129
+ if count_mysql_objects(database) != object_counts:
130
+ raise ValueError('source schema object counts changed during dump')
131
+ else:
132
+ capture_redis(artifact, setting('redis_host', '127.0.0.1'), int(setting('redis_port', '6379')), setting('redis_password') or None, timeout=int(setting('redis_timeout', '14400')))
133
+ if artifact.stat().st_size == 0:
134
+ raise ValueError('source produced an empty backup')
135
+ now = datetime.now(timezone.utc)
136
+ name = f'{now:%Y%m%dT%H%M%SZ}-{uuid.uuid4().hex}.bin'
137
+ manifest = make_manifest(artifact, config.project, args.source, name, now.isoformat(), object_counts)
138
+ failures = []
139
+ for label, store in stores.items():
140
+ try:
141
+ destination = store() if label == 's3' else store
142
+ destination.put(artifact, manifest)
143
+ print(f'{label}: {name}')
144
+ except Exception as exc:
145
+ print(f'{label}: backup failed ({type(exc).__name__})', file=sys.stderr)
146
+ failures.append(label)
147
+ if failures:
148
+ return 1
149
+ if retention and 'local' in stores:
150
+ removed = prune_local(stores['local'], config.project, args.source, int(retention), now)
151
+ print(f'local: removed {removed} expired backups')
152
+ return 0
153
+ if args.storage not in stores:
154
+ raise ValueError(f'{args.storage} storage is not configured')
155
+ store = stores[args.storage]
156
+ if args.storage == 's3':
157
+ store = store()
158
+ manifests = store.list(config.project, args.source) if args.storage == 'local' else s3_list(store, config.project, args.source)
159
+ if args.command == 'list':
160
+ for item in manifests:
161
+ print(f'{item.artifact}\t{item.timestamp}\t{item.size}\t{item.sha256}')
162
+ return 0
163
+ manifest = (next((item for item in manifests if item.artifact == args.file), None)
164
+ if args.file else (manifests[-1] if manifests else None))
165
+ if manifest is None:
166
+ raise ValueError('backup not found')
167
+ with artifact.open('xb') as output:
168
+ (local_get if args.storage == 'local' else s3_get)(store, manifest, output)
169
+ if args.command == 'verify':
170
+ print('SHA-256 verified against downloaded bytes' if args.storage == 's3' else 'SHA-256 verified')
171
+ elif args.command == 'download':
172
+ with artifact.open('rb') as source:
173
+ write_private_copy(source, args.destination)
174
+ elif args.command == 'restore-test':
175
+ if args.source == 'mysql':
176
+ restored = rehearse_mysql(artifact, setting('test_mysql_image'), setting('db_client', 'mariadb'), int(setting('restore_timeout', '14400')))
177
+ expected = manifest.object_counts or count_mysql_objects(mysql_config())
178
+ if expected != restored:
179
+ raise ValueError(f'object counts differ: expected={expected}, restored={restored}')
180
+ print(f'object counts match: {restored}')
181
+ elif args.source == 'redis':
182
+ rehearse_redis(artifact, setting('test_redis_image'), setting('test_redis_server', 'redis-server'))
183
+ else:
184
+ raise ValueError('restore-test supports mysql and redis')
185
+ elif args.source == 'files':
186
+ if not args.destination:
187
+ raise ValueError('--destination is required')
188
+ extract_archive(artifact, args.destination)
189
+ print('Extracted; original ownership is not restored', file=sys.stderr)
190
+ elif args.source == 'mysql':
191
+ if not args.target:
192
+ raise ValueError('--target is required')
193
+ if not sys.stdin.isatty() or input(f'Restore into database {args.target}? Type yes: ') != 'yes':
194
+ raise ValueError('restore not confirmed')
195
+ with artifact.open('rb') as source:
196
+ restore_mysql(mysql_config(args.target), source)
197
+ else:
198
+ raise ValueError('live Redis restore is manual; use a verified RDB snapshot')
199
+ return 0
200
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
201
+ print(f'cli-backup: {exc}', file=sys.stderr)
202
+ return 2
203
+ except Exception as exc:
204
+ print(f'cli-backup: operation failed ({type(exc).__name__})', file=sys.stderr)
205
+ return 2
206
+
207
+
208
+ if __name__ == '__main__':
209
+ raise SystemExit(main())
@@ -0,0 +1,68 @@
1
+ """Configuration resolution without executing project files."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import shlex
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ PREFIX = "CLI_BACKUP_"
12
+
13
+
14
+ def resolve(name: str, argument: str | None, project: Mapping[str, str], global_env: Mapping[str, str] | None = None) -> str | None:
15
+ key = f"{PREFIX}{name.upper()}"
16
+ env = os.environ if global_env is None else global_env
17
+ return argument if argument is not None else project.get(key, env.get(key))
18
+
19
+
20
+ def validate_project(project: str) -> str:
21
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", project):
22
+ raise ValueError("invalid project identifier")
23
+ return project
24
+
25
+
26
+ def read_project_env(path: Path) -> dict[str, str]:
27
+ """Read literal CLI_BACKUP assignments; no expansion or shell execution."""
28
+ result: dict[str, str] = {}
29
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
30
+ line = line.strip()
31
+ if not line or line.startswith("#"):
32
+ continue
33
+ if line.startswith("export "):
34
+ line = line[7:].lstrip()
35
+ key, sep, value = line.partition("=")
36
+ key = key.strip()
37
+ if not key.startswith(PREFIX):
38
+ continue
39
+ if not sep or not re.fullmatch(r"CLI_BACKUP_[A-Z0-9_]+", key):
40
+ raise ValueError(f"invalid project setting at line {number}")
41
+ try:
42
+ tokens = shlex.split(value, comments=True, posix=True)
43
+ except ValueError as exc:
44
+ raise ValueError(f"invalid project setting at line {number}") from exc
45
+ if len(tokens) > 1:
46
+ raise ValueError(f"invalid project setting at line {number}")
47
+ if key in result:
48
+ raise ValueError(f"duplicate project setting at line {number}")
49
+ result[key] = tokens[0] if tokens else ""
50
+ return result
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Config:
55
+ project: str
56
+ local_root: str | None
57
+ s3_bucket: str | None
58
+
59
+
60
+ def load_config(global_env: Mapping[str, str], project_env: Mapping[str, str], args: Mapping[str, str | None]) -> Config:
61
+ project = resolve("project", args.get("project"), project_env, global_env)
62
+ if not project:
63
+ raise ValueError("CLI_BACKUP_PROJECT is required")
64
+ root = resolve("local_root", args.get("local_root"), project_env, global_env) or None
65
+ bucket = resolve("s3_bucket", args.get("s3_bucket"), project_env, global_env) or None
66
+ if not root and not bucket:
67
+ raise ValueError("configure CLI_BACKUP_LOCAL_ROOT or CLI_BACKUP_S3_BUCKET")
68
+ return Config(validate_project(project), root, bucket)
@@ -0,0 +1,21 @@
1
+ """Lower backup CPU scheduling priority without raising an inherited value."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+
7
+ def validate_cpu_nice(value: str | int) -> int:
8
+ try:
9
+ parsed = int(value)
10
+ except (TypeError, ValueError) as exc:
11
+ raise ValueError('CLI_BACKUP_CPU_NICE must be an integer from 0 to 19') from exc
12
+ if not 0 <= parsed <= 19:
13
+ raise ValueError('CLI_BACKUP_CPU_NICE must be an integer from 0 to 19')
14
+ return parsed
15
+
16
+
17
+ def lower_cpu_priority(value: int) -> None:
18
+ priority = validate_cpu_nice(value)
19
+ current = os.getpriority(os.PRIO_PROCESS, 0)
20
+ if current < priority:
21
+ os.setpriority(os.PRIO_PROCESS, 0, priority)