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/releases.py
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""Atomic deployment release manifests and active/previous selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Final, cast
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from dploydb.errors import OperationFailedError, SafetyCheckError, StateCorruptionError
|
|
20
|
+
from dploydb.models import (
|
|
21
|
+
DeploymentState,
|
|
22
|
+
FailureRecord,
|
|
23
|
+
ProductionApplicationHandle,
|
|
24
|
+
ReleaseHealthEvidence,
|
|
25
|
+
ReleaseHookEvidence,
|
|
26
|
+
ReleaseManifest,
|
|
27
|
+
ReleasePointers,
|
|
28
|
+
new_release_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_RELEASE_BYTES: Final = 1024 * 1024
|
|
36
|
+
POINTER_FILE_NAME: Final = "current.json"
|
|
37
|
+
|
|
38
|
+
_RELEASE_ID = re.compile(r"^release_[0-9a-f]{32}$")
|
|
39
|
+
_TERMINAL_STATES = frozenset(
|
|
40
|
+
{
|
|
41
|
+
DeploymentState.ACTIVE,
|
|
42
|
+
DeploymentState.ROLLED_BACK,
|
|
43
|
+
DeploymentState.FAILED_SAFE,
|
|
44
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
_TRANSITIONS: Final[dict[DeploymentState, frozenset[DeploymentState]]] = {
|
|
48
|
+
DeploymentState.CREATED: frozenset(
|
|
49
|
+
{
|
|
50
|
+
DeploymentState.PREFLIGHT_PASSED,
|
|
51
|
+
DeploymentState.FAILED_SAFE,
|
|
52
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
53
|
+
}
|
|
54
|
+
),
|
|
55
|
+
DeploymentState.PREFLIGHT_PASSED: frozenset(
|
|
56
|
+
{
|
|
57
|
+
DeploymentState.SNAPSHOT_VERIFIED,
|
|
58
|
+
DeploymentState.FAILED_SAFE,
|
|
59
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
60
|
+
}
|
|
61
|
+
),
|
|
62
|
+
DeploymentState.SNAPSHOT_VERIFIED: frozenset(
|
|
63
|
+
{
|
|
64
|
+
DeploymentState.REHEARSAL_PASSED,
|
|
65
|
+
DeploymentState.FAILED_SAFE,
|
|
66
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
67
|
+
}
|
|
68
|
+
),
|
|
69
|
+
DeploymentState.REHEARSAL_PASSED: frozenset(
|
|
70
|
+
{
|
|
71
|
+
DeploymentState.CANDIDATE_HEALTHY,
|
|
72
|
+
DeploymentState.FAILED_SAFE,
|
|
73
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
74
|
+
}
|
|
75
|
+
),
|
|
76
|
+
DeploymentState.CANDIDATE_HEALTHY: frozenset(
|
|
77
|
+
{
|
|
78
|
+
DeploymentState.MAINTENANCE_ENABLED,
|
|
79
|
+
DeploymentState.FAILED_SAFE,
|
|
80
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
81
|
+
}
|
|
82
|
+
),
|
|
83
|
+
DeploymentState.MAINTENANCE_ENABLED: frozenset(
|
|
84
|
+
{
|
|
85
|
+
DeploymentState.CURRENT_APP_STOPPED,
|
|
86
|
+
DeploymentState.ROLLBACK_STARTED,
|
|
87
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
88
|
+
}
|
|
89
|
+
),
|
|
90
|
+
DeploymentState.CURRENT_APP_STOPPED: frozenset(
|
|
91
|
+
{
|
|
92
|
+
DeploymentState.FINAL_SNAPSHOT_VERIFIED,
|
|
93
|
+
DeploymentState.ROLLBACK_STARTED,
|
|
94
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
95
|
+
}
|
|
96
|
+
),
|
|
97
|
+
DeploymentState.FINAL_SNAPSHOT_VERIFIED: frozenset(
|
|
98
|
+
{
|
|
99
|
+
DeploymentState.PRODUCTION_MIGRATED,
|
|
100
|
+
DeploymentState.ROLLBACK_STARTED,
|
|
101
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
102
|
+
}
|
|
103
|
+
),
|
|
104
|
+
DeploymentState.PRODUCTION_MIGRATED: frozenset(
|
|
105
|
+
{
|
|
106
|
+
DeploymentState.NEW_APP_HEALTHY,
|
|
107
|
+
DeploymentState.ROLLBACK_STARTED,
|
|
108
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
109
|
+
}
|
|
110
|
+
),
|
|
111
|
+
DeploymentState.NEW_APP_HEALTHY: frozenset(
|
|
112
|
+
{
|
|
113
|
+
DeploymentState.TRAFFIC_ACTIVATED,
|
|
114
|
+
DeploymentState.ROLLBACK_STARTED,
|
|
115
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
116
|
+
}
|
|
117
|
+
),
|
|
118
|
+
DeploymentState.TRAFFIC_ACTIVATED: frozenset(
|
|
119
|
+
{DeploymentState.ACTIVE, DeploymentState.RECOVERY_REQUIRED}
|
|
120
|
+
),
|
|
121
|
+
DeploymentState.ROLLBACK_STARTED: frozenset(
|
|
122
|
+
{DeploymentState.ROLLED_BACK, DeploymentState.RECOVERY_REQUIRED}
|
|
123
|
+
),
|
|
124
|
+
}
|
|
125
|
+
_UNSET = object()
|
|
126
|
+
_IGNORABLE_DIRECTORY_FSYNC_ERRORS = frozenset({errno.EINVAL, errno.ENOTSUP})
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class ReleaseHistorySnapshot:
|
|
131
|
+
"""One strictly validated, read-only view of local release history."""
|
|
132
|
+
|
|
133
|
+
releases: tuple[ReleaseManifest, ...]
|
|
134
|
+
pointers: ReleasePointers | None
|
|
135
|
+
|
|
136
|
+
def find(self, release_id: str) -> ReleaseManifest | None:
|
|
137
|
+
"""Return one release from this validated snapshot without filesystem access."""
|
|
138
|
+
return next((item for item in self.releases if item.release_id == release_id), None)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ReleaseStore:
|
|
142
|
+
"""Persist strict redacted release summaries without rewriting history."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
state_directory: Path,
|
|
147
|
+
*,
|
|
148
|
+
secrets: SecretRegistry,
|
|
149
|
+
clock: Callable[[], datetime] = utc_now,
|
|
150
|
+
) -> None:
|
|
151
|
+
if not state_directory.is_absolute() or state_directory == Path("/"):
|
|
152
|
+
raise ValueError("state directory must be an absolute non-root path")
|
|
153
|
+
self.state_directory = state_directory
|
|
154
|
+
self.releases_directory = state_directory / "releases"
|
|
155
|
+
self.pointer_path = self.releases_directory / POINTER_FILE_NAME
|
|
156
|
+
self.secrets = secrets
|
|
157
|
+
self.clock = clock
|
|
158
|
+
|
|
159
|
+
def create_release(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
operation_id: str,
|
|
163
|
+
project: str,
|
|
164
|
+
requested_version: str,
|
|
165
|
+
configuration_fingerprint: str,
|
|
166
|
+
operation_log_path: Path,
|
|
167
|
+
previous_application: ProductionApplicationHandle | None = None,
|
|
168
|
+
) -> ReleaseManifest:
|
|
169
|
+
"""Create the first durable release record before deployment work starts."""
|
|
170
|
+
self._ensure_layout()
|
|
171
|
+
pointers = self.read_pointers()
|
|
172
|
+
release_id = new_release_id()
|
|
173
|
+
directory = self._release_directory(release_id)
|
|
174
|
+
try:
|
|
175
|
+
directory.mkdir(mode=DIRECTORY_MODE)
|
|
176
|
+
self._validate_directory(directory)
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
raise self._write_error(
|
|
179
|
+
f"release directory could not be created: {exc}", directory
|
|
180
|
+
) from exc
|
|
181
|
+
now = self._now()
|
|
182
|
+
manifest = ReleaseManifest(
|
|
183
|
+
recovery_protocol_version=2,
|
|
184
|
+
release_id=release_id,
|
|
185
|
+
operation_id=operation_id,
|
|
186
|
+
project=self.secrets.redact_text(project),
|
|
187
|
+
requested_version=requested_version,
|
|
188
|
+
status=DeploymentState.CREATED,
|
|
189
|
+
configuration_fingerprint=configuration_fingerprint,
|
|
190
|
+
operation_log_path=operation_log_path,
|
|
191
|
+
previous_release_id=(None if pointers is None else pointers.active_release_id),
|
|
192
|
+
previous_application=previous_application,
|
|
193
|
+
started_at=now,
|
|
194
|
+
updated_at=now,
|
|
195
|
+
)
|
|
196
|
+
self._atomic_write(self._manifest_path(release_id), manifest)
|
|
197
|
+
return self.read_manifest(release_id)
|
|
198
|
+
|
|
199
|
+
def transition(
|
|
200
|
+
self,
|
|
201
|
+
release_id: str,
|
|
202
|
+
*,
|
|
203
|
+
status: DeploymentState,
|
|
204
|
+
previous_application: ProductionApplicationHandle | None | object = _UNSET,
|
|
205
|
+
new_application: ProductionApplicationHandle | None | object = _UNSET,
|
|
206
|
+
rehearsal_backup_id: str | None | object = _UNSET,
|
|
207
|
+
rehearsal_backup_sha256: str | None | object = _UNSET,
|
|
208
|
+
final_backup_id: str | None | object = _UNSET,
|
|
209
|
+
final_backup_sha256: str | None | object = _UNSET,
|
|
210
|
+
production_health_passed: bool | object = _UNSET,
|
|
211
|
+
production_changed: bool | object = _UNSET,
|
|
212
|
+
traffic_activated: bool | object = _UNSET,
|
|
213
|
+
traffic_hooks: tuple[ReleaseHookEvidence, ...] | object = _UNSET,
|
|
214
|
+
health_checks: tuple[ReleaseHealthEvidence, ...] | object = _UNSET,
|
|
215
|
+
failure: FailureRecord | None | object = _UNSET,
|
|
216
|
+
) -> ReleaseManifest:
|
|
217
|
+
"""Advance one release through an explicitly allowed durable transition."""
|
|
218
|
+
current = self.read_manifest(release_id)
|
|
219
|
+
if current.status in _TERMINAL_STATES:
|
|
220
|
+
raise ValueError(f"terminal release cannot transition from {current.status.value}")
|
|
221
|
+
allowed = _TRANSITIONS.get(current.status, frozenset())
|
|
222
|
+
if status not in allowed:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"release transition {current.status.value} -> {status.value} is not allowed"
|
|
225
|
+
)
|
|
226
|
+
now = self._now_not_before(current.updated_at)
|
|
227
|
+
changes: dict[str, Any] = {
|
|
228
|
+
"status": status,
|
|
229
|
+
"updated_at": now,
|
|
230
|
+
"completed_at": now if status in _TERMINAL_STATES else None,
|
|
231
|
+
}
|
|
232
|
+
supplied = {
|
|
233
|
+
"previous_application": previous_application,
|
|
234
|
+
"new_application": new_application,
|
|
235
|
+
"rehearsal_backup_id": rehearsal_backup_id,
|
|
236
|
+
"rehearsal_backup_sha256": rehearsal_backup_sha256,
|
|
237
|
+
"final_backup_id": final_backup_id,
|
|
238
|
+
"final_backup_sha256": final_backup_sha256,
|
|
239
|
+
"production_health_passed": production_health_passed,
|
|
240
|
+
"production_changed": production_changed,
|
|
241
|
+
"traffic_activated": traffic_activated,
|
|
242
|
+
"traffic_hooks": traffic_hooks,
|
|
243
|
+
"health_checks": health_checks,
|
|
244
|
+
"failure": failure,
|
|
245
|
+
}
|
|
246
|
+
changes.update({name: value for name, value in supplied.items() if value is not _UNSET})
|
|
247
|
+
updated = current.model_copy(update=changes)
|
|
248
|
+
persisted = ReleaseManifest.model_validate_json(updated.model_dump_json())
|
|
249
|
+
self._atomic_write(self._manifest_path(release_id), persisted)
|
|
250
|
+
return self.read_manifest(release_id)
|
|
251
|
+
|
|
252
|
+
def record_recovery_intent(
|
|
253
|
+
self,
|
|
254
|
+
release_id: str,
|
|
255
|
+
*,
|
|
256
|
+
production_migration_started: bool = False,
|
|
257
|
+
traffic_activation_attempted: bool = False,
|
|
258
|
+
) -> ReleaseManifest:
|
|
259
|
+
"""Durably mark a side-effect window before invoking the external action."""
|
|
260
|
+
if not production_migration_started and not traffic_activation_attempted:
|
|
261
|
+
raise ValueError("at least one recovery intent must be recorded")
|
|
262
|
+
current = self.read_manifest(release_id)
|
|
263
|
+
if current.status in _TERMINAL_STATES:
|
|
264
|
+
raise ValueError("terminal release recovery intent is immutable")
|
|
265
|
+
changes: dict[str, Any] = {"updated_at": self._now_not_before(current.updated_at)}
|
|
266
|
+
if production_migration_started:
|
|
267
|
+
changes["production_migration_started"] = True
|
|
268
|
+
if traffic_activation_attempted:
|
|
269
|
+
changes["traffic_activation_attempted"] = True
|
|
270
|
+
updated = current.model_copy(update=changes)
|
|
271
|
+
persisted = ReleaseManifest.model_validate_json(updated.model_dump_json())
|
|
272
|
+
self._atomic_write(self._manifest_path(release_id), persisted)
|
|
273
|
+
return self.read_manifest(release_id)
|
|
274
|
+
|
|
275
|
+
def resolve_recovery(
|
|
276
|
+
self,
|
|
277
|
+
release_id: str,
|
|
278
|
+
*,
|
|
279
|
+
status: DeploymentState,
|
|
280
|
+
recovery_operation_id: str,
|
|
281
|
+
traffic_activated: bool,
|
|
282
|
+
) -> ReleaseManifest:
|
|
283
|
+
"""Resolve recovery-required state while preserving its original failure."""
|
|
284
|
+
if status not in {DeploymentState.ACTIVE, DeploymentState.ROLLED_BACK}:
|
|
285
|
+
raise ValueError("recovery can resolve only to active or rolled_back")
|
|
286
|
+
current = self.read_manifest(release_id)
|
|
287
|
+
if current.status is not DeploymentState.RECOVERY_REQUIRED or current.failure is None:
|
|
288
|
+
raise ValueError("release is not recovery_required with preserved failure evidence")
|
|
289
|
+
now = self._now_not_before(current.updated_at)
|
|
290
|
+
changes: dict[str, Any] = {
|
|
291
|
+
"status": status,
|
|
292
|
+
"updated_at": now,
|
|
293
|
+
"completed_at": now,
|
|
294
|
+
"traffic_activated": traffic_activated,
|
|
295
|
+
"recovery_operation_id": recovery_operation_id,
|
|
296
|
+
"recovered_at": now,
|
|
297
|
+
"recovery_failure": current.failure,
|
|
298
|
+
}
|
|
299
|
+
if status is DeploymentState.ACTIVE:
|
|
300
|
+
changes["failure"] = None
|
|
301
|
+
updated = current.model_copy(update=changes)
|
|
302
|
+
persisted = ReleaseManifest.model_validate_json(updated.model_dump_json())
|
|
303
|
+
self._atomic_write(self._manifest_path(release_id), persisted)
|
|
304
|
+
return self.read_manifest(release_id)
|
|
305
|
+
|
|
306
|
+
def read_manifest(self, release_id: str) -> ReleaseManifest:
|
|
307
|
+
"""Read one strict complete release manifest by opaque ID."""
|
|
308
|
+
path = self._manifest_path(release_id)
|
|
309
|
+
payload = self._read_private_file(path)
|
|
310
|
+
try:
|
|
311
|
+
manifest = ReleaseManifest.model_validate_json(payload)
|
|
312
|
+
except ValidationError as exc:
|
|
313
|
+
raise self._corruption_error(
|
|
314
|
+
f"release manifest is invalid ({exc.error_count()} validation errors)", path
|
|
315
|
+
) from None
|
|
316
|
+
if manifest.release_id != release_id:
|
|
317
|
+
raise self._corruption_error("release manifest ID does not match its path", path)
|
|
318
|
+
return manifest
|
|
319
|
+
|
|
320
|
+
def read_pointers(self) -> ReleasePointers | None:
|
|
321
|
+
"""Read active/previous selection without creating state."""
|
|
322
|
+
if not self.pointer_path.exists():
|
|
323
|
+
if self.pointer_path.is_symlink():
|
|
324
|
+
raise self._corruption_error("release pointer is a symlink", self.pointer_path)
|
|
325
|
+
return None
|
|
326
|
+
payload = self._read_private_file(self.pointer_path)
|
|
327
|
+
try:
|
|
328
|
+
pointers = ReleasePointers.model_validate_json(payload)
|
|
329
|
+
except ValidationError as exc:
|
|
330
|
+
raise self._corruption_error(
|
|
331
|
+
f"release pointer is invalid ({exc.error_count()} validation errors)",
|
|
332
|
+
self.pointer_path,
|
|
333
|
+
) from None
|
|
334
|
+
active = self.read_manifest(pointers.active_release_id)
|
|
335
|
+
if active.status is not DeploymentState.ACTIVE:
|
|
336
|
+
raise self._corruption_error(
|
|
337
|
+
"release pointer does not select an active manifest", self.pointer_path
|
|
338
|
+
)
|
|
339
|
+
if pointers.previous_release_id is not None:
|
|
340
|
+
self.read_manifest(pointers.previous_release_id)
|
|
341
|
+
return pointers
|
|
342
|
+
|
|
343
|
+
def activate_release(self, release_id: str) -> ReleasePointers:
|
|
344
|
+
"""Atomically select a terminal active release and preserve the old selection."""
|
|
345
|
+
self._ensure_layout()
|
|
346
|
+
selected = self.read_manifest(release_id)
|
|
347
|
+
if selected.status is not DeploymentState.ACTIVE:
|
|
348
|
+
raise ValueError("only an active release manifest can become current")
|
|
349
|
+
current = self.read_pointers()
|
|
350
|
+
if current is not None and current.active_release_id == release_id:
|
|
351
|
+
return current
|
|
352
|
+
pointers = ReleasePointers(
|
|
353
|
+
active_release_id=release_id,
|
|
354
|
+
previous_release_id=(None if current is None else current.active_release_id),
|
|
355
|
+
updated_at=self._now(),
|
|
356
|
+
)
|
|
357
|
+
self._atomic_write(self.pointer_path, pointers)
|
|
358
|
+
reread = self.read_pointers()
|
|
359
|
+
assert reread is not None
|
|
360
|
+
return reread
|
|
361
|
+
|
|
362
|
+
def active_release(self) -> ReleaseManifest | None:
|
|
363
|
+
"""Return the selected active manifest, if deployment history has one."""
|
|
364
|
+
pointers = self.read_pointers()
|
|
365
|
+
return None if pointers is None else self.read_manifest(pointers.active_release_id)
|
|
366
|
+
|
|
367
|
+
def previous_release(self) -> ReleaseManifest | None:
|
|
368
|
+
"""Return the selected immediately previous manifest, if present."""
|
|
369
|
+
pointers = self.read_pointers()
|
|
370
|
+
if pointers is None or pointers.previous_release_id is None:
|
|
371
|
+
return None
|
|
372
|
+
return self.read_manifest(pointers.previous_release_id)
|
|
373
|
+
|
|
374
|
+
def read_history(self) -> ReleaseHistorySnapshot:
|
|
375
|
+
"""Validate and return every release without creating or rewriting state."""
|
|
376
|
+
if not self.state_directory.exists():
|
|
377
|
+
self._reject_symlink(self.state_directory)
|
|
378
|
+
return ReleaseHistorySnapshot(releases=(), pointers=None)
|
|
379
|
+
self._validate_directory(self.state_directory)
|
|
380
|
+
if not self.releases_directory.exists():
|
|
381
|
+
self._reject_symlink(self.releases_directory)
|
|
382
|
+
return ReleaseHistorySnapshot(releases=(), pointers=None)
|
|
383
|
+
self._validate_directory(self.releases_directory)
|
|
384
|
+
|
|
385
|
+
manifests: list[ReleaseManifest] = []
|
|
386
|
+
for entry in self.releases_directory.iterdir():
|
|
387
|
+
if entry.name == POINTER_FILE_NAME:
|
|
388
|
+
if entry.is_symlink() or not entry.is_file():
|
|
389
|
+
raise self._corruption_error("release pointer is not a regular file", entry)
|
|
390
|
+
continue
|
|
391
|
+
if (
|
|
392
|
+
entry.is_symlink()
|
|
393
|
+
or not entry.is_dir()
|
|
394
|
+
or _RELEASE_ID.fullmatch(entry.name) is None
|
|
395
|
+
):
|
|
396
|
+
raise self._corruption_error(
|
|
397
|
+
f"releases directory contains an unexpected entry: {entry.name}", entry
|
|
398
|
+
)
|
|
399
|
+
self._validate_release_directory_contents(entry)
|
|
400
|
+
manifests.append(self.read_manifest(entry.name))
|
|
401
|
+
|
|
402
|
+
pointers = self.read_pointers()
|
|
403
|
+
release_ids = {item.release_id for item in manifests}
|
|
404
|
+
if pointers is not None:
|
|
405
|
+
selected_ids = {pointers.active_release_id}
|
|
406
|
+
if pointers.previous_release_id is not None:
|
|
407
|
+
selected_ids.add(pointers.previous_release_id)
|
|
408
|
+
if not selected_ids.issubset(release_ids):
|
|
409
|
+
raise self._corruption_error(
|
|
410
|
+
"release pointers select a manifest missing from history", self.pointer_path
|
|
411
|
+
)
|
|
412
|
+
manifests.sort(key=lambda item: (item.started_at, item.release_id), reverse=True)
|
|
413
|
+
return ReleaseHistorySnapshot(releases=tuple(manifests), pointers=pointers)
|
|
414
|
+
|
|
415
|
+
def lookup_history_release(
|
|
416
|
+
self, release_id: str
|
|
417
|
+
) -> tuple[ReleaseManifest, ReleasePointers | None]:
|
|
418
|
+
"""Resolve one user-selected release from a fully validated history snapshot."""
|
|
419
|
+
try:
|
|
420
|
+
self._validate_release_id(release_id)
|
|
421
|
+
except (TypeError, ValueError):
|
|
422
|
+
raise SafetyCheckError(
|
|
423
|
+
"release ID is invalid",
|
|
424
|
+
production_changed=False,
|
|
425
|
+
previous_application_running=None,
|
|
426
|
+
log_path=self.releases_directory,
|
|
427
|
+
next_safe_action="Copy an exact release ID from dploydb releases.",
|
|
428
|
+
) from None
|
|
429
|
+
history = self.read_history()
|
|
430
|
+
selected = history.find(release_id)
|
|
431
|
+
if selected is None:
|
|
432
|
+
raise SafetyCheckError(
|
|
433
|
+
f"release does not exist: {release_id}",
|
|
434
|
+
production_changed=False,
|
|
435
|
+
previous_application_running=None,
|
|
436
|
+
log_path=self.releases_directory,
|
|
437
|
+
next_safe_action="Run dploydb releases and select an existing release ID.",
|
|
438
|
+
)
|
|
439
|
+
return selected, history.pointers
|
|
440
|
+
|
|
441
|
+
def _ensure_layout(self) -> None:
|
|
442
|
+
self._ensure_directory(self.state_directory, parents=True)
|
|
443
|
+
self._ensure_directory(self.releases_directory, parents=False)
|
|
444
|
+
|
|
445
|
+
def _release_directory(self, release_id: str) -> Path:
|
|
446
|
+
self._validate_release_id(release_id)
|
|
447
|
+
return self.releases_directory / release_id
|
|
448
|
+
|
|
449
|
+
def _manifest_path(self, release_id: str) -> Path:
|
|
450
|
+
return self._release_directory(release_id) / "manifest.json"
|
|
451
|
+
|
|
452
|
+
def _validate_release_directory_contents(self, directory: Path) -> None:
|
|
453
|
+
self._validate_directory(directory)
|
|
454
|
+
entries = tuple(directory.iterdir())
|
|
455
|
+
expected = directory / "manifest.json"
|
|
456
|
+
if len(entries) != 1 or entries[0] != expected:
|
|
457
|
+
unexpected = next((entry for entry in entries if entry != expected), directory)
|
|
458
|
+
raise self._corruption_error(
|
|
459
|
+
"release directory must contain exactly one manifest", unexpected
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def _validate_release_id(self, release_id: str) -> None:
|
|
463
|
+
if not isinstance(release_id, str) or _RELEASE_ID.fullmatch(release_id) is None:
|
|
464
|
+
raise ValueError("release ID is invalid or contains unsafe characters")
|
|
465
|
+
|
|
466
|
+
def _ensure_directory(self, path: Path, *, parents: bool) -> None:
|
|
467
|
+
self._reject_symlink(path)
|
|
468
|
+
try:
|
|
469
|
+
path.mkdir(mode=DIRECTORY_MODE, parents=parents, exist_ok=True)
|
|
470
|
+
except OSError as exc:
|
|
471
|
+
raise self._write_error(
|
|
472
|
+
f"private release directory could not be created: {exc}", path
|
|
473
|
+
) from exc
|
|
474
|
+
self._validate_directory(path)
|
|
475
|
+
|
|
476
|
+
def _validate_directory(self, path: Path) -> None:
|
|
477
|
+
self._reject_symlink(path)
|
|
478
|
+
try:
|
|
479
|
+
details = path.stat()
|
|
480
|
+
except OSError as exc:
|
|
481
|
+
raise self._corruption_error(f"release directory is unreadable: {exc}", path) from exc
|
|
482
|
+
mode = stat.S_IMODE(details.st_mode)
|
|
483
|
+
if not stat.S_ISDIR(details.st_mode) or mode != DIRECTORY_MODE:
|
|
484
|
+
raise self._corruption_error(
|
|
485
|
+
f"release directory must be mode-0700, found {mode:04o}", path
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def _read_private_file(self, path: Path) -> bytes:
|
|
489
|
+
abandoned = tuple(path.parent.glob(f".{path.name}.*.tmp"))
|
|
490
|
+
if abandoned:
|
|
491
|
+
raise self._corruption_error(
|
|
492
|
+
"release state has an abandoned atomic-write temporary file",
|
|
493
|
+
abandoned[0],
|
|
494
|
+
)
|
|
495
|
+
self._reject_symlink(path)
|
|
496
|
+
try:
|
|
497
|
+
details = path.stat()
|
|
498
|
+
mode = stat.S_IMODE(details.st_mode)
|
|
499
|
+
if not stat.S_ISREG(details.st_mode) or mode != FILE_MODE:
|
|
500
|
+
raise OSError(f"release file must be mode-0600, found {mode:04o}")
|
|
501
|
+
if details.st_size <= 0 or details.st_size > MAX_RELEASE_BYTES:
|
|
502
|
+
raise OSError("release file has an invalid size")
|
|
503
|
+
payload = path.read_bytes()
|
|
504
|
+
except OSError as exc:
|
|
505
|
+
raise self._corruption_error(
|
|
506
|
+
f"release file is invalid or unreadable: {exc}", path
|
|
507
|
+
) from exc
|
|
508
|
+
if not payload.endswith(b"\n"):
|
|
509
|
+
raise self._corruption_error("release file is truncated", path)
|
|
510
|
+
return payload
|
|
511
|
+
|
|
512
|
+
def _atomic_write(self, path: Path, value: ReleaseManifest | ReleasePointers) -> None:
|
|
513
|
+
self._reject_symlink(path)
|
|
514
|
+
raw = cast(JsonValue, value.model_dump(mode="json"))
|
|
515
|
+
redacted = self.secrets.redact(raw)
|
|
516
|
+
model = type(value)
|
|
517
|
+
persisted = model.model_validate_json(
|
|
518
|
+
json.dumps(redacted, ensure_ascii=False, allow_nan=False)
|
|
519
|
+
)
|
|
520
|
+
payload = (
|
|
521
|
+
json.dumps(
|
|
522
|
+
persisted.model_dump(mode="json"),
|
|
523
|
+
sort_keys=True,
|
|
524
|
+
separators=(",", ":"),
|
|
525
|
+
ensure_ascii=False,
|
|
526
|
+
allow_nan=False,
|
|
527
|
+
).encode("utf-8")
|
|
528
|
+
+ b"\n"
|
|
529
|
+
)
|
|
530
|
+
if len(payload) > MAX_RELEASE_BYTES:
|
|
531
|
+
raise self._write_error("release record exceeds the durable size limit", path)
|
|
532
|
+
temporary = path.parent / f".{path.name}.{uuid4().hex}.tmp"
|
|
533
|
+
descriptor = -1
|
|
534
|
+
try:
|
|
535
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
536
|
+
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
537
|
+
descriptor = os.open(temporary, flags, FILE_MODE)
|
|
538
|
+
os.fchmod(descriptor, FILE_MODE)
|
|
539
|
+
self._write_all(descriptor, payload)
|
|
540
|
+
os.fsync(descriptor)
|
|
541
|
+
os.close(descriptor)
|
|
542
|
+
descriptor = -1
|
|
543
|
+
os.replace(temporary, path)
|
|
544
|
+
self._fsync_directory(path.parent)
|
|
545
|
+
except OSError as exc:
|
|
546
|
+
if descriptor >= 0:
|
|
547
|
+
try:
|
|
548
|
+
os.close(descriptor)
|
|
549
|
+
except OSError:
|
|
550
|
+
pass
|
|
551
|
+
try:
|
|
552
|
+
temporary.unlink(missing_ok=True)
|
|
553
|
+
except OSError:
|
|
554
|
+
pass
|
|
555
|
+
raise self._write_error(
|
|
556
|
+
f"release record could not be written atomically: {exc}", path
|
|
557
|
+
) from exc
|
|
558
|
+
|
|
559
|
+
def _write_all(self, descriptor: int, payload: bytes) -> None:
|
|
560
|
+
written = 0
|
|
561
|
+
while written < len(payload):
|
|
562
|
+
count = os.write(descriptor, payload[written:])
|
|
563
|
+
if count <= 0:
|
|
564
|
+
raise OSError("release write made no progress")
|
|
565
|
+
written += count
|
|
566
|
+
|
|
567
|
+
def _fsync_directory(self, directory: Path) -> None:
|
|
568
|
+
descriptor = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
569
|
+
try:
|
|
570
|
+
try:
|
|
571
|
+
os.fsync(descriptor)
|
|
572
|
+
except OSError as exc:
|
|
573
|
+
if exc.errno not in _IGNORABLE_DIRECTORY_FSYNC_ERRORS:
|
|
574
|
+
raise
|
|
575
|
+
finally:
|
|
576
|
+
os.close(descriptor)
|
|
577
|
+
|
|
578
|
+
def _reject_symlink(self, path: Path) -> None:
|
|
579
|
+
if path.is_symlink():
|
|
580
|
+
raise self._corruption_error("refusing symlinked release state", path)
|
|
581
|
+
|
|
582
|
+
def _now(self) -> datetime:
|
|
583
|
+
value = self.clock()
|
|
584
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
585
|
+
raise ValueError("release-store clock must return an aware timestamp")
|
|
586
|
+
return value
|
|
587
|
+
|
|
588
|
+
def _now_not_before(self, previous: datetime) -> datetime:
|
|
589
|
+
current = self._now()
|
|
590
|
+
return previous if current < previous else current
|
|
591
|
+
|
|
592
|
+
def _write_error(self, detail: str, path: Path) -> OperationFailedError:
|
|
593
|
+
return OperationFailedError(
|
|
594
|
+
self.secrets.redact_text(detail),
|
|
595
|
+
production_changed=False,
|
|
596
|
+
previous_application_running=None,
|
|
597
|
+
log_path=path,
|
|
598
|
+
next_safe_action="Preserve the release state and retry only after fixing storage.",
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
def _corruption_error(self, detail: str, path: Path) -> StateCorruptionError:
|
|
602
|
+
return StateCorruptionError(
|
|
603
|
+
self.secrets.redact_text(detail),
|
|
604
|
+
production_changed=True,
|
|
605
|
+
previous_application_running=None,
|
|
606
|
+
log_path=path,
|
|
607
|
+
next_safe_action=(
|
|
608
|
+
"Do not guess the active release; preserve state and inspect the release "
|
|
609
|
+
"manifest and operation log."
|
|
610
|
+
),
|
|
611
|
+
)
|