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/state.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""Atomic operation manifests and append-only recovery events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import stat
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Final, cast
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
from pydantic import ValidationError
|
|
19
|
+
|
|
20
|
+
from dploydb.errors import OperationFailedError, StateCorruptionError
|
|
21
|
+
from dploydb.models import (
|
|
22
|
+
FailureRecord,
|
|
23
|
+
OperationEvent,
|
|
24
|
+
OperationManifest,
|
|
25
|
+
OperationStatus,
|
|
26
|
+
ProcessIdentity,
|
|
27
|
+
SafetyFacts,
|
|
28
|
+
new_operation_id,
|
|
29
|
+
utc_now,
|
|
30
|
+
)
|
|
31
|
+
from dploydb.redaction import JsonValue, SecretRegistry
|
|
32
|
+
|
|
33
|
+
DIRECTORY_MODE: Final = 0o700
|
|
34
|
+
FILE_MODE: Final = 0o600
|
|
35
|
+
MAX_EVENT_BYTES: Final = 1024 * 1024
|
|
36
|
+
MAX_EVENT_COUNT: Final = 256
|
|
37
|
+
MAX_EVENT_LOG_BYTES: Final = 32 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
_OPERATION_ID = re.compile(r"^op_[0-9a-f]{32}$")
|
|
40
|
+
_TEMP_FILE = re.compile(r"^\.manifest\.json\.[0-9a-f]{32}\.tmp$")
|
|
41
|
+
_IGNORABLE_DIRECTORY_FSYNC_ERRORS = frozenset({errno.EINVAL, errno.ENOTSUP})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class OperationPaths:
|
|
46
|
+
"""Filesystem paths owned by one operation."""
|
|
47
|
+
|
|
48
|
+
directory: Path
|
|
49
|
+
manifest: Path
|
|
50
|
+
events: Path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class StateStore:
|
|
54
|
+
"""Persist operation evidence without deleting or silently repairing it."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
root: Path,
|
|
59
|
+
*,
|
|
60
|
+
secrets: SecretRegistry,
|
|
61
|
+
clock: Callable[[], datetime] = utc_now,
|
|
62
|
+
) -> None:
|
|
63
|
+
if not root.is_absolute():
|
|
64
|
+
raise ValueError("state directory must be absolute")
|
|
65
|
+
self.root = root
|
|
66
|
+
self.secrets = secrets
|
|
67
|
+
self.clock = clock
|
|
68
|
+
self.operations_directory = root / "operations"
|
|
69
|
+
|
|
70
|
+
def ensure_layout(self) -> None:
|
|
71
|
+
"""Create the private state layout and validate managed permissions."""
|
|
72
|
+
self._ensure_private_directory(self.root, create_parents=True)
|
|
73
|
+
self._ensure_private_directory(self.operations_directory, create_parents=False)
|
|
74
|
+
|
|
75
|
+
def operation_paths(self, operation_id: str) -> OperationPaths:
|
|
76
|
+
"""Return safe paths for one syntactically valid operation ID."""
|
|
77
|
+
if _OPERATION_ID.fullmatch(operation_id) is None:
|
|
78
|
+
raise ValueError("operation ID is invalid or contains unsafe characters")
|
|
79
|
+
directory = self.operations_directory / operation_id
|
|
80
|
+
self._require_descendant(directory)
|
|
81
|
+
return OperationPaths(
|
|
82
|
+
directory=directory,
|
|
83
|
+
manifest=directory / "manifest.json",
|
|
84
|
+
events=directory / "events.jsonl",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def create_operation(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
operation_type: str,
|
|
91
|
+
project: str,
|
|
92
|
+
configuration_fingerprint: str,
|
|
93
|
+
stage: str = "created",
|
|
94
|
+
process: ProcessIdentity | None = None,
|
|
95
|
+
evidence: Mapping[str, Any] | None = None,
|
|
96
|
+
) -> OperationManifest:
|
|
97
|
+
"""Create durable in-progress evidence before external work begins."""
|
|
98
|
+
self.ensure_layout()
|
|
99
|
+
operation_id = str(new_operation_id())
|
|
100
|
+
paths = self.operation_paths(operation_id)
|
|
101
|
+
try:
|
|
102
|
+
paths.directory.mkdir(mode=DIRECTORY_MODE)
|
|
103
|
+
except OSError as exc:
|
|
104
|
+
raise self._write_error(
|
|
105
|
+
f"operation directory could not be created: {paths.directory}", paths.directory
|
|
106
|
+
) from exc
|
|
107
|
+
self._validate_private_directory(paths.directory)
|
|
108
|
+
|
|
109
|
+
now = self._now()
|
|
110
|
+
identity = process or ProcessIdentity(pid=os.getpid(), hostname=socket.gethostname())
|
|
111
|
+
event = OperationEvent(
|
|
112
|
+
sequence=1,
|
|
113
|
+
timestamp=now,
|
|
114
|
+
operation_id=operation_id,
|
|
115
|
+
status=OperationStatus.IN_PROGRESS,
|
|
116
|
+
stage=stage,
|
|
117
|
+
message="Operation started.",
|
|
118
|
+
evidence=self._redact_mapping(evidence),
|
|
119
|
+
)
|
|
120
|
+
manifest = OperationManifest(
|
|
121
|
+
operation_id=operation_id,
|
|
122
|
+
operation_type=operation_type,
|
|
123
|
+
project=project,
|
|
124
|
+
status=OperationStatus.IN_PROGRESS,
|
|
125
|
+
stage=stage,
|
|
126
|
+
configuration_fingerprint=configuration_fingerprint,
|
|
127
|
+
process=identity,
|
|
128
|
+
safety=SafetyFacts(),
|
|
129
|
+
started_at=now,
|
|
130
|
+
updated_at=now,
|
|
131
|
+
last_event_sequence=1,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self._append_event_record(paths, event)
|
|
135
|
+
try:
|
|
136
|
+
self._atomic_write_manifest(paths.manifest, manifest)
|
|
137
|
+
except OperationFailedError as exc:
|
|
138
|
+
raise self._corruption_error(
|
|
139
|
+
"operation creation was interrupted after its first event was recorded",
|
|
140
|
+
paths.events,
|
|
141
|
+
) from exc
|
|
142
|
+
return self.read_manifest(operation_id)
|
|
143
|
+
|
|
144
|
+
def transition(
|
|
145
|
+
self,
|
|
146
|
+
operation_id: str,
|
|
147
|
+
*,
|
|
148
|
+
status: OperationStatus,
|
|
149
|
+
stage: str,
|
|
150
|
+
message: str,
|
|
151
|
+
evidence: Mapping[str, Any] | None = None,
|
|
152
|
+
safety: SafetyFacts | None = None,
|
|
153
|
+
failure: FailureRecord | None = None,
|
|
154
|
+
) -> OperationManifest:
|
|
155
|
+
"""Append and persist one valid operation lifecycle transition."""
|
|
156
|
+
current, events = self._read_consistent(operation_id)
|
|
157
|
+
if current.status is not OperationStatus.IN_PROGRESS:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"terminal operation {operation_id} cannot transition from {current.status.value}"
|
|
160
|
+
)
|
|
161
|
+
if status is OperationStatus.IN_PROGRESS and stage == current.stage:
|
|
162
|
+
raise ValueError("in-progress transition must change the operation stage")
|
|
163
|
+
|
|
164
|
+
now = self._now_not_before(current.updated_at)
|
|
165
|
+
next_safety = safety or current.safety
|
|
166
|
+
redacted_failure = self._redact_failure(failure)
|
|
167
|
+
completed_at = None if status is OperationStatus.IN_PROGRESS else now
|
|
168
|
+
updated = current.model_copy(
|
|
169
|
+
update={
|
|
170
|
+
"status": status,
|
|
171
|
+
"stage": stage,
|
|
172
|
+
"safety": next_safety,
|
|
173
|
+
"updated_at": now,
|
|
174
|
+
"completed_at": completed_at,
|
|
175
|
+
"failure": redacted_failure,
|
|
176
|
+
"last_event_sequence": current.last_event_sequence + 1,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
updated = OperationManifest.model_validate_json(updated.model_dump_json())
|
|
180
|
+
event = OperationEvent(
|
|
181
|
+
sequence=updated.last_event_sequence,
|
|
182
|
+
timestamp=now,
|
|
183
|
+
operation_id=operation_id,
|
|
184
|
+
status=status,
|
|
185
|
+
stage=stage,
|
|
186
|
+
message=self.secrets.redact_text(message),
|
|
187
|
+
evidence=self._redact_mapping(evidence),
|
|
188
|
+
)
|
|
189
|
+
paths = self.operation_paths(operation_id)
|
|
190
|
+
self._append_event_record(paths, event, expected_existing=len(events))
|
|
191
|
+
try:
|
|
192
|
+
self._atomic_write_manifest(paths.manifest, updated)
|
|
193
|
+
except OperationFailedError as exc:
|
|
194
|
+
raise self._corruption_error(
|
|
195
|
+
"operation transition event was durable but its manifest was not updated",
|
|
196
|
+
paths.manifest,
|
|
197
|
+
current,
|
|
198
|
+
) from exc
|
|
199
|
+
return self.read_manifest(operation_id)
|
|
200
|
+
|
|
201
|
+
def append_event(
|
|
202
|
+
self,
|
|
203
|
+
operation_id: str,
|
|
204
|
+
*,
|
|
205
|
+
message: str,
|
|
206
|
+
evidence: Mapping[str, Any] | None = None,
|
|
207
|
+
) -> OperationEvent:
|
|
208
|
+
"""Append same-stage evidence to an unfinished operation."""
|
|
209
|
+
current, events = self._read_consistent(operation_id)
|
|
210
|
+
if current.status is not OperationStatus.IN_PROGRESS:
|
|
211
|
+
raise ValueError("terminal operations are immutable")
|
|
212
|
+
now = self._now_not_before(current.updated_at)
|
|
213
|
+
event = OperationEvent(
|
|
214
|
+
sequence=current.last_event_sequence + 1,
|
|
215
|
+
timestamp=now,
|
|
216
|
+
operation_id=operation_id,
|
|
217
|
+
status=current.status,
|
|
218
|
+
stage=current.stage,
|
|
219
|
+
message=self.secrets.redact_text(message),
|
|
220
|
+
evidence=self._redact_mapping(evidence),
|
|
221
|
+
)
|
|
222
|
+
paths = self.operation_paths(operation_id)
|
|
223
|
+
self._append_event_record(paths, event, expected_existing=len(events))
|
|
224
|
+
updated = current.model_copy(
|
|
225
|
+
update={"updated_at": now, "last_event_sequence": event.sequence}
|
|
226
|
+
)
|
|
227
|
+
updated = OperationManifest.model_validate_json(updated.model_dump_json())
|
|
228
|
+
try:
|
|
229
|
+
self._atomic_write_manifest(paths.manifest, updated)
|
|
230
|
+
except OperationFailedError as exc:
|
|
231
|
+
raise self._corruption_error(
|
|
232
|
+
"operation evidence was durable but its manifest was not updated",
|
|
233
|
+
paths.manifest,
|
|
234
|
+
current,
|
|
235
|
+
) from exc
|
|
236
|
+
return self.read_events(operation_id)[-1]
|
|
237
|
+
|
|
238
|
+
def read_manifest(self, operation_id: str) -> OperationManifest:
|
|
239
|
+
"""Read one complete strict operation manifest."""
|
|
240
|
+
path = self.operation_paths(operation_id).manifest
|
|
241
|
+
data = self._read_private_file(path)
|
|
242
|
+
try:
|
|
243
|
+
manifest = OperationManifest.model_validate_json(data)
|
|
244
|
+
except ValidationError as exc:
|
|
245
|
+
raise self._corruption_error(
|
|
246
|
+
f"operation manifest is invalid ({exc.error_count()} validation errors)", path
|
|
247
|
+
) from None
|
|
248
|
+
if manifest.operation_id != operation_id:
|
|
249
|
+
raise self._corruption_error("operation manifest ID does not match its path", path)
|
|
250
|
+
return manifest
|
|
251
|
+
|
|
252
|
+
def read_operation(self, operation_id: str) -> tuple[OperationManifest, list[OperationEvent]]:
|
|
253
|
+
"""Read a manifest and event trail only when they agree completely."""
|
|
254
|
+
return self._read_consistent(operation_id)
|
|
255
|
+
|
|
256
|
+
def read_events(self, operation_id: str) -> list[OperationEvent]:
|
|
257
|
+
"""Read an event trail without repairing malformed evidence."""
|
|
258
|
+
path = self.operation_paths(operation_id).events
|
|
259
|
+
data = self._read_private_file(path, maximum_bytes=MAX_EVENT_LOG_BYTES)
|
|
260
|
+
try:
|
|
261
|
+
text = data.decode("utf-8")
|
|
262
|
+
except UnicodeError:
|
|
263
|
+
raise self._corruption_error("operation event log is not valid UTF-8", path) from None
|
|
264
|
+
if not text or not text.endswith("\n"):
|
|
265
|
+
raise self._corruption_error("operation event log is empty or truncated", path)
|
|
266
|
+
|
|
267
|
+
events: list[OperationEvent] = []
|
|
268
|
+
previous_timestamp: datetime | None = None
|
|
269
|
+
for sequence, line in enumerate(text.splitlines(), start=1):
|
|
270
|
+
if sequence > MAX_EVENT_COUNT:
|
|
271
|
+
raise self._corruption_error(
|
|
272
|
+
f"operation event log exceeds the {MAX_EVENT_COUNT}-record limit", path
|
|
273
|
+
)
|
|
274
|
+
if not line:
|
|
275
|
+
raise self._corruption_error("operation event log contains an empty record", path)
|
|
276
|
+
if len(line.encode("utf-8")) + 1 > MAX_EVENT_BYTES:
|
|
277
|
+
raise self._corruption_error(
|
|
278
|
+
f"operation event {sequence} exceeds the maximum record size", path
|
|
279
|
+
)
|
|
280
|
+
try:
|
|
281
|
+
event = OperationEvent.model_validate_json(line)
|
|
282
|
+
except ValidationError as exc:
|
|
283
|
+
raise self._corruption_error(
|
|
284
|
+
f"operation event {sequence} is invalid "
|
|
285
|
+
f"({exc.error_count()} validation errors)",
|
|
286
|
+
path,
|
|
287
|
+
) from None
|
|
288
|
+
if event.sequence != sequence:
|
|
289
|
+
raise self._corruption_error(
|
|
290
|
+
f"operation event sequence is invalid at record {sequence}", path
|
|
291
|
+
)
|
|
292
|
+
if event.operation_id != operation_id:
|
|
293
|
+
raise self._corruption_error(
|
|
294
|
+
f"operation event {sequence} belongs to another operation", path
|
|
295
|
+
)
|
|
296
|
+
if previous_timestamp is not None and event.timestamp < previous_timestamp:
|
|
297
|
+
raise self._corruption_error(
|
|
298
|
+
f"operation event timestamp regressed at record {sequence}", path
|
|
299
|
+
)
|
|
300
|
+
previous_timestamp = event.timestamp
|
|
301
|
+
events.append(event)
|
|
302
|
+
return events
|
|
303
|
+
|
|
304
|
+
def latest_operation(self) -> OperationManifest | None:
|
|
305
|
+
"""Return the newest valid operation after validating all durable evidence."""
|
|
306
|
+
operations = list(self.list_operations())
|
|
307
|
+
unfinished = [item for item in operations if item.status is OperationStatus.IN_PROGRESS]
|
|
308
|
+
if len(unfinished) > 1:
|
|
309
|
+
raise self._corruption_error(
|
|
310
|
+
"multiple unfinished operations make the active state contradictory",
|
|
311
|
+
self.operations_directory,
|
|
312
|
+
)
|
|
313
|
+
if not operations:
|
|
314
|
+
return None
|
|
315
|
+
return max(operations, key=lambda item: (item.started_at, item.operation_id))
|
|
316
|
+
|
|
317
|
+
def list_operations(self) -> tuple[OperationManifest, ...]:
|
|
318
|
+
"""Return every valid operation newest first without changing state."""
|
|
319
|
+
if not self.root.exists():
|
|
320
|
+
return ()
|
|
321
|
+
self._validate_private_directory(self.root)
|
|
322
|
+
if not self.operations_directory.exists():
|
|
323
|
+
return ()
|
|
324
|
+
self._validate_private_directory(self.operations_directory)
|
|
325
|
+
|
|
326
|
+
operations: list[OperationManifest] = []
|
|
327
|
+
for entry in self.operations_directory.iterdir():
|
|
328
|
+
if (
|
|
329
|
+
entry.is_symlink()
|
|
330
|
+
or not entry.is_dir()
|
|
331
|
+
or _OPERATION_ID.fullmatch(entry.name) is None
|
|
332
|
+
):
|
|
333
|
+
raise self._corruption_error(
|
|
334
|
+
f"operations directory contains an unexpected entry: {entry.name}", entry
|
|
335
|
+
)
|
|
336
|
+
manifest, _ = self._read_consistent(entry.name)
|
|
337
|
+
operations.append(manifest)
|
|
338
|
+
|
|
339
|
+
operations.sort(key=lambda item: (item.started_at, item.operation_id), reverse=True)
|
|
340
|
+
return tuple(operations)
|
|
341
|
+
|
|
342
|
+
def _read_consistent(self, operation_id: str) -> tuple[OperationManifest, list[OperationEvent]]:
|
|
343
|
+
paths = self.operation_paths(operation_id)
|
|
344
|
+
self._validate_private_directory(paths.directory)
|
|
345
|
+
leftovers = [
|
|
346
|
+
entry for entry in paths.directory.iterdir() if _TEMP_FILE.fullmatch(entry.name)
|
|
347
|
+
]
|
|
348
|
+
if leftovers:
|
|
349
|
+
raise self._corruption_error(
|
|
350
|
+
"operation contains an abandoned atomic-write temporary file", leftovers[0]
|
|
351
|
+
)
|
|
352
|
+
expected_names = {paths.manifest.name, paths.events.name}
|
|
353
|
+
unexpected = [
|
|
354
|
+
entry for entry in paths.directory.iterdir() if entry.name not in expected_names
|
|
355
|
+
]
|
|
356
|
+
if unexpected:
|
|
357
|
+
raise self._corruption_error(
|
|
358
|
+
f"operation directory contains an unexpected entry: {unexpected[0].name}",
|
|
359
|
+
unexpected[0],
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
manifest = self.read_manifest(operation_id)
|
|
363
|
+
events = self.read_events(operation_id)
|
|
364
|
+
final = events[-1]
|
|
365
|
+
if manifest.last_event_sequence != len(events):
|
|
366
|
+
raise self._corruption_error(
|
|
367
|
+
"manifest and event sequence disagree", paths.events, manifest
|
|
368
|
+
)
|
|
369
|
+
if final.status is not manifest.status or final.stage != manifest.stage:
|
|
370
|
+
raise self._corruption_error(
|
|
371
|
+
"manifest and final event state disagree", paths.events, manifest
|
|
372
|
+
)
|
|
373
|
+
if final.timestamp != manifest.updated_at:
|
|
374
|
+
raise self._corruption_error(
|
|
375
|
+
"manifest and final event timestamps disagree", paths.events, manifest
|
|
376
|
+
)
|
|
377
|
+
return manifest, events
|
|
378
|
+
|
|
379
|
+
def _atomic_write_manifest(self, path: Path, manifest: OperationManifest) -> None:
|
|
380
|
+
payload = self._json_bytes(manifest.model_dump(mode="json")) + b"\n"
|
|
381
|
+
temporary = path.parent / f".manifest.json.{uuid4().hex}.tmp"
|
|
382
|
+
descriptor = -1
|
|
383
|
+
replaced = False
|
|
384
|
+
try:
|
|
385
|
+
self._reject_symlink(path)
|
|
386
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
387
|
+
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
388
|
+
descriptor = os.open(temporary, flags, FILE_MODE)
|
|
389
|
+
os.fchmod(descriptor, FILE_MODE)
|
|
390
|
+
with os.fdopen(descriptor, "wb", closefd=True) as stream:
|
|
391
|
+
descriptor = -1
|
|
392
|
+
written = stream.write(payload)
|
|
393
|
+
if written != len(payload):
|
|
394
|
+
raise OSError("short manifest write")
|
|
395
|
+
stream.flush()
|
|
396
|
+
os.fsync(stream.fileno())
|
|
397
|
+
os.replace(temporary, path)
|
|
398
|
+
replaced = True
|
|
399
|
+
self._fsync_directory(path.parent)
|
|
400
|
+
except OSError as exc:
|
|
401
|
+
detail = (
|
|
402
|
+
f"manifest replacement completed but directory sync failed: {path}"
|
|
403
|
+
if replaced
|
|
404
|
+
else f"manifest could not be written atomically: {path}"
|
|
405
|
+
)
|
|
406
|
+
raise self._write_error(detail, path) from exc
|
|
407
|
+
finally:
|
|
408
|
+
if descriptor >= 0:
|
|
409
|
+
try:
|
|
410
|
+
os.close(descriptor)
|
|
411
|
+
except OSError:
|
|
412
|
+
pass
|
|
413
|
+
if not replaced:
|
|
414
|
+
try:
|
|
415
|
+
temporary.unlink(missing_ok=True)
|
|
416
|
+
except OSError:
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
def _append_event_record(
|
|
420
|
+
self,
|
|
421
|
+
paths: OperationPaths,
|
|
422
|
+
event: OperationEvent,
|
|
423
|
+
*,
|
|
424
|
+
expected_existing: int = 0,
|
|
425
|
+
) -> None:
|
|
426
|
+
if paths.events.exists():
|
|
427
|
+
existing = self.read_events(event.operation_id)
|
|
428
|
+
if len(existing) != expected_existing:
|
|
429
|
+
raise self._corruption_error(
|
|
430
|
+
"event log changed while preparing an append", paths.events
|
|
431
|
+
)
|
|
432
|
+
elif expected_existing:
|
|
433
|
+
raise self._corruption_error("operation event log disappeared", paths.events)
|
|
434
|
+
|
|
435
|
+
payload = self._json_bytes(event.model_dump(mode="json")) + b"\n"
|
|
436
|
+
if len(payload) > MAX_EVENT_BYTES:
|
|
437
|
+
raise ValueError(f"serialized operation event exceeds {MAX_EVENT_BYTES} bytes")
|
|
438
|
+
if expected_existing >= MAX_EVENT_COUNT:
|
|
439
|
+
raise ValueError(f"operation event log cannot exceed {MAX_EVENT_COUNT} records")
|
|
440
|
+
try:
|
|
441
|
+
existing_size = paths.events.stat().st_size if paths.events.exists() else 0
|
|
442
|
+
except OSError as exc:
|
|
443
|
+
raise self._corruption_error(
|
|
444
|
+
f"operation event log could not be inspected before append: {exc}", paths.events
|
|
445
|
+
) from None
|
|
446
|
+
if expected_existing and existing_size == 0:
|
|
447
|
+
raise self._corruption_error("operation event log disappeared", paths.events)
|
|
448
|
+
if existing_size + len(payload) > MAX_EVENT_LOG_BYTES:
|
|
449
|
+
raise ValueError(f"operation event log cannot exceed {MAX_EVENT_LOG_BYTES} bytes")
|
|
450
|
+
self._reject_symlink(paths.events)
|
|
451
|
+
flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
|
|
452
|
+
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
453
|
+
descriptor = -1
|
|
454
|
+
existed = paths.events.exists()
|
|
455
|
+
try:
|
|
456
|
+
descriptor = os.open(paths.events, flags, FILE_MODE)
|
|
457
|
+
os.fchmod(descriptor, FILE_MODE)
|
|
458
|
+
written = os.write(descriptor, payload)
|
|
459
|
+
if written != len(payload):
|
|
460
|
+
raise OSError("short event-log write")
|
|
461
|
+
os.fsync(descriptor)
|
|
462
|
+
os.close(descriptor)
|
|
463
|
+
descriptor = -1
|
|
464
|
+
if not existed:
|
|
465
|
+
self._fsync_directory(paths.events.parent)
|
|
466
|
+
except OSError as exc:
|
|
467
|
+
raise self._corruption_error(
|
|
468
|
+
f"operation event could not be appended durably: {exc}", paths.events
|
|
469
|
+
) from exc
|
|
470
|
+
finally:
|
|
471
|
+
if descriptor >= 0:
|
|
472
|
+
try:
|
|
473
|
+
os.close(descriptor)
|
|
474
|
+
except OSError:
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
def _read_private_file(self, path: Path, *, maximum_bytes: int | None = None) -> bytes:
|
|
478
|
+
self._reject_symlink(path)
|
|
479
|
+
try:
|
|
480
|
+
details = path.stat()
|
|
481
|
+
if not stat.S_ISREG(details.st_mode):
|
|
482
|
+
raise OSError("not a regular file")
|
|
483
|
+
if stat.S_IMODE(details.st_mode) != FILE_MODE:
|
|
484
|
+
raise OSError(f"unsafe mode {stat.S_IMODE(details.st_mode):04o}")
|
|
485
|
+
if maximum_bytes is not None and details.st_size > maximum_bytes:
|
|
486
|
+
raise OSError(f"file exceeds the {maximum_bytes}-byte limit")
|
|
487
|
+
return path.read_bytes()
|
|
488
|
+
except OSError as exc:
|
|
489
|
+
raise self._corruption_error(
|
|
490
|
+
f"state file is missing, unreadable, or unsafe: {exc}", path
|
|
491
|
+
) from None
|
|
492
|
+
|
|
493
|
+
def _ensure_private_directory(self, path: Path, *, create_parents: bool) -> None:
|
|
494
|
+
self._reject_symlink(path)
|
|
495
|
+
try:
|
|
496
|
+
path.mkdir(mode=DIRECTORY_MODE, parents=create_parents, exist_ok=True)
|
|
497
|
+
except OSError as exc:
|
|
498
|
+
raise self._write_error(f"state directory could not be created: {path}", path) from exc
|
|
499
|
+
self._validate_private_directory(path)
|
|
500
|
+
|
|
501
|
+
def _validate_private_directory(self, path: Path) -> None:
|
|
502
|
+
self._reject_symlink(path)
|
|
503
|
+
try:
|
|
504
|
+
details = path.stat()
|
|
505
|
+
except OSError:
|
|
506
|
+
raise self._corruption_error("state directory is missing or unreadable", path) from None
|
|
507
|
+
mode = stat.S_IMODE(details.st_mode)
|
|
508
|
+
if not stat.S_ISDIR(details.st_mode) or mode != DIRECTORY_MODE:
|
|
509
|
+
raise self._corruption_error(
|
|
510
|
+
f"state directory must be a mode-0700 directory, found {mode:04o}", path
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def _require_descendant(self, path: Path) -> None:
|
|
514
|
+
try:
|
|
515
|
+
path.relative_to(self.root)
|
|
516
|
+
except ValueError:
|
|
517
|
+
raise ValueError("state path is outside the configured root") from None
|
|
518
|
+
|
|
519
|
+
def _reject_symlink(self, path: Path) -> None:
|
|
520
|
+
try:
|
|
521
|
+
if path.is_symlink():
|
|
522
|
+
raise self._corruption_error("refusing symlinked managed state path", path)
|
|
523
|
+
except OSError:
|
|
524
|
+
raise self._corruption_error(
|
|
525
|
+
"managed state path could not be inspected", path
|
|
526
|
+
) from None
|
|
527
|
+
|
|
528
|
+
def _fsync_directory(self, directory: Path) -> None:
|
|
529
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
|
|
530
|
+
descriptor = os.open(directory, flags)
|
|
531
|
+
try:
|
|
532
|
+
try:
|
|
533
|
+
os.fsync(descriptor)
|
|
534
|
+
except OSError as exc:
|
|
535
|
+
if exc.errno not in _IGNORABLE_DIRECTORY_FSYNC_ERRORS:
|
|
536
|
+
raise
|
|
537
|
+
finally:
|
|
538
|
+
os.close(descriptor)
|
|
539
|
+
|
|
540
|
+
def _json_bytes(self, value: object) -> bytes:
|
|
541
|
+
redacted = self.secrets.redact(cast(JsonValue, value))
|
|
542
|
+
try:
|
|
543
|
+
return json.dumps(
|
|
544
|
+
redacted,
|
|
545
|
+
sort_keys=True,
|
|
546
|
+
separators=(",", ":"),
|
|
547
|
+
ensure_ascii=False,
|
|
548
|
+
allow_nan=False,
|
|
549
|
+
).encode("utf-8")
|
|
550
|
+
except (TypeError, ValueError) as exc:
|
|
551
|
+
raise ValueError(f"state evidence is not JSON-compatible: {exc}") from None
|
|
552
|
+
|
|
553
|
+
def _redact_mapping(self, value: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
554
|
+
source = {} if value is None else dict(value)
|
|
555
|
+
redacted = self.secrets.redact(cast(JsonValue, source))
|
|
556
|
+
if not isinstance(redacted, dict):
|
|
557
|
+
raise TypeError("redacted evidence must remain a mapping")
|
|
558
|
+
return cast(dict[str, Any], redacted)
|
|
559
|
+
|
|
560
|
+
def _redact_failure(self, failure: FailureRecord | None) -> FailureRecord | None:
|
|
561
|
+
if failure is None:
|
|
562
|
+
return None
|
|
563
|
+
redacted = self.secrets.redact(cast(JsonValue, failure.model_dump(mode="json")))
|
|
564
|
+
return FailureRecord.model_validate(redacted)
|
|
565
|
+
|
|
566
|
+
def _now(self) -> datetime:
|
|
567
|
+
value = self.clock()
|
|
568
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
569
|
+
raise ValueError("state clock must return a timezone-aware timestamp")
|
|
570
|
+
return value
|
|
571
|
+
|
|
572
|
+
def _now_not_before(self, previous: datetime) -> datetime:
|
|
573
|
+
value = self._now()
|
|
574
|
+
if value < previous:
|
|
575
|
+
raise ValueError("state clock moved backwards")
|
|
576
|
+
return value
|
|
577
|
+
|
|
578
|
+
def _write_error(self, detail: str, path: Path) -> OperationFailedError:
|
|
579
|
+
return OperationFailedError(
|
|
580
|
+
self.secrets.redact_text(detail),
|
|
581
|
+
production_changed=False,
|
|
582
|
+
previous_application_running=None,
|
|
583
|
+
log_path=self.secrets.redact_text(str(path)),
|
|
584
|
+
next_safe_action="Inspect the state path and retry only after correcting the cause.",
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def _corruption_error(
|
|
588
|
+
self,
|
|
589
|
+
detail: str,
|
|
590
|
+
path: Path,
|
|
591
|
+
manifest: OperationManifest | None = None,
|
|
592
|
+
) -> StateCorruptionError:
|
|
593
|
+
safety = manifest.safety if manifest is not None else None
|
|
594
|
+
return StateCorruptionError(
|
|
595
|
+
self.secrets.redact_text(detail),
|
|
596
|
+
production_changed=True if safety is None else safety.production_changed,
|
|
597
|
+
previous_application_running=(
|
|
598
|
+
None if safety is None else safety.previous_application_running
|
|
599
|
+
),
|
|
600
|
+
log_path=self.secrets.redact_text(str(path)),
|
|
601
|
+
next_safe_action=(
|
|
602
|
+
"Preserve the state files and inspect the recorded operation before any retry."
|
|
603
|
+
),
|
|
604
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Backup storage adapters."""
|
|
2
|
+
|
|
3
|
+
from dploydb.storage.base import BackupStorage, RemoteBackupStorage
|
|
4
|
+
from dploydb.storage.local import LocalBackupStorage
|
|
5
|
+
from dploydb.storage.s3 import S3BackupStorage, configured_s3_storage
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BackupStorage",
|
|
9
|
+
"LocalBackupStorage",
|
|
10
|
+
"RemoteBackupStorage",
|
|
11
|
+
"S3BackupStorage",
|
|
12
|
+
"configured_s3_storage",
|
|
13
|
+
]
|
dploydb/storage/base.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Narrow storage contract for immutable verified backups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from dploydb.models import (
|
|
9
|
+
BackupArtifact,
|
|
10
|
+
BackupMetadata,
|
|
11
|
+
RemoteBackupArtifact,
|
|
12
|
+
RemoteBackupMetadata,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BackupStorage(Protocol):
|
|
17
|
+
"""Storage behavior consumed by the backup engine."""
|
|
18
|
+
|
|
19
|
+
def create_staging_database(self, backup_id: str) -> Path: ...
|
|
20
|
+
|
|
21
|
+
def put(self, staged_database: Path, metadata: BackupMetadata) -> BackupArtifact: ...
|
|
22
|
+
|
|
23
|
+
def get(self, backup_id: str) -> BackupArtifact: ...
|
|
24
|
+
|
|
25
|
+
def exists(self, backup_id: str) -> bool: ...
|
|
26
|
+
|
|
27
|
+
def list(self) -> tuple[BackupMetadata, ...]: ...
|
|
28
|
+
|
|
29
|
+
def delete(self, backup_id: str) -> None: ...
|
|
30
|
+
|
|
31
|
+
def verify_metadata(self, backup_id: str) -> BackupMetadata: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RemoteBackupStorage(Protocol):
|
|
35
|
+
"""Off-server replica behavior for already verified local backups."""
|
|
36
|
+
|
|
37
|
+
def put(
|
|
38
|
+
self,
|
|
39
|
+
artifact: BackupArtifact,
|
|
40
|
+
*,
|
|
41
|
+
release_id: str | None = None,
|
|
42
|
+
) -> RemoteBackupArtifact: ...
|
|
43
|
+
|
|
44
|
+
def download(self, backup_id: str, destination: Path) -> RemoteBackupArtifact: ...
|
|
45
|
+
|
|
46
|
+
def exists(self, backup_id: str) -> bool: ...
|
|
47
|
+
|
|
48
|
+
def list(self) -> tuple[RemoteBackupMetadata, ...]: ...
|
|
49
|
+
|
|
50
|
+
def delete(self, backup_id: str) -> None: ...
|
|
51
|
+
|
|
52
|
+
def verify_metadata(self, backup_id: str) -> RemoteBackupMetadata: ...
|