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.
- django_offsite_backup-0.1.0.dist-info/METADATA +133 -0
- django_offsite_backup-0.1.0.dist-info/RECORD +59 -0
- django_offsite_backup-0.1.0.dist-info/WHEEL +4 -0
- django_offsite_backup-0.1.0.dist-info/entry_points.txt +2 -0
- django_offsite_backup-0.1.0.dist-info/licenses/LICENSE +21 -0
- offsite_backup/__init__.py +52 -0
- offsite_backup/apps.py +19 -0
- offsite_backup/backend.py +48 -0
- offsite_backup/check.py +65 -0
- offsite_backup/checks.py +145 -0
- offsite_backup/conf.py +212 -0
- offsite_backup/config/__init__.py +3 -0
- offsite_backup/config/base.py +20 -0
- offsite_backup/config/paths.py +125 -0
- offsite_backup/databases/__init__.py +3 -0
- offsite_backup/databases/base.py +32 -0
- offsite_backup/databases/postgres.py +204 -0
- offsite_backup/encryption/__init__.py +3 -0
- offsite_backup/encryption/age_cli.py +58 -0
- offsite_backup/encryption/base.py +19 -0
- offsite_backup/exceptions.py +49 -0
- offsite_backup/guard.py +57 -0
- offsite_backup/management/__init__.py +3 -0
- offsite_backup/management/commands/__init__.py +3 -0
- offsite_backup/management/commands/backup_check.py +27 -0
- offsite_backup/management/commands/backup_list.py +86 -0
- offsite_backup/management/commands/backup_offsite.py +49 -0
- offsite_backup/management/commands/backup_restore.py +59 -0
- offsite_backup/media/__init__.py +3 -0
- offsite_backup/media/base.py +125 -0
- offsite_backup/media/filesystem.py +75 -0
- offsite_backup/media/s3.py +52 -0
- offsite_backup/messages/__init__.py +3 -0
- offsite_backup/messages/default.py +122 -0
- offsite_backup/naming.py +119 -0
- offsite_backup/notifiers/__init__.py +3 -0
- offsite_backup/notifiers/base.py +16 -0
- offsite_backup/notifiers/django_aiogram.py +49 -0
- offsite_backup/notifiers/log.py +22 -0
- offsite_backup/notifiers/telegram.py +76 -0
- offsite_backup/notify.py +96 -0
- offsite_backup/outcome.py +56 -0
- offsite_backup/pass_.py +265 -0
- offsite_backup/pipeline.py +139 -0
- offsite_backup/py.typed +0 -0
- offsite_backup/registry.py +71 -0
- offsite_backup/restore.py +198 -0
- offsite_backup/rotation.py +95 -0
- offsite_backup/schedule/__init__.py +3 -0
- offsite_backup/schedule/celery.py +45 -0
- offsite_backup/schedule/taskiq.py +35 -0
- offsite_backup/sealing.py +25 -0
- offsite_backup/stores/__init__.py +3 -0
- offsite_backup/stores/base.py +67 -0
- offsite_backup/stores/filesystem.py +90 -0
- offsite_backup/stores/s3.py +260 -0
- offsite_backup/testing/__init__.py +88 -0
- offsite_backup/testing/fake_s3.py +274 -0
- 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
|
+
[](https://github.com/CorneiZeR/django-offsite-backup/actions/workflows/ci.yml)
|
|
49
|
+
[](https://pypi.org/project/django-offsite-backup/)
|
|
50
|
+
[](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,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 []
|
offsite_backup/check.py
ADDED
|
@@ -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)
|
offsite_backup/checks.py
ADDED
|
@@ -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)
|