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,966 @@
1
+ """Docker Compose production lifecycle with exact previous-container preservation."""
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, ProductionTopology
14
+ from dploydb.health import CANDIDATE_URL_ENV
15
+ from dploydb.models import ProductionApplicationHandle
16
+ from dploydb.redaction import SecretRegistry, is_sensitive_key
17
+ from dploydb.runners.base import (
18
+ CandidateMount,
19
+ CommandExecutor,
20
+ ProductionCleanup,
21
+ ProductionCleanupError,
22
+ ProductionCleanupProof,
23
+ ProductionDiscovery,
24
+ ProductionDiscoveryError,
25
+ ProductionInspection,
26
+ ProductionInspectionError,
27
+ ProductionLogs,
28
+ ProductionNetworkRefresh,
29
+ ProductionRestart,
30
+ ProductionRestartError,
31
+ ProductionStart,
32
+ ProductionStartError,
33
+ ProductionStop,
34
+ ProductionStopError,
35
+ validate_operation_id,
36
+ validate_release_identifier,
37
+ )
38
+ from dploydb.runners.docker_compose import (
39
+ COMPOSE_PROJECT_LABEL,
40
+ COMPOSE_SERVICE_LABEL,
41
+ DPLOYDB_VERSION_ENV,
42
+ OPERATION_LABEL,
43
+ ROLE_LABEL,
44
+ )
45
+ from dploydb.subprocesses import CommandOutcome, CommandResult, SubprocessRunner
46
+
47
+ ROLE_PRODUCTION_RELEASE: Final = "production_release"
48
+ RELEASE_LABEL: Final = "io.dploydb.release_id"
49
+ PRODUCTION_MAX_OUTPUT_BYTES: Final = 256 * 1024
50
+
51
+ _CONTAINER_ID = re.compile(r"[0-9a-f]{12,64}\Z")
52
+ _RELEASE_ID = re.compile(r"release_[0-9a-f]{32}\Z")
53
+ _ENVIRONMENT_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
54
+ _PROJECT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
55
+
56
+
57
+ class DockerComposeProductionRunner:
58
+ """Preserve, stop, start, inspect, and restart exact production containers."""
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ project: str,
64
+ application: ApplicationConfig,
65
+ topology: ProductionTopology,
66
+ database_environment_name: str,
67
+ production_database_path: Path,
68
+ secrets: SecretRegistry,
69
+ working_directory: Path,
70
+ command_environment: Mapping[str, str] | None = None,
71
+ command_runner: CommandExecutor | None = None,
72
+ ) -> None:
73
+ if not isinstance(project, str) or _PROJECT_NAME.fullmatch(project) is None:
74
+ raise ValueError("project must be a bounded DployDB project identifier")
75
+ if (
76
+ not isinstance(database_environment_name, str)
77
+ or _ENVIRONMENT_NAME.fullmatch(database_environment_name) is None
78
+ or database_environment_name == DPLOYDB_VERSION_ENV
79
+ ):
80
+ raise ValueError("database_environment_name must be a safe non-reserved name")
81
+ if application.production_project != topology.compose_project:
82
+ raise ValueError("production topology project does not match application config")
83
+ if application.production_port != topology.host_port:
84
+ raise ValueError("production topology port does not match application config")
85
+ if application.production_health_url != topology.health_url:
86
+ raise ValueError("production topology health URL does not match application config")
87
+ self.project = project
88
+ self.application = application
89
+ self.topology = topology
90
+ self.database_environment_name = database_environment_name
91
+ self.production_database_path = _absolute_resolved_path(
92
+ production_database_path, "production_database_path"
93
+ )
94
+ if not self.production_database_path.is_file():
95
+ raise ValueError("production_database_path must identify an existing file")
96
+ self.secrets = secrets
97
+ self.working_directory = _absolute_resolved_path(working_directory, "working_directory")
98
+ self.command_environment = dict(
99
+ os.environ if command_environment is None else command_environment
100
+ )
101
+ self.command_runner = command_runner or SubprocessRunner(
102
+ secrets=secrets,
103
+ max_output_bytes=PRODUCTION_MAX_OUTPUT_BYTES,
104
+ )
105
+
106
+ def discover_current(
107
+ self,
108
+ *,
109
+ cancellation_event: threading.Event | None = None,
110
+ ) -> ProductionDiscovery:
111
+ """Find exactly one configured current service and validate its live topology."""
112
+ query = self._run(
113
+ self._compose_command(
114
+ self.topology.compose_project,
115
+ "ps",
116
+ "--all",
117
+ "--quiet",
118
+ self.application.service,
119
+ ),
120
+ environment=self._base_environment(),
121
+ cancellation_event=cancellation_event,
122
+ )
123
+ references = _nonempty_lines(query.stdout.text)
124
+ if not _successful_complete(query) or len(references) != 1:
125
+ raise ProductionDiscoveryError(
126
+ _command_failure("current application discovery", query),
127
+ command=query,
128
+ )
129
+ reference = references[0]
130
+ if _CONTAINER_ID.fullmatch(reference) is None:
131
+ raise ProductionDiscoveryError(
132
+ "current application discovery returned an invalid container identity",
133
+ command=query,
134
+ )
135
+ inspect_result = query
136
+ try:
137
+ inspect_result, raw = self._inspect_raw(
138
+ reference,
139
+ environment=self._base_environment(),
140
+ cancellation_event=cancellation_event,
141
+ )
142
+ container_id = _required_text(raw, "Id")
143
+ container_name = _required_text(raw, "Name").removeprefix("/")
144
+ handle = ProductionApplicationHandle(
145
+ source="bootstrap",
146
+ container_id=container_id,
147
+ container_name=container_name,
148
+ compose_project=self.topology.compose_project,
149
+ compose_service=self.application.service,
150
+ version=None,
151
+ release_id=None,
152
+ operation_id=None,
153
+ database_directory=self.production_database_path.parent,
154
+ database_target=self.application.database_volume_target,
155
+ host_port=self.topology.host_port,
156
+ container_port=self.application.candidate_container_port,
157
+ health_url=self.topology.health_url,
158
+ )
159
+ inspection = self._validated_inspection(
160
+ handle,
161
+ raw,
162
+ inspect_result,
163
+ expected_running=True,
164
+ )
165
+ except (ProductionInspectionError, KeyError, TypeError, ValueError) as exc:
166
+ raise ProductionDiscoveryError(
167
+ "current application inspection was unsafe: " + self.secrets.redact_text(str(exc)),
168
+ command=(
169
+ exc.command if isinstance(exc, ProductionInspectionError) else inspect_result
170
+ ),
171
+ ) from None
172
+ return ProductionDiscovery(query=query, inspection=inspection)
173
+
174
+ def inspect(
175
+ self,
176
+ handle: ProductionApplicationHandle,
177
+ *,
178
+ expected_running: bool,
179
+ cancellation_event: threading.Event | None = None,
180
+ ) -> ProductionInspection:
181
+ """Revalidate the exact stored application identity and required topology."""
182
+ self._validate_handle(handle)
183
+ result, raw = self._inspect_raw(
184
+ handle.container_id,
185
+ environment=self._environment_for_handle(handle),
186
+ cancellation_event=cancellation_event,
187
+ )
188
+ try:
189
+ return self._validated_inspection(
190
+ handle,
191
+ raw,
192
+ result,
193
+ expected_running=expected_running,
194
+ )
195
+ except (KeyError, TypeError, ValueError) as exc:
196
+ raise ProductionInspectionError(
197
+ "production application inspection was unsafe: "
198
+ + self.secrets.redact_text(str(exc)),
199
+ command=result,
200
+ ) from None
201
+
202
+ def inspect_live(
203
+ self,
204
+ handle: ProductionApplicationHandle,
205
+ *,
206
+ cancellation_event: threading.Event | None = None,
207
+ ) -> ProductionInspection:
208
+ """Validate exact identity and topology while accepting either running state."""
209
+ self._validate_handle(handle)
210
+ result, raw = self._inspect_raw(
211
+ handle.container_id,
212
+ environment=self._environment_for_handle(handle),
213
+ cancellation_event=cancellation_event,
214
+ )
215
+ try:
216
+ return self._validated_inspection(handle, raw, result, expected_running=None)
217
+ except (KeyError, TypeError, ValueError) as exc:
218
+ raise ProductionInspectionError(
219
+ "production application inspection was unsafe: "
220
+ + self.secrets.redact_text(str(exc)),
221
+ command=result,
222
+ ) from None
223
+
224
+ def stop_current(
225
+ self,
226
+ handle: ProductionApplicationHandle,
227
+ *,
228
+ cancellation_event: threading.Event | None = None,
229
+ ) -> ProductionStop:
230
+ """Stop, but never remove, the exact previous application container."""
231
+ self._validate_handle(handle)
232
+ result = self._run(
233
+ (
234
+ "docker",
235
+ "container",
236
+ "stop",
237
+ "--time",
238
+ str(self.application.startup_timeout_seconds),
239
+ handle.container_id,
240
+ ),
241
+ environment=self._environment_for_handle(handle),
242
+ cancellation_event=cancellation_event,
243
+ )
244
+ if not _successful_complete(result):
245
+ raise ProductionStopError(
246
+ _command_failure("current application stop", result),
247
+ command=result,
248
+ )
249
+ try:
250
+ inspection = self.inspect(
251
+ handle,
252
+ expected_running=False,
253
+ cancellation_event=cancellation_event,
254
+ )
255
+ except ProductionInspectionError as exc:
256
+ raise ProductionStopError(str(exc), command=exc.command) from None
257
+ return ProductionStop(handle=handle, command=result, inspection=inspection)
258
+
259
+ def start_new(
260
+ self,
261
+ *,
262
+ operation_id: str,
263
+ release_id: str,
264
+ version: str,
265
+ cancellation_event: threading.Event | None = None,
266
+ ) -> ProductionStart:
267
+ """Start a new release on the production port while preserving the old container."""
268
+ operation = validate_operation_id(operation_id)
269
+ if not isinstance(release_id, str) or _RELEASE_ID.fullmatch(release_id) is None:
270
+ raise ValueError("release_id must be an opaque DployDB release ID")
271
+ release = validate_release_identifier(version)
272
+ compose_project, container_name = self._new_identity(release_id)
273
+ environment = self._production_environment(release)
274
+ target = PurePosixPath(self.application.database_volume_target)
275
+ container_database_path = str(target / self.production_database_path.name)
276
+ command = self._compose_command(
277
+ compose_project,
278
+ "run",
279
+ "--detach",
280
+ "--no-TTY",
281
+ "--no-deps",
282
+ "--build",
283
+ "--name",
284
+ container_name,
285
+ "--label",
286
+ f"{OPERATION_LABEL}={operation}",
287
+ "--label",
288
+ f"{ROLE_LABEL}={ROLE_PRODUCTION_RELEASE}",
289
+ "--label",
290
+ f"{RELEASE_LABEL}={release_id}",
291
+ "--publish",
292
+ (f"127.0.0.1:{self.topology.host_port}:{self.application.candidate_container_port}"),
293
+ "--volume",
294
+ f"{self.production_database_path.parent}:{self.application.database_volume_target}:rw",
295
+ "--env",
296
+ f"{self.database_environment_name}={container_database_path}",
297
+ self.application.service,
298
+ )
299
+ result = self._run(
300
+ command,
301
+ environment=environment,
302
+ cancellation_event=cancellation_event,
303
+ )
304
+ output_lines = _nonempty_lines(result.stdout.text)
305
+ reference = "" if not output_lines else output_lines[-1]
306
+ if not _successful_complete(result) or (
307
+ _CONTAINER_ID.fullmatch(reference) is None and reference != container_name
308
+ ):
309
+ cleanup = self._cleanup_identity(
310
+ compose_project=compose_project,
311
+ container_name=container_name,
312
+ version=release,
313
+ )
314
+ message = _command_failure("new production application startup", result)
315
+ if not cleanup.proof.proven:
316
+ message += "; new-release cleanup could not be proven"
317
+ raise ProductionStartError(message, command=result, cleanup=cleanup)
318
+
319
+ try:
320
+ inspect_result, raw = self._inspect_raw(
321
+ container_name,
322
+ environment=environment,
323
+ cancellation_event=cancellation_event,
324
+ )
325
+ handle = ProductionApplicationHandle(
326
+ source="release",
327
+ container_id=_required_text(raw, "Id"),
328
+ container_name=container_name,
329
+ compose_project=compose_project,
330
+ compose_service=self.application.service,
331
+ version=release,
332
+ release_id=release_id,
333
+ operation_id=operation,
334
+ database_directory=self.production_database_path.parent,
335
+ database_target=self.application.database_volume_target,
336
+ host_port=self.topology.host_port,
337
+ container_port=self.application.candidate_container_port,
338
+ health_url=self.topology.health_url,
339
+ )
340
+ inspection = self._validated_inspection(
341
+ handle,
342
+ raw,
343
+ inspect_result,
344
+ expected_running=True,
345
+ )
346
+ except (ProductionInspectionError, KeyError, TypeError, ValueError) as exc:
347
+ cleanup = self._cleanup_identity(
348
+ compose_project=compose_project,
349
+ container_name=container_name,
350
+ version=release,
351
+ )
352
+ raise ProductionStartError(
353
+ "new production application identity or inspection was unsafe: "
354
+ + self.secrets.redact_text(str(exc)),
355
+ command=result,
356
+ cleanup=cleanup,
357
+ ) from None
358
+ return ProductionStart(
359
+ handle=handle,
360
+ container_reference=reference,
361
+ command=result,
362
+ inspection=inspection,
363
+ )
364
+
365
+ def collect_logs(
366
+ self,
367
+ handle: ProductionApplicationHandle,
368
+ *,
369
+ cancellation_event: threading.Event | None = None,
370
+ ) -> ProductionLogs:
371
+ """Collect bounded logs from exactly the stored production container."""
372
+ self._validate_handle(handle)
373
+ result = self._run(
374
+ ("docker", "container", "logs", handle.container_id),
375
+ environment=self._environment_for_handle(handle),
376
+ cancellation_event=cancellation_event,
377
+ )
378
+ return ProductionLogs(handle=handle, command=result)
379
+
380
+ def remove_new(self, handle: ProductionApplicationHandle) -> ProductionCleanup:
381
+ """Idempotently remove only an operation-created new release and its network."""
382
+ self._validate_release_handle(handle)
383
+ assert handle.version is not None
384
+ cleanup = self._cleanup_identity(
385
+ compose_project=handle.compose_project,
386
+ container_name=handle.container_name,
387
+ version=handle.version,
388
+ )
389
+ commands_succeeded = _successful_complete(cleanup.presence_query) and _successful_complete(
390
+ cleanup.compose_down
391
+ )
392
+ if cleanup.remove_command is not None:
393
+ commands_succeeded = commands_succeeded and _successful_complete(cleanup.remove_command)
394
+ if not commands_succeeded or not cleanup.proof.proven:
395
+ raise ProductionCleanupError(
396
+ "new production release cleanup could not be proven",
397
+ command=cleanup.compose_down,
398
+ cleanup=cleanup,
399
+ )
400
+ return cleanup
401
+
402
+ def restart_previous(
403
+ self,
404
+ handle: ProductionApplicationHandle,
405
+ *,
406
+ cancellation_event: threading.Event | None = None,
407
+ ) -> ProductionRestart:
408
+ """Restart the exact preserved previous container and prove it is running."""
409
+ self._validate_handle(handle)
410
+ network_refreshes: tuple[ProductionNetworkRefresh, ...] = ()
411
+ if handle.source == "release":
412
+ try:
413
+ inspect_result, raw = self._inspect_raw(
414
+ handle.container_id,
415
+ environment=self._environment_for_handle(handle),
416
+ cancellation_event=cancellation_event,
417
+ )
418
+ self._validated_inspection(
419
+ handle,
420
+ raw,
421
+ inspect_result,
422
+ expected_running=False,
423
+ )
424
+ network_refreshes = self._refresh_stopped_network_endpoints(
425
+ handle,
426
+ raw,
427
+ cancellation_event=cancellation_event,
428
+ )
429
+ except (ProductionInspectionError, KeyError, TypeError, ValueError) as exc:
430
+ raise ProductionRestartError(
431
+ "previous release network refresh was unsafe: "
432
+ + self.secrets.redact_text(str(exc)),
433
+ command=(exc.command if isinstance(exc, ProductionInspectionError) else None),
434
+ ) from None
435
+ result = self._run(
436
+ ("docker", "container", "start", handle.container_id),
437
+ environment=self._environment_for_handle(handle),
438
+ cancellation_event=cancellation_event,
439
+ )
440
+ if not _successful_complete(result):
441
+ raise ProductionRestartError(
442
+ _command_failure("previous application restart", result),
443
+ command=result,
444
+ )
445
+ try:
446
+ inspection = self.inspect(
447
+ handle,
448
+ expected_running=True,
449
+ cancellation_event=cancellation_event,
450
+ )
451
+ except ProductionInspectionError as exc:
452
+ raise ProductionRestartError(str(exc), command=exc.command) from None
453
+ return ProductionRestart(
454
+ handle=handle,
455
+ command=result,
456
+ inspection=inspection,
457
+ network_refreshes=network_refreshes,
458
+ )
459
+
460
+ def _refresh_stopped_network_endpoints(
461
+ self,
462
+ handle: ProductionApplicationHandle,
463
+ raw: dict[str, Any],
464
+ *,
465
+ cancellation_event: threading.Event | None,
466
+ ) -> tuple[ProductionNetworkRefresh, ...]:
467
+ """Refresh exact Compose endpoints before reusing a release's published port."""
468
+ network_settings = _required_mapping(raw, "NetworkSettings")
469
+ networks = _required_mapping(network_settings, "Networks")
470
+ if not networks:
471
+ raise ValueError("previous release has no inspectable network attachment")
472
+ environment = self._environment_for_handle(handle)
473
+ refreshed: list[ProductionNetworkRefresh] = []
474
+ for network_name, raw_attachment in sorted(networks.items()):
475
+ if not isinstance(network_name, str) or not network_name or len(network_name) > 255:
476
+ raise ValueError("previous release has an invalid network identity")
477
+ if not isinstance(raw_attachment, dict):
478
+ raise ValueError(f"network {network_name} attachment must be an object")
479
+ attachment = raw_attachment
480
+ raw_aliases = attachment.get("Aliases")
481
+ if raw_aliases is None:
482
+ aliases: tuple[str, ...] = ()
483
+ elif isinstance(raw_aliases, list) and all(
484
+ isinstance(alias, str) and 0 < len(alias) <= 255 for alias in raw_aliases
485
+ ):
486
+ aliases = tuple(dict.fromkeys(raw_aliases))
487
+ else:
488
+ raise ValueError("previous release network aliases are invalid")
489
+ disconnected = self._run(
490
+ (
491
+ "docker",
492
+ "network",
493
+ "disconnect",
494
+ "--force",
495
+ network_name,
496
+ handle.container_id,
497
+ ),
498
+ environment=environment,
499
+ cancellation_event=cancellation_event,
500
+ )
501
+ if not _successful_complete(disconnected):
502
+ raise ProductionRestartError(
503
+ _command_failure("previous release network disconnect", disconnected),
504
+ command=disconnected,
505
+ )
506
+ alias_arguments = tuple(
507
+ argument for alias in aliases for argument in ("--alias", alias)
508
+ )
509
+ connected = self._run(
510
+ (
511
+ "docker",
512
+ "network",
513
+ "connect",
514
+ *alias_arguments,
515
+ network_name,
516
+ handle.container_id,
517
+ ),
518
+ environment=environment,
519
+ cancellation_event=cancellation_event,
520
+ )
521
+ if not _successful_complete(connected):
522
+ raise ProductionRestartError(
523
+ _command_failure("previous release network reconnect", connected),
524
+ command=connected,
525
+ )
526
+ refreshed.append(
527
+ ProductionNetworkRefresh(
528
+ network_name=network_name,
529
+ aliases=aliases,
530
+ disconnect=disconnected,
531
+ connect=connected,
532
+ )
533
+ )
534
+ return tuple(refreshed)
535
+
536
+ def prove_new_absent(
537
+ self,
538
+ *,
539
+ compose_project: str,
540
+ container_name: str,
541
+ version: str,
542
+ ) -> ProductionCleanupProof:
543
+ """Prove exact new-release container and project-network absence."""
544
+ environment = self._production_environment(version)
545
+ container_query = self._container_query(container_name, environment=environment)
546
+ network_query = self._run(
547
+ (
548
+ "docker",
549
+ "network",
550
+ "ls",
551
+ "--filter",
552
+ f"label={COMPOSE_PROJECT_LABEL}={compose_project}",
553
+ "--format",
554
+ "{{.ID}}",
555
+ ),
556
+ environment=environment,
557
+ )
558
+ return ProductionCleanupProof(
559
+ container_absent=_successful_complete(container_query)
560
+ and not _nonempty_lines(container_query.stdout.text),
561
+ networks_absent=_successful_complete(network_query)
562
+ and not _nonempty_lines(network_query.stdout.text),
563
+ container_query=container_query,
564
+ network_query=network_query,
565
+ )
566
+
567
+ def prove_release_absent(
568
+ self,
569
+ *,
570
+ release_id: str,
571
+ version: str,
572
+ ) -> ProductionCleanupProof:
573
+ """Prove deterministic resources for a release are absent without mutation."""
574
+ if not isinstance(release_id, str) or _RELEASE_ID.fullmatch(release_id) is None:
575
+ raise ValueError("release_id must be an opaque DployDB release ID")
576
+ selected_version = validate_release_identifier(version)
577
+ compose_project, container_name = self._new_identity(release_id)
578
+ return self.prove_new_absent(
579
+ compose_project=compose_project,
580
+ container_name=container_name,
581
+ version=selected_version,
582
+ )
583
+
584
+ def _validated_inspection(
585
+ self,
586
+ handle: ProductionApplicationHandle,
587
+ raw: dict[str, Any],
588
+ command: CommandResult,
589
+ *,
590
+ expected_running: bool | None,
591
+ ) -> ProductionInspection:
592
+ failures: list[str] = []
593
+ container_id = _required_text(raw, "Id")
594
+ container_name = _required_text(raw, "Name").removeprefix("/")
595
+ state = _required_mapping(raw, "State")
596
+ running = state.get("Running") is True
597
+ config = _required_mapping(raw, "Config")
598
+ labels = config.get("Labels")
599
+ if not isinstance(labels, dict):
600
+ labels = {}
601
+ environment = config.get("Env")
602
+ if not isinstance(environment, list) or not all(
603
+ isinstance(item, str) for item in environment
604
+ ):
605
+ raise ValueError("inspection Config.Env must be an array of strings")
606
+ mounts = _mounts(raw.get("Mounts"))
607
+ ports = _published_ports(raw, running=running)
608
+
609
+ if container_id != handle.container_id:
610
+ failures.append("container ID does not match the durable application handle")
611
+ if container_name != handle.container_name:
612
+ failures.append("container name does not match the durable application handle")
613
+ if expected_running is not None and running is not expected_running:
614
+ failures.append(
615
+ "container running state does not match the required "
616
+ + ("running" if expected_running else "stopped")
617
+ + " state"
618
+ )
619
+ if labels.get(COMPOSE_PROJECT_LABEL) != handle.compose_project:
620
+ failures.append("Compose project label contradicts the application handle")
621
+ if labels.get(COMPOSE_SERVICE_LABEL) != handle.compose_service:
622
+ failures.append("Compose service label contradicts the application handle")
623
+ if handle.source == "release":
624
+ if labels.get(ROLE_LABEL) != ROLE_PRODUCTION_RELEASE:
625
+ failures.append("production-release role label is missing or contradictory")
626
+ if labels.get(RELEASE_LABEL) != handle.release_id:
627
+ failures.append("release label contradicts the application handle")
628
+ if labels.get(OPERATION_LABEL) != handle.operation_id:
629
+ failures.append("operation label contradicts the application handle")
630
+
631
+ expected_source = self.production_database_path.parent.resolve()
632
+ database_mounts = [mount for mount in mounts if mount.destination == handle.database_target]
633
+ if len(database_mounts) != 1:
634
+ failures.append("production database target does not have exactly one mount")
635
+ else:
636
+ mount = database_mounts[0]
637
+ source = Path(mount.source).resolve() if mount.source.startswith("/") else None
638
+ if mount.mount_type != "bind" or source != expected_source:
639
+ failures.append(
640
+ "production database target is not bound to its configured directory"
641
+ )
642
+ if not mount.read_write:
643
+ failures.append("production database mount is not writable")
644
+
645
+ expected_database = str(
646
+ PurePosixPath(handle.database_target) / self.production_database_path.name
647
+ )
648
+ expected_assignment = f"{self.database_environment_name}={expected_database}"
649
+ if expected_assignment not in environment:
650
+ failures.append("container database environment does not select production SQLite")
651
+
652
+ published = [binding for binding in ports if binding[3]]
653
+ matching = [binding for binding in published if binding[0] == handle.container_port]
654
+ if len(matching) != 1:
655
+ failures.append("production container port lacks exactly one published binding")
656
+ else:
657
+ _container_port, host_ip, host_port, _published = matching[0]
658
+ if host_ip != "127.0.0.1" or host_port != handle.host_port:
659
+ failures.append("production port is not bound to the configured loopback endpoint")
660
+ if len(published) != 1:
661
+ failures.append("production application publishes unexpected additional ports")
662
+
663
+ if failures:
664
+ raise ProductionInspectionError(
665
+ "production application inspection failed: " + "; ".join(failures),
666
+ command=command,
667
+ )
668
+ return ProductionInspection(
669
+ handle=handle,
670
+ running=running,
671
+ mounts=mounts,
672
+ command=command,
673
+ )
674
+
675
+ def _inspect_raw(
676
+ self,
677
+ reference: str,
678
+ *,
679
+ environment: Mapping[str, str],
680
+ cancellation_event: threading.Event | None,
681
+ ) -> tuple[CommandResult, dict[str, Any]]:
682
+ result = self._run(
683
+ ("docker", "container", "inspect", reference),
684
+ environment=environment,
685
+ cancellation_event=cancellation_event,
686
+ )
687
+ if not _successful_complete(result):
688
+ raise ProductionInspectionError(
689
+ _command_failure("production application inspection", result),
690
+ command=result,
691
+ )
692
+ try:
693
+ payload = json.loads(result.stdout.text)
694
+ raw = _single_inspection(payload)
695
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
696
+ raise ProductionInspectionError(
697
+ "production application inspection returned invalid Docker JSON: "
698
+ + self.secrets.redact_text(str(exc)),
699
+ command=result,
700
+ ) from None
701
+ return result, raw
702
+
703
+ def _cleanup_identity(
704
+ self,
705
+ *,
706
+ compose_project: str,
707
+ container_name: str,
708
+ version: str,
709
+ ) -> ProductionCleanup:
710
+ environment = self._production_environment(version)
711
+ presence = self._container_query(container_name, environment=environment)
712
+ remove: CommandResult | None = None
713
+ if _successful_complete(presence) and container_name in _nonempty_lines(
714
+ presence.stdout.text
715
+ ):
716
+ remove = self._run(
717
+ ("docker", "container", "rm", "--force", container_name),
718
+ environment=environment,
719
+ )
720
+ down = self._run(
721
+ self._compose_command(
722
+ compose_project,
723
+ "down",
724
+ "--remove-orphans",
725
+ "--timeout",
726
+ str(self.application.startup_timeout_seconds),
727
+ ),
728
+ environment=environment,
729
+ )
730
+ proof = self.prove_new_absent(
731
+ compose_project=compose_project,
732
+ container_name=container_name,
733
+ version=version,
734
+ )
735
+ return ProductionCleanup(
736
+ presence_query=presence,
737
+ remove_command=remove,
738
+ compose_down=down,
739
+ proof=proof,
740
+ )
741
+
742
+ def _container_query(
743
+ self,
744
+ container_name: str,
745
+ *,
746
+ environment: Mapping[str, str],
747
+ ) -> CommandResult:
748
+ return self._run(
749
+ (
750
+ "docker",
751
+ "container",
752
+ "ls",
753
+ "--all",
754
+ "--filter",
755
+ f"name=^/{container_name}$",
756
+ "--format",
757
+ "{{.Names}}",
758
+ ),
759
+ environment=environment,
760
+ )
761
+
762
+ def _validate_handle(self, handle: ProductionApplicationHandle) -> None:
763
+ if not isinstance(handle, ProductionApplicationHandle):
764
+ raise TypeError("handle must be a ProductionApplicationHandle")
765
+ if handle.database_directory.resolve() != self.production_database_path.parent:
766
+ raise ValueError("application handle database directory is not production")
767
+ if handle.database_target != self.application.database_volume_target:
768
+ raise ValueError("application handle database target contradicts configuration")
769
+ if (
770
+ handle.host_port != self.topology.host_port
771
+ or handle.container_port != self.application.candidate_container_port
772
+ or handle.health_url != self.topology.health_url
773
+ ):
774
+ raise ValueError("application handle network topology contradicts configuration")
775
+ if handle.compose_service != self.application.service:
776
+ raise ValueError("application handle service contradicts configuration")
777
+ if handle.source == "bootstrap" and handle.compose_project != self.topology.compose_project:
778
+ raise ValueError("bootstrap handle project contradicts configured production project")
779
+ if handle.source == "release":
780
+ self._validate_release_handle(handle)
781
+
782
+ def _validate_release_handle(self, handle: ProductionApplicationHandle) -> None:
783
+ if handle.source != "release":
784
+ raise ValueError("cleanup requires an operation-created release handle")
785
+ assert handle.release_id is not None
786
+ expected_project, expected_name = self._new_identity(handle.release_id)
787
+ if handle.compose_project != expected_project or handle.container_name != expected_name:
788
+ raise ValueError("release handle does not match its derived resource identity")
789
+
790
+ def _new_identity(self, release_id: str) -> tuple[str, str]:
791
+ release_suffix = release_id.removeprefix("release_")[:16]
792
+ safe_project = re.sub(r"[^a-z0-9_-]", "-", self.project.lower())
793
+ safe_project = safe_project.strip("-_") or "app"
794
+ compose_project = f"dploydb-{safe_project[:20]}-release-{release_suffix}"
795
+ return compose_project, f"{compose_project}-app"
796
+
797
+ def _compose_command(self, project: str, *arguments: str) -> tuple[str, ...]:
798
+ return (
799
+ "docker",
800
+ "compose",
801
+ "--file",
802
+ str(self.application.compose_file),
803
+ "--project-name",
804
+ project,
805
+ *arguments,
806
+ )
807
+
808
+ def _base_environment(self) -> dict[str, str]:
809
+ environment = dict(self.command_environment)
810
+ for name, value in environment.items():
811
+ if is_sensitive_key(name):
812
+ self.secrets.register(value)
813
+ return environment
814
+
815
+ def _production_environment(self, version: str) -> dict[str, str]:
816
+ environment = self._base_environment()
817
+ environment[DPLOYDB_VERSION_ENV] = version
818
+ environment.pop(CANDIDATE_URL_ENV, None)
819
+ for name in self.application.test_mode_env:
820
+ environment.pop(name, None)
821
+ return environment
822
+
823
+ def _environment_for_handle(self, handle: ProductionApplicationHandle) -> dict[str, str]:
824
+ return (
825
+ self._base_environment()
826
+ if handle.version is None
827
+ else self._production_environment(handle.version)
828
+ )
829
+
830
+ def _run(
831
+ self,
832
+ command: tuple[str, ...],
833
+ *,
834
+ environment: Mapping[str, str],
835
+ cancellation_event: threading.Event | None = None,
836
+ ) -> CommandResult:
837
+ return self.command_runner.run(
838
+ command,
839
+ timeout_seconds=self.application.startup_timeout_seconds,
840
+ environment=environment,
841
+ working_directory=self.working_directory,
842
+ cancellation_event=cancellation_event,
843
+ )
844
+
845
+
846
+ def _absolute_resolved_path(value: Path, name: str) -> Path:
847
+ if not isinstance(value, Path):
848
+ raise TypeError(f"{name} must be a pathlib.Path")
849
+ if not value.is_absolute():
850
+ raise ValueError(f"{name} must be absolute")
851
+ return value.resolve()
852
+
853
+
854
+ def _single_inspection(value: object) -> dict[str, Any]:
855
+ if not isinstance(value, list) or len(value) != 1 or not isinstance(value[0], dict):
856
+ raise ValueError("expected exactly one inspected container")
857
+ return value[0]
858
+
859
+
860
+ def _required_mapping(value: Mapping[str, Any], key: str) -> dict[str, Any]:
861
+ selected = value.get(key)
862
+ if not isinstance(selected, dict):
863
+ raise ValueError(f"inspection field {key} must be an object")
864
+ return selected
865
+
866
+
867
+ def _required_text(value: Mapping[str, Any], key: str) -> str:
868
+ selected = value.get(key)
869
+ if not isinstance(selected, str) or not selected:
870
+ raise ValueError(f"inspection field {key} must be non-empty text")
871
+ return selected
872
+
873
+
874
+ def _mounts(value: object) -> tuple[CandidateMount, ...]:
875
+ if not isinstance(value, list):
876
+ raise ValueError("inspection Mounts must be an array")
877
+ mounts: list[CandidateMount] = []
878
+ for item in value:
879
+ if not isinstance(item, dict):
880
+ raise ValueError("inspection mount must be an object")
881
+ mount_type = item.get("Type")
882
+ source = item.get("Source")
883
+ destination = item.get("Destination")
884
+ read_write = item.get("RW")
885
+ if (
886
+ not isinstance(mount_type, str)
887
+ or not isinstance(source, str)
888
+ or not isinstance(destination, str)
889
+ or not isinstance(read_write, bool)
890
+ ):
891
+ raise ValueError("inspection mount contains invalid fields")
892
+ mounts.append(
893
+ CandidateMount(
894
+ mount_type=mount_type,
895
+ source=source,
896
+ destination=destination,
897
+ read_write=read_write,
898
+ )
899
+ )
900
+ return tuple(mounts)
901
+
902
+
903
+ def _published_ports(
904
+ value: Mapping[str, Any],
905
+ *,
906
+ running: bool,
907
+ ) -> list[tuple[int, str, int, bool]]:
908
+ parent = _required_mapping(value, "NetworkSettings" if running else "HostConfig")
909
+ ports = parent.get("Ports" if running else "PortBindings")
910
+ if not isinstance(ports, dict):
911
+ location = "NetworkSettings.Ports" if running else "HostConfig.PortBindings"
912
+ raise ValueError(f"inspection {location} must be an object")
913
+ bindings: list[tuple[int, str, int, bool]] = []
914
+ for name, raw_bindings in ports.items():
915
+ if not isinstance(name, str) or not name.endswith("/tcp"):
916
+ continue
917
+ try:
918
+ container_port = int(name.removesuffix("/tcp"))
919
+ except ValueError as exc:
920
+ raise ValueError("inspection contains an invalid container port") from exc
921
+ if raw_bindings is None:
922
+ bindings.append((container_port, "", 0, False))
923
+ continue
924
+ if not isinstance(raw_bindings, list):
925
+ raise ValueError("inspection port bindings must be an array or null")
926
+ for binding in raw_bindings:
927
+ if not isinstance(binding, dict):
928
+ raise ValueError("inspection port binding must be an object")
929
+ host_ip = binding.get("HostIp")
930
+ host_port_text = binding.get("HostPort")
931
+ if not isinstance(host_ip, str) or not isinstance(host_port_text, str):
932
+ raise ValueError("inspection port binding contains invalid fields")
933
+ try:
934
+ host_port = int(host_port_text)
935
+ except ValueError as exc:
936
+ raise ValueError("inspection host port is invalid") from exc
937
+ bindings.append((container_port, host_ip, host_port, True))
938
+ return bindings
939
+
940
+
941
+ def _successful_complete(result: CommandResult) -> bool:
942
+ return (
943
+ result.outcome is CommandOutcome.SUCCEEDED
944
+ and not result.stdout.truncated
945
+ and not result.stderr.truncated
946
+ )
947
+
948
+
949
+ def _nonempty_lines(value: str) -> tuple[str, ...]:
950
+ return tuple(line.strip() for line in value.splitlines() if line.strip())
951
+
952
+
953
+ def _command_failure(action: str, result: CommandResult) -> str:
954
+ if result.stdout.truncated or result.stderr.truncated:
955
+ return f"{action} exceeded the bounded evidence limit"
956
+ if result.outcome is CommandOutcome.NONZERO_EXIT:
957
+ return f"{action} exited with status {result.exit_code}"
958
+ if result.outcome is CommandOutcome.TIMED_OUT:
959
+ return f"{action} timed out and its process group was terminated"
960
+ if result.outcome is CommandOutcome.CANCELLED:
961
+ return f"{action} was cancelled and its process group was terminated"
962
+ if result.outcome is CommandOutcome.CLEANUP_FAILED:
963
+ return f"{action} process cleanup could not be proven"
964
+ if result.outcome is CommandOutcome.START_FAILED:
965
+ return f"{action} could not start: {result.start_error or 'unknown error'}"
966
+ return f"{action} returned invalid success evidence"