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