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.
- cli_backup-0.1.0/LICENSE +21 -0
- cli_backup-0.1.0/PKG-INFO +119 -0
- cli_backup-0.1.0/README.md +103 -0
- cli_backup-0.1.0/pyproject.toml +23 -0
- cli_backup-0.1.0/setup.cfg +4 -0
- cli_backup-0.1.0/src/cli_backup/__init__.py +1 -0
- cli_backup-0.1.0/src/cli_backup/cli.py +209 -0
- cli_backup-0.1.0/src/cli_backup/config.py +68 -0
- cli_backup-0.1.0/src/cli_backup/cpu_priority.py +21 -0
- cli_backup-0.1.0/src/cli_backup/files.py +216 -0
- cli_backup-0.1.0/src/cli_backup/mysql.py +109 -0
- cli_backup-0.1.0/src/cli_backup/redis_backup.py +24 -0
- cli_backup-0.1.0/src/cli_backup/rehearsal.py +79 -0
- cli_backup-0.1.0/src/cli_backup/scheduled.py +616 -0
- cli_backup-0.1.0/src/cli_backup/storage.py +386 -0
- cli_backup-0.1.0/src/cli_backup.egg-info/PKG-INFO +119 -0
- cli_backup-0.1.0/src/cli_backup.egg-info/SOURCES.txt +29 -0
- cli_backup-0.1.0/src/cli_backup.egg-info/dependency_links.txt +1 -0
- cli_backup-0.1.0/src/cli_backup.egg-info/entry_points.txt +2 -0
- cli_backup-0.1.0/src/cli_backup.egg-info/requires.txt +4 -0
- cli_backup-0.1.0/src/cli_backup.egg-info/top_level.txt +1 -0
- cli_backup-0.1.0/tests/test_cli.py +221 -0
- cli_backup-0.1.0/tests/test_config.py +16 -0
- cli_backup-0.1.0/tests/test_cpu_priority.py +50 -0
- cli_backup-0.1.0/tests/test_files.py +227 -0
- cli_backup-0.1.0/tests/test_mysql.py +25 -0
- cli_backup-0.1.0/tests/test_redis.py +30 -0
- cli_backup-0.1.0/tests/test_rehearsal.py +44 -0
- cli_backup-0.1.0/tests/test_scheduled.py +323 -0
- cli_backup-0.1.0/tests/test_sqlite_podman.py +76 -0
- cli_backup-0.1.0/tests/test_storage.py +219 -0
cli_backup-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](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
|
+
[](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 @@
|
|
|
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)
|