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/models.py
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
"""Shared durable contracts for DployDB operations and failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal, NewType, Self, TypedDict
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from pydantic import (
|
|
13
|
+
BaseModel,
|
|
14
|
+
ConfigDict,
|
|
15
|
+
Field,
|
|
16
|
+
field_serializer,
|
|
17
|
+
field_validator,
|
|
18
|
+
model_validator,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
OperationId = NewType("OperationId", str)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DeploymentState(StrEnum):
|
|
25
|
+
"""Durable states allowed by the DployDB deployment contract."""
|
|
26
|
+
|
|
27
|
+
CREATED = "created"
|
|
28
|
+
PREFLIGHT_PASSED = "preflight_passed"
|
|
29
|
+
SNAPSHOT_VERIFIED = "snapshot_verified"
|
|
30
|
+
REHEARSAL_PASSED = "rehearsal_passed"
|
|
31
|
+
CANDIDATE_HEALTHY = "candidate_healthy"
|
|
32
|
+
MAINTENANCE_ENABLED = "maintenance_enabled"
|
|
33
|
+
CURRENT_APP_STOPPED = "current_app_stopped"
|
|
34
|
+
FINAL_SNAPSHOT_VERIFIED = "final_snapshot_verified"
|
|
35
|
+
PRODUCTION_MIGRATED = "production_migrated"
|
|
36
|
+
NEW_APP_HEALTHY = "new_app_healthy"
|
|
37
|
+
TRAFFIC_ACTIVATED = "traffic_activated"
|
|
38
|
+
ACTIVE = "active"
|
|
39
|
+
ROLLBACK_STARTED = "rollback_started"
|
|
40
|
+
ROLLED_BACK = "rolled_back"
|
|
41
|
+
FAILED_SAFE = "failed_safe"
|
|
42
|
+
RECOVERY_REQUIRED = "recovery_required"
|
|
43
|
+
MANUAL_RESTORE_STARTED = "manual_restore_started"
|
|
44
|
+
MANUAL_RESTORE_COMPLETED = "manual_restore_completed"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OperationStatus(StrEnum):
|
|
48
|
+
"""Generic durable lifecycle for all state-tracked operations."""
|
|
49
|
+
|
|
50
|
+
IN_PROGRESS = "in_progress"
|
|
51
|
+
SUCCEEDED = "succeeded"
|
|
52
|
+
FAILED_SAFE = "failed_safe"
|
|
53
|
+
RECOVERY_REQUIRED = "recovery_required"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BackupPurpose(StrEnum):
|
|
57
|
+
"""Why an immutable verified backup was created."""
|
|
58
|
+
|
|
59
|
+
STANDALONE = "standalone"
|
|
60
|
+
PRE_RESTORE = "pre_restore"
|
|
61
|
+
REHEARSAL = "rehearsal"
|
|
62
|
+
FINAL = "final"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LockOwnerState(StrEnum):
|
|
66
|
+
"""Lifecycle recorded in the diagnostic deployment-lock owner file."""
|
|
67
|
+
|
|
68
|
+
ACTIVE = "active"
|
|
69
|
+
RELEASED = "released"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DiagnosticOutcome(StrEnum):
|
|
73
|
+
"""Stable outcome values emitted by host diagnostics."""
|
|
74
|
+
|
|
75
|
+
PASSED = "passed"
|
|
76
|
+
WARNING = "warning"
|
|
77
|
+
FAILED = "failed"
|
|
78
|
+
SKIPPED = "skipped"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RuntimeStatus(StrEnum):
|
|
82
|
+
"""Read-only summary of the deployment lock and durable operation state."""
|
|
83
|
+
|
|
84
|
+
IDLE = "idle"
|
|
85
|
+
ACTIVE = "active"
|
|
86
|
+
INTERRUPTED = "interrupted"
|
|
87
|
+
RECOVERY_REQUIRED = "recovery_required"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DurableModel(BaseModel):
|
|
91
|
+
"""Strict immutable base for versioned JSON state records."""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ProcessIdentity(DurableModel):
|
|
97
|
+
"""Diagnostic identity of the process that created an operation."""
|
|
98
|
+
|
|
99
|
+
pid: int = Field(gt=0)
|
|
100
|
+
hostname: str = Field(min_length=1, max_length=255)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LockOwnerMetadata(DurableModel):
|
|
104
|
+
"""Versioned diagnostic metadata; the kernel lock remains authoritative."""
|
|
105
|
+
|
|
106
|
+
schema_version: Literal[1] = 1
|
|
107
|
+
owner_id: str = Field(pattern=r"^lock_[0-9a-f]{32}$")
|
|
108
|
+
operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
|
|
109
|
+
operation_type: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$")
|
|
110
|
+
process: ProcessIdentity
|
|
111
|
+
state: LockOwnerState
|
|
112
|
+
acquired_at: datetime
|
|
113
|
+
released_at: datetime | None = None
|
|
114
|
+
|
|
115
|
+
@field_validator("acquired_at", "released_at")
|
|
116
|
+
@classmethod
|
|
117
|
+
def normalize_lock_timestamp(cls, value: datetime | None) -> datetime | None:
|
|
118
|
+
if value is None:
|
|
119
|
+
return None
|
|
120
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
121
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
122
|
+
return value.astimezone(UTC)
|
|
123
|
+
|
|
124
|
+
@field_serializer("acquired_at", "released_at")
|
|
125
|
+
def serialize_lock_timestamp(self, value: datetime | None) -> str | None:
|
|
126
|
+
return None if value is None else serialize_utc_timestamp(value)
|
|
127
|
+
|
|
128
|
+
@model_validator(mode="after")
|
|
129
|
+
def validate_lock_lifecycle(self) -> Self:
|
|
130
|
+
if self.state is LockOwnerState.ACTIVE:
|
|
131
|
+
if self.released_at is not None:
|
|
132
|
+
raise ValueError("active lock owner must not have released_at")
|
|
133
|
+
elif self.released_at is None:
|
|
134
|
+
raise ValueError("released lock owner requires released_at")
|
|
135
|
+
if self.released_at is not None and self.released_at < self.acquired_at:
|
|
136
|
+
raise ValueError("released_at must not precede acquired_at")
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class SafetyFacts(DurableModel):
|
|
141
|
+
"""Persisted safety facts needed to diagnose an interrupted operation."""
|
|
142
|
+
|
|
143
|
+
production_changed: bool = False
|
|
144
|
+
previous_application_running: bool | None = None
|
|
145
|
+
recovery_required: bool = False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class FailureRecord(DurableModel):
|
|
149
|
+
"""Redacted durable details for an operation failure."""
|
|
150
|
+
|
|
151
|
+
error_code: str = Field(min_length=1, max_length=128)
|
|
152
|
+
what_failed: str = Field(min_length=1, max_length=4096)
|
|
153
|
+
log_path: str | None = Field(default=None, max_length=4096)
|
|
154
|
+
next_safe_action: str = Field(min_length=1, max_length=4096)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class OperationManifest(DurableModel):
|
|
158
|
+
"""Atomic summary of one generic DployDB operation."""
|
|
159
|
+
|
|
160
|
+
schema_version: Literal[1] = 1
|
|
161
|
+
operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
|
|
162
|
+
operation_type: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$")
|
|
163
|
+
project: str = Field(min_length=1, max_length=64)
|
|
164
|
+
status: OperationStatus
|
|
165
|
+
stage: str = Field(pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
166
|
+
configuration_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
167
|
+
process: ProcessIdentity
|
|
168
|
+
safety: SafetyFacts
|
|
169
|
+
started_at: datetime
|
|
170
|
+
updated_at: datetime
|
|
171
|
+
completed_at: datetime | None = None
|
|
172
|
+
failure: FailureRecord | None = None
|
|
173
|
+
last_event_sequence: int = Field(ge=1)
|
|
174
|
+
|
|
175
|
+
@field_validator("started_at", "updated_at", "completed_at")
|
|
176
|
+
@classmethod
|
|
177
|
+
def normalize_timestamp(cls, value: datetime | None) -> datetime | None:
|
|
178
|
+
if value is None:
|
|
179
|
+
return None
|
|
180
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
181
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
182
|
+
return value.astimezone(UTC)
|
|
183
|
+
|
|
184
|
+
@field_serializer("started_at", "updated_at", "completed_at")
|
|
185
|
+
def serialize_timestamp(self, value: datetime | None) -> str | None:
|
|
186
|
+
return None if value is None else serialize_utc_timestamp(value)
|
|
187
|
+
|
|
188
|
+
@model_validator(mode="after")
|
|
189
|
+
def validate_lifecycle(self) -> Self:
|
|
190
|
+
if self.updated_at < self.started_at:
|
|
191
|
+
raise ValueError("updated_at must not precede started_at")
|
|
192
|
+
|
|
193
|
+
terminal = self.status is not OperationStatus.IN_PROGRESS
|
|
194
|
+
if terminal:
|
|
195
|
+
if self.completed_at is None:
|
|
196
|
+
raise ValueError("terminal operation requires completed_at")
|
|
197
|
+
if self.completed_at != self.updated_at:
|
|
198
|
+
raise ValueError("completed_at must equal updated_at for a terminal operation")
|
|
199
|
+
elif self.completed_at is not None:
|
|
200
|
+
raise ValueError("in-progress operation must not have completed_at")
|
|
201
|
+
|
|
202
|
+
failed = self.status in {
|
|
203
|
+
OperationStatus.FAILED_SAFE,
|
|
204
|
+
OperationStatus.RECOVERY_REQUIRED,
|
|
205
|
+
}
|
|
206
|
+
if failed != (self.failure is not None):
|
|
207
|
+
raise ValueError("failure details must be present exactly for failed operations")
|
|
208
|
+
|
|
209
|
+
recovery_required = self.status is OperationStatus.RECOVERY_REQUIRED
|
|
210
|
+
if self.safety.recovery_required != recovery_required:
|
|
211
|
+
raise ValueError("recovery_required safety fact must match operation status")
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class OperationEvent(DurableModel):
|
|
216
|
+
"""One immutable append-only operation event."""
|
|
217
|
+
|
|
218
|
+
schema_version: Literal[1] = 1
|
|
219
|
+
sequence: int = Field(ge=1)
|
|
220
|
+
timestamp: datetime
|
|
221
|
+
operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
|
|
222
|
+
status: OperationStatus
|
|
223
|
+
stage: str = Field(pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
224
|
+
message: str = Field(min_length=1, max_length=4096)
|
|
225
|
+
evidence: dict[str, Any] = Field(default_factory=dict)
|
|
226
|
+
|
|
227
|
+
@field_validator("timestamp")
|
|
228
|
+
@classmethod
|
|
229
|
+
def normalize_event_timestamp(cls, value: datetime) -> datetime:
|
|
230
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
231
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
232
|
+
return value.astimezone(UTC)
|
|
233
|
+
|
|
234
|
+
@field_serializer("timestamp")
|
|
235
|
+
def serialize_event_timestamp(self, value: datetime) -> str:
|
|
236
|
+
return serialize_utc_timestamp(value)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class DiagnosticCheck(DurableModel):
|
|
240
|
+
"""One stable, redacted doctor check result."""
|
|
241
|
+
|
|
242
|
+
check_id: str = Field(pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
243
|
+
outcome: DiagnosticOutcome
|
|
244
|
+
message: str = Field(min_length=1, max_length=4096)
|
|
245
|
+
evidence: dict[str, Any] = Field(default_factory=dict)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class SQLiteVerification(DurableModel):
|
|
249
|
+
"""Bounded read-only evidence that a SQLite database passed required checks."""
|
|
250
|
+
|
|
251
|
+
schema_version: Literal[1] = 1
|
|
252
|
+
quick_check_passed: Literal[True] = True
|
|
253
|
+
foreign_key_check_passed: Literal[True] = True
|
|
254
|
+
integrity_check_passed: Literal[True] | None = None
|
|
255
|
+
checked_at: datetime
|
|
256
|
+
duration_seconds: float = Field(ge=0)
|
|
257
|
+
|
|
258
|
+
@field_validator("checked_at")
|
|
259
|
+
@classmethod
|
|
260
|
+
def normalize_checked_at(cls, value: datetime) -> datetime:
|
|
261
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
262
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
263
|
+
return value.astimezone(UTC)
|
|
264
|
+
|
|
265
|
+
@field_serializer("checked_at")
|
|
266
|
+
def serialize_checked_at(self, value: datetime) -> str:
|
|
267
|
+
return serialize_utc_timestamp(value)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class BackupMetadata(DurableModel):
|
|
271
|
+
"""Metadata commit marker for one immutable verified local backup."""
|
|
272
|
+
|
|
273
|
+
schema_version: Literal[1] = 1
|
|
274
|
+
backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
|
|
275
|
+
project: str = Field(min_length=1, max_length=64)
|
|
276
|
+
purpose: BackupPurpose
|
|
277
|
+
source_database_path: Path
|
|
278
|
+
database_file_name: str = Field(pattern=r"^backup_[0-9a-f]{32}\.db$")
|
|
279
|
+
size_bytes: int = Field(gt=0)
|
|
280
|
+
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
281
|
+
sqlite: SQLiteVerification
|
|
282
|
+
operation_id: str | None = Field(default=None, pattern=r"^op_[0-9a-f]{32}$")
|
|
283
|
+
created_at: datetime
|
|
284
|
+
completed_at: datetime
|
|
285
|
+
|
|
286
|
+
@field_validator("source_database_path")
|
|
287
|
+
@classmethod
|
|
288
|
+
def validate_source_path(cls, value: Path) -> Path:
|
|
289
|
+
if not value.is_absolute():
|
|
290
|
+
raise ValueError("source database path must be absolute")
|
|
291
|
+
return value
|
|
292
|
+
|
|
293
|
+
@field_validator("created_at", "completed_at")
|
|
294
|
+
@classmethod
|
|
295
|
+
def normalize_backup_timestamp(cls, value: datetime) -> datetime:
|
|
296
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
297
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
298
|
+
return value.astimezone(UTC)
|
|
299
|
+
|
|
300
|
+
@field_serializer("created_at", "completed_at")
|
|
301
|
+
def serialize_backup_timestamp(self, value: datetime) -> str:
|
|
302
|
+
return serialize_utc_timestamp(value)
|
|
303
|
+
|
|
304
|
+
@model_validator(mode="after")
|
|
305
|
+
def validate_backup_identity(self) -> Self:
|
|
306
|
+
if self.database_file_name != f"{self.backup_id}.db":
|
|
307
|
+
raise ValueError("database filename must match backup ID")
|
|
308
|
+
if self.completed_at < self.created_at:
|
|
309
|
+
raise ValueError("completed_at must not precede created_at")
|
|
310
|
+
return self
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class BackupArtifact(DurableModel):
|
|
314
|
+
"""Resolved paths and metadata for one committed local backup."""
|
|
315
|
+
|
|
316
|
+
metadata: BackupMetadata
|
|
317
|
+
database_path: Path
|
|
318
|
+
metadata_path: Path
|
|
319
|
+
|
|
320
|
+
@field_validator("database_path", "metadata_path")
|
|
321
|
+
@classmethod
|
|
322
|
+
def validate_artifact_path(cls, value: Path) -> Path:
|
|
323
|
+
if not value.is_absolute():
|
|
324
|
+
raise ValueError("backup artifact paths must be absolute")
|
|
325
|
+
return value
|
|
326
|
+
|
|
327
|
+
@model_validator(mode="after")
|
|
328
|
+
def validate_artifact_identity(self) -> Self:
|
|
329
|
+
if self.database_path.name != self.metadata.database_file_name:
|
|
330
|
+
raise ValueError("database artifact path does not match metadata")
|
|
331
|
+
if self.metadata_path.name != f"{self.metadata.backup_id}.json":
|
|
332
|
+
raise ValueError("metadata artifact path does not match backup ID")
|
|
333
|
+
if self.database_path.parent != self.metadata_path.parent:
|
|
334
|
+
raise ValueError("backup database and metadata must share a directory")
|
|
335
|
+
return self
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class RemoteBackupMetadata(DurableModel):
|
|
339
|
+
"""Metadata-last commit marker for one immutable off-server backup."""
|
|
340
|
+
|
|
341
|
+
schema_version: Literal[1] = 1
|
|
342
|
+
backup: BackupMetadata
|
|
343
|
+
release_id: str | None = Field(default=None, pattern=r"^release_[0-9a-f]{32}$")
|
|
344
|
+
database_object_key: str = Field(min_length=1, max_length=1024)
|
|
345
|
+
uploaded_at: datetime
|
|
346
|
+
|
|
347
|
+
@field_validator("database_object_key")
|
|
348
|
+
@classmethod
|
|
349
|
+
def validate_database_object_key(cls, value: str) -> str:
|
|
350
|
+
parts = value.split("/")
|
|
351
|
+
if (
|
|
352
|
+
value.startswith("/")
|
|
353
|
+
or "\x00" in value
|
|
354
|
+
or any(part in {"", ".", ".."} for part in parts)
|
|
355
|
+
):
|
|
356
|
+
raise ValueError("remote database object key must be normalized and relative")
|
|
357
|
+
if len(value.encode("utf-8")) > 1024:
|
|
358
|
+
raise ValueError("remote database object key exceeds the S3 key limit")
|
|
359
|
+
return value
|
|
360
|
+
|
|
361
|
+
@field_validator("uploaded_at")
|
|
362
|
+
@classmethod
|
|
363
|
+
def normalize_uploaded_at(cls, value: datetime) -> datetime:
|
|
364
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
365
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
366
|
+
normalized = value.astimezone(UTC)
|
|
367
|
+
return normalized.replace(microsecond=(normalized.microsecond // 1000) * 1000)
|
|
368
|
+
|
|
369
|
+
@field_serializer("uploaded_at")
|
|
370
|
+
def serialize_uploaded_at(self, value: datetime) -> str:
|
|
371
|
+
return serialize_utc_timestamp(value)
|
|
372
|
+
|
|
373
|
+
@model_validator(mode="after")
|
|
374
|
+
def validate_remote_identity(self) -> Self:
|
|
375
|
+
if self.database_object_key.split("/")[-1] != self.backup.database_file_name:
|
|
376
|
+
raise ValueError("remote database object key does not match backup identity")
|
|
377
|
+
return self
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class RemoteBackupArtifact(DurableModel):
|
|
381
|
+
"""Resolved non-secret identity of one committed remote backup."""
|
|
382
|
+
|
|
383
|
+
metadata: RemoteBackupMetadata
|
|
384
|
+
bucket: str = Field(min_length=1, max_length=255)
|
|
385
|
+
metadata_object_key: str = Field(min_length=1, max_length=1024)
|
|
386
|
+
|
|
387
|
+
@field_validator("metadata_object_key")
|
|
388
|
+
@classmethod
|
|
389
|
+
def validate_metadata_object_key(cls, value: str) -> str:
|
|
390
|
+
parts = value.split("/")
|
|
391
|
+
if (
|
|
392
|
+
value.startswith("/")
|
|
393
|
+
or "\x00" in value
|
|
394
|
+
or any(part in {"", ".", ".."} for part in parts)
|
|
395
|
+
):
|
|
396
|
+
raise ValueError("remote metadata object key must be normalized and relative")
|
|
397
|
+
if len(value.encode("utf-8")) > 1024:
|
|
398
|
+
raise ValueError("remote metadata object key exceeds the S3 key limit")
|
|
399
|
+
return value
|
|
400
|
+
|
|
401
|
+
@model_validator(mode="after")
|
|
402
|
+
def validate_remote_artifact_identity(self) -> Self:
|
|
403
|
+
expected = f"{self.metadata.backup.backup_id}.json"
|
|
404
|
+
if self.metadata_object_key.split("/")[-1] != expected:
|
|
405
|
+
raise ValueError("remote metadata object key does not match backup identity")
|
|
406
|
+
return self
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class RestoreResult(DurableModel):
|
|
410
|
+
"""Durable result returned by the internal stopped-application restore engine."""
|
|
411
|
+
|
|
412
|
+
selected_backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
|
|
413
|
+
pre_restore_backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
|
|
414
|
+
database_path: Path
|
|
415
|
+
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
416
|
+
restored_at: datetime
|
|
417
|
+
|
|
418
|
+
@field_validator("database_path")
|
|
419
|
+
@classmethod
|
|
420
|
+
def validate_restore_path(cls, value: Path) -> Path:
|
|
421
|
+
if not value.is_absolute():
|
|
422
|
+
raise ValueError("restored database path must be absolute")
|
|
423
|
+
return value
|
|
424
|
+
|
|
425
|
+
@field_validator("restored_at")
|
|
426
|
+
@classmethod
|
|
427
|
+
def normalize_restored_at(cls, value: datetime) -> datetime:
|
|
428
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
429
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
430
|
+
return value.astimezone(UTC)
|
|
431
|
+
|
|
432
|
+
@field_serializer("restored_at")
|
|
433
|
+
def serialize_restored_at(self, value: datetime) -> str:
|
|
434
|
+
return serialize_utc_timestamp(value)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class ProductionApplicationHandle(DurableModel):
|
|
438
|
+
"""Exact Docker application identity preserved across cutover and rollback."""
|
|
439
|
+
|
|
440
|
+
source: Literal["bootstrap", "release"]
|
|
441
|
+
container_id: str = Field(pattern=r"^[0-9a-f]{12,64}$")
|
|
442
|
+
container_name: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$")
|
|
443
|
+
compose_project: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
444
|
+
compose_service: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
445
|
+
version: str | None = Field(
|
|
446
|
+
default=None,
|
|
447
|
+
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
|
|
448
|
+
)
|
|
449
|
+
release_id: str | None = Field(default=None, pattern=r"^release_[0-9a-f]{32}$")
|
|
450
|
+
operation_id: str | None = Field(default=None, pattern=r"^op_[0-9a-f]{32}$")
|
|
451
|
+
database_directory: Path
|
|
452
|
+
database_target: str = Field(pattern=r"^/[^\x00:]+$")
|
|
453
|
+
host_port: int = Field(ge=1, le=65535)
|
|
454
|
+
container_port: int = Field(ge=1, le=65535)
|
|
455
|
+
health_url: str = Field(min_length=1, max_length=4096)
|
|
456
|
+
|
|
457
|
+
@field_validator("database_directory")
|
|
458
|
+
@classmethod
|
|
459
|
+
def validate_database_directory(cls, value: Path) -> Path:
|
|
460
|
+
if not value.is_absolute() or value == Path("/"):
|
|
461
|
+
raise ValueError("database directory must be an absolute non-root path")
|
|
462
|
+
return value
|
|
463
|
+
|
|
464
|
+
@model_validator(mode="after")
|
|
465
|
+
def validate_source_identity(self) -> Self:
|
|
466
|
+
if self.source == "release":
|
|
467
|
+
if self.version is None or self.release_id is None or self.operation_id is None:
|
|
468
|
+
raise ValueError(
|
|
469
|
+
"release application handle requires version, release_id, and operation_id"
|
|
470
|
+
)
|
|
471
|
+
elif self.release_id is not None or self.operation_id is not None:
|
|
472
|
+
raise ValueError(
|
|
473
|
+
"bootstrap application handle must not claim a release_id or operation_id"
|
|
474
|
+
)
|
|
475
|
+
return self
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class ReleaseHookEvidence(DurableModel):
|
|
479
|
+
"""Bounded hook summary kept with the release; full capture stays in events."""
|
|
480
|
+
|
|
481
|
+
action: Literal[
|
|
482
|
+
"enable_maintenance",
|
|
483
|
+
"disable_maintenance",
|
|
484
|
+
"activate_new",
|
|
485
|
+
"activate_old",
|
|
486
|
+
]
|
|
487
|
+
passed: bool
|
|
488
|
+
outcome: Literal[
|
|
489
|
+
"succeeded",
|
|
490
|
+
"nonzero_exit",
|
|
491
|
+
"start_failed",
|
|
492
|
+
"timed_out",
|
|
493
|
+
"cancelled",
|
|
494
|
+
"cleanup_failed",
|
|
495
|
+
]
|
|
496
|
+
exit_code: int | None
|
|
497
|
+
output_complete: bool
|
|
498
|
+
duration_seconds: float = Field(ge=0)
|
|
499
|
+
termination_reason: Literal["timeout", "cancellation", "interruption"] | None = None
|
|
500
|
+
forced_kill: bool = False
|
|
501
|
+
cleanup_error: str | None = Field(default=None, max_length=4096)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class ReleaseHealthEvidence(DurableModel):
|
|
505
|
+
"""Passing new or previous application health summary for one release."""
|
|
506
|
+
|
|
507
|
+
role: Literal["new", "previous"]
|
|
508
|
+
version: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
509
|
+
url: str = Field(min_length=1, max_length=4096)
|
|
510
|
+
readiness_attempts: int = Field(gt=0)
|
|
511
|
+
smoke_outcome: Literal["succeeded"] | None = None
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class ReleaseManifest(DurableModel):
|
|
515
|
+
"""Atomic durable summary of one deployment release and its safety facts."""
|
|
516
|
+
|
|
517
|
+
schema_version: Literal[1] = 1
|
|
518
|
+
recovery_protocol_version: Literal[2] | None = None
|
|
519
|
+
release_id: str = Field(pattern=r"^release_[0-9a-f]{32}$")
|
|
520
|
+
operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
|
|
521
|
+
project: str = Field(min_length=1, max_length=64)
|
|
522
|
+
requested_version: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
523
|
+
status: DeploymentState
|
|
524
|
+
configuration_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
525
|
+
operation_log_path: Path
|
|
526
|
+
previous_release_id: str | None = Field(default=None, pattern=r"^release_[0-9a-f]{32}$")
|
|
527
|
+
previous_application: ProductionApplicationHandle | None = None
|
|
528
|
+
new_application: ProductionApplicationHandle | None = None
|
|
529
|
+
rehearsal_backup_id: str | None = Field(default=None, pattern=r"^backup_[0-9a-f]{32}$")
|
|
530
|
+
rehearsal_backup_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
|
|
531
|
+
final_backup_id: str | None = Field(default=None, pattern=r"^backup_[0-9a-f]{32}$")
|
|
532
|
+
final_backup_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
|
|
533
|
+
production_health_passed: bool = False
|
|
534
|
+
production_changed: bool = False
|
|
535
|
+
production_migration_started: bool = False
|
|
536
|
+
traffic_activation_attempted: bool = False
|
|
537
|
+
traffic_activated: bool = False
|
|
538
|
+
traffic_hooks: tuple[ReleaseHookEvidence, ...] = ()
|
|
539
|
+
health_checks: tuple[ReleaseHealthEvidence, ...] = ()
|
|
540
|
+
started_at: datetime
|
|
541
|
+
updated_at: datetime
|
|
542
|
+
completed_at: datetime | None = None
|
|
543
|
+
failure: FailureRecord | None = None
|
|
544
|
+
recovery_operation_id: str | None = Field(default=None, pattern=r"^op_[0-9a-f]{32}$")
|
|
545
|
+
recovered_at: datetime | None = None
|
|
546
|
+
recovery_failure: FailureRecord | None = None
|
|
547
|
+
|
|
548
|
+
@field_validator("operation_log_path")
|
|
549
|
+
@classmethod
|
|
550
|
+
def validate_operation_log_path(cls, value: Path) -> Path:
|
|
551
|
+
if not value.is_absolute():
|
|
552
|
+
raise ValueError("operation log path must be absolute")
|
|
553
|
+
return value
|
|
554
|
+
|
|
555
|
+
@field_validator("started_at", "updated_at", "completed_at", "recovered_at")
|
|
556
|
+
@classmethod
|
|
557
|
+
def normalize_release_timestamp(cls, value: datetime | None) -> datetime | None:
|
|
558
|
+
if value is None:
|
|
559
|
+
return None
|
|
560
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
561
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
562
|
+
return value.astimezone(UTC)
|
|
563
|
+
|
|
564
|
+
@field_serializer("started_at", "updated_at", "completed_at", "recovered_at")
|
|
565
|
+
def serialize_release_timestamp(self, value: datetime | None) -> str | None:
|
|
566
|
+
return None if value is None else serialize_utc_timestamp(value)
|
|
567
|
+
|
|
568
|
+
@model_validator(mode="after")
|
|
569
|
+
def validate_release_lifecycle(self) -> Self:
|
|
570
|
+
if self.updated_at < self.started_at:
|
|
571
|
+
raise ValueError("updated_at must not precede started_at")
|
|
572
|
+
if (self.rehearsal_backup_id is None) != (self.rehearsal_backup_sha256 is None):
|
|
573
|
+
raise ValueError("rehearsal backup ID and checksum must be recorded together")
|
|
574
|
+
if (self.final_backup_id is None) != (self.final_backup_sha256 is None):
|
|
575
|
+
raise ValueError("final backup ID and checksum must be recorded together")
|
|
576
|
+
|
|
577
|
+
terminal = self.status in {
|
|
578
|
+
DeploymentState.ACTIVE,
|
|
579
|
+
DeploymentState.ROLLED_BACK,
|
|
580
|
+
DeploymentState.FAILED_SAFE,
|
|
581
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
582
|
+
}
|
|
583
|
+
if terminal != (self.completed_at is not None):
|
|
584
|
+
raise ValueError("terminal release state and completed_at must agree")
|
|
585
|
+
if self.completed_at is not None and self.completed_at != self.updated_at:
|
|
586
|
+
raise ValueError("completed_at must equal updated_at for a terminal release")
|
|
587
|
+
|
|
588
|
+
failed = self.status in {
|
|
589
|
+
DeploymentState.ROLLED_BACK,
|
|
590
|
+
DeploymentState.FAILED_SAFE,
|
|
591
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
592
|
+
}
|
|
593
|
+
if failed != (self.failure is not None):
|
|
594
|
+
raise ValueError("failure details must be present exactly for non-active terminals")
|
|
595
|
+
|
|
596
|
+
recovery_fields = (
|
|
597
|
+
self.recovery_operation_id,
|
|
598
|
+
self.recovered_at,
|
|
599
|
+
self.recovery_failure,
|
|
600
|
+
)
|
|
601
|
+
if any(value is not None for value in recovery_fields):
|
|
602
|
+
if not all(value is not None for value in recovery_fields):
|
|
603
|
+
raise ValueError("recovery resolution fields must be recorded together")
|
|
604
|
+
if self.status not in {DeploymentState.ACTIVE, DeploymentState.ROLLED_BACK}:
|
|
605
|
+
raise ValueError("only active or rolled-back releases can record resolution")
|
|
606
|
+
|
|
607
|
+
if self.status in {
|
|
608
|
+
DeploymentState.MANUAL_RESTORE_STARTED,
|
|
609
|
+
DeploymentState.MANUAL_RESTORE_COMPLETED,
|
|
610
|
+
}:
|
|
611
|
+
raise ValueError("manual restore states do not belong to a deployment release")
|
|
612
|
+
if self.previous_release_id is not None and self.previous_application is not None:
|
|
613
|
+
if self.previous_application.release_id != self.previous_release_id:
|
|
614
|
+
raise ValueError("previous release and application identities contradict")
|
|
615
|
+
if self.new_application is not None and (
|
|
616
|
+
self.new_application.release_id != self.release_id
|
|
617
|
+
or self.new_application.version != self.requested_version
|
|
618
|
+
or self.new_application.operation_id != self.operation_id
|
|
619
|
+
):
|
|
620
|
+
raise ValueError("new application identity contradicts the release manifest")
|
|
621
|
+
rehearsal_required = self.status in {
|
|
622
|
+
DeploymentState.SNAPSHOT_VERIFIED,
|
|
623
|
+
DeploymentState.REHEARSAL_PASSED,
|
|
624
|
+
DeploymentState.CANDIDATE_HEALTHY,
|
|
625
|
+
DeploymentState.MAINTENANCE_ENABLED,
|
|
626
|
+
DeploymentState.CURRENT_APP_STOPPED,
|
|
627
|
+
DeploymentState.FINAL_SNAPSHOT_VERIFIED,
|
|
628
|
+
DeploymentState.PRODUCTION_MIGRATED,
|
|
629
|
+
DeploymentState.NEW_APP_HEALTHY,
|
|
630
|
+
DeploymentState.TRAFFIC_ACTIVATED,
|
|
631
|
+
DeploymentState.ACTIVE,
|
|
632
|
+
DeploymentState.ROLLBACK_STARTED,
|
|
633
|
+
DeploymentState.ROLLED_BACK,
|
|
634
|
+
}
|
|
635
|
+
if rehearsal_required and self.rehearsal_backup_id is None:
|
|
636
|
+
raise ValueError("release state requires verified rehearsal backup evidence")
|
|
637
|
+
previous_required = self.status in {
|
|
638
|
+
DeploymentState.CURRENT_APP_STOPPED,
|
|
639
|
+
DeploymentState.FINAL_SNAPSHOT_VERIFIED,
|
|
640
|
+
DeploymentState.PRODUCTION_MIGRATED,
|
|
641
|
+
DeploymentState.NEW_APP_HEALTHY,
|
|
642
|
+
DeploymentState.TRAFFIC_ACTIVATED,
|
|
643
|
+
DeploymentState.ACTIVE,
|
|
644
|
+
DeploymentState.ROLLBACK_STARTED,
|
|
645
|
+
DeploymentState.ROLLED_BACK,
|
|
646
|
+
}
|
|
647
|
+
if previous_required and self.previous_application is None:
|
|
648
|
+
raise ValueError("release state requires the exact previous application identity")
|
|
649
|
+
if self.traffic_activated and self.status not in {
|
|
650
|
+
DeploymentState.TRAFFIC_ACTIVATED,
|
|
651
|
+
DeploymentState.ACTIVE,
|
|
652
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
653
|
+
}:
|
|
654
|
+
raise ValueError("traffic activation fact contradicts the release state")
|
|
655
|
+
if self.traffic_activated and not self.production_changed:
|
|
656
|
+
raise ValueError("activated traffic requires a changed production release")
|
|
657
|
+
if self.production_migration_started and self.final_backup_id is None:
|
|
658
|
+
raise ValueError("production migration intent requires a verified final backup")
|
|
659
|
+
if self.traffic_activation_attempted and (
|
|
660
|
+
not self.production_migration_started
|
|
661
|
+
or not self.production_changed
|
|
662
|
+
or self.new_application is None
|
|
663
|
+
or not self.production_health_passed
|
|
664
|
+
):
|
|
665
|
+
raise ValueError("traffic activation intent requires a checked production application")
|
|
666
|
+
if self.traffic_activated and not self.traffic_activation_attempted:
|
|
667
|
+
raise ValueError("activated traffic requires a durable activation attempt")
|
|
668
|
+
if self.status is DeploymentState.FAILED_SAFE and (
|
|
669
|
+
self.production_changed or self.traffic_activated
|
|
670
|
+
):
|
|
671
|
+
raise ValueError("failed_safe release must leave production unchanged")
|
|
672
|
+
if self.status is DeploymentState.ROLLED_BACK and self.traffic_activated:
|
|
673
|
+
raise ValueError("rolled_back release cannot have activated new traffic")
|
|
674
|
+
|
|
675
|
+
if self.status in {
|
|
676
|
+
DeploymentState.PRODUCTION_MIGRATED,
|
|
677
|
+
DeploymentState.NEW_APP_HEALTHY,
|
|
678
|
+
DeploymentState.TRAFFIC_ACTIVATED,
|
|
679
|
+
DeploymentState.ACTIVE,
|
|
680
|
+
} and (self.final_backup_id is None or not self.production_changed):
|
|
681
|
+
raise ValueError("production mutation requires the verified final backup")
|
|
682
|
+
if (
|
|
683
|
+
self.status
|
|
684
|
+
in {
|
|
685
|
+
DeploymentState.PRODUCTION_MIGRATED,
|
|
686
|
+
DeploymentState.NEW_APP_HEALTHY,
|
|
687
|
+
DeploymentState.TRAFFIC_ACTIVATED,
|
|
688
|
+
DeploymentState.ACTIVE,
|
|
689
|
+
}
|
|
690
|
+
and not self.production_migration_started
|
|
691
|
+
):
|
|
692
|
+
raise ValueError("production mutation state requires durable migration intent")
|
|
693
|
+
if self.status in {
|
|
694
|
+
DeploymentState.NEW_APP_HEALTHY,
|
|
695
|
+
DeploymentState.TRAFFIC_ACTIVATED,
|
|
696
|
+
DeploymentState.ACTIVE,
|
|
697
|
+
} and (self.new_application is None or not self.production_health_passed):
|
|
698
|
+
raise ValueError("healthy new-application evidence is required")
|
|
699
|
+
if self.status in {DeploymentState.TRAFFIC_ACTIVATED, DeploymentState.ACTIVE} and not (
|
|
700
|
+
self.traffic_activated
|
|
701
|
+
):
|
|
702
|
+
raise ValueError("traffic-activated state requires the stored traffic fact")
|
|
703
|
+
if self.status is DeploymentState.ACTIVE and self.previous_application is None:
|
|
704
|
+
raise ValueError("active release must preserve the previous application identity")
|
|
705
|
+
return self
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class ReleasePointers(DurableModel):
|
|
709
|
+
"""Atomic selection of the active and immediately previous release manifests."""
|
|
710
|
+
|
|
711
|
+
schema_version: Literal[1] = 1
|
|
712
|
+
active_release_id: str = Field(pattern=r"^release_[0-9a-f]{32}$")
|
|
713
|
+
previous_release_id: str | None = Field(default=None, pattern=r"^release_[0-9a-f]{32}$")
|
|
714
|
+
updated_at: datetime
|
|
715
|
+
|
|
716
|
+
@field_validator("updated_at")
|
|
717
|
+
@classmethod
|
|
718
|
+
def normalize_pointer_timestamp(cls, value: datetime) -> datetime:
|
|
719
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
720
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
721
|
+
return value.astimezone(UTC)
|
|
722
|
+
|
|
723
|
+
@field_serializer("updated_at")
|
|
724
|
+
def serialize_pointer_timestamp(self, value: datetime) -> str:
|
|
725
|
+
return serialize_utc_timestamp(value)
|
|
726
|
+
|
|
727
|
+
@model_validator(mode="after")
|
|
728
|
+
def validate_pointer_identity(self) -> Self:
|
|
729
|
+
if self.previous_release_id == self.active_release_id:
|
|
730
|
+
raise ValueError("active and previous release IDs must differ")
|
|
731
|
+
return self
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class CapturedCommandOutput(DurableModel):
|
|
735
|
+
"""One complete bounded and redacted subprocess stream."""
|
|
736
|
+
|
|
737
|
+
text: str
|
|
738
|
+
total_bytes: int = Field(ge=0)
|
|
739
|
+
retained_bytes: int = Field(ge=0)
|
|
740
|
+
truncated: bool
|
|
741
|
+
|
|
742
|
+
@model_validator(mode="after")
|
|
743
|
+
def validate_capture(self) -> Self:
|
|
744
|
+
if self.retained_bytes > self.total_bytes:
|
|
745
|
+
raise ValueError("retained output bytes must not exceed total bytes")
|
|
746
|
+
if self.truncated != (self.retained_bytes < self.total_bytes):
|
|
747
|
+
raise ValueError("output truncation metadata is contradictory")
|
|
748
|
+
return self
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
class MigrationCommandEvidence(DurableModel):
|
|
752
|
+
"""Redacted durable evidence for the developer-supplied migration command."""
|
|
753
|
+
|
|
754
|
+
command: tuple[str, ...]
|
|
755
|
+
working_directory: str
|
|
756
|
+
environment_keys: tuple[str, ...]
|
|
757
|
+
outcome: Literal[
|
|
758
|
+
"succeeded",
|
|
759
|
+
"nonzero_exit",
|
|
760
|
+
"start_failed",
|
|
761
|
+
"timed_out",
|
|
762
|
+
"cancelled",
|
|
763
|
+
"cleanup_failed",
|
|
764
|
+
]
|
|
765
|
+
exit_code: int | None
|
|
766
|
+
stdout: CapturedCommandOutput
|
|
767
|
+
stderr: CapturedCommandOutput
|
|
768
|
+
duration_seconds: float = Field(ge=0)
|
|
769
|
+
termination_reason: Literal["timeout", "cancellation", "interruption"] | None = None
|
|
770
|
+
termination_attempted: bool = False
|
|
771
|
+
forced_kill: bool = False
|
|
772
|
+
start_error: str | None = None
|
|
773
|
+
cleanup_error: str | None = None
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
class MigrationRehearsalResult(DurableModel):
|
|
777
|
+
"""Verified evidence produced by a completed migration rehearsal."""
|
|
778
|
+
|
|
779
|
+
operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
|
|
780
|
+
backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
|
|
781
|
+
backup_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
782
|
+
database_size_bytes: int = Field(gt=0)
|
|
783
|
+
database_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
784
|
+
command: MigrationCommandEvidence
|
|
785
|
+
sqlite: SQLiteVerification
|
|
786
|
+
completed_at: datetime
|
|
787
|
+
|
|
788
|
+
@field_validator("completed_at")
|
|
789
|
+
@classmethod
|
|
790
|
+
def normalize_completed_at(cls, value: datetime) -> datetime:
|
|
791
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
792
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
793
|
+
return value.astimezone(UTC)
|
|
794
|
+
|
|
795
|
+
@field_serializer("completed_at")
|
|
796
|
+
def serialize_completed_at(self, value: datetime) -> str:
|
|
797
|
+
return serialize_utc_timestamp(value)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class ProductionMigrationResult(DurableModel):
|
|
801
|
+
"""Checked evidence produced after running the rehearsed command on production."""
|
|
802
|
+
|
|
803
|
+
operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
|
|
804
|
+
final_backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
|
|
805
|
+
final_backup_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
806
|
+
command: MigrationCommandEvidence
|
|
807
|
+
sqlite: SQLiteVerification
|
|
808
|
+
completed_at: datetime
|
|
809
|
+
|
|
810
|
+
@field_validator("completed_at")
|
|
811
|
+
@classmethod
|
|
812
|
+
def normalize_production_migration_time(cls, value: datetime) -> datetime:
|
|
813
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
814
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
815
|
+
return value.astimezone(UTC)
|
|
816
|
+
|
|
817
|
+
@field_serializer("completed_at")
|
|
818
|
+
def serialize_production_migration_time(self, value: datetime) -> str:
|
|
819
|
+
return serialize_utc_timestamp(value)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
class VerifiedDatabaseRestoreResult(DurableModel):
|
|
823
|
+
"""Evidence that one verified backup now exactly occupies production."""
|
|
824
|
+
|
|
825
|
+
backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
|
|
826
|
+
database_path: Path
|
|
827
|
+
size_bytes: int = Field(gt=0)
|
|
828
|
+
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
829
|
+
sqlite: SQLiteVerification
|
|
830
|
+
restored_at: datetime
|
|
831
|
+
|
|
832
|
+
@field_validator("database_path")
|
|
833
|
+
@classmethod
|
|
834
|
+
def validate_verified_restore_path(cls, value: Path) -> Path:
|
|
835
|
+
if not value.is_absolute():
|
|
836
|
+
raise ValueError("restored database path must be absolute")
|
|
837
|
+
return value
|
|
838
|
+
|
|
839
|
+
@field_validator("restored_at")
|
|
840
|
+
@classmethod
|
|
841
|
+
def normalize_verified_restore_time(cls, value: datetime) -> datetime:
|
|
842
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
843
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
844
|
+
return value.astimezone(UTC)
|
|
845
|
+
|
|
846
|
+
@field_serializer("restored_at")
|
|
847
|
+
def serialize_verified_restore_time(self, value: datetime) -> str:
|
|
848
|
+
return serialize_utc_timestamp(value)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
class FailureData(TypedDict):
|
|
852
|
+
"""Stable JSON-compatible failure shape used by human and CI output."""
|
|
853
|
+
|
|
854
|
+
ok: Literal[False]
|
|
855
|
+
error_code: str
|
|
856
|
+
exit_code: int
|
|
857
|
+
what_failed: str
|
|
858
|
+
production_changed: bool
|
|
859
|
+
previous_application_running: bool | None
|
|
860
|
+
recovery_required: bool
|
|
861
|
+
log_path: str | None
|
|
862
|
+
next_safe_action: str
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
@dataclass(frozen=True, slots=True)
|
|
866
|
+
class FailurePayload:
|
|
867
|
+
"""Safety facts that every expected DployDB failure must report."""
|
|
868
|
+
|
|
869
|
+
error_code: str
|
|
870
|
+
exit_code: int
|
|
871
|
+
what_failed: str
|
|
872
|
+
production_changed: bool
|
|
873
|
+
previous_application_running: bool | None
|
|
874
|
+
recovery_required: bool
|
|
875
|
+
log_path: str | None
|
|
876
|
+
next_safe_action: str
|
|
877
|
+
|
|
878
|
+
def __post_init__(self) -> None:
|
|
879
|
+
if not self.error_code.strip():
|
|
880
|
+
raise ValueError("error_code must not be empty")
|
|
881
|
+
if self.exit_code <= 0:
|
|
882
|
+
raise ValueError("failure exit_code must be positive")
|
|
883
|
+
if not self.what_failed.strip():
|
|
884
|
+
raise ValueError("what_failed must not be empty")
|
|
885
|
+
if not self.next_safe_action.strip():
|
|
886
|
+
raise ValueError("next_safe_action must not be empty")
|
|
887
|
+
|
|
888
|
+
def as_dict(self) -> FailureData:
|
|
889
|
+
"""Return the stable machine-readable representation."""
|
|
890
|
+
return {
|
|
891
|
+
"ok": False,
|
|
892
|
+
"error_code": self.error_code,
|
|
893
|
+
"exit_code": self.exit_code,
|
|
894
|
+
"what_failed": self.what_failed,
|
|
895
|
+
"production_changed": self.production_changed,
|
|
896
|
+
"previous_application_running": self.previous_application_running,
|
|
897
|
+
"recovery_required": self.recovery_required,
|
|
898
|
+
"log_path": self.log_path,
|
|
899
|
+
"next_safe_action": self.next_safe_action,
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def new_operation_id() -> OperationId:
|
|
904
|
+
"""Create an opaque identifier suitable for durable operation records."""
|
|
905
|
+
return OperationId(f"op_{uuid4().hex}")
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def new_backup_id() -> str:
|
|
909
|
+
"""Create an opaque identifier for one immutable backup artifact."""
|
|
910
|
+
return f"backup_{uuid4().hex}"
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def new_release_id() -> str:
|
|
914
|
+
"""Create an opaque identifier for one durable deployment release."""
|
|
915
|
+
return f"release_{uuid4().hex}"
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def utc_now() -> datetime:
|
|
919
|
+
"""Return an aware current UTC timestamp."""
|
|
920
|
+
return datetime.now(UTC)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def serialize_utc_timestamp(value: datetime) -> str:
|
|
924
|
+
"""Serialize an aware timestamp as stable RFC 3339 UTC with milliseconds."""
|
|
925
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
926
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
927
|
+
return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|