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/locking.py ADDED
@@ -0,0 +1,609 @@
1
+ """Durable OS-backed deployment locking and stale-owner diagnosis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import fcntl
7
+ import json
8
+ import os
9
+ import socket
10
+ import stat
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from enum import StrEnum
15
+ from pathlib import Path
16
+ from types import TracebackType
17
+ from typing import Final, Literal, Self, cast
18
+ from uuid import uuid4
19
+
20
+ from pydantic import ValidationError
21
+
22
+ from dploydb.errors import (
23
+ DployDBError,
24
+ LockUnavailableError,
25
+ RecoveryRequiredError,
26
+ SafetyCheckError,
27
+ )
28
+ from dploydb.models import (
29
+ LockOwnerMetadata,
30
+ LockOwnerState,
31
+ ProcessIdentity,
32
+ utc_now,
33
+ )
34
+ from dploydb.redaction import JsonValue, SecretRegistry
35
+
36
+ DIRECTORY_MODE: Final = 0o700
37
+ FILE_MODE: Final = 0o600
38
+ LOCK_FILE_NAME: Final = "deployment.lock"
39
+ OWNER_FILE_NAME: Final = "deployment-lock-owner.json"
40
+ MAX_OWNER_BYTES: Final = 64 * 1024
41
+
42
+ _OWNER_TEMPORARY_PREFIX: Final = f".{OWNER_FILE_NAME}."
43
+ _IGNORABLE_DIRECTORY_FSYNC_ERRORS = frozenset({errno.EINVAL, errno.ENOTSUP})
44
+ _CONTENTION_ERRORS = frozenset({errno.EACCES, errno.EAGAIN})
45
+
46
+
47
+ class LockInspectionState(StrEnum):
48
+ """Read-only deployment-lock states consumed by later diagnostics."""
49
+
50
+ IDLE = "idle"
51
+ ACTIVE = "active"
52
+ STALE_OWNER = "stale_owner"
53
+ RECOVERY_REQUIRED = "recovery_required"
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class LockInspection:
58
+ """One read-only snapshot of kernel-lock and diagnostic metadata state."""
59
+
60
+ state: LockInspectionState
61
+ lock_held: bool
62
+ owner: LockOwnerMetadata | None
63
+ metadata_error: str | None
64
+ lock_path: Path
65
+ owner_path: Path
66
+
67
+
68
+ class DeploymentLock:
69
+ """Nonblocking exclusive flock with separate diagnostic owner metadata."""
70
+
71
+ def __init__(
72
+ self,
73
+ state_directory: Path,
74
+ *,
75
+ secrets: SecretRegistry,
76
+ clock: Callable[[], datetime] = utc_now,
77
+ ) -> None:
78
+ if not state_directory.is_absolute():
79
+ raise ValueError("state directory must be absolute")
80
+ if state_directory == Path(state_directory.anchor):
81
+ raise ValueError("state directory must not be the filesystem root")
82
+ self.state_directory = state_directory
83
+ self.lock_path = state_directory / LOCK_FILE_NAME
84
+ self.owner_path = state_directory / OWNER_FILE_NAME
85
+ self.secrets = secrets
86
+ self.clock = clock
87
+ self._descriptor: int | None = None
88
+ self._acquired_at: datetime | None = None
89
+ self._owner: LockOwnerMetadata | None = None
90
+ self.previous_owner: LockOwnerMetadata | None = None
91
+
92
+ @property
93
+ def acquired(self) -> bool:
94
+ """Return whether this instance currently owns the kernel lock."""
95
+ return self._descriptor is not None
96
+
97
+ @property
98
+ def owner(self) -> LockOwnerMetadata | None:
99
+ """Return the redacted owner record written by this lock instance."""
100
+ return self._owner
101
+
102
+ def __enter__(self) -> Self:
103
+ return self.acquire()
104
+
105
+ def __exit__(
106
+ self,
107
+ exc_type: type[BaseException] | None,
108
+ exc_value: BaseException | None,
109
+ traceback: TracebackType | None,
110
+ ) -> Literal[False]:
111
+ del exc_type, traceback
112
+ try:
113
+ self.release()
114
+ except BaseException as cleanup_error:
115
+ if exc_value is None:
116
+ raise
117
+ exc_value.add_note(
118
+ self.secrets.redact_text(f"Deployment lock cleanup also failed: {cleanup_error}")
119
+ )
120
+ return False
121
+
122
+ def acquire(self) -> Self:
123
+ """Acquire the kernel lock without replacing prior diagnostic evidence."""
124
+ if self._descriptor is not None:
125
+ raise RuntimeError("deployment lock instance is already acquired")
126
+ self._ensure_layout()
127
+ descriptor = self._open_lock_for_acquire()
128
+ try:
129
+ try:
130
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
131
+ except OSError as exc:
132
+ if exc.errno in _CONTENTION_ERRORS:
133
+ owner = self._read_owner_best_effort()
134
+ raise self._contention_error(owner) from None
135
+ raise self._safety_error(
136
+ f"deployment lock could not be acquired: {exc}", self.lock_path
137
+ ) from exc
138
+
139
+ try:
140
+ previous_owner = self._read_owner_metadata()
141
+ except BaseException:
142
+ self._unlock_and_close(descriptor)
143
+ raise
144
+ acquired_at = self._now()
145
+ self._descriptor = descriptor
146
+ self._acquired_at = acquired_at
147
+ self.previous_owner = previous_owner
148
+ self._owner = None
149
+ return self
150
+ except BaseException:
151
+ if self._descriptor is None:
152
+ try:
153
+ os.close(descriptor)
154
+ except OSError:
155
+ pass
156
+ raise
157
+
158
+ def record_owner(
159
+ self,
160
+ *,
161
+ operation_id: str,
162
+ operation_type: str,
163
+ process: ProcessIdentity | None = None,
164
+ replace_stale_owner_id: str | None = None,
165
+ ) -> LockOwnerMetadata:
166
+ """Record a new owner after the matching operation manifest is durable."""
167
+ if self._descriptor is None or self._acquired_at is None:
168
+ raise RuntimeError("the kernel lock must be held before recording an owner")
169
+ if self._owner is not None:
170
+ raise RuntimeError("this lock instance already recorded its owner")
171
+
172
+ current = self._read_owner_metadata()
173
+ if current != self.previous_owner:
174
+ raise self._recovery_error(
175
+ "deployment lock owner metadata changed while the kernel lock was held",
176
+ self.owner_path,
177
+ )
178
+ if current is not None and current.state is LockOwnerState.ACTIVE:
179
+ if replace_stale_owner_id != current.owner_id:
180
+ raise self._recovery_error(
181
+ "stale lock-owner evidence requires explicit owner-token acknowledgement",
182
+ self.owner_path,
183
+ )
184
+ elif replace_stale_owner_id is not None:
185
+ raise ValueError("replace_stale_owner_id was supplied without stale owner metadata")
186
+
187
+ owner = LockOwnerMetadata(
188
+ owner_id=f"lock_{uuid4().hex}",
189
+ operation_id=operation_id,
190
+ operation_type=operation_type,
191
+ process=process or ProcessIdentity(pid=os.getpid(), hostname=socket.gethostname()),
192
+ state=LockOwnerState.ACTIVE,
193
+ acquired_at=self._acquired_at,
194
+ )
195
+ persisted = self._atomic_write_owner(owner)
196
+ self._owner = persisted
197
+ return persisted
198
+
199
+ def release(self) -> None:
200
+ """Finalize owner metadata and release the kernel lock idempotently."""
201
+ descriptor = self._descriptor
202
+ if descriptor is None:
203
+ return
204
+
205
+ metadata_error: BaseException | None = None
206
+ try:
207
+ if self._owner is not None:
208
+ current = self._read_owner_metadata()
209
+ if current is None or current.owner_id != self._owner.owner_id:
210
+ raise self._recovery_error(
211
+ "deployment lock owner metadata no longer matches its holder",
212
+ self.owner_path,
213
+ )
214
+ if current.state is not LockOwnerState.ACTIVE:
215
+ raise self._recovery_error(
216
+ "deployment lock owner was not active during release", self.owner_path
217
+ )
218
+ released = current.model_copy(
219
+ update={
220
+ "state": LockOwnerState.RELEASED,
221
+ "released_at": self._now_not_before(current.acquired_at),
222
+ }
223
+ )
224
+ released = LockOwnerMetadata.model_validate_json(released.model_dump_json())
225
+ self._owner = self._atomic_write_owner(released)
226
+ except BaseException as exc:
227
+ metadata_error = exc
228
+
229
+ cleanup_errors = self._unlock_and_close(descriptor)
230
+ self._descriptor = None
231
+ self._acquired_at = None
232
+
233
+ if metadata_error is not None:
234
+ for detail in cleanup_errors:
235
+ metadata_error.add_note(self.secrets.redact_text(detail))
236
+ raise metadata_error
237
+ if cleanup_errors:
238
+ raise self._recovery_error("; ".join(cleanup_errors), self.lock_path)
239
+
240
+ def inspect(self) -> LockInspection:
241
+ """Inspect lock state without creating, replacing, or deleting any path."""
242
+ return inspect_lock(self.state_directory, secrets=self.secrets)
243
+
244
+ def _ensure_layout(self) -> None:
245
+ self._reject_symlink(self.state_directory)
246
+ try:
247
+ self.state_directory.mkdir(mode=DIRECTORY_MODE, parents=True, exist_ok=True)
248
+ except OSError as exc:
249
+ raise self._safety_error(
250
+ f"state directory could not be created for locking: {exc}",
251
+ self.state_directory,
252
+ ) from exc
253
+ self._validate_directory(self.state_directory)
254
+
255
+ def _open_lock_for_acquire(self) -> int:
256
+ self._reject_symlink(self.lock_path)
257
+ existed = self.lock_path.exists()
258
+ if existed:
259
+ self._validate_regular_file(self.lock_path)
260
+ flags = os.O_CREAT | os.O_RDWR
261
+ flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
262
+ try:
263
+ descriptor = os.open(self.lock_path, flags, FILE_MODE)
264
+ if not existed:
265
+ os.fchmod(descriptor, FILE_MODE)
266
+ self._validate_lock_descriptor(descriptor)
267
+ if not existed:
268
+ self._fsync_directory(self.state_directory)
269
+ return descriptor
270
+ except OSError as exc:
271
+ try:
272
+ os.close(descriptor)
273
+ except (OSError, UnboundLocalError):
274
+ pass
275
+ raise self._safety_error(
276
+ f"deployment lock file could not be opened safely: {exc}", self.lock_path
277
+ ) from exc
278
+
279
+ def _validate_lock_descriptor(self, descriptor: int) -> None:
280
+ details = os.fstat(descriptor)
281
+ mode = stat.S_IMODE(details.st_mode)
282
+ if not stat.S_ISREG(details.st_mode) or mode != FILE_MODE:
283
+ raise OSError(f"deployment lock must be a mode-0600 regular file, found {mode:04o}")
284
+
285
+ def _read_owner_metadata(self) -> LockOwnerMetadata | None:
286
+ if not self.owner_path.exists():
287
+ if self.owner_path.is_symlink():
288
+ raise self._recovery_error(
289
+ "refusing symlinked deployment lock owner metadata", self.owner_path
290
+ )
291
+ return None
292
+ self._reject_owner_symlink()
293
+ try:
294
+ details = self.owner_path.stat()
295
+ mode = stat.S_IMODE(details.st_mode)
296
+ if not stat.S_ISREG(details.st_mode) or mode != FILE_MODE:
297
+ raise OSError(
298
+ f"lock owner metadata must be a mode-0600 regular file, found {mode:04o}"
299
+ )
300
+ if details.st_size <= 0 or details.st_size > MAX_OWNER_BYTES:
301
+ raise OSError("lock owner metadata has an invalid size")
302
+ payload = self.owner_path.read_bytes()
303
+ if not payload.endswith(b"\n"):
304
+ raise OSError("lock owner metadata is truncated")
305
+ return LockOwnerMetadata.model_validate_json(payload)
306
+ except (OSError, ValidationError) as exc:
307
+ raise self._recovery_error(
308
+ f"deployment lock owner metadata is invalid or unreadable: {exc}",
309
+ self.owner_path,
310
+ ) from None
311
+
312
+ def _read_owner_best_effort(self) -> LockOwnerMetadata | None:
313
+ try:
314
+ return self._read_owner_metadata()
315
+ except DployDBError:
316
+ return None
317
+
318
+ def _atomic_write_owner(self, owner: LockOwnerMetadata) -> LockOwnerMetadata:
319
+ raw = cast(JsonValue, owner.model_dump(mode="json"))
320
+ redacted = self.secrets.redact(raw)
321
+ persisted = LockOwnerMetadata.model_validate_json(
322
+ json.dumps(redacted, ensure_ascii=False, allow_nan=False)
323
+ )
324
+ payload = (
325
+ json.dumps(
326
+ persisted.model_dump(mode="json"),
327
+ sort_keys=True,
328
+ separators=(",", ":"),
329
+ ensure_ascii=False,
330
+ allow_nan=False,
331
+ ).encode("utf-8")
332
+ + b"\n"
333
+ )
334
+ if len(payload) > MAX_OWNER_BYTES:
335
+ raise ValueError("serialized lock owner metadata exceeds the size limit")
336
+
337
+ temporary = self.state_directory / f"{_OWNER_TEMPORARY_PREFIX}{uuid4().hex}.tmp"
338
+ descriptor = -1
339
+ replaced = False
340
+ try:
341
+ self._reject_owner_symlink()
342
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
343
+ flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
344
+ descriptor = os.open(temporary, flags, FILE_MODE)
345
+ os.fchmod(descriptor, FILE_MODE)
346
+ written = os.write(descriptor, payload)
347
+ if written != len(payload):
348
+ raise OSError("short lock-owner metadata write")
349
+ os.fsync(descriptor)
350
+ os.close(descriptor)
351
+ descriptor = -1
352
+ os.replace(temporary, self.owner_path)
353
+ replaced = True
354
+ self._fsync_directory(self.state_directory)
355
+ return persisted
356
+ except OSError as exc:
357
+ if replaced:
358
+ raise self._recovery_error(
359
+ "lock owner metadata was replaced but its directory sync failed",
360
+ self.owner_path,
361
+ ) from exc
362
+ raise self._safety_error(
363
+ f"lock owner metadata could not be written atomically: {exc}",
364
+ self.owner_path,
365
+ ) from exc
366
+ finally:
367
+ if descriptor >= 0:
368
+ try:
369
+ os.close(descriptor)
370
+ except OSError:
371
+ pass
372
+ if not replaced:
373
+ try:
374
+ temporary.unlink(missing_ok=True)
375
+ except OSError:
376
+ pass
377
+
378
+ def _validate_directory(self, path: Path) -> None:
379
+ self._reject_symlink(path)
380
+ try:
381
+ details = path.stat()
382
+ except OSError as exc:
383
+ raise self._safety_error(
384
+ f"state directory could not be inspected for locking: {exc}", path
385
+ ) from None
386
+ mode = stat.S_IMODE(details.st_mode)
387
+ if not stat.S_ISDIR(details.st_mode) or mode != DIRECTORY_MODE:
388
+ raise self._safety_error(
389
+ f"state directory must be a mode-0700 directory, found {mode:04o}", path
390
+ )
391
+
392
+ def _validate_regular_file(self, path: Path) -> None:
393
+ self._reject_symlink(path)
394
+ try:
395
+ details = path.stat()
396
+ except OSError as exc:
397
+ raise self._safety_error(f"managed lock file is unreadable: {exc}", path) from None
398
+ mode = stat.S_IMODE(details.st_mode)
399
+ if not stat.S_ISREG(details.st_mode) or mode != FILE_MODE:
400
+ raise self._safety_error(
401
+ f"deployment lock must be a mode-0600 regular file, found {mode:04o}", path
402
+ )
403
+
404
+ def _reject_symlink(self, path: Path) -> None:
405
+ try:
406
+ if path.is_symlink():
407
+ raise self._safety_error(f"refusing symlinked managed lock path: {path}", path)
408
+ except OSError:
409
+ raise self._safety_error("managed lock path could not be inspected", path) from None
410
+
411
+ def _reject_owner_symlink(self) -> None:
412
+ try:
413
+ if self.owner_path.is_symlink():
414
+ raise self._recovery_error(
415
+ "refusing symlinked deployment lock owner metadata", self.owner_path
416
+ )
417
+ except OSError:
418
+ raise self._recovery_error(
419
+ "deployment lock owner metadata could not be inspected", self.owner_path
420
+ ) from None
421
+
422
+ def _fsync_directory(self, directory: Path) -> None:
423
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
424
+ descriptor = os.open(directory, flags)
425
+ try:
426
+ try:
427
+ os.fsync(descriptor)
428
+ except OSError as exc:
429
+ if exc.errno not in _IGNORABLE_DIRECTORY_FSYNC_ERRORS:
430
+ raise
431
+ finally:
432
+ os.close(descriptor)
433
+
434
+ def _unlock_and_close(self, descriptor: int) -> list[str]:
435
+ errors: list[str] = []
436
+ try:
437
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
438
+ except OSError as exc:
439
+ errors.append(f"deployment lock could not be explicitly unlocked: {exc}")
440
+ try:
441
+ os.close(descriptor)
442
+ except OSError as exc:
443
+ errors.append(f"deployment lock descriptor could not be closed: {exc}")
444
+ return errors
445
+
446
+ def _now(self) -> datetime:
447
+ value = self.clock()
448
+ if value.tzinfo is None or value.utcoffset() is None:
449
+ raise ValueError("lock clock must return a timezone-aware timestamp")
450
+ return value
451
+
452
+ def _now_not_before(self, previous: datetime) -> datetime:
453
+ value = self._now()
454
+ if value < previous:
455
+ raise ValueError("lock clock moved backwards")
456
+ return value
457
+
458
+ def _contention_error(self, owner: LockOwnerMetadata | None) -> LockUnavailableError:
459
+ detail = "Another DployDB operation holds the deployment lock."
460
+ if owner is not None:
461
+ detail += (
462
+ f" Owner operation: {owner.operation_id}; PID: {owner.process.pid}; "
463
+ f"host: {owner.process.hostname}."
464
+ )
465
+ return LockUnavailableError(
466
+ self.secrets.redact_text(detail),
467
+ production_changed=False,
468
+ previous_application_running=None,
469
+ log_path=self.secrets.redact_text(str(self.owner_path)),
470
+ next_safe_action=(
471
+ "Wait for the active operation to finish. If it does not, preserve the owner "
472
+ "metadata and inspect operation state; do not delete the lock file."
473
+ ),
474
+ )
475
+
476
+ def _safety_error(self, detail: str, path: Path) -> SafetyCheckError:
477
+ return SafetyCheckError(
478
+ self.secrets.redact_text(detail),
479
+ production_changed=False,
480
+ previous_application_running=None,
481
+ log_path=self.secrets.redact_text(str(path)),
482
+ next_safe_action="Correct the lock path safety problem before retrying.",
483
+ )
484
+
485
+ def _recovery_error(self, detail: str, path: Path) -> RecoveryRequiredError:
486
+ return RecoveryRequiredError(
487
+ self.secrets.redact_text(detail),
488
+ production_changed=True,
489
+ previous_application_running=None,
490
+ log_path=self.secrets.redact_text(str(path)),
491
+ next_safe_action=(
492
+ "Preserve the lock metadata and inspect the recorded operation before retrying."
493
+ ),
494
+ )
495
+
496
+
497
+ def inspect_lock(state_directory: Path, *, secrets: SecretRegistry) -> LockInspection:
498
+ """Return a read-only snapshot; PID metadata never substitutes for flock."""
499
+ probe = DeploymentLock(state_directory, secrets=secrets)
500
+ if state_directory.is_symlink():
501
+ return _inspection_recovery(
502
+ probe,
503
+ probe._recovery_error("state directory is a symlink", state_directory),
504
+ )
505
+ if not state_directory.exists():
506
+ return LockInspection(
507
+ state=LockInspectionState.IDLE,
508
+ lock_held=False,
509
+ owner=None,
510
+ metadata_error=None,
511
+ lock_path=probe.lock_path,
512
+ owner_path=probe.owner_path,
513
+ )
514
+ try:
515
+ probe._validate_directory(state_directory)
516
+ except DployDBError as error:
517
+ return _inspection_recovery(probe, error)
518
+
519
+ if not probe.lock_path.exists():
520
+ if probe.lock_path.is_symlink():
521
+ return _inspection_recovery(
522
+ probe,
523
+ probe._recovery_error("deployment lock path is a symlink", probe.lock_path),
524
+ )
525
+ return _classify_available_lock(probe)
526
+
527
+ try:
528
+ probe._validate_regular_file(probe.lock_path)
529
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
530
+ descriptor = os.open(probe.lock_path, flags)
531
+ probe._validate_lock_descriptor(descriptor)
532
+ except (DployDBError, OSError) as exc:
533
+ if isinstance(exc, DployDBError):
534
+ inspection_error = exc
535
+ else:
536
+ inspection_error = probe._recovery_error(
537
+ f"deployment lock could not be inspected safely: {exc}", probe.lock_path
538
+ )
539
+ try:
540
+ os.close(descriptor)
541
+ except (OSError, UnboundLocalError):
542
+ pass
543
+ return _inspection_recovery(probe, inspection_error)
544
+
545
+ try:
546
+ try:
547
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
548
+ except OSError as exc:
549
+ if exc.errno not in _CONTENTION_ERRORS:
550
+ return _inspection_recovery(
551
+ probe,
552
+ probe._recovery_error(f"deployment lock probe failed: {exc}", probe.lock_path),
553
+ )
554
+ try:
555
+ owner = probe._read_owner_metadata()
556
+ metadata_error = None
557
+ except DployDBError as error:
558
+ owner = None
559
+ metadata_error = error.payload.what_failed
560
+ return LockInspection(
561
+ state=LockInspectionState.ACTIVE,
562
+ lock_held=True,
563
+ owner=owner,
564
+ metadata_error=metadata_error,
565
+ lock_path=probe.lock_path,
566
+ owner_path=probe.owner_path,
567
+ )
568
+
569
+ return _classify_available_lock(probe)
570
+ finally:
571
+ try:
572
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
573
+ except OSError:
574
+ pass
575
+ try:
576
+ os.close(descriptor)
577
+ except OSError:
578
+ pass
579
+
580
+
581
+ def _classify_available_lock(probe: DeploymentLock) -> LockInspection:
582
+ try:
583
+ owner = probe._read_owner_metadata()
584
+ except DployDBError as error:
585
+ return _inspection_recovery(probe, error)
586
+ state = (
587
+ LockInspectionState.STALE_OWNER
588
+ if owner is not None and owner.state is LockOwnerState.ACTIVE
589
+ else LockInspectionState.IDLE
590
+ )
591
+ return LockInspection(
592
+ state=state,
593
+ lock_held=False,
594
+ owner=owner,
595
+ metadata_error=None,
596
+ lock_path=probe.lock_path,
597
+ owner_path=probe.owner_path,
598
+ )
599
+
600
+
601
+ def _inspection_recovery(probe: DeploymentLock, error: DployDBError) -> LockInspection:
602
+ return LockInspection(
603
+ state=LockInspectionState.RECOVERY_REQUIRED,
604
+ lock_held=False,
605
+ owner=None,
606
+ metadata_error=error.payload.what_failed,
607
+ lock_path=probe.lock_path,
608
+ owner_path=probe.owner_path,
609
+ )