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
|
@@ -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"
|