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.
@@ -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