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/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
+ )