django-offsite-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 (105) hide show
  1. django_offsite_backup-0.1.0/.gitignore +28 -0
  2. django_offsite_backup-0.1.0/AGENTS.md +76 -0
  3. django_offsite_backup-0.1.0/CHANGELOG.md +30 -0
  4. django_offsite_backup-0.1.0/CONTRIBUTING.md +52 -0
  5. django_offsite_backup-0.1.0/LICENSE +21 -0
  6. django_offsite_backup-0.1.0/PKG-INFO +133 -0
  7. django_offsite_backup-0.1.0/README.md +88 -0
  8. django_offsite_backup-0.1.0/SECURITY.md +22 -0
  9. django_offsite_backup-0.1.0/docs/wiki/AI-assistants.md +105 -0
  10. django_offsite_backup-0.1.0/docs/wiki/Changelog.md +58 -0
  11. django_offsite_backup-0.1.0/docs/wiki/Configuration-archive.md +106 -0
  12. django_offsite_backup-0.1.0/docs/wiki/Databases.md +143 -0
  13. django_offsite_backup-0.1.0/docs/wiki/Deploy-integration.md +121 -0
  14. django_offsite_backup-0.1.0/docs/wiki/Encryption.md +97 -0
  15. django_offsite_backup-0.1.0/docs/wiki/Installation.md +157 -0
  16. django_offsite_backup-0.1.0/docs/wiki/Media.md +140 -0
  17. django_offsite_backup-0.1.0/docs/wiki/Monitoring.md +97 -0
  18. django_offsite_backup-0.1.0/docs/wiki/Notifications.md +193 -0
  19. django_offsite_backup-0.1.0/docs/wiki/Restore.md +164 -0
  20. django_offsite_backup-0.1.0/docs/wiki/Retention.md +107 -0
  21. django_offsite_backup-0.1.0/docs/wiki/S3-providers.md +170 -0
  22. django_offsite_backup-0.1.0/docs/wiki/Scheduling.md +127 -0
  23. django_offsite_backup-0.1.0/docs/wiki/Security-model.md +108 -0
  24. django_offsite_backup-0.1.0/docs/wiki/Settings.md +274 -0
  25. django_offsite_backup-0.1.0/docs/wiki/Stores-S3.md +95 -0
  26. django_offsite_backup-0.1.0/docs/wiki/Stores-directory.md +60 -0
  27. django_offsite_backup-0.1.0/docs/wiki/System-checks.md +65 -0
  28. django_offsite_backup-0.1.0/docs/wiki/Testing.md +165 -0
  29. django_offsite_backup-0.1.0/docs/wiki/Troubleshooting.md +229 -0
  30. django_offsite_backup-0.1.0/docs/wiki/Upgrading.md +29 -0
  31. django_offsite_backup-0.1.0/docs/wiki/index.md +98 -0
  32. django_offsite_backup-0.1.0/mkdocs.yml +119 -0
  33. django_offsite_backup-0.1.0/pyproject.toml +176 -0
  34. django_offsite_backup-0.1.0/scripts/smoke_install.sh +51 -0
  35. django_offsite_backup-0.1.0/src/offsite_backup/__init__.py +52 -0
  36. django_offsite_backup-0.1.0/src/offsite_backup/apps.py +19 -0
  37. django_offsite_backup-0.1.0/src/offsite_backup/backend.py +48 -0
  38. django_offsite_backup-0.1.0/src/offsite_backup/check.py +65 -0
  39. django_offsite_backup-0.1.0/src/offsite_backup/checks.py +145 -0
  40. django_offsite_backup-0.1.0/src/offsite_backup/conf.py +212 -0
  41. django_offsite_backup-0.1.0/src/offsite_backup/config/__init__.py +3 -0
  42. django_offsite_backup-0.1.0/src/offsite_backup/config/base.py +20 -0
  43. django_offsite_backup-0.1.0/src/offsite_backup/config/paths.py +125 -0
  44. django_offsite_backup-0.1.0/src/offsite_backup/databases/__init__.py +3 -0
  45. django_offsite_backup-0.1.0/src/offsite_backup/databases/base.py +32 -0
  46. django_offsite_backup-0.1.0/src/offsite_backup/databases/postgres.py +204 -0
  47. django_offsite_backup-0.1.0/src/offsite_backup/encryption/__init__.py +3 -0
  48. django_offsite_backup-0.1.0/src/offsite_backup/encryption/age_cli.py +58 -0
  49. django_offsite_backup-0.1.0/src/offsite_backup/encryption/base.py +19 -0
  50. django_offsite_backup-0.1.0/src/offsite_backup/exceptions.py +49 -0
  51. django_offsite_backup-0.1.0/src/offsite_backup/guard.py +57 -0
  52. django_offsite_backup-0.1.0/src/offsite_backup/management/__init__.py +3 -0
  53. django_offsite_backup-0.1.0/src/offsite_backup/management/commands/__init__.py +3 -0
  54. django_offsite_backup-0.1.0/src/offsite_backup/management/commands/backup_check.py +27 -0
  55. django_offsite_backup-0.1.0/src/offsite_backup/management/commands/backup_list.py +86 -0
  56. django_offsite_backup-0.1.0/src/offsite_backup/management/commands/backup_offsite.py +49 -0
  57. django_offsite_backup-0.1.0/src/offsite_backup/management/commands/backup_restore.py +59 -0
  58. django_offsite_backup-0.1.0/src/offsite_backup/media/__init__.py +3 -0
  59. django_offsite_backup-0.1.0/src/offsite_backup/media/base.py +125 -0
  60. django_offsite_backup-0.1.0/src/offsite_backup/media/filesystem.py +75 -0
  61. django_offsite_backup-0.1.0/src/offsite_backup/media/s3.py +52 -0
  62. django_offsite_backup-0.1.0/src/offsite_backup/messages/__init__.py +3 -0
  63. django_offsite_backup-0.1.0/src/offsite_backup/messages/default.py +122 -0
  64. django_offsite_backup-0.1.0/src/offsite_backup/naming.py +119 -0
  65. django_offsite_backup-0.1.0/src/offsite_backup/notifiers/__init__.py +3 -0
  66. django_offsite_backup-0.1.0/src/offsite_backup/notifiers/base.py +16 -0
  67. django_offsite_backup-0.1.0/src/offsite_backup/notifiers/django_aiogram.py +49 -0
  68. django_offsite_backup-0.1.0/src/offsite_backup/notifiers/log.py +22 -0
  69. django_offsite_backup-0.1.0/src/offsite_backup/notifiers/telegram.py +76 -0
  70. django_offsite_backup-0.1.0/src/offsite_backup/notify.py +96 -0
  71. django_offsite_backup-0.1.0/src/offsite_backup/outcome.py +56 -0
  72. django_offsite_backup-0.1.0/src/offsite_backup/pass_.py +265 -0
  73. django_offsite_backup-0.1.0/src/offsite_backup/pipeline.py +139 -0
  74. django_offsite_backup-0.1.0/src/offsite_backup/py.typed +0 -0
  75. django_offsite_backup-0.1.0/src/offsite_backup/registry.py +71 -0
  76. django_offsite_backup-0.1.0/src/offsite_backup/restore.py +198 -0
  77. django_offsite_backup-0.1.0/src/offsite_backup/rotation.py +95 -0
  78. django_offsite_backup-0.1.0/src/offsite_backup/schedule/__init__.py +3 -0
  79. django_offsite_backup-0.1.0/src/offsite_backup/schedule/celery.py +45 -0
  80. django_offsite_backup-0.1.0/src/offsite_backup/schedule/taskiq.py +35 -0
  81. django_offsite_backup-0.1.0/src/offsite_backup/sealing.py +25 -0
  82. django_offsite_backup-0.1.0/src/offsite_backup/stores/__init__.py +3 -0
  83. django_offsite_backup-0.1.0/src/offsite_backup/stores/base.py +67 -0
  84. django_offsite_backup-0.1.0/src/offsite_backup/stores/filesystem.py +90 -0
  85. django_offsite_backup-0.1.0/src/offsite_backup/stores/s3.py +260 -0
  86. django_offsite_backup-0.1.0/src/offsite_backup/testing/__init__.py +88 -0
  87. django_offsite_backup-0.1.0/src/offsite_backup/testing/fake_s3.py +274 -0
  88. django_offsite_backup-0.1.0/src/offsite_backup/testing/pytest_plugin.py +27 -0
  89. django_offsite_backup-0.1.0/tests/__init__.py +0 -0
  90. django_offsite_backup-0.1.0/tests/backends.py +69 -0
  91. django_offsite_backup-0.1.0/tests/conftest.py +77 -0
  92. django_offsite_backup-0.1.0/tests/integration/__init__.py +0 -0
  93. django_offsite_backup-0.1.0/tests/integration/test_round_trip.py +253 -0
  94. django_offsite_backup-0.1.0/tests/integration_settings.py +17 -0
  95. django_offsite_backup-0.1.0/tests/settings.py +5 -0
  96. django_offsite_backup-0.1.0/tests/test_checks_commands.py +388 -0
  97. django_offsite_backup-0.1.0/tests/test_conf.py +88 -0
  98. django_offsite_backup-0.1.0/tests/test_media_config.py +157 -0
  99. django_offsite_backup-0.1.0/tests/test_messages.py +102 -0
  100. django_offsite_backup-0.1.0/tests/test_naming.py +51 -0
  101. django_offsite_backup-0.1.0/tests/test_notify_guard_check.py +251 -0
  102. django_offsite_backup-0.1.0/tests/test_pass.py +241 -0
  103. django_offsite_backup-0.1.0/tests/test_pipeline.py +47 -0
  104. django_offsite_backup-0.1.0/tests/test_rotation.py +174 -0
  105. django_offsite_backup-0.1.0/tests/test_store_s3.py +136 -0
@@ -0,0 +1,28 @@
1
+ .idea/
2
+ .vscode/
3
+ venv/
4
+ .venv/
5
+ *.egg-info/
6
+
7
+ # Python cache.
8
+ *.py[cod]
9
+ __pycache__/
10
+
11
+ # build
12
+ build/
13
+ dist/
14
+ # the documentation site, built from docs/wiki/ by `mkdocs build`
15
+ site/
16
+
17
+ # tooling caches
18
+ .pytest_cache/
19
+ .mypy_cache/
20
+ .ruff_cache/
21
+ .coverage
22
+ htmlcov/
23
+
24
+ # JetBrains AI plugin checkpoints: 45 MB of local cache, not source
25
+ .proxyai/
26
+
27
+ # macOS
28
+ .DS_Store
@@ -0,0 +1,76 @@
1
+ # AGENTS.md
2
+
3
+ Instructions for coding agents working on **this repository**. For wiring the package into a
4
+ project, see the wiki page [AI assistants](https://corneizer.github.io/django-offsite-backup/latest/AI-assistants/).
5
+
6
+ ## What this is
7
+
8
+ A Django app that backs up databases, media and deployment configuration off the machine:
9
+ compress, then encrypt to public keys, upload to a store that locks every object, rotate by
10
+ whole nights, and say the outcome. Every backend is a dotted path in `OFFSITE_BACKUP` and is
11
+ imported only when named.
12
+
13
+ ```text
14
+ src/offsite_backup/
15
+ __init__.py lazy exports (PEP 562), __version__
16
+ apps.py registers the checks, imports nothing else
17
+ conf.py the setting: defaults, on/off/partly-configured
18
+ registry.py SHIPPED (path -> driver, extra), build()
19
+ backend.py what every backend shares (REQUIRED, SIGNALS, BINARIES, checks)
20
+ checks.py system checks; network ones are deploy checks
21
+ naming.py object names; the night is read from the name
22
+ pipeline.py subprocess pipelines with pipefail semantics, no shell
23
+ sealing.py compress THEN encrypt
24
+ pass_.py one pass, back_up_database, keep_apart
25
+ rotation.py whole-night rotation and depth
26
+ guard.py once per UTC night
27
+ check.py the morning check
28
+ restore.py download, decrypt, verify, load
29
+ notify.py formatter + notifiers; silence() and capture()
30
+ outcome.py the records messages are written from
31
+ messages/ the shipped format
32
+ notifiers/ databases/ media/ config/ encryption/ stores/ one backend per module
33
+ schedule/ Celery and taskiq adapters, imported only by projects using them
34
+ management/commands/ backup_offsite, backup_check, backup_list, backup_restore
35
+ testing/ FakeS3, Passthrough, use_fake_s3, the runner and the pytest plugin
36
+ ```
37
+
38
+ ## Commands
39
+
40
+ ```shell
41
+ uv venv && uv pip install -e '.[all]' --group dev
42
+ ruff check . && ruff format --check . && mypy && python -m pytest -q
43
+ ```
44
+
45
+ Integration (real PostgreSQL, an S3 server with Object Lock, `age`, `zstd`) — see
46
+ `CONTRIBUTING.md`:
47
+
48
+ ```shell
49
+ OFFSITE_BACKUP_TEST_S3_ENDPOINT=http://127.0.0.1:9000 OFFSITE_BACKUP_TEST_PG_PORT=5432 \
50
+ python -m pytest -m integration --ds=tests.integration_settings tests/integration
51
+ ```
52
+
53
+ ## Rules
54
+
55
+ - **Nothing happens at import time.** `import offsite_backup` loads no driver; every backend
56
+ imports its driver inside the function that needs it. `tests/test_checks_commands.py`
57
+ imports every module with every driver blocked.
58
+ - **No private references.** This package is public: no company, client, product, host or
59
+ ticket names in code, docs, tests or commit messages. Examples use `acme/prod`.
60
+ - **Every change carries a test that fails without it.** Swap the source, watch it go red,
61
+ put it back — never `git checkout` a file with uncommitted work. One case per guard; no
62
+ case that fails for an earlier reason.
63
+ - **A notifier never raises and never costs the backup**; it logs the error class, never the
64
+ text it sends or a token.
65
+ - **Secrets travel in the environment, never in argv**: argv is visible in `ps`, and error
66
+ text is posted to a chat.
67
+ - **The lock is an argument of every upload and is read back.** Do not rely on a bucket
68
+ default for a single PUT.
69
+ - **Rotation is scoped to `<prefix>/<kind>/` with the trailing slash**, counts nights read
70
+ from names, deletes versions (never a bare key), and leaves locked versions for later.
71
+ - A settings rename or a default flip is a major version.
72
+
73
+ ## Style
74
+
75
+ Code, comments, docstrings and docs in English. A comment is one line for the non-obvious
76
+ *why*; module docstrings explain design. Commits and pull requests carry no AI attribution.
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The project follows
4
+ [semantic versioning](https://semver.org/): renaming a setting or flipping a default is a
5
+ major version.
6
+
7
+ ## 0.1.0 — 2026-11-03
8
+
9
+ First release.
10
+
11
+ - A pass: PostgreSQL databases (a list, or computed at run time; plain or custom format),
12
+ media in volumes of whole files (a directory, a Docker volume mounted as one, or an S3
13
+ bucket), and the deployment configuration with the environment rebuilt from a list of
14
+ names — compressed with zstd, encrypted with age to one or more recipients.
15
+ - Stores: S3 with Object Lock COMPLIANCE passed on every upload and read back, versions and
16
+ delete markers, pagination; a local directory (no immutability, W001).
17
+ - Whole-night rotation per kind; passes without media (`--without-media`) never count as a
18
+ night; depth reported after rotation.
19
+ - One database failing does not cost the others their night; the pass fails at the end
20
+ naming each one.
21
+ - Once-per-night guard for the nightly pass; the morning check (MISSING / UNCHECKED).
22
+ - Messages in one HTML format; notifiers: log, Telegram (`[telegram]`), django-aiogram
23
+ (`[aiogram]`). Notifiers are silenced for every test run.
24
+ - `back_up_database` (extra recipients, `keep_apart`), `keep_apart`, `restore_database`.
25
+ - Commands: `backup_offsite`, `backup_check`, `backup_list`, `backup_restore` (download,
26
+ decrypt, verify; `--apply` loads a database).
27
+ - System checks E001–E008, W001–W005.
28
+ - Celery and taskiq adapters.
29
+ - `offsite_backup.testing`: FakeS3, Passthrough, `use_fake_s3`, `capture`, the test runner
30
+ and the pytest plugin.
@@ -0,0 +1,52 @@
1
+ # Contributing
2
+
3
+ ## Set up
4
+
5
+ ```shell
6
+ uv venv
7
+ uv pip install -e '.[all]' --group dev # pip 25.1+ also understands --group
8
+ ```
9
+
10
+ ## The gate
11
+
12
+ ```shell
13
+ ruff check . && ruff format --check . && mypy && python -m pytest -q
14
+ ```
15
+
16
+ The unit suite needs no network and no binary beyond a POSIX shell: it runs against an
17
+ in-memory S3 (`offsite_backup.testing.FakeS3`) and a pass-through sealer.
18
+
19
+ ## Integration
20
+
21
+ A full cycle against real services — backup, restore into a clean database, row counts of
22
+ every table compared; media volumes byte for byte; Object Lock read back; rotation over real
23
+ versions and delete markers.
24
+
25
+ ```shell
26
+ docker run -d --rm --name ob-pg -e POSTGRES_USER=ob -e POSTGRES_PASSWORD=ob -e POSTGRES_DB=source \
27
+ -p 5432:5432 postgres:17
28
+ docker exec ob-pg psql -U ob -d source -c 'CREATE DATABASE target'
29
+ docker run -d --rm --name ob-s3 -e MINIO_ROOT_USER=minio -e MINIO_ROOT_PASSWORD=minio12345 \
30
+ -p 9000:9000 cgr.dev/chainguard/minio:latest server /data
31
+
32
+ OFFSITE_BACKUP_TEST_S3_ENDPOINT=http://127.0.0.1:9000 \
33
+ python -m pytest -m integration --ds=tests.integration_settings tests/integration
34
+ ```
35
+
36
+ It needs `age`, `age-keygen`, `zstd`, `pg_dump`, `psql` and `pg_restore` on PATH, the client
37
+ at least as new as the server. Variables: `OFFSITE_BACKUP_TEST_PG_{HOST,PORT,USER,PASSWORD,SOURCE,TARGET}`,
38
+ `OFFSITE_BACKUP_TEST_S3_{ENDPOINT,ACCESS_KEY,SECRET_KEY}`. Each run creates fresh buckets:
39
+ COMPLIANCE locks cannot be lifted, so do not point it at a store you care about.
40
+
41
+ ## Pull requests
42
+
43
+ One change per pull request, with a test that fails without it, and the wiki page that
44
+ describes the behaviour updated in the same pull request (`docs/wiki/`, built with
45
+ `mkdocs build --strict`). Add a line to `CHANGELOG.md`.
46
+
47
+ ## Releasing
48
+
49
+ Bump `__version__` in `src/offsite_backup/__init__.py`, move the changelog section out of
50
+ "Unreleased", merge, then tag `vX.Y.Z` on master and push the tag. `publish.yml` checks the tag
51
+ against `__version__`, builds, smoke-installs the wheel, publishes to PyPI by trusted
52
+ publishing and creates the GitHub release.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oleksii Kolosiuk
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,133 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-offsite-backup
3
+ Version: 0.1.0
4
+ Summary: Encrypted, immutable off-site backups of a Django project's databases, media and deployment configuration
5
+ Project-URL: Homepage, https://github.com/CorneiZeR/django-offsite-backup
6
+ Project-URL: Documentation, https://corneizer.github.io/django-offsite-backup/
7
+ Project-URL: Changelog, https://github.com/CorneiZeR/django-offsite-backup/blob/master/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/CorneiZeR/django-offsite-backup/issues
9
+ Project-URL: Repository, https://github.com/CorneiZeR/django-offsite-backup
10
+ Author-email: Oleksii Kolosiuk <kolosyuk1@gmail.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: age,backup,django,encryption,object-lock,postgres,retention,s3,telegram
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Framework :: Django
16
+ Classifier: Framework :: Django :: 5.2
17
+ Classifier: Framework :: Django :: 6.0
18
+ Classifier: Framework :: Django :: 6.1
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Intended Audience :: System Administrators
21
+ Classifier: Operating System :: POSIX
22
+ Classifier: Programming Language :: Python
23
+ Classifier: Programming Language :: Python :: 3
24
+ Classifier: Programming Language :: Python :: 3.10
25
+ Classifier: Programming Language :: Python :: 3.11
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Classifier: Programming Language :: Python :: 3.14
29
+ Classifier: Topic :: Database
30
+ Classifier: Topic :: System :: Archiving :: Backup
31
+ Classifier: Typing :: Typed
32
+ Requires-Python: <3.15,>=3.10
33
+ Requires-Dist: django>=5.2
34
+ Provides-Extra: aiogram
35
+ Requires-Dist: django-aiogram>=5.2; extra == 'aiogram'
36
+ Provides-Extra: all
37
+ Requires-Dist: boto3>=1.34; extra == 'all'
38
+ Requires-Dist: django-aiogram>=5.2; extra == 'all'
39
+ Requires-Dist: httpx2>=2.0; extra == 'all'
40
+ Provides-Extra: s3
41
+ Requires-Dist: boto3>=1.34; extra == 's3'
42
+ Provides-Extra: telegram
43
+ Requires-Dist: httpx2>=2.0; extra == 'telegram'
44
+ Description-Content-Type: text/markdown
45
+
46
+ # django-offsite-backup
47
+
48
+ [![CI](https://github.com/CorneiZeR/django-offsite-backup/actions/workflows/ci.yml/badge.svg)](https://github.com/CorneiZeR/django-offsite-backup/actions/workflows/ci.yml)
49
+ [![PyPI](https://img.shields.io/pypi/v/django-offsite-backup.svg)](https://pypi.org/project/django-offsite-backup/)
50
+ [![Python](https://img.shields.io/pypi/pyversions/django-offsite-backup.svg)](https://pypi.org/project/django-offsite-backup/)
51
+
52
+ Off-site backups of a Django project's **databases, media and deployment configuration**:
53
+ compressed, encrypted to a public key the server holds (so the server can write a backup
54
+ and cannot read one), uploaded to S3 with **Object Lock COMPLIANCE** read back on every
55
+ object, and rotated by **whole nights** — never by age alone, never by object count.
56
+
57
+ - PostgreSQL (one database, a list, or a list computed at run time), plain or custom format
58
+ - media from a directory, a Docker volume or an S3 bucket, in volumes of whole files
59
+ - the deployment directory plus the environment rebuilt from a list of variable names
60
+ - `age` encryption to one or more recipients; `zstd` compression before it
61
+ - a pass before every deploy without media (fast, and never mistaken for a night)
62
+ - a once-per-night guard, and a morning check that notices the night that never ran
63
+ - every outcome said in Telegram (or through django-aiogram, or a log), in one glanceable format
64
+ - restore that downloads, decrypts, verifies — and, on request, loads a database
65
+ - system checks that refuse a half-configured machine by name and a bucket without a lock
66
+
67
+ Every backend is a dotted path in settings and imported only when named; each driver is an
68
+ extra. A base install pulls Django and nothing else.
69
+
70
+ ## 30-second setup
71
+
72
+ ```shell
73
+ pip install "django-offsite-backup[s3,telegram]"
74
+ ```
75
+
76
+ ```python
77
+ import os
78
+
79
+ INSTALLED_APPS = [..., 'offsite_backup']
80
+
81
+ OFFSITE_BACKUP = {
82
+ 'PREFIX': 'acme/prod',
83
+ 'ENV': 'prod',
84
+ 'STORE': {
85
+ 'BACKEND': 'offsite_backup.stores.s3.S3Store',
86
+ 'ENDPOINT_URL': os.environ.get('BACKUP_S3_ENDPOINT_URL', ''),
87
+ 'BUCKET': os.environ.get('BACKUP_S3_BUCKET', ''),
88
+ 'ACCESS_KEY': os.environ.get('BACKUP_S3_ACCESS_KEY', ''),
89
+ 'SECRET_KEY': os.environ.get('BACKUP_S3_SECRET_KEY', ''),
90
+ 'LOCK_DAYS': 13,
91
+ },
92
+ 'ENCRYPTION': {
93
+ 'BACKEND': 'offsite_backup.encryption.age_cli.AgeCli',
94
+ 'RECIPIENTS': [os.environ.get('BACKUP_AGE_RECIPIENT', '')],
95
+ },
96
+ 'DATABASES': {'BACKEND': 'offsite_backup.databases.postgres.PostgresSource'},
97
+ 'MEDIA': [{'BACKEND': 'offsite_backup.media.filesystem.DirectorySource'}],
98
+ 'CONFIG': {'BACKEND': 'offsite_backup.config.paths.PathsSource', 'PATHS': ['/app/deploy']},
99
+ 'RETENTION': {'KEEP_NIGHTS': 14},
100
+ 'NOTIFY': [
101
+ {
102
+ 'BACKEND': 'offsite_backup.notifiers.telegram.TelegramNotifier',
103
+ 'TOKEN': os.environ.get('BACKUP_TG_TOKEN', ''),
104
+ 'CHAT_ID': os.environ.get('BACKUP_TG_CHAT_ID', ''),
105
+ }
106
+ ],
107
+ }
108
+ ```
109
+
110
+ With every credential empty (a development machine) backups are **off** and the commands
111
+ say "nothing to do". With some set and some missing, `manage.py check` names each one.
112
+
113
+ ```shell
114
+ python manage.py check --deploy # bucket lock, pg_dump version
115
+ python manage.py backup_offsite # a pass now
116
+ python manage.py backup_offsite --without-media --reason deploy # before a migration
117
+ python manage.py backup_check # the morning check
118
+ python manage.py backup_list
119
+ python manage.py backup_restore --stamp latest --identity key.txt --into ./restore
120
+ ```
121
+
122
+ The image needs `pg_dump` (at least the server's major), `zstd` and `age`. The bucket must be
123
+ created **with Object Lock** and a default COMPLIANCE rule of `LOCK_DAYS` days.
124
+
125
+ ## Documentation
126
+
127
+ **<https://corneizer.github.io/django-offsite-backup/>** — installation, every setting,
128
+ S3 providers, retention and locks, restore drills, the security model, troubleshooting by
129
+ check code, and a page for AI assistants wiring the package into a project.
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,88 @@
1
+ # django-offsite-backup
2
+
3
+ [![CI](https://github.com/CorneiZeR/django-offsite-backup/actions/workflows/ci.yml/badge.svg)](https://github.com/CorneiZeR/django-offsite-backup/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/django-offsite-backup.svg)](https://pypi.org/project/django-offsite-backup/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/django-offsite-backup.svg)](https://pypi.org/project/django-offsite-backup/)
6
+
7
+ Off-site backups of a Django project's **databases, media and deployment configuration**:
8
+ compressed, encrypted to a public key the server holds (so the server can write a backup
9
+ and cannot read one), uploaded to S3 with **Object Lock COMPLIANCE** read back on every
10
+ object, and rotated by **whole nights** — never by age alone, never by object count.
11
+
12
+ - PostgreSQL (one database, a list, or a list computed at run time), plain or custom format
13
+ - media from a directory, a Docker volume or an S3 bucket, in volumes of whole files
14
+ - the deployment directory plus the environment rebuilt from a list of variable names
15
+ - `age` encryption to one or more recipients; `zstd` compression before it
16
+ - a pass before every deploy without media (fast, and never mistaken for a night)
17
+ - a once-per-night guard, and a morning check that notices the night that never ran
18
+ - every outcome said in Telegram (or through django-aiogram, or a log), in one glanceable format
19
+ - restore that downloads, decrypts, verifies — and, on request, loads a database
20
+ - system checks that refuse a half-configured machine by name and a bucket without a lock
21
+
22
+ Every backend is a dotted path in settings and imported only when named; each driver is an
23
+ extra. A base install pulls Django and nothing else.
24
+
25
+ ## 30-second setup
26
+
27
+ ```shell
28
+ pip install "django-offsite-backup[s3,telegram]"
29
+ ```
30
+
31
+ ```python
32
+ import os
33
+
34
+ INSTALLED_APPS = [..., 'offsite_backup']
35
+
36
+ OFFSITE_BACKUP = {
37
+ 'PREFIX': 'acme/prod',
38
+ 'ENV': 'prod',
39
+ 'STORE': {
40
+ 'BACKEND': 'offsite_backup.stores.s3.S3Store',
41
+ 'ENDPOINT_URL': os.environ.get('BACKUP_S3_ENDPOINT_URL', ''),
42
+ 'BUCKET': os.environ.get('BACKUP_S3_BUCKET', ''),
43
+ 'ACCESS_KEY': os.environ.get('BACKUP_S3_ACCESS_KEY', ''),
44
+ 'SECRET_KEY': os.environ.get('BACKUP_S3_SECRET_KEY', ''),
45
+ 'LOCK_DAYS': 13,
46
+ },
47
+ 'ENCRYPTION': {
48
+ 'BACKEND': 'offsite_backup.encryption.age_cli.AgeCli',
49
+ 'RECIPIENTS': [os.environ.get('BACKUP_AGE_RECIPIENT', '')],
50
+ },
51
+ 'DATABASES': {'BACKEND': 'offsite_backup.databases.postgres.PostgresSource'},
52
+ 'MEDIA': [{'BACKEND': 'offsite_backup.media.filesystem.DirectorySource'}],
53
+ 'CONFIG': {'BACKEND': 'offsite_backup.config.paths.PathsSource', 'PATHS': ['/app/deploy']},
54
+ 'RETENTION': {'KEEP_NIGHTS': 14},
55
+ 'NOTIFY': [
56
+ {
57
+ 'BACKEND': 'offsite_backup.notifiers.telegram.TelegramNotifier',
58
+ 'TOKEN': os.environ.get('BACKUP_TG_TOKEN', ''),
59
+ 'CHAT_ID': os.environ.get('BACKUP_TG_CHAT_ID', ''),
60
+ }
61
+ ],
62
+ }
63
+ ```
64
+
65
+ With every credential empty (a development machine) backups are **off** and the commands
66
+ say "nothing to do". With some set and some missing, `manage.py check` names each one.
67
+
68
+ ```shell
69
+ python manage.py check --deploy # bucket lock, pg_dump version
70
+ python manage.py backup_offsite # a pass now
71
+ python manage.py backup_offsite --without-media --reason deploy # before a migration
72
+ python manage.py backup_check # the morning check
73
+ python manage.py backup_list
74
+ python manage.py backup_restore --stamp latest --identity key.txt --into ./restore
75
+ ```
76
+
77
+ The image needs `pg_dump` (at least the server's major), `zstd` and `age`. The bucket must be
78
+ created **with Object Lock** and a default COMPLIANCE rule of `LOCK_DAYS` days.
79
+
80
+ ## Documentation
81
+
82
+ **<https://corneizer.github.io/django-offsite-backup/>** — installation, every setting,
83
+ S3 providers, retention and locks, restore drills, the security model, troubleshooting by
84
+ check code, and a page for AI assistants wiring the package into a project.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,22 @@
1
+ # Security policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please report security issues privately through GitHub's
6
+ [private vulnerability reporting](https://github.com/CorneiZeR/django-offsite-backup/security/advisories/new),
7
+ not in a public issue. Expect an acknowledgement within a few days.
8
+
9
+ ## Supported versions
10
+
11
+ The latest minor release receives security fixes.
12
+
13
+ ## What the package promises
14
+
15
+ - The server holds **public** keys only; a backup it wrote cannot be read with anything on it.
16
+ - Every object in an S3 store is written with Object Lock COMPLIANCE and the lock is read back;
17
+ the key the server holds cannot delete a locked object.
18
+ - Credentials reach subprocesses through the environment, never the command line, and no
19
+ notifier logs a token or the message body.
20
+
21
+ The [Security model](https://corneizer.github.io/django-offsite-backup/latest/Security-model/)
22
+ page describes the threat model and its limits.
@@ -0,0 +1,105 @@
1
+ # AI assistants
2
+
3
+ Coding agents wire this package into a project the way a person would — by reading the
4
+ docs — and the mistakes they make are the expensive kind: a private key on the server, a
5
+ test suite posting to the team's chat, a bucket without Object Lock. This page is the
6
+ brief to hand them.
7
+
8
+ ## Paste this in
9
+
10
+ Short enough for a system prompt or a `CLAUDE.md` / `AGENTS.md` / `.cursor/rules` file:
11
+
12
+ ```text
13
+ Project uses django-offsite-backup 0.1. Rules:
14
+
15
+ - Install with the extras the configuration names, nothing inferred:
16
+ `django-offsite-backup[s3,telegram]` for an S3 store and Telegram messages,
17
+ `[s3,aiogram]` to send through the project's django-aiogram bot. Add
18
+ 'offsite_backup' to INSTALLED_APPS. It has no models and no migrations.
19
+ - The image that runs a pass needs the binaries: pg_dump (major >= the
20
+ PostgreSQL server's), zstd, age. Not tar. Install them in the worker's image
21
+ AND the image the deploy pass runs in.
22
+ - Settings are one dict, OFFSITE_BACKUP, in settings.py. Read EVERY credential
23
+ from the environment with a default of '' — os.environ.get('X', '') — never
24
+ os.environ['X'] and never a placeholder value. Empty means "backups off on this
25
+ machine", which is what developer laptops and CI want.
26
+ - The age RECIPIENTS is a public key and may be read from the environment or
27
+ written into settings; it does not switch backups on by itself.
28
+ - Backend paths, exactly:
29
+ offsite_backup.stores.s3.S3Store / offsite_backup.stores.filesystem.DirectoryStore
30
+ offsite_backup.encryption.age_cli.AgeCli
31
+ offsite_backup.databases.postgres.PostgresSource
32
+ offsite_backup.media.filesystem.DirectorySource / offsite_backup.media.s3.S3Source
33
+ offsite_backup.config.paths.PathsSource
34
+ offsite_backup.notifiers.telegram.TelegramNotifier
35
+ offsite_backup.notifiers.django_aiogram.TopicNotifier
36
+ offsite_backup.notifiers.log.LogNotifier
37
+ - NEVER put an age private key (AGE-SECRET-KEY-...) on the server, in settings,
38
+ in .env, in CI variables, or in the repository. RECIPIENTS takes public keys
39
+ (age1...). The private key is generated on a person's machine with age-keygen
40
+ and kept in their password manager. E008 refuses a private key in RECIPIENTS.
41
+ Do not generate a key pair for the user: tell them to run age-keygen and give
42
+ you the public half.
43
+ - The S3 bucket must be created WITH Object Lock (it cannot be enabled later)
44
+ and given a default retention rule COMPLIANCE, days == STORE['LOCK_DAYS']
45
+ (13). Keep RETENTION['KEEP_NIGHTS'] (14) greater than LOCK_DAYS. Never
46
+ suggest GOVERNANCE mode. Never "fix" E005/E006 by setting LOCK_DAYS to 0
47
+ without the user explicitly accepting backups that the server can delete.
48
+ - PREFIX is required ('<project>/<env>', e.g. 'acme/prod'). Rotation never
49
+ leaves it.
50
+ - Scheduling: Celery -> CELERY_BEAT_SCHEDULE = {**beat_schedule(), ...} from
51
+ offsite_backup.schedule.celery, with CELERY_IMPORTS including
52
+ 'offsite_backup.schedule.celery' and the beat in UTC. taskiq ->
53
+ register(broker) from offsite_backup.schedule.taskiq. cron -> 03:00 UTC
54
+ `manage.py backup_offsite --reason nightly`, 09:00 UTC `manage.py backup_check`.
55
+ The guard needs GUARD['CACHE'] to be a cache shared by every worker (Redis,
56
+ Memcached, database) — not LocMemCache (W003).
57
+ - Deploy: run `python manage.py backup_offsite --without-media --reason deploy`
58
+ on the NEW image BEFORE `migrate`, and let a non-zero exit stop the deploy.
59
+ Do not add `|| true`. Mount the deployment directory read-only into the service
60
+ that runs it AND into the worker.
61
+ - Validate with `python manage.py check` and `python manage.py check --deploy`.
62
+ Treat offsite_backup.E0xx/W0xx as the spec. Do not silence an error; only
63
+ W001 (directory store) may be silenced, and only where the user chose it.
64
+ - Tests must never notify. pytest is covered by the package's plugin
65
+ automatically; with Django's runner set
66
+ TEST_RUNNER = 'offsite_backup.testing.OffsiteBackupTestRunner' (or mix it in).
67
+ Do not give CI test jobs the production notifier token or store keys. In
68
+ tests use offsite_backup.testing: use_fake_s3(FakeS3()), Passthrough as the
69
+ ENCRYPTION backend with COMPRESS [], capture() to assert on messages,
70
+ media_tree() for media. Passthrough is for tests only (W002).
71
+ - Restore: `manage.py backup_restore --stamp latest --into DIR` with the
72
+ private key in AGE_IDENTITY or --identity FILE, run by a person on their own
73
+ machine. Never write code that stores the identity on the server.
74
+ ```
75
+
76
+ ## Prompts that work
77
+
78
+ **Wire it in.** *"Add django-offsite-backup to this project: S3 store with credentials
79
+ from `BACKUP_S3_*` variables, age recipient from `BACKUP_AGE_RECIPIENT`, PostgreSQL
80
+ `default`, media from `MEDIA_ROOT`, the `deploy/` directory as configuration, Telegram from
81
+ `BACKUP_TELEGRAM_*`. Add the Celery beat entries, the pre-migration step to our deploy script,
82
+ the read-only mount on web and worker, the binaries to the Dockerfile, and
83
+ `OffsiteBackupTestRunner`. Do not generate keys. Show me `manage.py check` output with the
84
+ variables unset."* — the last sentence is the test that the laptop stays "off".
85
+
86
+ **Review a wiring.** *"Check our OFFSITE_BACKUP settings and deploy script against the
87
+ django-offsite-backup docs: credentials from the environment with '' defaults, no private
88
+ key anywhere, backup before migrate with a failing exit stopping the deploy, mounts on
89
+ both services, notifier silenced in tests."*
90
+
91
+ **Prepare a bucket.** *"Give me the AWS CLI commands to create a bucket with Object Lock and
92
+ a default COMPLIANCE rule of 13 days on <provider>, and a least-privilege policy for the
93
+ backup key under prefix `acme/prod/`. Do not run them."*
94
+
95
+ ## What to check after an agent is done
96
+
97
+ * `grep -rn "AGE-SECRET-KEY" .` finds nothing — in the repository, the CI configuration or
98
+ the server's `.env`.
99
+ * `python manage.py check` with the backup variables **unset** reports no `offsite_backup`
100
+ code (the machine is off); with them set, none but warnings you chose.
101
+ * `python manage.py check --deploy` on the server: no `E004`, `E005`, `E006`.
102
+ * The deploy script stops when `backup_offsite` exits non-zero — try it once with a wrong
103
+ bucket name on staging.
104
+ * The test job's environment holds no production notifier token.
105
+ * Someone has restored a backup — [Restore](Restore.md).
@@ -0,0 +1,58 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — unreleased
4
+
5
+ The first release.
6
+
7
+ **A pass.** `manage.py backup_offsite` (and `offsite_backup.run()`) backs up the
8
+ databases, the media and the deployment configuration, one part at a time, then rotates.
9
+ A database that fails does not cost the others their night; the pass fails at the end
10
+ naming every one. `--without-media` and `--only` for partial passes; `--reason` for the
11
+ message.
12
+
13
+ **Databases.** `offsite_backup.databases.postgres.PostgresSource`: one alias, a list, or a
14
+ callable that computes them at run time; plain SQL (`.sql.zst.age`) or the custom format
15
+ (`.dump.zst.age`, `pg_dump`'s compression off, then zstd). The password travels in
16
+ `PGPASSWORD`, never on a command line. `back_up_database()` for a one-off dump with extra
17
+ recipients, and `keep_apart` to set one aside from rotation.
18
+
19
+ **Media.** `offsite_backup.media.filesystem.DirectorySource` and
20
+ `offsite_backup.media.s3.S3Source`: volumes of whole files (4 GiB by default), each a
21
+ complete tar written with Python's `tarfile`, streamed one at a time; `INCLUDE`/`EXCLUDE`
22
+ globs; several sources.
23
+
24
+ **Configuration.** `offsite_backup.config.paths.PathsSource`: listed paths plus the
25
+ environment rebuilt from a list of names, written last.
26
+
27
+ **Encryption.** `offsite_backup.encryption.age_cli.AgeCli`: compress, then encrypt, to one
28
+ or more public keys, under pipefail semantics.
29
+
30
+ **Stores.** `offsite_backup.stores.s3.S3Store`: every upload locked COMPLIANCE by its own
31
+ arguments and read back; multipart in 64 MiB parts; versions and delete markers handled;
32
+ pagination everywhere; only the configured credentials.
33
+ `offsite_backup.stores.filesystem.DirectoryStore`: atomic writes, `0600` files, and a
34
+ warning that it has no immutability.
35
+
36
+ **Retention.** Whole UTC nights read from object names, per kind; passes without media never
37
+ count as nights; every version deleted; locked versions left for a later night; depth in
38
+ the message.
39
+
40
+ **Messages.** One HTML format for written, failed, missing and unchecked, every value
41
+ escaped. Notifiers: `LogNotifier`, `TelegramNotifier` (`[telegram]`, httpx2),
42
+ `TopicNotifier` through django-aiogram (`[aiogram]`). A notifier never costs the backup.
43
+
44
+ **Scheduling.** `run_nightly()` with a once-per-night guard in a shared cache; Celery
45
+ (`offsite_backup.schedule.celery.beat_schedule`) and taskiq
46
+ (`offsite_backup.schedule.taskiq.register`) adapters.
47
+
48
+ **Monitoring.** `manage.py backup_check` — the morning check over the configuration folder
49
+ and every database's; `LAST_SUCCESS_FILE`; `manage.py backup_list`.
50
+
51
+ **Restore.** `manage.py backup_restore`: download, decrypt and verify every object of a
52
+ stamp; `--apply` loads a database (`--from-database`, `--clean`); `restore_database()`.
53
+
54
+ **System checks.** `E001`–`E008`, `W001`–`W005`; network checks under `check --deploy`
55
+ only.
56
+
57
+ **Testing.** A pytest plugin and `OffsiteBackupTestRunner` that silence network
58
+ notifiers; `capture()`, `FakeS3`, `use_fake_s3`, `Passthrough`, `media_tree`.