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/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
+ ]
@@ -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: ...