django-offsite-backup 0.1.0__py3-none-any.whl

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 (59) hide show
  1. django_offsite_backup-0.1.0.dist-info/METADATA +133 -0
  2. django_offsite_backup-0.1.0.dist-info/RECORD +59 -0
  3. django_offsite_backup-0.1.0.dist-info/WHEEL +4 -0
  4. django_offsite_backup-0.1.0.dist-info/entry_points.txt +2 -0
  5. django_offsite_backup-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. offsite_backup/__init__.py +52 -0
  7. offsite_backup/apps.py +19 -0
  8. offsite_backup/backend.py +48 -0
  9. offsite_backup/check.py +65 -0
  10. offsite_backup/checks.py +145 -0
  11. offsite_backup/conf.py +212 -0
  12. offsite_backup/config/__init__.py +3 -0
  13. offsite_backup/config/base.py +20 -0
  14. offsite_backup/config/paths.py +125 -0
  15. offsite_backup/databases/__init__.py +3 -0
  16. offsite_backup/databases/base.py +32 -0
  17. offsite_backup/databases/postgres.py +204 -0
  18. offsite_backup/encryption/__init__.py +3 -0
  19. offsite_backup/encryption/age_cli.py +58 -0
  20. offsite_backup/encryption/base.py +19 -0
  21. offsite_backup/exceptions.py +49 -0
  22. offsite_backup/guard.py +57 -0
  23. offsite_backup/management/__init__.py +3 -0
  24. offsite_backup/management/commands/__init__.py +3 -0
  25. offsite_backup/management/commands/backup_check.py +27 -0
  26. offsite_backup/management/commands/backup_list.py +86 -0
  27. offsite_backup/management/commands/backup_offsite.py +49 -0
  28. offsite_backup/management/commands/backup_restore.py +59 -0
  29. offsite_backup/media/__init__.py +3 -0
  30. offsite_backup/media/base.py +125 -0
  31. offsite_backup/media/filesystem.py +75 -0
  32. offsite_backup/media/s3.py +52 -0
  33. offsite_backup/messages/__init__.py +3 -0
  34. offsite_backup/messages/default.py +122 -0
  35. offsite_backup/naming.py +119 -0
  36. offsite_backup/notifiers/__init__.py +3 -0
  37. offsite_backup/notifiers/base.py +16 -0
  38. offsite_backup/notifiers/django_aiogram.py +49 -0
  39. offsite_backup/notifiers/log.py +22 -0
  40. offsite_backup/notifiers/telegram.py +76 -0
  41. offsite_backup/notify.py +96 -0
  42. offsite_backup/outcome.py +56 -0
  43. offsite_backup/pass_.py +265 -0
  44. offsite_backup/pipeline.py +139 -0
  45. offsite_backup/py.typed +0 -0
  46. offsite_backup/registry.py +71 -0
  47. offsite_backup/restore.py +198 -0
  48. offsite_backup/rotation.py +95 -0
  49. offsite_backup/schedule/__init__.py +3 -0
  50. offsite_backup/schedule/celery.py +45 -0
  51. offsite_backup/schedule/taskiq.py +35 -0
  52. offsite_backup/sealing.py +25 -0
  53. offsite_backup/stores/__init__.py +3 -0
  54. offsite_backup/stores/base.py +67 -0
  55. offsite_backup/stores/filesystem.py +90 -0
  56. offsite_backup/stores/s3.py +260 -0
  57. offsite_backup/testing/__init__.py +88 -0
  58. offsite_backup/testing/fake_s3.py +274 -0
  59. offsite_backup/testing/pytest_plugin.py +27 -0
@@ -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,59 @@
1
+ offsite_backup/__init__.py,sha256=e9ixtQw21GQzoCL7gjQwcdWWiwZ5vLByNQUJSB_IE8s,1664
2
+ offsite_backup/apps.py,sha256=lKvu2XJPJV5UO6HVK16FqgRZgZ15s1uIpjqKHTZk-BQ,596
3
+ offsite_backup/backend.py,sha256=LbfDbIUY8_twsYuvZq_7GXbfMannffWxwLathQDwZnc,1971
4
+ offsite_backup/check.py,sha256=HQooAd-RSGPeBUGPTMDlPYWg7mm5oWFwQsW8mrOZMqs,3236
5
+ offsite_backup/checks.py,sha256=FM8IqjkyG11019sJohYKFHlwGvtzo59XyMej-Oi_ys8,5809
6
+ offsite_backup/conf.py,sha256=jgCoS4yMMZJlDZr36GhkonAfgL2YVlNAPeDWtK5MEy0,7878
7
+ offsite_backup/exceptions.py,sha256=iZRM9R_mOIYxUHSdI9c9FLlenRxl5PyBvxEmHEJEwUo,1284
8
+ offsite_backup/guard.py,sha256=IBIRNGgsn2W7DL5L96GcCSji77gtGSWcxU1knKlvckk,2356
9
+ offsite_backup/naming.py,sha256=-qy6gcA0g2tHIs8KPe6mEbG60elIH3g8Xi7JLZmGUZA,3514
10
+ offsite_backup/notify.py,sha256=BFSYqGmkYdaFN3CJoYGnMs5Ozv4Z0ZpyntWSHWV6XEk,3659
11
+ offsite_backup/outcome.py,sha256=thOBkcJQQ6ZtBJQgVI1JsCCTRbPz_Kis81pjcfHqy5k,1416
12
+ offsite_backup/pass_.py,sha256=jd2fyNryi0NjLs1aiF9BIiyUezsAq0G3tY4YDjR-fp4,11396
13
+ offsite_backup/pipeline.py,sha256=CMe3MD3u_mj-OEB3ou0aqa7VjoAkDmUNJDHsHwvuaFU,5305
14
+ offsite_backup/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ offsite_backup/registry.py,sha256=7bjb8gnjv6PJLcnWyqUF5tZjqJDcMANgnsJq294hlNw,2940
16
+ offsite_backup/restore.py,sha256=KpQjdcsNQJhvPVGoyiGKXgW9te4tndZd3nyZkBW1_pc,7369
17
+ offsite_backup/rotation.py,sha256=mxIwN6wt1wM6QmMGJoMbkGXTWKi39u8owXAVZMLIGqI,3791
18
+ offsite_backup/sealing.py,sha256=UUoa23kUa9A2YIZM2O9TDvnDBh0DMP1CiiH-SWySgP0,830
19
+ offsite_backup/config/__init__.py,sha256=YRGYdWHVH0ATCi7cGPX_bBOI7J-V8gb2ehP5WEPmfLs,45
20
+ offsite_backup/config/base.py,sha256=EzwrtIVKwYYWvYVt9Kv9-AYPApAUmxkD8Awx-ktQlvA,602
21
+ offsite_backup/config/paths.py,sha256=9mp0H_CWIZDFbKBp4gr61vTl3mfaHyz01VsQ8iEXt2Q,5669
22
+ offsite_backup/databases/__init__.py,sha256=pUEGQ3Xa36XdqEc6OZ6gD1evtXgbPi4-FO1KdgkFAX4,48
23
+ offsite_backup/databases/base.py,sha256=sKL9S4bGA6OWVjhKr6_4CR3hjLDE9-SmoOWJI3WI4Tg,1111
24
+ offsite_backup/databases/postgres.py,sha256=02UvUu6FkDQw_FMEZbJ9IT-q5FQPhuEubfKqzRTu9uk,8515
25
+ offsite_backup/encryption/__init__.py,sha256=i9umI8tcO1M87YcXKiomR6T_P8FUAN7aXfRq07Afzck,49
26
+ offsite_backup/encryption/age_cli.py,sha256=dn04GDSi8MF9ndALZTIKBtVKaV2rrDxZDHd2PCJaCXI,2235
27
+ offsite_backup/encryption/base.py,sha256=gBiLHuE3DZcDelYfTfegxP5oWZSvl_8MDlIbydg48Ek,680
28
+ offsite_backup/management/__init__.py,sha256=vcqDX6mxQ9kPOJernLPiYVbh_fT6_XGStBUxXfEG-fA,49
29
+ offsite_backup/management/commands/__init__.py,sha256=kAx43lKcH5YmrRyekdKsKoJIUpPO2Y8p3RV-raqGKtI,58
30
+ offsite_backup/management/commands/backup_check.py,sha256=M5nJgx0M3Da9-juj_xNLDhr2RKJU3OXfyslZ4pOCzjU,1063
31
+ offsite_backup/management/commands/backup_list.py,sha256=96C6qlLPF_jwO40GC1RZoN0L8FTpnRoTwT_QbnLErQQ,4011
32
+ offsite_backup/management/commands/backup_offsite.py,sha256=oLH8g1ZG4dX6Kfl7eOhuTwQvTE91i6n-vMOO-bUUH_A,2055
33
+ offsite_backup/management/commands/backup_restore.py,sha256=WN7CT4LH9lvtuWHr3qF6pFFaoFKUzMdOwvIwH51sCHQ,3119
34
+ offsite_backup/media/__init__.py,sha256=LltKrhhcIU_9bt_TmzOpGEBcdrlgF_fIZCfHlKV3EnA,44
35
+ offsite_backup/media/base.py,sha256=5ipIwQv9r_NLA07gbFG-F2mpZsi8gCSvoHKCRWRyniE,5050
36
+ offsite_backup/media/filesystem.py,sha256=-EmIK1hDLEV9ms5knWcbKeob7Z4WNJi12_7RbcWxiGo,3097
37
+ offsite_backup/media/s3.py,sha256=cp2QHJtoMptaRvJ--9vYvr3jF_-oqJK4C4kpMrmmcOA,1902
38
+ offsite_backup/messages/__init__.py,sha256=C6yxHrUo_LOdnHXsTMAudTHD1y4E2vMlGO7pLmF6zTI,47
39
+ offsite_backup/messages/default.py,sha256=RuKQQqv4KF8xHuNDGwhCpSm3YwsCAYffF4EQ_l98KzI,4438
40
+ offsite_backup/notifiers/__init__.py,sha256=0X7uJIL1kXIjLGV1YKk7NSrpXvnj4TVYBozUsXRN8hI,48
41
+ offsite_backup/notifiers/base.py,sha256=YUVreA0b5PIUIBZoY2A9dYxduD92C66xNCLvMPjDoAM,540
42
+ offsite_backup/notifiers/django_aiogram.py,sha256=oa0X3HhLYFczX1rHrECwQ0NEvNiBpC2EP5XWJrfeW-k,1824
43
+ offsite_backup/notifiers/log.py,sha256=dRepc9FAJ2yZpopGtGzZUavYxCJvWZ5A9s-45dWPs98,710
44
+ offsite_backup/notifiers/telegram.py,sha256=exOBSZiS-GJwExmuPI3L8rFufobi7eqpwSbo9nYlFOU,3216
45
+ offsite_backup/schedule/__init__.py,sha256=5YyZvDkluM_WYztCiEtiwJra4tE_odFo_thvdpApIMk,47
46
+ offsite_backup/schedule/celery.py,sha256=g682IvRkzqRol1vlZ-826P3ai8wN6y9YiUV9gi99T_w,1651
47
+ offsite_backup/schedule/taskiq.py,sha256=XX1LzeMcb40qNTXTCFmFlZDnWnxpbxjKqyP3S66nzgk,1551
48
+ offsite_backup/stores/__init__.py,sha256=v0CNT_EjbIcw1q6QxV0DHUmzrWuxe3zrkihRkFLIhXU,45
49
+ offsite_backup/stores/base.py,sha256=vCFJ-Ovp-zTX9SS4Op21TYlZI6ZSHbQyjJ3eNqkmOTg,2200
50
+ offsite_backup/stores/filesystem.py,sha256=yn7g2itSs5T4xuDD417LF8SINP3msc77SNs7ciFeUZU,3392
51
+ offsite_backup/stores/s3.py,sha256=0-OBfiOOEYwdeL5idHbfCqfhsC7CpD7Hy4SeitVwSOA,12374
52
+ offsite_backup/testing/__init__.py,sha256=SSR2863QBQyi-YeBNBxAUUT3NXHxSfY07Ws24PeiZNY,2826
53
+ offsite_backup/testing/fake_s3.py,sha256=VToHI0FuAZr45lZJisCJCwHl6_T-ga0KcyYCwL9a8j0,11693
54
+ offsite_backup/testing/pytest_plugin.py,sha256=n8Q7SAUNH0HRNUkd26tIqTbwVoaIGFXMgjq9pYzJqCs,848
55
+ django_offsite_backup-0.1.0.dist-info/METADATA,sha256=MiQIxMZUMh15qyJ2dm8pIm7YMM-ktk7eZitHhmbXO4I,6128
56
+ django_offsite_backup-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
57
+ django_offsite_backup-0.1.0.dist-info/entry_points.txt,sha256=zuksgsw_Q6ik9qa7T6mHP_YJ4W8htU-pn7vjssnxigs,65
58
+ django_offsite_backup-0.1.0.dist-info/licenses/LICENSE,sha256=tXJD1E9WjBwUT_uLFM0IuzULlvjAF40iDz-1HbCUfKU,1073
59
+ django_offsite_backup-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ offsite_backup = offsite_backup.testing.pytest_plugin
@@ -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,52 @@
1
+ """Encrypted, immutable off-site backups for Django projects.
2
+
3
+ Importing the package loads nothing but Django's settings machinery: every backend,
4
+ driver and the pass itself are imported when first used (PEP 562 ``__getattr__``).
5
+ """
6
+
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ __version__ = '0.1.0'
10
+
11
+ __all__ = (
12
+ 'BackupError',
13
+ 'NotConfiguredError',
14
+ '__version__',
15
+ 'back_up_database',
16
+ 'check_last_night',
17
+ 'keep_apart',
18
+ 'restore_database',
19
+ 'run',
20
+ 'run_nightly',
21
+ )
22
+
23
+ _EXPORTS = {
24
+ 'run': 'offsite_backup.pass_',
25
+ 'back_up_database': 'offsite_backup.pass_',
26
+ 'keep_apart': 'offsite_backup.pass_',
27
+ 'run_nightly': 'offsite_backup.guard',
28
+ 'check_last_night': 'offsite_backup.check',
29
+ 'restore_database': 'offsite_backup.restore',
30
+ 'BackupError': 'offsite_backup.exceptions',
31
+ 'NotConfiguredError': 'offsite_backup.exceptions',
32
+ }
33
+
34
+ if TYPE_CHECKING:
35
+ from offsite_backup.check import check_last_night
36
+ from offsite_backup.exceptions import BackupError, NotConfiguredError
37
+ from offsite_backup.guard import run_nightly
38
+ from offsite_backup.pass_ import back_up_database, keep_apart, run
39
+ from offsite_backup.restore import restore_database
40
+
41
+
42
+ def __getattr__(name: str) -> Any:
43
+ """Resolve a public name on first use, so ``import offsite_backup`` stays cheap."""
44
+ module = _EXPORTS.get(name)
45
+ if module is None:
46
+ msg = f'module {__name__!r} has no attribute {name!r}'
47
+ raise AttributeError(msg)
48
+ import importlib # noqa: PLC0415 - only when an export is first asked for
49
+
50
+ value = getattr(importlib.import_module(module), name)
51
+ globals()[name] = value
52
+ return value
offsite_backup/apps.py ADDED
@@ -0,0 +1,19 @@
1
+ """The Django app: registers the system checks and imports nothing else."""
2
+
3
+ from django.apps import AppConfig
4
+
5
+ __all__ = ('OffsiteBackupConfig',)
6
+
7
+
8
+ class OffsiteBackupConfig(AppConfig):
9
+ """Registers the checks; every backend stays unimported until a pass needs it."""
10
+
11
+ name = 'offsite_backup'
12
+ verbose_name = 'Off-site backup'
13
+ default_auto_field = 'django.db.models.BigAutoField'
14
+
15
+ def ready(self) -> None:
16
+ """Register the system checks."""
17
+ from offsite_backup import checks # noqa: PLC0415 - registration is the import's effect
18
+
19
+ checks.register_all()
@@ -0,0 +1,48 @@
1
+ """What every pluggable backend shares: its options block, its requirements, its checks."""
2
+
3
+ import shutil
4
+ from collections.abc import Mapping
5
+ from typing import Any, ClassVar
6
+
7
+ from django.core import checks
8
+
9
+ __all__ = ('Backend',)
10
+
11
+
12
+ class Backend:
13
+ """A backend built from its own settings block (``BACKEND`` plus options)."""
14
+
15
+ #: options that must be non-empty once backups are on; E001 names each one missing
16
+ REQUIRED: ClassVar[tuple[str, ...]] = ()
17
+ #: options whose presence means "this machine is meant to back up" (see ``conf.enabled``)
18
+ SIGNALS: ClassVar[tuple[str, ...]] = ()
19
+ #: executables this backend runs; E003 names each one absent from PATH
20
+ BINARIES: ClassVar[tuple[str, ...]] = ()
21
+ #: a backend that cannot reach the network may run under a test suite (see ``testing``)
22
+ REACHES_NETWORK: ClassVar[bool] = False
23
+
24
+ def __init__(self, options: Mapping[str, Any], *, label: str = '') -> None:
25
+ """Keep the options block and the label checks and errors name it by."""
26
+ self.options = dict(options)
27
+ self.label = label or type(self).__name__
28
+
29
+ def option(self, name: str, default: Any = None) -> Any:
30
+ """One option, with ``default`` for absent *and* for ``None``."""
31
+ value = self.options.get(name)
32
+ return default if value is None else value
33
+
34
+ def check(self) -> list[checks.CheckMessage]:
35
+ """Local, offline checks: binaries present, options well formed."""
36
+ return [
37
+ checks.Error(
38
+ f'{self.label} runs `{binary}`, which is not on PATH.',
39
+ hint=f'Install {binary} in the image that runs the backup.',
40
+ id='offsite_backup.E003',
41
+ )
42
+ for binary in self.BINARIES
43
+ if shutil.which(binary) is None
44
+ ]
45
+
46
+ def deploy_check(self) -> list[checks.CheckMessage]:
47
+ """Checks that touch the network or a database; run with ``check --deploy`` only."""
48
+ return []
@@ -0,0 +1,65 @@
1
+ """The morning check: notice the night that never ran.
2
+
3
+ It asks the store for the newest *whole* backup — objects of a pass without media do not
4
+ count, so a deploy at noon cannot hide a night that never ran — under the config folder
5
+ and under every database's folder. Older than ``STALE_HOURS`` or absent: MISSING, said
6
+ and raised (so the scheduler's failure mail fires too). Store unreadable: UNCHECKED, as
7
+ loud. Media written tonight does not stand in for a database that was not.
8
+ """
9
+
10
+ from dataclasses import replace
11
+ from datetime import datetime, timedelta, timezone
12
+
13
+ from offsite_backup import conf, naming
14
+ from offsite_backup.exceptions import BackupMissingError, BackupUncheckedError
15
+ from offsite_backup.notify import notify
16
+ from offsite_backup.outcome import MISSING, UNCHECKED, Outcome
17
+ from offsite_backup.stores.base import Store
18
+
19
+ __all__ = ('check_last_night', 'newest_whole')
20
+
21
+
22
+ def newest_whole(store: Store, folder: str) -> datetime | None:
23
+ """When the newest whole backup under ``folder`` was written, or ``None``."""
24
+ found = [naming.parse(key) for key in store.current(folder)]
25
+ stamps = [name.when for name in found if name is not None and name.marker != naming.WITHOUT_MEDIA]
26
+ return max(stamps, default=None)
27
+
28
+
29
+ def check_last_night(*, now: datetime | None = None) -> datetime | None:
30
+ """Raise unless every checked folder has a whole backup within ``STALE_HOURS``; answer the oldest newest.
31
+
32
+ ``None`` when backups are off on this machine.
33
+ """
34
+ from offsite_backup.pass_ import components # noqa: PLC0415 - builds backends
35
+
36
+ settings = conf.read()
37
+ if not conf.enabled(settings):
38
+ return None
39
+ moment = now or datetime.now(timezone.utc)
40
+ base = Outcome(
41
+ kind=MISSING, prefix=settings.prefix, env=str(settings['ENV']), name=str(settings['NAME']), now=moment
42
+ )
43
+ try:
44
+ parts = components(conf.load())
45
+ kinds = [naming.CONFIG] if parts.config is not None else []
46
+ if parts.databases is not None:
47
+ kinds += [naming.db_kind(alias) for alias in parts.databases.targets()]
48
+ if not kinds:
49
+ kinds = [naming.media_kind(source.name) for source in parts.media]
50
+ newest = {kind: newest_whole(parts.store, naming.folder(settings.prefix, kind)) for kind in kinds}
51
+ except Exception as error:
52
+ text = f'{type(error).__name__}: {error}'
53
+ notify(replace(base, kind=UNCHECKED, error=text), settings)
54
+ raise BackupUncheckedError(text) from error
55
+ limit = timedelta(hours=settings.stale_hours)
56
+ stale = {kind: when for kind, when in newest.items() if when is None or moment - when > limit}
57
+ if stale:
58
+ oldest = min(stale.values(), key=lambda when: when or datetime.min.replace(tzinfo=timezone.utc))
59
+ notify(replace(base, kind=MISSING, newest=oldest, stale_hours=settings.stale_hours), settings)
60
+ named = ', '.join(
61
+ f'{kind}/ ({when:%Y-%m-%d %H:%M} UTC)' if when else f'{kind}/ (none)' for kind, when in stale.items()
62
+ )
63
+ msg = f'No whole backup within {settings.stale_hours:g} h under {settings.prefix}/: {named}'
64
+ raise BackupMissingError(msg)
65
+ return min((when for when in newest.values() if when is not None), default=None)
@@ -0,0 +1,145 @@
1
+ """System checks. Codes are stable; the Troubleshooting page explains each one.
2
+
3
+ Network-touching checks (the bucket's lock configuration, the database server's version)
4
+ are registered as deploy checks, so a plain ``manage.py`` never reaches S3 or the database.
5
+ Nothing is checked on a machine where backups are off.
6
+ """
7
+
8
+ import shutil
9
+ import sys
10
+ from collections.abc import Sequence
11
+ from typing import Any
12
+
13
+ from django.core import checks
14
+
15
+ from offsite_backup import conf
16
+ from offsite_backup.backend import Backend
17
+ from offsite_backup.exceptions import BackendError
18
+ from offsite_backup.registry import backend_class, missing_extra
19
+
20
+ __all__ = ('backends', 'check_deploy', 'check_settings', 'register_all')
21
+
22
+ _TAG = 'offsite_backup'
23
+
24
+
25
+ def _under_test() -> bool:
26
+ return 'pytest' in sys.modules or sys.argv[1:2] == ['test']
27
+
28
+
29
+ def backends(settings: conf.Settings) -> tuple[list[Backend], list[checks.CheckMessage]]:
30
+ """Every configured backend built, and an E002 for each that cannot be."""
31
+ from offsite_backup.notifiers.base import Notifier # noqa: PLC0415 - base classes only
32
+
33
+ found: list[Backend] = []
34
+ errors: list[checks.CheckMessage] = []
35
+ blocks = conf._blocks(settings) # noqa: SLF001 - the module's own table
36
+ blocks += [(conf.label('NOTIFY', index), block, Notifier) for index, block in enumerate(settings['NOTIFY'] or [])]
37
+ for name, block, base in blocks:
38
+ path = str(block.get('BACKEND') or '').strip()
39
+ install = missing_extra(path)
40
+ if install:
41
+ errors.append(
42
+ checks.Error(
43
+ f"{name}['BACKEND'] is {path.rsplit('.', 1)[-1]}, but its driver is not installed.",
44
+ hint=install,
45
+ id='offsite_backup.E002',
46
+ )
47
+ )
48
+ continue
49
+ try:
50
+ cls = backend_class(path, base, label=name)
51
+ except BackendError as error:
52
+ errors.append(checks.Error(str(error), id='offsite_backup.E002'))
53
+ continue
54
+ found.append(cls({key: value for key, value in block.items() if key != 'BACKEND'}, label=name))
55
+ return found, errors
56
+
57
+
58
+ def check_settings(app_configs: Sequence[Any] | None = None, **kwargs: Any) -> list[checks.CheckMessage]:
59
+ """E001-E003, E007, E008, W001-W005: everything that needs no network."""
60
+ settings = conf.read()
61
+ if not conf.enabled(settings):
62
+ return []
63
+ messages: list[checks.CheckMessage] = []
64
+ missing = conf.problems(settings)
65
+ if missing:
66
+ messages.append(
67
+ checks.Error(
68
+ 'Backups are configured by halves; these are unset: ' + ', '.join(missing),
69
+ hint='Set every one, or leave the store credentials empty to turn backups off on this machine.',
70
+ id='offsite_backup.E001',
71
+ )
72
+ )
73
+ built, errors = backends(settings)
74
+ messages += errors
75
+ for backend in built:
76
+ messages += backend.check()
77
+ # the decompressor only matters where something is compressed
78
+ compressors = (settings['COMPRESS'], settings['DECOMPRESS']) if settings['COMPRESS'] else ()
79
+ for binary in sorted({command[0] for command in compressors if command}):
80
+ if not shutil.which(binary):
81
+ messages.append(
82
+ checks.Error(
83
+ f'`{binary}` is not on PATH, and COMPRESS or DECOMPRESS runs it.',
84
+ hint=f'Install {binary} in the image that runs the backup.',
85
+ id='offsite_backup.E003',
86
+ )
87
+ )
88
+ guard = settings['GUARD']
89
+ if guard.get('ENABLED', True):
90
+ messages += _guard_cache(str(guard.get('CACHE', 'default')))
91
+ if _under_test() and settings['NOTIFY']:
92
+ from offsite_backup.notify import silenced # noqa: PLC0415
93
+
94
+ if not silenced():
95
+ messages.append(
96
+ checks.Warning(
97
+ 'Notifiers are configured in a test run and not silenced.',
98
+ hint='Use offsite_backup.testing.OffsiteBackupTestRunner (pytest is covered by the plugin), '
99
+ "and keep production notifier tokens out of the CI test job's variables.",
100
+ id='offsite_backup.W004',
101
+ )
102
+ )
103
+ return messages
104
+
105
+
106
+ def _guard_cache(alias: str) -> list[checks.CheckMessage]:
107
+ from django.conf import settings # noqa: PLC0415
108
+
109
+ backend = str(settings.CACHES.get(alias, {}).get('BACKEND', ''))
110
+ if alias not in settings.CACHES:
111
+ return [
112
+ checks.Error(
113
+ f"GUARD['CACHE'] is {alias!r}, which CACHES does not define.",
114
+ id='offsite_backup.E001',
115
+ )
116
+ ]
117
+ if backend.endswith(('LocMemCache', 'DummyCache')):
118
+ return [
119
+ checks.Warning(
120
+ f'The once-per-night guard uses cache {alias!r} ({backend.rsplit(".", 1)[-1]}), '
121
+ 'which workers do not share.',
122
+ hint='Point GUARD["CACHE"] at a shared cache (Redis, Memcached, database), '
123
+ 'or two schedulers can both run tonight.',
124
+ id='offsite_backup.W003',
125
+ )
126
+ ]
127
+ return []
128
+
129
+
130
+ def check_deploy(app_configs: Sequence[Any] | None = None, **kwargs: Any) -> list[checks.CheckMessage]:
131
+ """E004-E006: the bucket's lock configuration and ``pg_dump`` against the server."""
132
+ settings = conf.read()
133
+ if not conf.enabled(settings) or conf.problems(settings):
134
+ return []
135
+ built, _ = backends(settings)
136
+ messages: list[checks.CheckMessage] = []
137
+ for backend in built:
138
+ messages += backend.deploy_check()
139
+ return messages
140
+
141
+
142
+ def register_all() -> None:
143
+ """Register both checks (idempotent: Django's registry is a set)."""
144
+ checks.register(check_settings, _TAG)
145
+ checks.register(check_deploy, _TAG, deploy=True)