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/subprocesses.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""Bounded, redacted execution of untrusted external commands.
|
|
2
|
+
|
|
3
|
+
The runner deliberately returns structured outcomes rather than user-facing
|
|
4
|
+
``DployDBError`` instances. Only an orchestration layer knows whether a command
|
|
5
|
+
ran before or after production changed, so it must attach those safety facts when
|
|
6
|
+
it converts an unsuccessful result into a CLI failure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import errno
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from enum import StrEnum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import BinaryIO, Final, cast
|
|
23
|
+
|
|
24
|
+
from dploydb.redaction import REDACTION_MARKER, JsonValue, SecretRegistry, is_sensitive_key
|
|
25
|
+
|
|
26
|
+
DEFAULT_MAX_OUTPUT_BYTES: Final = 1024 * 1024
|
|
27
|
+
DEFAULT_TERMINATION_GRACE_SECONDS: Final = 2.0
|
|
28
|
+
DEFAULT_POLL_INTERVAL_SECONDS: Final = 0.05
|
|
29
|
+
_READ_CHUNK_BYTES: Final = 64 * 1024
|
|
30
|
+
_TRUNCATION_MARKER: Final = b"\n...[output truncated]...\n"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CommandOutcome(StrEnum):
|
|
34
|
+
"""Terminal outcome of one bounded command invocation."""
|
|
35
|
+
|
|
36
|
+
SUCCEEDED = "succeeded"
|
|
37
|
+
NONZERO_EXIT = "nonzero_exit"
|
|
38
|
+
START_FAILED = "start_failed"
|
|
39
|
+
TIMED_OUT = "timed_out"
|
|
40
|
+
CANCELLED = "cancelled"
|
|
41
|
+
CLEANUP_FAILED = "cleanup_failed"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TerminationReason(StrEnum):
|
|
45
|
+
"""Reason DployDB attempted to terminate a command process group."""
|
|
46
|
+
|
|
47
|
+
TIMEOUT = "timeout"
|
|
48
|
+
CANCELLATION = "cancellation"
|
|
49
|
+
INTERRUPTION = "interruption"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class CapturedOutput:
|
|
54
|
+
"""One redacted, memory-bounded output stream."""
|
|
55
|
+
|
|
56
|
+
text: str
|
|
57
|
+
total_bytes: int
|
|
58
|
+
retained_bytes: int
|
|
59
|
+
truncated: bool
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
if self.total_bytes < 0 or self.retained_bytes < 0:
|
|
63
|
+
raise ValueError("captured output byte counts must not be negative")
|
|
64
|
+
if self.retained_bytes > self.total_bytes:
|
|
65
|
+
raise ValueError("retained output bytes must not exceed total bytes")
|
|
66
|
+
if self.truncated != (self.retained_bytes < self.total_bytes):
|
|
67
|
+
raise ValueError("captured output truncation metadata is contradictory")
|
|
68
|
+
|
|
69
|
+
def as_evidence(self) -> dict[str, JsonValue]:
|
|
70
|
+
"""Return the stable JSON-compatible evidence representation."""
|
|
71
|
+
return {
|
|
72
|
+
"text": self.text,
|
|
73
|
+
"total_bytes": self.total_bytes,
|
|
74
|
+
"retained_bytes": self.retained_bytes,
|
|
75
|
+
"truncated": self.truncated,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class CommandResult:
|
|
81
|
+
"""Redacted terminal evidence for one attempted external command."""
|
|
82
|
+
|
|
83
|
+
command: tuple[str, ...]
|
|
84
|
+
working_directory: str
|
|
85
|
+
environment_keys: tuple[str, ...]
|
|
86
|
+
outcome: CommandOutcome
|
|
87
|
+
exit_code: int | None
|
|
88
|
+
stdout: CapturedOutput
|
|
89
|
+
stderr: CapturedOutput
|
|
90
|
+
duration_seconds: float
|
|
91
|
+
termination_reason: TerminationReason | None = None
|
|
92
|
+
termination_attempted: bool = False
|
|
93
|
+
forced_kill: bool = False
|
|
94
|
+
start_error: str | None = None
|
|
95
|
+
cleanup_error: str | None = None
|
|
96
|
+
|
|
97
|
+
def __post_init__(self) -> None:
|
|
98
|
+
if not self.command:
|
|
99
|
+
raise ValueError("command result requires at least one argument")
|
|
100
|
+
if not math.isfinite(self.duration_seconds) or self.duration_seconds < 0:
|
|
101
|
+
raise ValueError("command duration must be finite and non-negative")
|
|
102
|
+
if self.outcome is CommandOutcome.SUCCEEDED and self.exit_code != 0:
|
|
103
|
+
raise ValueError("successful command requires exit code zero")
|
|
104
|
+
if self.outcome is CommandOutcome.NONZERO_EXIT and self.exit_code in {None, 0}:
|
|
105
|
+
raise ValueError("non-zero command outcome requires a non-zero exit code")
|
|
106
|
+
if self.outcome is CommandOutcome.START_FAILED:
|
|
107
|
+
if self.exit_code is not None or self.start_error is None:
|
|
108
|
+
raise ValueError("start failure requires start_error and no exit code")
|
|
109
|
+
elif self.start_error is not None:
|
|
110
|
+
raise ValueError("start_error is valid only for a start failure")
|
|
111
|
+
if self.outcome is CommandOutcome.CLEANUP_FAILED:
|
|
112
|
+
if self.cleanup_error is None:
|
|
113
|
+
raise ValueError("cleanup failure requires cleanup_error")
|
|
114
|
+
elif self.cleanup_error is not None:
|
|
115
|
+
raise ValueError("cleanup_error is valid only for a cleanup failure")
|
|
116
|
+
if self.outcome in {CommandOutcome.TIMED_OUT, CommandOutcome.CANCELLED}:
|
|
117
|
+
if self.termination_reason is None:
|
|
118
|
+
raise ValueError("terminated command requires a reason")
|
|
119
|
+
if self.outcome is CommandOutcome.TIMED_OUT and not self.termination_attempted:
|
|
120
|
+
raise ValueError("timed-out command requires attempted cleanup")
|
|
121
|
+
if (
|
|
122
|
+
self.outcome is CommandOutcome.CANCELLED
|
|
123
|
+
and self.exit_code is not None
|
|
124
|
+
and not self.termination_attempted
|
|
125
|
+
):
|
|
126
|
+
raise ValueError("started cancelled command requires attempted cleanup")
|
|
127
|
+
if self.outcome is CommandOutcome.CLEANUP_FAILED and self.termination_reason is None:
|
|
128
|
+
raise ValueError("cleanup failure requires its initiating termination reason")
|
|
129
|
+
if self.forced_kill and not self.termination_attempted:
|
|
130
|
+
raise ValueError("forced kill requires an attempted termination")
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def succeeded(self) -> bool:
|
|
134
|
+
"""Return whether the command completed with exit code zero."""
|
|
135
|
+
return self.outcome is CommandOutcome.SUCCEEDED
|
|
136
|
+
|
|
137
|
+
def as_evidence(self) -> dict[str, JsonValue]:
|
|
138
|
+
"""Return redacted JSON-compatible evidence for persistence or display."""
|
|
139
|
+
return {
|
|
140
|
+
"command": list(self.command),
|
|
141
|
+
"working_directory": self.working_directory,
|
|
142
|
+
"environment_keys": list(self.environment_keys),
|
|
143
|
+
"outcome": self.outcome.value,
|
|
144
|
+
"exit_code": self.exit_code,
|
|
145
|
+
"stdout": self.stdout.as_evidence(),
|
|
146
|
+
"stderr": self.stderr.as_evidence(),
|
|
147
|
+
"duration_seconds": self.duration_seconds,
|
|
148
|
+
"termination_reason": (
|
|
149
|
+
None if self.termination_reason is None else self.termination_reason.value
|
|
150
|
+
),
|
|
151
|
+
"termination_attempted": self.termination_attempted,
|
|
152
|
+
"forced_kill": self.forced_kill,
|
|
153
|
+
"start_error": self.start_error,
|
|
154
|
+
"cleanup_error": self.cleanup_error,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _BoundedCapture:
|
|
159
|
+
"""Drain a byte stream while retaining only a bounded head and tail."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, limit: int) -> None:
|
|
162
|
+
self._head_limit = limit // 2
|
|
163
|
+
self._tail_limit = limit - self._head_limit
|
|
164
|
+
self._head = bytearray()
|
|
165
|
+
self._tail = bytearray()
|
|
166
|
+
self._total_bytes = 0
|
|
167
|
+
self._lock = threading.Lock()
|
|
168
|
+
|
|
169
|
+
def append(self, data: bytes) -> None:
|
|
170
|
+
with self._lock:
|
|
171
|
+
self._total_bytes += len(data)
|
|
172
|
+
head_missing = self._head_limit - len(self._head)
|
|
173
|
+
if head_missing > 0:
|
|
174
|
+
self._head.extend(data[:head_missing])
|
|
175
|
+
data = data[head_missing:]
|
|
176
|
+
if not data or self._tail_limit == 0:
|
|
177
|
+
return
|
|
178
|
+
if len(data) >= self._tail_limit:
|
|
179
|
+
self._tail[:] = data[-self._tail_limit :]
|
|
180
|
+
return
|
|
181
|
+
self._tail.extend(data)
|
|
182
|
+
overflow = len(self._tail) - self._tail_limit
|
|
183
|
+
if overflow > 0:
|
|
184
|
+
del self._tail[:overflow]
|
|
185
|
+
|
|
186
|
+
def snapshot(self, secrets: SecretRegistry) -> CapturedOutput:
|
|
187
|
+
with self._lock:
|
|
188
|
+
head = bytes(self._head)
|
|
189
|
+
tail = bytes(self._tail)
|
|
190
|
+
total_bytes = self._total_bytes
|
|
191
|
+
retained_bytes = len(head) + len(tail)
|
|
192
|
+
truncated = retained_bytes < total_bytes
|
|
193
|
+
raw = head + (_TRUNCATION_MARKER if truncated else b"") + tail
|
|
194
|
+
text = raw.decode("utf-8", errors="replace")
|
|
195
|
+
return CapturedOutput(
|
|
196
|
+
text=secrets.redact_text(text),
|
|
197
|
+
total_bytes=total_bytes,
|
|
198
|
+
retained_bytes=retained_bytes,
|
|
199
|
+
truncated=truncated,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(slots=True)
|
|
204
|
+
class _Reader:
|
|
205
|
+
pipe: BinaryIO
|
|
206
|
+
capture: _BoundedCapture
|
|
207
|
+
thread: threading.Thread | None = None
|
|
208
|
+
error: BaseException | None = None
|
|
209
|
+
|
|
210
|
+
def start(self, name: str) -> None:
|
|
211
|
+
self.thread = threading.Thread(target=self._drain, name=name, daemon=True)
|
|
212
|
+
self.thread.start()
|
|
213
|
+
|
|
214
|
+
def _drain(self) -> None:
|
|
215
|
+
try:
|
|
216
|
+
while True:
|
|
217
|
+
chunk = self.pipe.read(_READ_CHUNK_BYTES)
|
|
218
|
+
if not chunk:
|
|
219
|
+
return
|
|
220
|
+
self.capture.append(chunk)
|
|
221
|
+
except BaseException as exc: # preserved for the controlling thread
|
|
222
|
+
self.error = exc
|
|
223
|
+
finally:
|
|
224
|
+
try:
|
|
225
|
+
self.pipe.close()
|
|
226
|
+
except OSError:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def alive(self) -> bool:
|
|
231
|
+
return self.thread is not None and self.thread.is_alive()
|
|
232
|
+
|
|
233
|
+
def join(self, timeout: float) -> None:
|
|
234
|
+
if self.thread is not None:
|
|
235
|
+
self.thread.join(timeout=max(0.0, timeout))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True, slots=True)
|
|
239
|
+
class _CleanupResult:
|
|
240
|
+
forced_kill: bool
|
|
241
|
+
error: str | None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class SubprocessRunner:
|
|
245
|
+
"""Execute commands with mandatory bounds, redaction, and group cleanup."""
|
|
246
|
+
|
|
247
|
+
def __init__(
|
|
248
|
+
self,
|
|
249
|
+
*,
|
|
250
|
+
secrets: SecretRegistry,
|
|
251
|
+
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
252
|
+
termination_grace_seconds: float = DEFAULT_TERMINATION_GRACE_SECONDS,
|
|
253
|
+
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
|
254
|
+
clock: Callable[[], float] = time.monotonic,
|
|
255
|
+
) -> None:
|
|
256
|
+
self.max_output_bytes = _positive_integer(max_output_bytes, "max_output_bytes")
|
|
257
|
+
self.termination_grace_seconds = _positive_seconds(
|
|
258
|
+
termination_grace_seconds, "termination_grace_seconds"
|
|
259
|
+
)
|
|
260
|
+
self.poll_interval_seconds = _positive_seconds(
|
|
261
|
+
poll_interval_seconds, "poll_interval_seconds"
|
|
262
|
+
)
|
|
263
|
+
self.secrets = secrets
|
|
264
|
+
self._clock = clock
|
|
265
|
+
|
|
266
|
+
def __repr__(self) -> str:
|
|
267
|
+
return (
|
|
268
|
+
"SubprocessRunner("
|
|
269
|
+
f"max_output_bytes={self.max_output_bytes}, "
|
|
270
|
+
f"termination_grace_seconds={self.termination_grace_seconds}, "
|
|
271
|
+
f"poll_interval_seconds={self.poll_interval_seconds}, "
|
|
272
|
+
f"secrets={self.secrets!r})"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def run(
|
|
276
|
+
self,
|
|
277
|
+
command: Sequence[str],
|
|
278
|
+
*,
|
|
279
|
+
timeout_seconds: float,
|
|
280
|
+
environment: Mapping[str, str],
|
|
281
|
+
working_directory: Path | None = None,
|
|
282
|
+
cancellation_event: threading.Event | None = None,
|
|
283
|
+
) -> CommandResult:
|
|
284
|
+
"""Run one command and return redacted evidence for every expected outcome."""
|
|
285
|
+
arguments = _validate_command(command)
|
|
286
|
+
timeout = _positive_seconds(timeout_seconds, "timeout_seconds")
|
|
287
|
+
child_environment = _validate_environment(environment)
|
|
288
|
+
directory = _working_directory(working_directory)
|
|
289
|
+
|
|
290
|
+
for name, value in child_environment.items():
|
|
291
|
+
if is_sensitive_key(name):
|
|
292
|
+
self.secrets.register(value)
|
|
293
|
+
|
|
294
|
+
safe_command = _redacted_command(arguments, self.secrets)
|
|
295
|
+
safe_directory = self.secrets.redact_text(str(directory))
|
|
296
|
+
safe_environment_keys = tuple(
|
|
297
|
+
sorted(self.secrets.redact_text(name) for name in child_environment)
|
|
298
|
+
)
|
|
299
|
+
empty = CapturedOutput(text="", total_bytes=0, retained_bytes=0, truncated=False)
|
|
300
|
+
started_at = self._clock()
|
|
301
|
+
|
|
302
|
+
if cancellation_event is not None and cancellation_event.is_set():
|
|
303
|
+
return CommandResult(
|
|
304
|
+
command=safe_command,
|
|
305
|
+
working_directory=safe_directory,
|
|
306
|
+
environment_keys=safe_environment_keys,
|
|
307
|
+
outcome=CommandOutcome.CANCELLED,
|
|
308
|
+
exit_code=None,
|
|
309
|
+
stdout=empty,
|
|
310
|
+
stderr=empty,
|
|
311
|
+
duration_seconds=self._duration(started_at),
|
|
312
|
+
termination_reason=TerminationReason.CANCELLATION,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
process = subprocess.Popen(
|
|
317
|
+
arguments,
|
|
318
|
+
cwd=directory,
|
|
319
|
+
env=child_environment,
|
|
320
|
+
stdin=subprocess.DEVNULL,
|
|
321
|
+
stdout=subprocess.PIPE,
|
|
322
|
+
stderr=subprocess.PIPE,
|
|
323
|
+
shell=False,
|
|
324
|
+
start_new_session=True,
|
|
325
|
+
close_fds=True,
|
|
326
|
+
)
|
|
327
|
+
except OSError as exc:
|
|
328
|
+
return CommandResult(
|
|
329
|
+
command=safe_command,
|
|
330
|
+
working_directory=safe_directory,
|
|
331
|
+
environment_keys=safe_environment_keys,
|
|
332
|
+
outcome=CommandOutcome.START_FAILED,
|
|
333
|
+
exit_code=None,
|
|
334
|
+
stdout=empty,
|
|
335
|
+
stderr=empty,
|
|
336
|
+
duration_seconds=self._duration(started_at),
|
|
337
|
+
start_error=self._safe_exception(exc),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
assert process.stdout is not None
|
|
341
|
+
assert process.stderr is not None
|
|
342
|
+
stdout_reader = _Reader(
|
|
343
|
+
cast(BinaryIO, process.stdout), _BoundedCapture(self.max_output_bytes)
|
|
344
|
+
)
|
|
345
|
+
stderr_reader = _Reader(
|
|
346
|
+
cast(BinaryIO, process.stderr), _BoundedCapture(self.max_output_bytes)
|
|
347
|
+
)
|
|
348
|
+
readers = (stdout_reader, stderr_reader)
|
|
349
|
+
stdout_reader.start(f"dploydb-stdout-{process.pid}")
|
|
350
|
+
stderr_reader.start(f"dploydb-stderr-{process.pid}")
|
|
351
|
+
deadline = started_at + timeout
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
reason = self._wait_for_completion(
|
|
355
|
+
process,
|
|
356
|
+
readers,
|
|
357
|
+
deadline=deadline,
|
|
358
|
+
cancellation_event=cancellation_event,
|
|
359
|
+
)
|
|
360
|
+
if reason is None:
|
|
361
|
+
reader_error = self._reader_error(readers)
|
|
362
|
+
if reader_error is not None:
|
|
363
|
+
cleanup = self._terminate_group(process, readers)
|
|
364
|
+
errors = [reader_error]
|
|
365
|
+
if cleanup.error is not None:
|
|
366
|
+
errors.append(cleanup.error)
|
|
367
|
+
return self._result(
|
|
368
|
+
safe_command=safe_command,
|
|
369
|
+
safe_directory=safe_directory,
|
|
370
|
+
safe_environment_keys=safe_environment_keys,
|
|
371
|
+
process=process,
|
|
372
|
+
readers=readers,
|
|
373
|
+
started_at=started_at,
|
|
374
|
+
outcome=CommandOutcome.CLEANUP_FAILED,
|
|
375
|
+
termination_reason=TerminationReason.INTERRUPTION,
|
|
376
|
+
termination_attempted=True,
|
|
377
|
+
forced_kill=cleanup.forced_kill,
|
|
378
|
+
cleanup_error="; ".join(errors),
|
|
379
|
+
)
|
|
380
|
+
outcome = (
|
|
381
|
+
CommandOutcome.SUCCEEDED
|
|
382
|
+
if process.returncode == 0
|
|
383
|
+
else CommandOutcome.NONZERO_EXIT
|
|
384
|
+
)
|
|
385
|
+
return self._result(
|
|
386
|
+
safe_command=safe_command,
|
|
387
|
+
safe_directory=safe_directory,
|
|
388
|
+
safe_environment_keys=safe_environment_keys,
|
|
389
|
+
process=process,
|
|
390
|
+
readers=readers,
|
|
391
|
+
started_at=started_at,
|
|
392
|
+
outcome=outcome,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
cleanup = self._terminate_group(process, readers)
|
|
396
|
+
outcome = (
|
|
397
|
+
CommandOutcome.CLEANUP_FAILED
|
|
398
|
+
if cleanup.error is not None
|
|
399
|
+
else (
|
|
400
|
+
CommandOutcome.TIMED_OUT
|
|
401
|
+
if reason is TerminationReason.TIMEOUT
|
|
402
|
+
else CommandOutcome.CANCELLED
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
return self._result(
|
|
406
|
+
safe_command=safe_command,
|
|
407
|
+
safe_directory=safe_directory,
|
|
408
|
+
safe_environment_keys=safe_environment_keys,
|
|
409
|
+
process=process,
|
|
410
|
+
readers=readers,
|
|
411
|
+
started_at=started_at,
|
|
412
|
+
outcome=outcome,
|
|
413
|
+
termination_reason=reason,
|
|
414
|
+
termination_attempted=True,
|
|
415
|
+
forced_kill=cleanup.forced_kill,
|
|
416
|
+
cleanup_error=cleanup.error,
|
|
417
|
+
)
|
|
418
|
+
except BaseException as exc:
|
|
419
|
+
cleanup = self._terminate_group(process, readers)
|
|
420
|
+
if cleanup.error is not None:
|
|
421
|
+
exc.add_note(
|
|
422
|
+
"DployDB subprocess cleanup could not be proven: "
|
|
423
|
+
+ self.secrets.redact_text(cleanup.error)
|
|
424
|
+
)
|
|
425
|
+
raise
|
|
426
|
+
|
|
427
|
+
def _wait_for_completion(
|
|
428
|
+
self,
|
|
429
|
+
process: subprocess.Popen[bytes],
|
|
430
|
+
readers: tuple[_Reader, _Reader],
|
|
431
|
+
*,
|
|
432
|
+
deadline: float,
|
|
433
|
+
cancellation_event: threading.Event | None,
|
|
434
|
+
) -> TerminationReason | None:
|
|
435
|
+
while True:
|
|
436
|
+
process.poll()
|
|
437
|
+
if process.returncode is not None and not any(reader.alive for reader in readers):
|
|
438
|
+
return None
|
|
439
|
+
if cancellation_event is not None and cancellation_event.is_set():
|
|
440
|
+
return TerminationReason.CANCELLATION
|
|
441
|
+
remaining = deadline - self._clock()
|
|
442
|
+
if remaining <= 0:
|
|
443
|
+
return TerminationReason.TIMEOUT
|
|
444
|
+
time.sleep(min(self.poll_interval_seconds, remaining))
|
|
445
|
+
|
|
446
|
+
def _terminate_group(
|
|
447
|
+
self,
|
|
448
|
+
process: subprocess.Popen[bytes],
|
|
449
|
+
readers: tuple[_Reader, _Reader],
|
|
450
|
+
) -> _CleanupResult:
|
|
451
|
+
errors: list[str] = []
|
|
452
|
+
forced_kill = False
|
|
453
|
+
group_id = process.pid
|
|
454
|
+
|
|
455
|
+
term_error = self._signal_group(group_id, signal.SIGTERM)
|
|
456
|
+
if term_error is not None:
|
|
457
|
+
errors.append(term_error)
|
|
458
|
+
if not self._wait_for_group_cleanup(process, readers, self.termination_grace_seconds):
|
|
459
|
+
forced_kill = True
|
|
460
|
+
kill_error = self._signal_group(group_id, signal.SIGKILL)
|
|
461
|
+
if kill_error is not None:
|
|
462
|
+
errors.append(kill_error)
|
|
463
|
+
if not self._wait_for_group_cleanup(process, readers, self.termination_grace_seconds):
|
|
464
|
+
errors.append("process group remained after forced termination")
|
|
465
|
+
|
|
466
|
+
reader_error = self._reader_error(readers)
|
|
467
|
+
if reader_error is not None:
|
|
468
|
+
errors.append(reader_error)
|
|
469
|
+
return _CleanupResult(
|
|
470
|
+
forced_kill=forced_kill,
|
|
471
|
+
error=None if not errors else self.secrets.redact_text("; ".join(errors)),
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def _wait_for_group_cleanup(
|
|
475
|
+
self,
|
|
476
|
+
process: subprocess.Popen[bytes],
|
|
477
|
+
readers: tuple[_Reader, _Reader],
|
|
478
|
+
timeout: float,
|
|
479
|
+
) -> bool:
|
|
480
|
+
deadline = self._clock() + timeout
|
|
481
|
+
while True:
|
|
482
|
+
process.poll()
|
|
483
|
+
group_exists = _process_group_exists(process.pid)
|
|
484
|
+
readers_alive = any(reader.alive for reader in readers)
|
|
485
|
+
if not group_exists and process.returncode is not None and not readers_alive:
|
|
486
|
+
return True
|
|
487
|
+
remaining = deadline - self._clock()
|
|
488
|
+
if remaining <= 0:
|
|
489
|
+
break
|
|
490
|
+
for reader in readers:
|
|
491
|
+
reader.join(min(self.poll_interval_seconds, remaining))
|
|
492
|
+
if process.returncode is None:
|
|
493
|
+
try:
|
|
494
|
+
process.wait(timeout=min(self.poll_interval_seconds, remaining))
|
|
495
|
+
except subprocess.TimeoutExpired:
|
|
496
|
+
pass
|
|
497
|
+
|
|
498
|
+
process.poll()
|
|
499
|
+
return (
|
|
500
|
+
not _process_group_exists(process.pid)
|
|
501
|
+
and process.returncode is not None
|
|
502
|
+
and not any(reader.alive for reader in readers)
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
@staticmethod
|
|
506
|
+
def _signal_group(group_id: int, sig: signal.Signals) -> str | None:
|
|
507
|
+
try:
|
|
508
|
+
os.killpg(group_id, sig)
|
|
509
|
+
except ProcessLookupError:
|
|
510
|
+
return None
|
|
511
|
+
except OSError as exc:
|
|
512
|
+
if exc.errno == errno.ESRCH:
|
|
513
|
+
return None
|
|
514
|
+
return f"{sig.name} could not be sent to process group: {exc}"
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
def _reader_error(self, readers: tuple[_Reader, _Reader]) -> str | None:
|
|
518
|
+
details = [self._safe_exception(reader.error) for reader in readers if reader.error]
|
|
519
|
+
return None if not details else "output capture failed: " + "; ".join(details)
|
|
520
|
+
|
|
521
|
+
def _result(
|
|
522
|
+
self,
|
|
523
|
+
*,
|
|
524
|
+
safe_command: tuple[str, ...],
|
|
525
|
+
safe_directory: str,
|
|
526
|
+
safe_environment_keys: tuple[str, ...],
|
|
527
|
+
process: subprocess.Popen[bytes],
|
|
528
|
+
readers: tuple[_Reader, _Reader],
|
|
529
|
+
started_at: float,
|
|
530
|
+
outcome: CommandOutcome,
|
|
531
|
+
termination_reason: TerminationReason | None = None,
|
|
532
|
+
termination_attempted: bool = False,
|
|
533
|
+
forced_kill: bool = False,
|
|
534
|
+
cleanup_error: str | None = None,
|
|
535
|
+
) -> CommandResult:
|
|
536
|
+
return CommandResult(
|
|
537
|
+
command=safe_command,
|
|
538
|
+
working_directory=safe_directory,
|
|
539
|
+
environment_keys=safe_environment_keys,
|
|
540
|
+
outcome=outcome,
|
|
541
|
+
exit_code=process.returncode,
|
|
542
|
+
stdout=readers[0].capture.snapshot(self.secrets),
|
|
543
|
+
stderr=readers[1].capture.snapshot(self.secrets),
|
|
544
|
+
duration_seconds=self._duration(started_at),
|
|
545
|
+
termination_reason=termination_reason,
|
|
546
|
+
termination_attempted=termination_attempted,
|
|
547
|
+
forced_kill=forced_kill,
|
|
548
|
+
cleanup_error=cleanup_error,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def _duration(self, started_at: float) -> float:
|
|
552
|
+
return max(0.0, self._clock() - started_at)
|
|
553
|
+
|
|
554
|
+
def _safe_exception(self, exc: BaseException) -> str:
|
|
555
|
+
return self.secrets.redact_text(f"{type(exc).__name__}: {exc}")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _validate_command(command: Sequence[str]) -> tuple[str, ...]:
|
|
559
|
+
if isinstance(command, str | bytes) or not isinstance(command, Sequence):
|
|
560
|
+
raise TypeError("command must be an argument sequence")
|
|
561
|
+
if not command:
|
|
562
|
+
raise ValueError("command must contain at least one argument")
|
|
563
|
+
arguments: list[str] = []
|
|
564
|
+
for argument in command:
|
|
565
|
+
if not isinstance(argument, str):
|
|
566
|
+
raise TypeError("command arguments must be strings")
|
|
567
|
+
if not argument:
|
|
568
|
+
raise ValueError("command arguments must not be empty")
|
|
569
|
+
if "\x00" in argument:
|
|
570
|
+
raise ValueError("command arguments must not contain NUL bytes")
|
|
571
|
+
arguments.append(argument)
|
|
572
|
+
return tuple(arguments)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _validate_environment(environment: Mapping[str, str]) -> dict[str, str]:
|
|
576
|
+
if not isinstance(environment, Mapping):
|
|
577
|
+
raise TypeError("environment must be a string mapping")
|
|
578
|
+
result: dict[str, str] = {}
|
|
579
|
+
for name, value in environment.items():
|
|
580
|
+
if not isinstance(name, str) or not isinstance(value, str):
|
|
581
|
+
raise TypeError("environment names and values must be strings")
|
|
582
|
+
if not name:
|
|
583
|
+
raise ValueError("environment names must not be empty")
|
|
584
|
+
if "=" in name or "\x00" in name or "\x00" in value:
|
|
585
|
+
raise ValueError("environment contains an invalid name or value")
|
|
586
|
+
result[name] = value
|
|
587
|
+
return result
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _working_directory(value: Path | None) -> Path:
|
|
591
|
+
if value is None:
|
|
592
|
+
return Path.cwd()
|
|
593
|
+
if not isinstance(value, Path):
|
|
594
|
+
raise TypeError("working_directory must be a pathlib.Path")
|
|
595
|
+
return value if value.is_absolute() else Path.cwd() / value
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _positive_seconds(value: float, name: str) -> float:
|
|
599
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
600
|
+
raise TypeError(f"{name} must be a number")
|
|
601
|
+
result = float(value)
|
|
602
|
+
if not math.isfinite(result) or result <= 0:
|
|
603
|
+
raise ValueError(f"{name} must be finite and greater than zero")
|
|
604
|
+
return result
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _positive_integer(value: int, name: str) -> int:
|
|
608
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
609
|
+
raise TypeError(f"{name} must be an integer")
|
|
610
|
+
if value <= 0:
|
|
611
|
+
raise ValueError(f"{name} must be greater than zero")
|
|
612
|
+
return value
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _redacted_command(command: tuple[str, ...], secrets: SecretRegistry) -> tuple[str, ...]:
|
|
616
|
+
redacted: list[str] = []
|
|
617
|
+
redact_next = False
|
|
618
|
+
for argument in command:
|
|
619
|
+
if redact_next:
|
|
620
|
+
redacted.append(REDACTION_MARKER)
|
|
621
|
+
redact_next = False
|
|
622
|
+
continue
|
|
623
|
+
redacted.append(secrets.redact_text(argument))
|
|
624
|
+
option = argument.removeprefix("--")
|
|
625
|
+
if argument.startswith("--") and "=" not in option and is_sensitive_key(option):
|
|
626
|
+
redact_next = True
|
|
627
|
+
return tuple(redacted)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _process_group_exists(group_id: int) -> bool:
|
|
631
|
+
try:
|
|
632
|
+
os.killpg(group_id, 0)
|
|
633
|
+
except ProcessLookupError:
|
|
634
|
+
return False
|
|
635
|
+
except PermissionError:
|
|
636
|
+
return True
|
|
637
|
+
except OSError as exc:
|
|
638
|
+
if exc.errno == errno.ESRCH:
|
|
639
|
+
return False
|
|
640
|
+
return True
|
|
641
|
+
return True
|