Skip to content

Commit bb527a2

Browse files
authored
feat: add custom metadata support (#663)
Added support for custom metadata writing and validation during operations via `DBBACKUP_BACKUP_METADATA_SETTER` and `DBBACKUP_RESTORE_METADATA_VALIDATOR` settings.
1 parent 89cda19 commit bb527a2

10 files changed

Lines changed: 405 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ Don't forget to remove deprecated code on each major release!
1414

1515
## [Unreleased]
1616

17-
- Nothing (yet)!
17+
### Added
18+
- Added support for custom metadata writing and validation during operations via `DBBACKUP_BACKUP_METADATA_SETTER` and `DBBACKUP_RESTORE_METADATA_VALIDATOR` settings.
1819

1920
## [5.1.2] - 2026-01-14
2021

dbbackup/management/commands/dbbackup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ def _save_metadata(self, filename, local=False):
101101
"connector": f"{self.connector.__module__}.{self.connector.__class__.__name__}",
102102
}
103103
metadata_filename = f"{filename}.metadata"
104+
105+
# Load custom metadata if configured
106+
user_metadata = utils.get_user_metadata(metadata)
107+
metadata.update(user_metadata)
108+
104109
metadata_content = json.dumps(metadata)
105110

106111
if local:

dbbackup/management/commands/dbrestore.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ def _check_metadata(self, filename):
153153
)
154154
raise CommandError(msg)
155155

156+
# Check if we custom metadata validation is configured
157+
user_metadata_valid = utils.validate_user_metadata(metadata)
158+
if not user_metadata_valid:
159+
msg = f"Custom metadata validation failed for backup file '{filename}'."
160+
raise CommandError(msg)
161+
156162
return metadata
157163

158164
def _restore_backup(self):

dbbackup/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,5 @@
4242
SERVER_EMAIL = getattr(settings, "DBBACKUP_SERVER_EMAIL", settings.SERVER_EMAIL)
4343
ADMINS = getattr(settings, "DBBACKUP_ADMIN", settings.ADMINS)
4444
EMAIL_SUBJECT_PREFIX = getattr(settings, "DBBACKUP_EMAIL_SUBJECT_PREFIX", "[dbbackup] ")
45+
BACKUP_METADATA_SETTER = getattr(settings, "DBBACKUP_BACKUP_METADATA_SETTER", None)
46+
RESTORE_METADATA_VALIDATOR = getattr(settings, "DBBACKUP_RESTORE_METADATA_VALIDATOR", None)

dbbackup/utils.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
Utility functions for dbbackup.
33
"""
44

5+
from __future__ import annotations
6+
7+
import copy
58
import gzip
9+
import json
610
import logging
711
import os
812
import re
@@ -12,6 +16,7 @@
1216
from datetime import datetime
1317
from functools import wraps
1418
from getpass import getpass
19+
from importlib import import_module
1520
from shutil import copyfileobj
1621

1722
from django.core.mail import EmailMultiAlternatives
@@ -431,3 +436,93 @@ def filename_generate(extension, database_name="", servername=None, content_type
431436
filename = REG_FILENAME_CLEAN.sub("-", filename)
432437
filename = filename.removeprefix("-")
433438
return filename
439+
440+
441+
def _get_function_from_path(func_or_path):
442+
"""
443+
Load a callable from a dotted path or return the callable itself.
444+
445+
:param func_or_path: Dotted path to callable or callable itself
446+
:type func_or_path: str | callable
447+
448+
:returns: Callable object
449+
:rtype: callable
450+
"""
451+
if callable(func_or_path):
452+
return func_or_path
453+
454+
path = func_or_path
455+
module_path, func_name = path.rsplit(".", 1)
456+
try:
457+
module = import_module(module_path)
458+
except ImportError as e:
459+
msg = f"Could not import module '{module_path}': {e}"
460+
raise ImportError(msg) from e
461+
func = getattr(module, func_name)
462+
if not callable(func):
463+
msg = f"The object at '{path}' is not callable."
464+
raise TypeError(msg)
465+
return func
466+
467+
468+
def get_user_metadata(metadata: dict | None = None) -> dict:
469+
"""
470+
Get user generated metadata from the user's custom metadata setter.
471+
472+
:returns: Custom metadata dictionary
473+
:rtype: dict
474+
"""
475+
user_metadata = {}
476+
setter_setting = settings.BACKUP_METADATA_SETTER
477+
if setter_setting:
478+
setter_function = _get_function_from_path(setter_setting)
479+
try:
480+
# We pass a copy to avoid side effects
481+
user_metadata = setter_function(copy.deepcopy(metadata))
482+
except Exception:
483+
logger = logging.getLogger("dbbackup")
484+
logger.exception("Error loading custom metadata: %s")
485+
486+
if user_metadata is None:
487+
user_metadata = {}
488+
489+
if not isinstance(user_metadata, dict):
490+
msg = "DBBACKUP_BACKUP_METADATA_SETTER must return a dictionary."
491+
raise ValueError(msg)
492+
493+
# Validate that we can serialize the provided data
494+
try:
495+
json.dumps(user_metadata)
496+
except Exception as e:
497+
msg = f"Custom metadata is not JSON serializable: {e}"
498+
raise ValueError(msg) from e
499+
return user_metadata
500+
501+
502+
def validate_user_metadata(metadata) -> bool | None:
503+
"""
504+
Validate custom metadata using a callable defined in `settings.py`.
505+
Raise a CommandError to provide custom feedback if validation fails.
506+
507+
:param metadata: Metadata dictionary to validate
508+
:type metadata: dict
509+
510+
:returns: True if validation passes (or None), False otherwise
511+
:rtype: Optional[bool]
512+
"""
513+
validator_setting = settings.RESTORE_METADATA_VALIDATOR
514+
if validator_setting:
515+
validator_function = _get_function_from_path(validator_setting)
516+
try:
517+
# We pass a copy to avoid side effects
518+
user_metadata = validator_function(copy.deepcopy(metadata))
519+
except Exception as e:
520+
msg = f"Error during custom metadata validation: {e}"
521+
raise ValueError(msg) from e
522+
if user_metadata is None:
523+
return None
524+
if not isinstance(user_metadata, bool):
525+
msg = "DBBACKUP_RESTORE_METADATA_VALIDATOR must return a boolean or None."
526+
raise TypeError(msg)
527+
return user_metadata
528+
return True

docs/src/configuration.md

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,9 @@ python manage.py dbrestore --decrypt
123123

124124
Requirements:
125125

126-
- Install the python package python-gnupg: `pip install python-gnupg>=0.5.0`.
127-
- You need a GPG key. ([GPG manual](https://www.gnupg.org/gph/en/manual/c14.html))
128-
- Set the setting `DBBACKUP_GPG_RECIPIENT` to the name of the GPG key.
126+
- Install the python package python-gnupg: `pip install python-gnupg>=0.5.0`.
127+
- You need a GPG key. ([GPG manual](https://www.gnupg.org/gph/en/manual/c14.html))
128+
- Set the setting `DBBACKUP_GPG_RECIPIENT` to the name of the GPG key.
129129

130130
Note (Windows): The `gpg` executable must be installed and on your PATH for encryption/decryption. If it is absent, django-dbbackup still works; only encryption-related features are unavailable. The test suite will automatically skip encryption tests when `gpg` is not found.
131131

@@ -204,3 +204,53 @@ By default DBBackup uses values from `settings.DATABASES`. Use
204204

205205
You must configure a storage backend (`STORAGES['dbbackup']`) to persist
206206
backups. See [Storage settings](storage.md) for supported options.
207+
208+
## Custom metadata
209+
210+
### DBBACKUP_BACKUP_METADATA_SETTER
211+
212+
Function or dotted path (string) to a callable that returns a dictionary of extra metadata.
213+
214+
The function must accept a single argument: a metadata dictionary containing information about the current backup operation. If a dictionary is returned, it will be merged into the current backup's metadata file. If `None` is returned, it is treated as an empty dictionary.
215+
216+
Default: `None`
217+
218+
```python
219+
def metadata_set(metadata):
220+
last_backup_time = datetime.now().isoformat()
221+
if metadata and metadata.get('engine') == 'my.custom.Engine':
222+
region = os.environ.get('AWS_REGION')
223+
return {'environment': 'AWS', 'region': region, 'backup_time': last_backup_time}
224+
else:
225+
return {'backup_time': last_backup_time}
226+
227+
DBBACKUP_BACKUP_METADATA_SETTER = metadata_set
228+
```
229+
230+
### DBBACKUP_RESTORE_METADATA_VALIDATOR
231+
232+
Function or dotted path (string) to a callable that performs additional validation on the backup's metadata during restore operations. This validator runs **after** the built-in validation (e.g. database engine checks).
233+
234+
The callable should accept a single argument: the metadata dictionary loaded from the current restore operation's metadata file. If this function returns `True`, the metadata is valid, `False` means the restore operation will be aborted. `None` can be returned to do nothing and allow `dbbackup` to decide. It may raise a `CommandError` with a descriptive message if validation fails.
235+
236+
Default: `None`
237+
238+
```python
239+
import os
240+
241+
def validate_restore(metadata):
242+
region = os.environ.get('AWS_REGION')
243+
if not metadata.get('region') == region:
244+
raise CommandError(f"Backup region does not match current region {region}; cross region restores are not allowed due to SMP-0123.")
245+
246+
last_backup_time = metadata.get('backup_time')
247+
if datetime.now() - datetime.fromisoformat(last_backup_time) > timedelta(days=120):
248+
return False # Skip restore since it is stale (>120 days)
249+
250+
if os.environ.get('AWS_REGION') == None:
251+
return None # Do nothing if performing a backup outside AWS_REGION
252+
253+
return True
254+
255+
DBBACKUP_RESTORE_METADATA_VALIDATOR = validate_restore
256+
```

docs/src/dictionary.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,5 @@ postgis
4848
postgresql
4949
gzip
5050
tarfile
51+
validator
52+
no-op

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ matrix.django.dependencies = [
119119
# >>> Documentation Scripts <<<
120120

121121
[tool.hatch.envs.docs]
122+
installer = "uv"
123+
python = "3.11" # `editdistpy` (v0.1.6) is a dependency of `mkdocs-spellcheck[all]`, and is incompatible with Python 3.12+.
122124
template = "docs"
123125
detached = true
124126
dependencies = [

tests/commands/test_dbrestore_metadata.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import importlib
12
import json
23
from unittest.mock import Mock, patch
34

45
import pytest
56
from django.conf import settings
67
from django.core.management.base import CommandError
78
from django.test import TestCase
9+
from django.test.utils import override_settings
810

11+
from dbbackup.management.commands import dbbackup
912
from dbbackup.management.commands.dbrestore import Command as DbrestoreCommand
1013

1114

@@ -83,6 +86,55 @@ def test_django_connector_mismatch_allowed(self):
8386
# Should not raise
8487
self.command._check_metadata("backup.dump")
8588

89+
@override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.dummy_validator")
90+
def test_metadata_match_custom(self):
91+
importlib.reload(dbbackup.settings)
92+
# Setup metadata
93+
metadata = {"engine": settings.DATABASES["default"]["ENGINE"], "CSMT_VAL": "aabbcc-1122-3344_eu-west"}
94+
self.command.storage.read_file.return_value = Mock(read=lambda: json.dumps(metadata))
95+
96+
# Should not raise
97+
self.command._check_metadata("backup.dump")
98+
99+
@override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.dummy_validator")
100+
def test_metadata_match_custom_fail(self):
101+
importlib.reload(dbbackup.settings)
102+
# Setup metadata
103+
metadata = {"engine": settings.DATABASES["default"]["ENGINE"], "CSMT_VAL": "aabbcc-1122-3344_eu-ttt"}
104+
self.command.storage.read_file.return_value = Mock(read=lambda: json.dumps(metadata))
105+
106+
# Should raise CommandError due to validation failure
107+
with pytest.raises(CommandError):
108+
self.command._check_metadata("backup.dump")
109+
110+
@override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.dummy_validator")
111+
def test_metadata_match_custom_failwithcustomerror(self):
112+
importlib.reload(dbbackup.settings)
113+
# Setup metadata
114+
metadata = {"engine": settings.DATABASES["default"]["ENGINE"], "CSMT_VAL": "xx-1122-3344_eu-west"}
115+
self.command.storage.read_file.return_value = Mock(read=lambda: json.dumps(metadata))
116+
117+
# Should raise CommandError due to validation failure
118+
with pytest.raises(ValueError, match="CSMT_VAL must start with 'aabbcc'"):
119+
self.command._check_metadata("backup.dump")
120+
121+
@override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.dummy_validator")
122+
def test_metadata_mismatch_custom_valid(self):
123+
"""Test that built-in validation runs before custom validation."""
124+
importlib.reload(dbbackup.settings)
125+
# Setup metadata with different engine (primary fail) but valid custom data
126+
metadata = {
127+
"engine": "django.db.backends.postgresql",
128+
"CSMT_VAL": "aabbcc-1122-3344_eu-west"
129+
}
130+
self.command.storage.read_file.return_value = Mock(read=lambda: json.dumps(metadata))
131+
132+
# Should raise CommandError due to engine mismatch (primary), not custom validation
133+
with pytest.raises(CommandError) as cm:
134+
self.command._check_metadata("backup.dump")
135+
136+
assert "Restoring to a different database engine is not supported" in str(cm.value)
137+
86138

87139
class DbrestoreConnectorOverrideTest(TestCase):
88140
def setUp(self):

0 commit comments

Comments
 (0)