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.
@@ -0,0 +1,590 @@
1
+ """Isolated Docker Compose candidate runner for one Linux host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import threading
9
+ from collections.abc import Mapping
10
+ from pathlib import Path, PurePosixPath
11
+ from typing import Any, Final
12
+
13
+ from dploydb.config import ApplicationConfig
14
+ from dploydb.redaction import SecretRegistry, is_sensitive_key
15
+ from dploydb.runners.base import (
16
+ CandidateCleanup,
17
+ CandidateCleanupError,
18
+ CandidateCleanupProof,
19
+ CandidateHandle,
20
+ CandidateInspection,
21
+ CandidateInspectionError,
22
+ CandidateLogs,
23
+ CandidateMount,
24
+ CandidateStart,
25
+ CandidateStartError,
26
+ CommandExecutor,
27
+ validate_operation_id,
28
+ validate_release_identifier,
29
+ )
30
+ from dploydb.subprocesses import CommandOutcome, CommandResult, SubprocessRunner
31
+
32
+ DPLOYDB_VERSION_ENV: Final = "DPLOYDB_VERSION"
33
+ OPERATION_LABEL: Final = "io.dploydb.operation_id"
34
+ ROLE_LABEL: Final = "io.dploydb.role"
35
+ ROLE_CANDIDATE: Final = "candidate"
36
+ COMPOSE_PROJECT_LABEL: Final = "com.docker.compose.project"
37
+ COMPOSE_SERVICE_LABEL: Final = "com.docker.compose.service"
38
+ CANDIDATE_MAX_OUTPUT_BYTES: Final = 256 * 1024
39
+ _CONTAINER_ID = re.compile(r"[0-9a-f]{12,64}\Z")
40
+ _ENVIRONMENT_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
41
+ _PROJECT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
42
+
43
+
44
+ class DockerComposeCandidateRunner:
45
+ """Start and remove one operation-scoped Compose candidate without production control."""
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ project: str,
51
+ application: ApplicationConfig,
52
+ database_environment_name: str,
53
+ production_database_path: Path,
54
+ secrets: SecretRegistry,
55
+ working_directory: Path,
56
+ command_environment: Mapping[str, str] | None = None,
57
+ command_runner: CommandExecutor | None = None,
58
+ ) -> None:
59
+ if not isinstance(project, str) or _PROJECT_NAME.fullmatch(project) is None:
60
+ raise ValueError("project must be a bounded DployDB project identifier")
61
+ if (
62
+ not isinstance(database_environment_name, str)
63
+ or _ENVIRONMENT_NAME.fullmatch(database_environment_name) is None
64
+ or database_environment_name == DPLOYDB_VERSION_ENV
65
+ ):
66
+ raise ValueError("database_environment_name must be a safe non-reserved name")
67
+ self.project = project
68
+ self.application = application
69
+ if DPLOYDB_VERSION_ENV in application.test_mode_env:
70
+ raise ValueError("test_mode_env must not override DPLOYDB_VERSION")
71
+ self.database_environment_name = database_environment_name
72
+ self.production_database_path = _absolute_resolved_path(
73
+ production_database_path, "production_database_path"
74
+ )
75
+ if not self.production_database_path.is_file():
76
+ raise ValueError("production_database_path must identify an existing file")
77
+ self.secrets = secrets
78
+ self.working_directory = _absolute_resolved_path(working_directory, "working_directory")
79
+ self.command_environment = dict(
80
+ os.environ if command_environment is None else command_environment
81
+ )
82
+ self.command_runner = command_runner or SubprocessRunner(
83
+ secrets=secrets,
84
+ max_output_bytes=CANDIDATE_MAX_OUTPUT_BYTES,
85
+ )
86
+
87
+ def start(
88
+ self,
89
+ *,
90
+ operation_id: str,
91
+ version: str,
92
+ rehearsal_database_path: Path,
93
+ cancellation_event: threading.Event | None = None,
94
+ ) -> CandidateStart:
95
+ """Create one isolated one-off service container and clean failed starts."""
96
+ operation = validate_operation_id(operation_id)
97
+ release = validate_release_identifier(version)
98
+ rehearsal = _absolute_resolved_path(rehearsal_database_path, "rehearsal_database_path")
99
+ if not rehearsal.is_file():
100
+ raise ValueError("rehearsal_database_path must identify an existing file")
101
+ if ":" in str(rehearsal.parent):
102
+ raise ValueError("rehearsal workspace path must not contain a colon")
103
+ if rehearsal == self.production_database_path or os.path.samefile(
104
+ rehearsal, self.production_database_path
105
+ ):
106
+ raise ValueError("candidate database must not alias the production database")
107
+ if _is_relative_to(self.production_database_path, rehearsal.parent):
108
+ raise ValueError("rehearsal workspace must not contain the production database")
109
+
110
+ handle = self._handle(operation, release, rehearsal)
111
+ environment = self._environment(release)
112
+ command = self._compose_command(
113
+ handle.compose_project,
114
+ "run",
115
+ "--detach",
116
+ "--no-TTY",
117
+ "--no-deps",
118
+ "--build",
119
+ "--name",
120
+ handle.container_name,
121
+ "--label",
122
+ f"{OPERATION_LABEL}={operation}",
123
+ "--label",
124
+ f"{ROLE_LABEL}={ROLE_CANDIDATE}",
125
+ "--publish",
126
+ (
127
+ f"127.0.0.1:{self.application.candidate_port}:"
128
+ f"{self.application.candidate_container_port}"
129
+ ),
130
+ "--volume",
131
+ f"{rehearsal.parent}:{self.application.database_volume_target}:rw",
132
+ "--env",
133
+ f"{self.database_environment_name}={handle.candidate_database_path}",
134
+ *self._test_environment_arguments(),
135
+ self.application.service,
136
+ )
137
+ result = self._run(command, environment=environment, cancellation_event=cancellation_event)
138
+ output_lines = _nonempty_lines(result.stdout.text)
139
+ container_reference = "" if not output_lines else output_lines[-1]
140
+ expected_reference = (
141
+ _CONTAINER_ID.fullmatch(container_reference) is not None
142
+ or container_reference == handle.container_name
143
+ )
144
+ if (
145
+ result.outcome is not CommandOutcome.SUCCEEDED
146
+ or result.stdout.truncated
147
+ or result.stderr.truncated
148
+ or not expected_reference
149
+ ):
150
+ message = _command_failure("candidate Compose startup", result)
151
+ cleanup = self._cleanup_after_failure(handle)
152
+ if not cleanup.proof.proven:
153
+ message += "; candidate cleanup could not be proven"
154
+ raise CandidateStartError(message, command=result, cleanup=cleanup)
155
+ return CandidateStart(
156
+ handle=handle,
157
+ container_reference=container_reference,
158
+ command=result,
159
+ )
160
+
161
+ def inspect(
162
+ self,
163
+ handle: CandidateHandle,
164
+ *,
165
+ cancellation_event: threading.Event | None = None,
166
+ ) -> CandidateInspection:
167
+ """Inspect and validate actual state before readiness checks may begin."""
168
+ self._validate_handle(handle)
169
+ result = self._run(
170
+ ("docker", "container", "inspect", handle.container_name),
171
+ environment=self._environment(handle.version),
172
+ cancellation_event=cancellation_event,
173
+ )
174
+ if result.outcome is not CommandOutcome.SUCCEEDED:
175
+ raise CandidateInspectionError(
176
+ _command_failure("candidate inspection", result), command=result
177
+ )
178
+ if result.stdout.truncated or result.stderr.truncated:
179
+ raise CandidateInspectionError(
180
+ "candidate inspection exceeded the bounded evidence limit", command=result
181
+ )
182
+ try:
183
+ payload = json.loads(result.stdout.text)
184
+ raw = _single_inspection(payload)
185
+ inspection, failures = self._validated_inspection(handle, raw, result)
186
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
187
+ raise CandidateInspectionError(
188
+ "candidate inspection returned invalid Docker JSON: "
189
+ + self.secrets.redact_text(str(exc)),
190
+ command=result,
191
+ ) from None
192
+ if failures:
193
+ raise CandidateInspectionError(
194
+ "candidate isolation inspection failed: " + "; ".join(failures),
195
+ command=result,
196
+ )
197
+ return inspection
198
+
199
+ def collect_logs(
200
+ self,
201
+ handle: CandidateHandle,
202
+ *,
203
+ cancellation_event: threading.Event | None = None,
204
+ ) -> CandidateLogs:
205
+ """Collect bounded redacted logs from exactly the candidate container."""
206
+ self._validate_handle(handle)
207
+ result = self._run(
208
+ ("docker", "container", "logs", handle.container_name),
209
+ environment=self._environment(handle.version),
210
+ cancellation_event=cancellation_event,
211
+ )
212
+ return CandidateLogs(handle=handle, command=result)
213
+
214
+ def stop(self, handle: CandidateHandle) -> CandidateCleanup:
215
+ """Idempotently remove only the candidate and its isolated Compose project."""
216
+ self._validate_handle(handle)
217
+ environment = self._environment(handle.version)
218
+ presence = self._candidate_container_query(handle, environment=environment)
219
+ remove: CommandResult | None = None
220
+ if _successful_complete(presence):
221
+ names = _nonempty_lines(presence.stdout.text)
222
+ if handle.container_name in names:
223
+ remove = self._run(
224
+ ("docker", "container", "rm", "--force", handle.container_name),
225
+ environment=environment,
226
+ )
227
+ down = self._run(
228
+ self._compose_command(
229
+ handle.compose_project,
230
+ "down",
231
+ "--remove-orphans",
232
+ "--timeout",
233
+ str(self.application.startup_timeout_seconds),
234
+ ),
235
+ environment=environment,
236
+ )
237
+ proof = self.prove_cleanup(handle)
238
+ cleanup = CandidateCleanup(
239
+ presence_query=presence,
240
+ remove_command=remove,
241
+ compose_down=down,
242
+ proof=proof,
243
+ )
244
+ commands_succeeded = _successful_complete(presence) and _successful_complete(down)
245
+ if remove is not None:
246
+ commands_succeeded = commands_succeeded and _successful_complete(remove)
247
+ if not commands_succeeded or not proof.proven:
248
+ raise CandidateCleanupError(
249
+ "candidate cleanup could not be proven for the isolated Compose project",
250
+ command=down,
251
+ cleanup=cleanup,
252
+ )
253
+ return cleanup
254
+
255
+ def prove_cleanup(self, handle: CandidateHandle) -> CandidateCleanupProof:
256
+ """Prove absence through successful bounded Docker container/network queries."""
257
+ self._validate_handle(handle)
258
+ environment = self._environment(handle.version)
259
+ container_query = self._candidate_container_query(handle, environment=environment)
260
+ network_query = self._run(
261
+ (
262
+ "docker",
263
+ "network",
264
+ "ls",
265
+ "--filter",
266
+ f"label={COMPOSE_PROJECT_LABEL}={handle.compose_project}",
267
+ "--format",
268
+ "{{.ID}}",
269
+ ),
270
+ environment=environment,
271
+ )
272
+ container_absent = _successful_complete(container_query) and not _nonempty_lines(
273
+ container_query.stdout.text
274
+ )
275
+ networks_absent = _successful_complete(network_query) and not _nonempty_lines(
276
+ network_query.stdout.text
277
+ )
278
+ return CandidateCleanupProof(
279
+ container_absent=container_absent,
280
+ networks_absent=networks_absent,
281
+ container_query=container_query,
282
+ network_query=network_query,
283
+ )
284
+
285
+ def _handle(self, operation_id: str, version: str, rehearsal: Path) -> CandidateHandle:
286
+ operation_suffix = operation_id.removeprefix("op_")[:16]
287
+ safe_project = re.sub(r"[^a-z0-9_-]", "-", self.project.lower())
288
+ safe_project = safe_project.strip("-_") or "app"
289
+ compose_project = f"dploydb-{safe_project[:24]}-{operation_suffix}"
290
+ container_name = f"{compose_project}-candidate"
291
+ target = PurePosixPath(self.application.database_volume_target)
292
+ return CandidateHandle(
293
+ operation_id=operation_id,
294
+ version=version,
295
+ compose_project=compose_project,
296
+ container_name=container_name,
297
+ rehearsal_database_path=rehearsal,
298
+ candidate_database_path=str(target / rehearsal.name),
299
+ )
300
+
301
+ def _validate_handle(self, handle: CandidateHandle) -> None:
302
+ if not isinstance(handle, CandidateHandle):
303
+ raise TypeError("handle must be a CandidateHandle")
304
+ expected = self._handle(
305
+ validate_operation_id(handle.operation_id),
306
+ validate_release_identifier(handle.version),
307
+ _absolute_resolved_path(
308
+ handle.rehearsal_database_path, "handle.rehearsal_database_path"
309
+ ),
310
+ )
311
+ if handle != expected:
312
+ raise ValueError("candidate handle does not match its derived resource identity")
313
+
314
+ def _validated_inspection(
315
+ self,
316
+ handle: CandidateHandle,
317
+ raw: dict[str, Any],
318
+ command: CommandResult,
319
+ ) -> tuple[CandidateInspection, list[str]]:
320
+ failures: list[str] = []
321
+ container_id = _required_text(raw, "Id")
322
+ name = _required_text(raw, "Name").removeprefix("/")
323
+ state = _required_mapping(raw, "State")
324
+ running = state.get("Running") is True
325
+ config = _required_mapping(raw, "Config")
326
+ labels = config.get("Labels")
327
+ if not isinstance(labels, dict):
328
+ labels = {}
329
+ mounts = _mounts(raw.get("Mounts"))
330
+ port_bindings = _published_ports(raw)
331
+
332
+ if name != handle.container_name:
333
+ failures.append("container name does not match the operation-derived name")
334
+ if _CONTAINER_ID.fullmatch(container_id) is None:
335
+ failures.append("container ID is invalid")
336
+ if not running:
337
+ failures.append("container is not running")
338
+ if labels.get(COMPOSE_PROJECT_LABEL) != handle.compose_project:
339
+ failures.append("Compose project label does not match the isolated project")
340
+ if labels.get(COMPOSE_SERVICE_LABEL) != self.application.service:
341
+ failures.append("Compose service label does not match the configured service")
342
+ if labels.get(OPERATION_LABEL) != handle.operation_id:
343
+ failures.append("DployDB operation label is missing or contradictory")
344
+ if labels.get(ROLE_LABEL) != ROLE_CANDIDATE:
345
+ failures.append("DployDB candidate role label is missing or contradictory")
346
+
347
+ expected_target = self.application.database_volume_target
348
+ database_mounts = [mount for mount in mounts if mount.destination == expected_target]
349
+ expected_source = handle.rehearsal_database_path.parent.resolve()
350
+ if len(database_mounts) != 1:
351
+ failures.append("candidate database target does not have exactly one mount")
352
+ else:
353
+ database_mount = database_mounts[0]
354
+ source = Path(database_mount.source).resolve()
355
+ if database_mount.mount_type != "bind" or source != expected_source:
356
+ failures.append("candidate database target is not bound to the rehearsal workspace")
357
+ if not database_mount.read_write:
358
+ failures.append("candidate database mount is not writable")
359
+
360
+ production = self.production_database_path
361
+ for mount in mounts:
362
+ if mount.mount_type != "bind" or not mount.source.startswith("/"):
363
+ continue
364
+ source = Path(mount.source).resolve()
365
+ if source == production or _is_relative_to(production, source):
366
+ failures.append("candidate bind mount exposes the production database")
367
+ break
368
+
369
+ expected_port = self.application.candidate_container_port
370
+ published = [binding for binding in port_bindings if binding[3]]
371
+ matching = [binding for binding in published if binding[0] == expected_port]
372
+ if len(matching) != 1:
373
+ failures.append("candidate container port does not have exactly one published binding")
374
+ host_ip, host_port = "", 0
375
+ else:
376
+ _container_port, host_ip, host_port, _published = matching[0]
377
+ if host_ip != "127.0.0.1" or host_port != self.application.candidate_port:
378
+ failures.append("candidate port is not bound to the configured loopback endpoint")
379
+ if len(published) != 1:
380
+ failures.append("candidate publishes ports beyond the configured candidate endpoint")
381
+
382
+ inspection = CandidateInspection(
383
+ container_id=container_id,
384
+ container_name=name,
385
+ running=running,
386
+ compose_project=str(labels.get(COMPOSE_PROJECT_LABEL, "")),
387
+ compose_service=str(labels.get(COMPOSE_SERVICE_LABEL, "")),
388
+ operation_id=str(labels.get(OPERATION_LABEL, "")),
389
+ host_ip=host_ip,
390
+ host_port=host_port,
391
+ container_port=expected_port,
392
+ mounts=mounts,
393
+ command=command,
394
+ )
395
+ return inspection, failures
396
+
397
+ def _cleanup_after_failure(self, handle: CandidateHandle) -> CandidateCleanup:
398
+ try:
399
+ return self.stop(handle)
400
+ except CandidateCleanupError as error:
401
+ assert error.cleanup is not None
402
+ return error.cleanup
403
+
404
+ def _candidate_container_query(
405
+ self,
406
+ handle: CandidateHandle,
407
+ *,
408
+ environment: Mapping[str, str],
409
+ ) -> CommandResult:
410
+ return self._run(
411
+ (
412
+ "docker",
413
+ "container",
414
+ "ls",
415
+ "--all",
416
+ "--filter",
417
+ f"name=^/{handle.container_name}$",
418
+ "--format",
419
+ "{{.Names}}",
420
+ ),
421
+ environment=environment,
422
+ )
423
+
424
+ def _compose_command(self, project: str, *arguments: str) -> tuple[str, ...]:
425
+ return (
426
+ "docker",
427
+ "compose",
428
+ "--file",
429
+ str(self.application.compose_file),
430
+ "--project-name",
431
+ project,
432
+ *arguments,
433
+ )
434
+
435
+ def _test_environment_arguments(self) -> tuple[str, ...]:
436
+ arguments: list[str] = []
437
+ for name in sorted(self.application.test_mode_env):
438
+ arguments.extend(("--env", f"{name}={self.application.test_mode_env[name]}"))
439
+ return tuple(arguments)
440
+
441
+ def _environment(self, version: str) -> dict[str, str]:
442
+ environment = dict(self.command_environment)
443
+ environment[DPLOYDB_VERSION_ENV] = version
444
+ environment.update(self.application.test_mode_env)
445
+ for name, value in environment.items():
446
+ if is_sensitive_key(name):
447
+ self.secrets.register(value)
448
+ return environment
449
+
450
+ def _run(
451
+ self,
452
+ command: tuple[str, ...],
453
+ *,
454
+ environment: Mapping[str, str],
455
+ cancellation_event: threading.Event | None = None,
456
+ ) -> CommandResult:
457
+ return self.command_runner.run(
458
+ command,
459
+ timeout_seconds=self.application.startup_timeout_seconds,
460
+ environment=environment,
461
+ working_directory=self.working_directory,
462
+ cancellation_event=cancellation_event,
463
+ )
464
+
465
+
466
+ def _absolute_resolved_path(value: Path, name: str) -> Path:
467
+ if not isinstance(value, Path):
468
+ raise TypeError(f"{name} must be a pathlib.Path")
469
+ if not value.is_absolute():
470
+ raise ValueError(f"{name} must be absolute")
471
+ return value.resolve()
472
+
473
+
474
+ def _single_inspection(value: object) -> dict[str, Any]:
475
+ if not isinstance(value, list) or len(value) != 1 or not isinstance(value[0], dict):
476
+ raise ValueError("expected exactly one inspected container")
477
+ return value[0]
478
+
479
+
480
+ def _required_mapping(value: Mapping[str, Any], key: str) -> dict[str, Any]:
481
+ selected = value.get(key)
482
+ if not isinstance(selected, dict):
483
+ raise ValueError(f"inspection field {key} must be an object")
484
+ return selected
485
+
486
+
487
+ def _required_text(value: Mapping[str, Any], key: str) -> str:
488
+ selected = value.get(key)
489
+ if not isinstance(selected, str) or not selected:
490
+ raise ValueError(f"inspection field {key} must be non-empty text")
491
+ return selected
492
+
493
+
494
+ def _mounts(value: object) -> tuple[CandidateMount, ...]:
495
+ if not isinstance(value, list):
496
+ raise ValueError("inspection Mounts must be an array")
497
+ mounts: list[CandidateMount] = []
498
+ for item in value:
499
+ if not isinstance(item, dict):
500
+ raise ValueError("inspection mount must be an object")
501
+ mount_type = item.get("Type")
502
+ source = item.get("Source")
503
+ destination = item.get("Destination")
504
+ read_write = item.get("RW")
505
+ if (
506
+ not isinstance(mount_type, str)
507
+ or not isinstance(source, str)
508
+ or not isinstance(destination, str)
509
+ or not isinstance(read_write, bool)
510
+ ):
511
+ raise ValueError("inspection mount contains invalid fields")
512
+ mounts.append(
513
+ CandidateMount(
514
+ mount_type=mount_type,
515
+ source=source,
516
+ destination=destination,
517
+ read_write=read_write,
518
+ )
519
+ )
520
+ return tuple(mounts)
521
+
522
+
523
+ def _published_ports(value: Mapping[str, Any]) -> list[tuple[int, str, int, bool]]:
524
+ network = _required_mapping(value, "NetworkSettings")
525
+ ports = network.get("Ports")
526
+ if not isinstance(ports, dict):
527
+ raise ValueError("inspection NetworkSettings.Ports must be an object")
528
+ bindings: list[tuple[int, str, int, bool]] = []
529
+ for name, raw_bindings in ports.items():
530
+ if not isinstance(name, str) or not name.endswith("/tcp"):
531
+ continue
532
+ try:
533
+ container_port = int(name.removesuffix("/tcp"))
534
+ except ValueError as exc:
535
+ raise ValueError("inspection contains an invalid container port") from exc
536
+ if raw_bindings is None:
537
+ bindings.append((container_port, "", 0, False))
538
+ continue
539
+ if not isinstance(raw_bindings, list):
540
+ raise ValueError("inspection port bindings must be an array or null")
541
+ for binding in raw_bindings:
542
+ if not isinstance(binding, dict):
543
+ raise ValueError("inspection port binding must be an object")
544
+ host_ip = binding.get("HostIp")
545
+ host_port_text = binding.get("HostPort")
546
+ if not isinstance(host_ip, str) or not isinstance(host_port_text, str):
547
+ raise ValueError("inspection port binding contains invalid fields")
548
+ try:
549
+ host_port = int(host_port_text)
550
+ except ValueError as exc:
551
+ raise ValueError("inspection host port is invalid") from exc
552
+ bindings.append((container_port, host_ip, host_port, True))
553
+ return bindings
554
+
555
+
556
+ def _successful_complete(result: CommandResult) -> bool:
557
+ return (
558
+ result.outcome is CommandOutcome.SUCCEEDED
559
+ and not result.stdout.truncated
560
+ and not result.stderr.truncated
561
+ )
562
+
563
+
564
+ def _nonempty_lines(value: str) -> tuple[str, ...]:
565
+ return tuple(line.strip() for line in value.splitlines() if line.strip())
566
+
567
+
568
+ def _command_failure(action: str, result: CommandResult) -> str:
569
+ if result.stdout.truncated or result.stderr.truncated:
570
+ return f"{action} exceeded the bounded evidence limit"
571
+ if result.outcome is CommandOutcome.NONZERO_EXIT:
572
+ return f"{action} exited with status {result.exit_code}"
573
+ if result.outcome is CommandOutcome.TIMED_OUT:
574
+ return f"{action} timed out and its process group was terminated"
575
+ if result.outcome is CommandOutcome.CANCELLED:
576
+ return f"{action} was cancelled and its process group was terminated"
577
+ if result.outcome is CommandOutcome.CLEANUP_FAILED:
578
+ return f"{action} process cleanup could not be proven"
579
+ if result.outcome is CommandOutcome.START_FAILED:
580
+ return f"{action} could not start: {result.start_error or 'unknown error'}"
581
+ reference = result.stdout.text.strip()
582
+ return f"{action} returned invalid success evidence: {reference!r}"
583
+
584
+
585
+ def _is_relative_to(path: Path, parent: Path) -> bool:
586
+ try:
587
+ path.relative_to(parent)
588
+ except ValueError:
589
+ return False
590
+ return True