dploydb 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.
- dploydb/__init__.py +1 -0
- dploydb/__main__.py +6 -0
- dploydb/backup.py +455 -0
- dploydb/candidate.py +868 -0
- dploydb/cli.py +1111 -0
- dploydb/config.py +784 -0
- dploydb/cutover.py +326 -0
- dploydb/deploy.py +1163 -0
- dploydb/deployment_dependencies.py +366 -0
- dploydb/deployment_evidence.py +136 -0
- dploydb/diagnostics.py +1020 -0
- dploydb/errors.py +140 -0
- dploydb/health.py +625 -0
- dploydb/locking.py +609 -0
- dploydb/manual_restore.py +825 -0
- dploydb/migration.py +575 -0
- dploydb/models.py +927 -0
- dploydb/recovery.py +1210 -0
- dploydb/redaction.py +229 -0
- dploydb/releases.py +611 -0
- dploydb/restore.py +461 -0
- dploydb/retention.py +165 -0
- dploydb/runners/__init__.py +33 -0
- dploydb/runners/base.py +389 -0
- dploydb/runners/docker_compose.py +590 -0
- dploydb/runners/docker_compose_production.py +966 -0
- dploydb/sqlite_checks.py +136 -0
- dploydb/state.py +604 -0
- dploydb/storage/__init__.py +13 -0
- dploydb/storage/base.py +52 -0
- dploydb/storage/local.py +301 -0
- dploydb/storage/s3.py +737 -0
- dploydb/subprocesses.py +641 -0
- dploydb/traffic.py +165 -0
- dploydb-0.1.0.dist-info/METADATA +583 -0
- dploydb-0.1.0.dist-info/RECORD +40 -0
- dploydb-0.1.0.dist-info/WHEEL +4 -0
- dploydb-0.1.0.dist-info/entry_points.txt +2 -0
- dploydb-0.1.0.dist-info/licenses/LICENSE +201 -0
- dploydb-0.1.0.dist-info/licenses/NOTICE +4 -0
dploydb/storage/local.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Path-safe local storage for immutable verified backups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Final
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from dploydb.errors import OperationFailedError, SafetyCheckError
|
|
17
|
+
from dploydb.models import BackupArtifact, BackupMetadata
|
|
18
|
+
|
|
19
|
+
DIRECTORY_MODE: Final = 0o700
|
|
20
|
+
FILE_MODE: Final = 0o600
|
|
21
|
+
MAX_METADATA_BYTES: Final = 128 * 1024
|
|
22
|
+
_BACKUP_ID = re.compile(r"^backup_[0-9a-f]{32}$")
|
|
23
|
+
_IGNORABLE_DIRECTORY_FSYNC_ERRORS = frozenset({errno.EINVAL, errno.ENOTSUP})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LocalBackupStorage:
|
|
27
|
+
"""Publish backup metadata last so it is the durable success marker."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, root: Path) -> None:
|
|
30
|
+
if not root.is_absolute() or root == Path(root.anchor):
|
|
31
|
+
raise ValueError("local backup directory must be an absolute non-root path")
|
|
32
|
+
self.root = root
|
|
33
|
+
|
|
34
|
+
def ensure_layout(self) -> None:
|
|
35
|
+
if self.root.is_symlink():
|
|
36
|
+
raise self._storage_error("refusing a symlinked backup directory")
|
|
37
|
+
try:
|
|
38
|
+
self.root.mkdir(mode=DIRECTORY_MODE, parents=True, exist_ok=True)
|
|
39
|
+
details = self.root.stat()
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
raise self._storage_error(f"backup directory could not be created: {exc}") from None
|
|
42
|
+
mode = stat.S_IMODE(details.st_mode)
|
|
43
|
+
if not stat.S_ISDIR(details.st_mode) or mode != DIRECTORY_MODE:
|
|
44
|
+
raise self._storage_error(
|
|
45
|
+
f"backup directory must be a mode-0700 directory, found {mode:04o}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def create_staging_database(self, backup_id: str) -> Path:
|
|
49
|
+
self._validate_id(backup_id)
|
|
50
|
+
self.ensure_layout()
|
|
51
|
+
path = self.root / f".{backup_id}.{uuid4().hex}.tmp"
|
|
52
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_RDWR
|
|
53
|
+
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
54
|
+
descriptor = -1
|
|
55
|
+
try:
|
|
56
|
+
descriptor = os.open(path, flags, FILE_MODE)
|
|
57
|
+
os.fchmod(descriptor, FILE_MODE)
|
|
58
|
+
os.close(descriptor)
|
|
59
|
+
descriptor = -1
|
|
60
|
+
self._fsync_directory()
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
if descriptor >= 0:
|
|
63
|
+
os.close(descriptor)
|
|
64
|
+
try:
|
|
65
|
+
path.unlink(missing_ok=True)
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
raise self._storage_error(f"backup staging file could not be created: {exc}") from None
|
|
69
|
+
return path
|
|
70
|
+
|
|
71
|
+
def put(self, staged_database: Path, metadata: BackupMetadata) -> BackupArtifact:
|
|
72
|
+
self.ensure_layout()
|
|
73
|
+
self._validate_id(metadata.backup_id)
|
|
74
|
+
self._validate_staged_database(staged_database, metadata.backup_id)
|
|
75
|
+
database_path = self.root / metadata.database_file_name
|
|
76
|
+
metadata_path = self.root / f"{metadata.backup_id}.json"
|
|
77
|
+
if database_path.exists() or database_path.is_symlink():
|
|
78
|
+
raise self._storage_error(f"backup database already exists: {database_path}")
|
|
79
|
+
if metadata_path.exists() or metadata_path.is_symlink():
|
|
80
|
+
raise self._storage_error(f"backup metadata already exists: {metadata_path}")
|
|
81
|
+
|
|
82
|
+
database_published = False
|
|
83
|
+
try:
|
|
84
|
+
os.link(staged_database, database_path, follow_symlinks=False)
|
|
85
|
+
database_published = True
|
|
86
|
+
staged_database.unlink()
|
|
87
|
+
os.chmod(database_path, FILE_MODE, follow_symlinks=False)
|
|
88
|
+
self._fsync_directory()
|
|
89
|
+
self._publish_metadata(metadata_path, metadata)
|
|
90
|
+
except (OSError, OperationFailedError) as exc:
|
|
91
|
+
cleanup_error: OSError | None = None
|
|
92
|
+
if database_published:
|
|
93
|
+
try:
|
|
94
|
+
database_path.unlink(missing_ok=True)
|
|
95
|
+
self._fsync_directory()
|
|
96
|
+
except OSError as cleanup:
|
|
97
|
+
cleanup_error = cleanup
|
|
98
|
+
detail = f"backup publication failed: {exc}"
|
|
99
|
+
if cleanup_error is not None:
|
|
100
|
+
detail += f"; incomplete database cleanup also failed: {cleanup_error}"
|
|
101
|
+
raise self._storage_error(detail) from None
|
|
102
|
+
return self.get(metadata.backup_id)
|
|
103
|
+
|
|
104
|
+
def get(self, backup_id: str) -> BackupArtifact:
|
|
105
|
+
metadata = self.verify_metadata(backup_id)
|
|
106
|
+
database_path = self.root / metadata.database_file_name
|
|
107
|
+
self._validate_committed_file(database_path, "backup database")
|
|
108
|
+
return BackupArtifact(
|
|
109
|
+
metadata=metadata,
|
|
110
|
+
database_path=database_path,
|
|
111
|
+
metadata_path=self.root / f"{backup_id}.json",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def exists(self, backup_id: str) -> bool:
|
|
115
|
+
try:
|
|
116
|
+
self.get(backup_id)
|
|
117
|
+
except SafetyCheckError:
|
|
118
|
+
return False
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
def list(self) -> tuple[BackupMetadata, ...]:
|
|
122
|
+
self._validate_layout()
|
|
123
|
+
metadata: list[BackupMetadata] = []
|
|
124
|
+
for path in sorted(self.root.glob("backup_*.json")):
|
|
125
|
+
backup_id = path.name.removesuffix(".json")
|
|
126
|
+
metadata.append(self.verify_metadata(backup_id))
|
|
127
|
+
return tuple(metadata)
|
|
128
|
+
|
|
129
|
+
def delete(self, backup_id: str) -> None:
|
|
130
|
+
"""Delete metadata first and safely resume an interrupted deletion."""
|
|
131
|
+
self._validate_id(backup_id)
|
|
132
|
+
self._validate_layout()
|
|
133
|
+
metadata_path = self.root / f"{backup_id}.json"
|
|
134
|
+
database_path = self.root / f"{backup_id}.db"
|
|
135
|
+
metadata_present = metadata_path.exists() or metadata_path.is_symlink()
|
|
136
|
+
database_present = database_path.exists() or database_path.is_symlink()
|
|
137
|
+
if not metadata_present and not database_present:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
if metadata_present:
|
|
141
|
+
metadata = self.verify_metadata(backup_id)
|
|
142
|
+
database_path = self.root / metadata.database_file_name
|
|
143
|
+
database_present = database_path.exists() or database_path.is_symlink()
|
|
144
|
+
if not database_present:
|
|
145
|
+
raise self._verification_error(
|
|
146
|
+
"committed backup metadata exists but its database is missing",
|
|
147
|
+
metadata_path,
|
|
148
|
+
)
|
|
149
|
+
if database_present:
|
|
150
|
+
self._validate_committed_file(database_path, "backup database")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
if metadata_present:
|
|
154
|
+
metadata_path.unlink()
|
|
155
|
+
self._fsync_directory()
|
|
156
|
+
if database_present:
|
|
157
|
+
database_path.unlink()
|
|
158
|
+
self._fsync_directory()
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
raise self._storage_error(f"backup deletion failed: {exc}") from None
|
|
161
|
+
|
|
162
|
+
def verify_metadata(self, backup_id: str) -> BackupMetadata:
|
|
163
|
+
self._validate_id(backup_id)
|
|
164
|
+
self._validate_layout()
|
|
165
|
+
path = self.root / f"{backup_id}.json"
|
|
166
|
+
self._validate_committed_file(path, "backup metadata")
|
|
167
|
+
try:
|
|
168
|
+
details = path.stat()
|
|
169
|
+
if details.st_size <= 0 or details.st_size > MAX_METADATA_BYTES:
|
|
170
|
+
raise OSError("metadata has an invalid size")
|
|
171
|
+
payload = path.read_bytes()
|
|
172
|
+
if not payload.endswith(b"\n"):
|
|
173
|
+
raise OSError("metadata is truncated")
|
|
174
|
+
metadata = BackupMetadata.model_validate_json(payload)
|
|
175
|
+
except (OSError, ValidationError) as exc:
|
|
176
|
+
raise self._verification_error(f"backup metadata is invalid: {exc}", path) from None
|
|
177
|
+
if metadata.backup_id != backup_id:
|
|
178
|
+
raise self._verification_error("backup metadata ID does not match its path", path)
|
|
179
|
+
return metadata
|
|
180
|
+
|
|
181
|
+
def _publish_metadata(self, path: Path, metadata: BackupMetadata) -> None:
|
|
182
|
+
payload = (
|
|
183
|
+
json.dumps(
|
|
184
|
+
metadata.model_dump(mode="json"),
|
|
185
|
+
sort_keys=True,
|
|
186
|
+
separators=(",", ":"),
|
|
187
|
+
ensure_ascii=False,
|
|
188
|
+
allow_nan=False,
|
|
189
|
+
).encode("utf-8")
|
|
190
|
+
+ b"\n"
|
|
191
|
+
)
|
|
192
|
+
if len(payload) > MAX_METADATA_BYTES:
|
|
193
|
+
raise self._storage_error("serialized backup metadata exceeds the size limit")
|
|
194
|
+
temporary = self.root / f".{metadata.backup_id}.{uuid4().hex}.json.tmp"
|
|
195
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
196
|
+
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
197
|
+
descriptor = -1
|
|
198
|
+
try:
|
|
199
|
+
descriptor = os.open(temporary, flags, FILE_MODE)
|
|
200
|
+
os.fchmod(descriptor, FILE_MODE)
|
|
201
|
+
_write_all(descriptor, payload)
|
|
202
|
+
os.fsync(descriptor)
|
|
203
|
+
os.close(descriptor)
|
|
204
|
+
descriptor = -1
|
|
205
|
+
os.link(temporary, path, follow_symlinks=False)
|
|
206
|
+
temporary.unlink()
|
|
207
|
+
self._fsync_directory()
|
|
208
|
+
except OSError as exc:
|
|
209
|
+
if descriptor >= 0:
|
|
210
|
+
os.close(descriptor)
|
|
211
|
+
try:
|
|
212
|
+
temporary.unlink(missing_ok=True)
|
|
213
|
+
except OSError:
|
|
214
|
+
pass
|
|
215
|
+
raise self._storage_error(f"backup metadata could not be published: {exc}") from None
|
|
216
|
+
|
|
217
|
+
def _validate_layout(self) -> None:
|
|
218
|
+
if self.root.is_symlink():
|
|
219
|
+
raise self._verification_error("backup directory is a symlink", self.root)
|
|
220
|
+
try:
|
|
221
|
+
details = self.root.stat()
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
raise self._verification_error(
|
|
224
|
+
f"backup directory is unavailable: {exc}", self.root
|
|
225
|
+
) from None
|
|
226
|
+
mode = stat.S_IMODE(details.st_mode)
|
|
227
|
+
if not stat.S_ISDIR(details.st_mode) or mode != DIRECTORY_MODE:
|
|
228
|
+
raise self._verification_error(
|
|
229
|
+
f"backup directory must be a mode-0700 directory, found {mode:04o}",
|
|
230
|
+
self.root,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _validate_staged_database(self, path: Path, backup_id: str) -> None:
|
|
234
|
+
try:
|
|
235
|
+
relative = path.relative_to(self.root)
|
|
236
|
+
details = path.lstat()
|
|
237
|
+
except (OSError, ValueError) as exc:
|
|
238
|
+
raise self._storage_error(f"backup staging file is unsafe: {exc}") from None
|
|
239
|
+
if (
|
|
240
|
+
relative.parent != Path(".")
|
|
241
|
+
or not path.name.startswith(f".{backup_id}.")
|
|
242
|
+
or path.is_symlink()
|
|
243
|
+
or not stat.S_ISREG(details.st_mode)
|
|
244
|
+
or stat.S_IMODE(details.st_mode) != FILE_MODE
|
|
245
|
+
):
|
|
246
|
+
raise self._storage_error("backup staging file is not a private managed file")
|
|
247
|
+
|
|
248
|
+
def _validate_committed_file(self, path: Path, label: str) -> None:
|
|
249
|
+
try:
|
|
250
|
+
details = path.lstat()
|
|
251
|
+
except OSError as exc:
|
|
252
|
+
raise self._verification_error(f"{label} is unavailable: {exc}", path) from None
|
|
253
|
+
if path.is_symlink() or not stat.S_ISREG(details.st_mode):
|
|
254
|
+
raise self._verification_error(f"{label} must be a regular non-symlink file", path)
|
|
255
|
+
mode = stat.S_IMODE(details.st_mode)
|
|
256
|
+
if mode != FILE_MODE:
|
|
257
|
+
raise self._verification_error(f"{label} must have mode 0600, found {mode:04o}", path)
|
|
258
|
+
|
|
259
|
+
def _validate_id(self, backup_id: str) -> None:
|
|
260
|
+
if _BACKUP_ID.fullmatch(backup_id) is None:
|
|
261
|
+
raise self._verification_error("backup ID is invalid", self.root)
|
|
262
|
+
|
|
263
|
+
def _fsync_directory(self) -> None:
|
|
264
|
+
descriptor = -1
|
|
265
|
+
try:
|
|
266
|
+
descriptor = os.open(self.root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
267
|
+
os.fsync(descriptor)
|
|
268
|
+
except OSError as exc:
|
|
269
|
+
if exc.errno not in _IGNORABLE_DIRECTORY_FSYNC_ERRORS:
|
|
270
|
+
raise
|
|
271
|
+
finally:
|
|
272
|
+
if descriptor >= 0:
|
|
273
|
+
os.close(descriptor)
|
|
274
|
+
|
|
275
|
+
def _storage_error(self, detail: str) -> OperationFailedError:
|
|
276
|
+
return OperationFailedError(
|
|
277
|
+
detail,
|
|
278
|
+
production_changed=False,
|
|
279
|
+
previous_application_running=None,
|
|
280
|
+
log_path=self.root,
|
|
281
|
+
next_safe_action="Correct local backup storage, then retry the backup.",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _verification_error(detail: str, path: Path) -> SafetyCheckError:
|
|
286
|
+
return SafetyCheckError(
|
|
287
|
+
detail,
|
|
288
|
+
production_changed=False,
|
|
289
|
+
previous_application_running=None,
|
|
290
|
+
log_path=path,
|
|
291
|
+
next_safe_action="Use another committed verified backup or create a new backup.",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _write_all(descriptor: int, payload: bytes) -> None:
|
|
296
|
+
written = 0
|
|
297
|
+
while written < len(payload):
|
|
298
|
+
count = os.write(descriptor, payload[written:])
|
|
299
|
+
if count <= 0:
|
|
300
|
+
raise OSError("metadata write made no progress")
|
|
301
|
+
written += count
|