cs-image-system-docker-runtime-plugin 0.1.6__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,574 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """The docker runtime's builder (stage 103): every hook the core asks a
6
+ runtime for, answered against a daemon that speaks the Docker Engine API
7
+ through the ``docker`` CLI. Images live in the daemon's own store; a build
8
+ is its image id, its names are tags, and what a label cannot say after the
9
+ build (a parent bound later, a release) is said by a tag that MOVES."""
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import subprocess
15
+ from collections.abc import Mapping
16
+ from typing import Any
17
+
18
+ from cs_image_system.base.basic.abstract_version_checker import AbstractVersionChecker
19
+ from cs_image_system.base.basic.builder_base_container import ContainerBuilderBase
20
+ from cs_image_system.base.models.os_builder_runtime_config import OSBuilderBaseImageBuilderSubconfig
21
+ from cs_image_system.base.models.provider_specific_image import ProviderSpecificImage
22
+ from cs_image_system.base import power_state
23
+ from cs_image_system.contract.constants import VCT
24
+ from cs_image_system.contract.core import PluginArtifactProtocol
25
+ from cs_image_system.contract.image import ImageQuery
26
+ from cs_image_system.contract.runtime import LAUNCH_DELIVERY_SESSION
27
+
28
+ from .docker_names import (full_id, is_digest_reference, is_image_id, label_key_problem, movable_tag,
29
+ parse_movable_tag, same_repository, short_id, tag_name, tag_problem)
30
+ from .docker_provider_specific_image import DockerProviderSpecificImage
31
+ from .docker_runtime_models import DockerContainerBuilderModel
32
+
33
+ DOCKER_RUNTIME: str = "docker"
34
+ #: the keys of the system's image-query vocabulary a daemon can answer: a
35
+ #: repository (`name`), a tag (`version`), a registry namespace (`publisher`),
36
+ #: a platform (`architecture`), and `native.image` verbatim
37
+ DOCKER_QUERY_KEYS: frozenset[str] = frozenset({"name", "version", "publisher", "architecture", "native"})
38
+ _PLATFORMS = {"x86_64": "linux/amd64", "amd64": "linux/amd64", "arm64": "linux/arm64", "aarch64": "linux/arm64"}
39
+
40
+ log = logging.getLogger(__name__)
41
+
42
+
43
+ class DockerUnreachable(RuntimeError):
44
+ """The daemon could not be reached (one line; load failures classify it)."""
45
+
46
+
47
+ def translate_image_query(query: ImageQuery) -> dict[str, Any]:
48
+ """The system's query in the daemon's words: ``{"reference":
49
+ "<repository>:<tag>", "platform": "<os/arch>" | None}``. A key the daemon
50
+ cannot answer raises ValueError naming it (``validate`` refuses first)."""
51
+ from cs_image_system.base.image_sources import container_reference
52
+ unsupported = sorted(k for k in query.keys() if k not in DOCKER_QUERY_KEYS)
53
+ if unsupported:
54
+ raise ValueError(f"a container daemon cannot translate image query key(s) {', '.join(unsupported)}")
55
+ reference = container_reference(query)
56
+ if not reference:
57
+ raise ValueError("a container image query names a repository (`name`) or `native.image`")
58
+ platform = _PLATFORMS.get(str(query.architecture)) if query.architecture else None
59
+ return {"reference": reference, "platform": platform}
60
+
61
+
62
+ class DockerContainerBuilder(ContainerBuilderBase[DockerContainerBuilderModel], PluginArtifactProtocol):
63
+ """A daemon speaking the Docker Engine API, as a runtime."""
64
+
65
+ @classmethod
66
+ def csis_name(cls) -> str:
67
+ return DOCKER_RUNTIME
68
+
69
+ @property
70
+ def model(self) -> DockerContainerBuilderModel:
71
+ return self._model # type: ignore
72
+
73
+ def provider_specific_image_class(self) -> type[ProviderSpecificImage]:
74
+ return DockerProviderSpecificImage
75
+
76
+ # ------------------------------------------------------------ the CLI
77
+ def docker_binary(self) -> str:
78
+ """The docker CLI this runtime runs: the ``binary`` of the
79
+ ``cfg/executables.yml`` entry its ``executable`` names (default
80
+ ``docker``), the one ``validate`` version-checks."""
81
+ name = self.model.get_executable() or "docker"
82
+ exe = (self._get_context().executables or {}).get(name)
83
+ if exe is None:
84
+ raise ValueError(f"docker runtime {self.get_name()}: executable {name!r} is not declared in "
85
+ f"cfg/executables.yml; declare it (name: {name}, binary: <path to docker>)")
86
+ return str(exe.binary or exe.name)
87
+
88
+ def tool_environment(self) -> dict[str, str]:
89
+ """``DOCKER_HOST`` / ``DOCKER_CONTEXT`` (and TLS) for every tool a
90
+ bake on this runtime runs: packer's docker plugin and ansible's
91
+ docker connection both read them."""
92
+ return self.model.daemon_environment()
93
+
94
+ def _docker(self, *args: str, timeout: int = 600, check: bool = True) -> subprocess.CompletedProcess[str]:
95
+ cmd = [self.docker_binary(), *args]
96
+ try:
97
+ res = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=timeout,
98
+ env=self.model.process_environment())
99
+ except FileNotFoundError as e:
100
+ raise DockerUnreachable(f"docker runtime {self.get_name()}: {cmd[0]} is not an executable ({e})") from e
101
+ err = (res.stderr or "").strip()
102
+ if res.returncode != 0 and ("Cannot connect to the Docker daemon" in err or "error during connect" in err
103
+ or "connection refused" in err.lower()):
104
+ raise DockerUnreachable(f"docker runtime {self.get_name()}: the daemon at {self.model.where()} cannot be "
105
+ f"reached ({err.splitlines()[0][:200]})")
106
+ if check and res.returncode != 0:
107
+ raise RuntimeError(f"docker runtime {self.get_name()}: `docker {' '.join(args[:3])}` failed: {err[-400:]}")
108
+ return res
109
+
110
+ def _inspect(self, reference: str) -> dict[str, Any] | None:
111
+ res = self._docker("image", "inspect", reference, check=False)
112
+ if res.returncode != 0:
113
+ return None
114
+ data = json.loads(res.stdout or "[]")
115
+ return dict(data[0]) if data else None
116
+
117
+ @staticmethod
118
+ def _record(info: Mapping[str, Any]) -> dict[str, Any]:
119
+ """The parts of an inspect answer the system keeps (serialisable)."""
120
+ cfg = dict(info.get("Config") or {})
121
+ return {"Id": info.get("Id"), "RepoTags": list(info.get("RepoTags") or []),
122
+ "RepoDigests": list(info.get("RepoDigests") or []), "Created": info.get("Created"),
123
+ "Architecture": info.get("Architecture"), "Os": info.get("Os"),
124
+ "Labels": dict(cfg.get("Labels") or {}), "Size": info.get("Size")}
125
+
126
+ # ------------------------------------------------- the image query (stage 106)
127
+ def supported_query_keys(self) -> frozenset[str]:
128
+ return DOCKER_QUERY_KEYS
129
+
130
+ def translate_image_query(self, query: ImageQuery) -> dict[str, Any]:
131
+ return translate_image_query(query)
132
+
133
+ def _pull(self, reference: str, platform: str | None) -> dict[str, Any]:
134
+ args = ["pull", "--quiet"]
135
+ if platform:
136
+ args += ["--platform", platform]
137
+ self._docker(*args, reference, timeout=1800)
138
+ info = self._inspect(reference)
139
+ if info is None:
140
+ raise RuntimeError(f"docker runtime {self.get_name()}: {reference} pulled but not inspectable")
141
+ return info
142
+
143
+ def _resolved(self, reference: str, info: Mapping[str, Any], publisher: str) -> tuple[str, str, Mapping[str, Any]]:
144
+ """``(identifier, owner, record)``: the identifier is the digest
145
+ reference ``<repository>@sha256:...`` (recorded, so a vendor moving
146
+ a tag shows as a move), the owner the publisher."""
147
+ digests = list(info.get("RepoDigests") or [])
148
+ repo = reference.split("@", 1)[0].rsplit(":", 1)[0] if ("@" in reference or ":" in reference.rsplit("/", 1)[-1]) else reference
149
+ chosen = next((d for d in digests if d.split("@", 1)[0] == repo), digests[0] if digests else None)
150
+ identifier = chosen or str(info.get("Id"))
151
+ return identifier, publisher, self._record(info)
152
+
153
+ def query_provider_image(self, os_builder: OSBuilderBaseImageBuilderSubconfig) -> tuple[str, str, Mapping[str, Any]] | None:
154
+ q = os_builder.image_query() if os_builder else ImageQuery()
155
+ native = self.translate_image_query(q)
156
+ reference = str(native["reference"])
157
+ info = self._pull(reference, native.get("platform"))
158
+ publisher = q.publisher[0] if q.publisher else "library"
159
+ return self._resolved(reference, info, str(publisher))
160
+
161
+ def query_provider_image_by_id(self, os_builder: OSBuilderBaseImageBuilderSubconfig,
162
+ image_id: str) -> tuple[str, str, Mapping[str, Any]] | None:
163
+ """The image an entry pins by ``image_id``: a digest reference
164
+ (``<repository>@sha256:...``), pulled and inspected."""
165
+ if not is_digest_reference(image_id):
166
+ raise ValueError(f"docker runtime {self.get_name()}: image_id {image_id!r} is not a digest reference "
167
+ "(`<repository>@sha256:<digest>`); a tag moves, a digest does not")
168
+ q = os_builder.image_query() if os_builder else ImageQuery()
169
+ platform = _PLATFORMS.get(str(q.architecture)) if q.architecture else None
170
+ info = self._pull(image_id, platform)
171
+ publisher = q.publisher[0] if q.publisher else "library"
172
+ return image_id, str(publisher), self._record(info)
173
+
174
+ # ------------------------------------------------------- the inventory
175
+ def _our_images(self) -> list[dict[str, Any]]:
176
+ """Every image in the store that carries a lineage series label."""
177
+ from cs_image_system.base.lineage import TAG_PREFIX
178
+ res = self._docker("image", "ls", "--no-trunc", "--filter", f"label={TAG_PREFIX}series", "--format", "{{.ID}}")
179
+ ids = sorted({ln.strip() for ln in (res.stdout or "").splitlines() if ln.strip()})
180
+ out: list[dict[str, Any]] = []
181
+ for iid in ids:
182
+ info = self._inspect(iid)
183
+ if info is not None:
184
+ out.append(self._record(info))
185
+ return out
186
+
187
+ def _labels_with_movable(self, record: Mapping[str, Any]) -> dict[str, str]:
188
+ """The image's labels with the movable tags' values on top."""
189
+ labels = {str(k): str(v) for k, v in dict(record.get("Labels") or {}).items()}
190
+ sid = short_id(str(record.get("Id") or ""))
191
+ repo = self.model.get_repository()
192
+ for ref in record.get("RepoTags") or []:
193
+ r, _, tag = str(ref).rpartition(":")
194
+ if not same_repository(r, repo):
195
+ continue
196
+ parsed = parse_movable_tag(tag)
197
+ if parsed and parsed[0] == sid:
198
+ labels[parsed[1]] = parsed[2]
199
+ return labels
200
+
201
+ def _display_name(self, record: Mapping[str, Any]) -> str:
202
+ repo = self.model.get_repository()
203
+ for ref in record.get("RepoTags") or []:
204
+ r, _, tag = str(ref).rpartition(":")
205
+ if same_repository(r, repo) and parse_movable_tag(tag) is None:
206
+ return tag
207
+ return short_id(str(record.get("Id") or ""))
208
+
209
+ def query_images(self, series: list[str]) -> list[dict[str, Any]]:
210
+ """Every image in the store labelled with ``csis_series`` (read-only),
211
+ narrowed to the series asked about; its lineage facts are its labels
212
+ as built, with what the movable tags say on top."""
213
+ from cs_image_system.base.lineage import TAG_PREFIX
214
+ wanted = set(series or [])
215
+ out: list[dict[str, Any]] = []
216
+ for rec in self._our_images():
217
+ labels = self._labels_with_movable(rec)
218
+ if wanted and labels.get(f"{TAG_PREFIX}series") not in wanted:
219
+ continue
220
+ out.append({"image_id": full_id(str(rec.get("Id"))), "name": self._display_name(rec), "state": "available",
221
+ "created": str(rec.get("Created") or ""), "tags": labels})
222
+ return out
223
+
224
+ def inventory(self) -> dict[str, Any]:
225
+ """Every container and every labelled image in the store (read-only),
226
+ and under ``owners`` the ``csis_config`` label each carries."""
227
+ from cs_image_system.base.lineage import CONFIG_TAG
228
+ owners: dict[str, dict[str, str]] = {"instances": {}, "images": {}, "disks": {}, "buckets": {}}
229
+ images: list[str] = []
230
+ for rec in self._our_images():
231
+ name = self._display_name(rec)
232
+ images.append(name)
233
+ owner = str(dict(rec.get("Labels") or {}).get(CONFIG_TAG) or "")
234
+ if owner:
235
+ owners["images"][name] = owner
236
+ res = self._docker("ps", "-a", "--format", "{{.Names}}\t{{.Labels}}")
237
+ instances: list[str] = []
238
+ for ln in (res.stdout or "").splitlines():
239
+ name, _, labels = ln.partition("\t")
240
+ if not name.strip():
241
+ continue
242
+ instances.append(name.strip())
243
+ for kv in labels.split(","):
244
+ k, _, v = kv.partition("=")
245
+ if k.strip() == CONFIG_TAG and v.strip():
246
+ owners["instances"][name.strip()] = v.strip()
247
+ res = self._docker("volume", "ls", "--format", "{{.Name}}\t{{.Labels}}")
248
+ disks: list[str] = []
249
+ for ln in (res.stdout or "").splitlines():
250
+ name, _, labels = ln.partition("\t")
251
+ if not name.strip():
252
+ continue
253
+ disks.append(name.strip())
254
+ for kv in labels.split(","):
255
+ k, _, v = kv.partition("=")
256
+ if k.strip() == CONFIG_TAG and v.strip():
257
+ owners["disks"][name.strip()] = v.strip()
258
+ return {"instances": sorted(instances), "images": sorted(images), "disks": sorted(disks), "buckets": [],
259
+ "owners": owners}
260
+
261
+ # ------------------------------------------------- disposal and tags
262
+ def dispose_image(self, build_id: str) -> bool:
263
+ """Remove one of OUR OWN builds from the store, every tag with it;
264
+ refused while a container was created from it (the container is the
265
+ operator's, and a running one would lose its image)."""
266
+ res = self._docker("ps", "-a", "--filter", f"ancestor={build_id}", "--format", "{{.Names}}", check=False)
267
+ users = [ln.strip() for ln in (res.stdout or "").splitlines() if ln.strip()]
268
+ if users:
269
+ raise RuntimeError(f"docker runtime {self.get_name()}: image {short_id(build_id)} is in use by "
270
+ f"container(s) {', '.join(sorted(users))}; decommission them first")
271
+ res = self._docker("image", "rm", "--force", build_id, check=False)
272
+ if res.returncode != 0:
273
+ if "No such image" in (res.stderr or "") or "no such image" in (res.stderr or "").lower():
274
+ log.warning(f"docker image {short_id(build_id)} not found on {self.get_name()}; nothing to remove")
275
+ return False
276
+ raise RuntimeError(f"docker runtime {self.get_name()}: image rm {short_id(build_id)} failed: "
277
+ f"{(res.stderr or '').strip()[-300:]}")
278
+ log.info(f"docker image {short_id(build_id)} removed from {self.get_name()}")
279
+ return True
280
+
281
+ def _move_tags(self, image_id: str, new_tags: list[str], stale_prefixes: list[str]) -> None:
282
+ """Put ``new_tags`` on the image (a tag moves off whatever held it),
283
+ then untag the image's own older tags that begin a stale prefix. New
284
+ first: untagging an image's last reference would remove it."""
285
+ repo = self.model.get_repository()
286
+ for tag in new_tags:
287
+ self._docker("tag", image_id, f"{repo}:{tag}")
288
+ info = self._inspect(image_id) or {}
289
+ for ref in info.get("RepoTags") or []:
290
+ r, _, tag = str(ref).rpartition(":")
291
+ if same_repository(r, repo) and tag not in new_tags and any(tag.startswith(p) for p in stale_prefixes):
292
+ self._docker("rmi", f"{repo}:{tag}", check=False)
293
+
294
+ def retag_image(self, image_id: str, tags: dict[str, str]) -> bool:
295
+ """A label is fixed when the image is built, so the lineage facts the
296
+ core stamps after a bake (the parent bound, the fingerprint) are
297
+ carried by MOVABLE TAGS: ``<repository>:<short id>--<key>--<value>``,
298
+ one per key, the older one of the key untagged."""
299
+ if not tags:
300
+ return True
301
+ sid = short_id(image_id)
302
+ new = [movable_tag(image_id, k, str(v)) for k, v in sorted(tags.items())]
303
+ stale = [f"{sid}--{tag_name(k)}--" for k in tags]
304
+ self._move_tags(image_id, new, stale)
305
+ log.info(f"docker image {sid} on {self.get_name()}: {sorted(tags)} moved onto it as tags")
306
+ return True
307
+
308
+ def release_commands(self, build_id: str, tags: dict[str, str]):
309
+ """Mark a released build: the tag ``<repository>:<series>--<model>``
310
+ moves onto it (reviewable, gated by config.apply_release)."""
311
+ from cs_image_system.base.models.executable import ExecutableModel
312
+ series = tag_name(str(tags.get("csis_series") or "build"))
313
+ model = tag_name(str(tags.get("csis_release") or "release"))
314
+ e = ExecutableModel(name="docker", type_="executable", binary=self.docker_binary())
315
+ e.args = ["tag", build_id, f"{self.model.get_repository()}:{series}--{model}"]
316
+ e.environment = dict(self.model.daemon_environment())
317
+ return [e]
318
+
319
+ def label_key_problem(self, key: str) -> str | None:
320
+ return label_key_problem(key)
321
+
322
+ def image_name_as_made(self, name: str) -> str:
323
+ return tag_name(name)
324
+
325
+ def image_name_problem(self, name: str) -> str | None:
326
+ return tag_problem(name)
327
+
328
+ # ------------------------------------------------ instances (branch 2)
329
+ #: ``docker inspect .State.Status`` in the system's own words. A container
330
+ #: that exited or was only created EXISTS and can be started: STOPPED, the
331
+ #: operator's choice. Paused keeps its memory: SUSPENDED. Nothing here is
332
+ #: a cloud's spelling, and absent is "no such container".
333
+ _DOCKER_POWER = {
334
+ "running": power_state.RUNNING,
335
+ "created": power_state.STOPPED,
336
+ "exited": power_state.STOPPED,
337
+ "paused": power_state.SUSPENDED,
338
+ "restarting": power_state.STARTING,
339
+ "removing": power_state.STOPPING,
340
+ "dead": power_state.UNKNOWN,
341
+ }
342
+
343
+ def _container(self, instance_name: str) -> dict[str, Any] | None:
344
+ """``docker inspect`` of the container bearing the instance's name
345
+ (a container's name is unique on a daemon), None when none stands;
346
+ a container of our name that another configuration labelled as its
347
+ own is not ours (stage 93) and reads as absent."""
348
+ res = self._docker("container", "inspect", instance_name, check=False)
349
+ if res.returncode != 0:
350
+ err = (res.stderr or "").lower()
351
+ if "no such" in err or "not found" in err:
352
+ return None
353
+ raise RuntimeError(f"docker runtime {self.get_name()}: `docker container inspect {instance_name}` "
354
+ f"failed: {(res.stderr or '').strip()[-300:]}")
355
+ data = json.loads(res.stdout or "[]")
356
+ if not data:
357
+ return None
358
+ info = dict(data[0])
359
+ from cs_image_system.base.lineage import belongs_to_another_configuration
360
+ labels = dict((info.get("Config") or {}).get("Labels") or {})
361
+ if belongs_to_another_configuration(self._get_context(), labels):
362
+ return None
363
+ return info
364
+
365
+ def launch_delivery(self) -> str:
366
+ """A container has no first boot: the instance root uploads the
367
+ launch script at creation and the system runs it by exec after the
368
+ apply (decision 2 of stage 103)."""
369
+ return LAUNCH_DELIVERY_SESSION
370
+
371
+ def instance_tags(self, instance_name: str) -> dict[str, str] | None:
372
+ info = self._container(instance_name)
373
+ if info is None:
374
+ return None
375
+ return {str(k): str(v) for k, v in dict((info.get("Config") or {}).get("Labels") or {}).items()}
376
+
377
+ def retag_instance(self, instance_name: str, tags: dict[str, str]) -> bool:
378
+ """A container's labels are fixed when it is created: relabel reports
379
+ what a re-create would set and re-creates nothing (an upgrade or a
380
+ decommission and redeclaration makes a container that carries them)."""
381
+ raise NotImplementedError(
382
+ f"docker runtime {self.get_name()}: a container's labels are fixed when it is created; "
383
+ f"{instance_name} would carry {', '.join(f'{k}={v}' for k, v in sorted(tags.items()))} "
384
+ "after a re-create (an upgrade, or a decommission and redeclaration), which relabel does not do")
385
+
386
+ def can_query_instance_boot_image(self) -> bool:
387
+ return True
388
+
389
+ def query_instance_boot_image(self, instance_name: str) -> str | None:
390
+ """The image id the container was created from (``.Image``): the
391
+ build id itself on this runtime. None when it cannot be read."""
392
+ try:
393
+ info = self._container(instance_name)
394
+ except Exception as e: # noqa: BLE001 - read-only probe
395
+ log.debug(f"docker runtime {self.get_name()}: boot image of {instance_name!r} unavailable: {e}")
396
+ return None
397
+ if info is None:
398
+ return None
399
+ image = str(info.get("Image") or "")
400
+ return full_id(image) if is_image_id(image) else (image or None)
401
+
402
+ def can_query_instance_identity(self) -> bool:
403
+ return True
404
+
405
+ def query_instance_identity(self, instance_name: str) -> dict[str, str] | None:
406
+ """The container id (a re-create is a new id, so a new generation, as
407
+ the ledger expects) and the hostname the container was given."""
408
+ try:
409
+ info = self._container(instance_name)
410
+ except Exception as e: # noqa: BLE001 - read-only probe
411
+ log.debug(f"docker runtime {self.get_name()}: identity of {instance_name!r} unavailable: {e}")
412
+ return None
413
+ if info is None:
414
+ return None
415
+ host = str((info.get("Config") or {}).get("Hostname") or "")
416
+ return {"instance_id": str(info.get("Id") or ""), "provider_hostname": host}
417
+
418
+ def can_query_instance_power_state(self) -> bool:
419
+ return True
420
+
421
+ def query_instance_power_state(self, instance_name: str) -> str | None:
422
+ try:
423
+ info = self._container(instance_name)
424
+ except Exception as e: # noqa: BLE001 - read-only probe: unavailable, never fatal
425
+ log.debug(f"docker runtime {self.get_name()}: power state of {instance_name!r} unavailable: {e}")
426
+ return None
427
+ if info is None:
428
+ return power_state.ABSENT
429
+ raw = str((info.get("State") or {}).get("Status") or "")
430
+ mapped = self._DOCKER_POWER.get(raw)
431
+ if mapped is None:
432
+ log.warning(f"docker runtime {self.get_name()}: container {instance_name!r} reports unrecognised "
433
+ f"status {raw!r}; treating it as unknown")
434
+ return power_state.UNKNOWN
435
+ return mapped
436
+
437
+ def can_set_instance_power_state(self) -> bool:
438
+ return True
439
+
440
+ def start_instance(self, instance_name: str, timeout: int = 300) -> bool:
441
+ self._docker("start", instance_name, timeout=timeout)
442
+ return self.query_instance_power_state(instance_name) == power_state.RUNNING
443
+
444
+ def stop_instance(self, instance_name: str, timeout: int = 300) -> bool:
445
+ self._docker("stop", instance_name, timeout=timeout)
446
+ return power_state.is_off(self.query_instance_power_state(instance_name))
447
+
448
+ def run_session_command(self, instance_name: str, script: str, timeout: int = 300) -> tuple[int, str]:
449
+ """``docker exec`` as root, the script on stdin (``bash -s``): the
450
+ session mechanism of a container (decision 3 of stage 103)."""
451
+ cmd = [self.docker_binary(), "exec", "-i", "-u", "root", instance_name, "bash", "-s"]
452
+ try:
453
+ res = subprocess.run(cmd, input=script, capture_output=True, text=True, check=False, timeout=timeout,
454
+ env=self.model.process_environment())
455
+ except FileNotFoundError as e:
456
+ raise DockerUnreachable(f"docker runtime {self.get_name()}: {cmd[0]} is not an executable ({e})") from e
457
+ return res.returncode, (res.stdout or "") + (res.stderr or "")
458
+
459
+ def ssh_proxy_command(self, instance_name: str) -> list[str] | None:
460
+ return None # login is by exec, not ssh
461
+
462
+ def verify_instance(self, instance_name: str, expected_build: str | None = None,
463
+ expect_mounts: int = 0, timeout: int = 600,
464
+ mount_points: list[str] | None = None) -> dict[str, Any]:
465
+ """By exec: the launch marker (the script's last act, so its presence
466
+ means every launch parameter applied), each declared mount point
467
+ in the container's own mount table, and the image the container was
468
+ created from against the expected build."""
469
+ from cs_image_system.base.launch_delivery import LAUNCH_MARKER_PATH
470
+ checks: list[dict[str, Any]] = []
471
+ evidence: list[str] = []
472
+ probe = (f"if [ -f {LAUNCH_MARKER_PATH} ]; then echo \"csis-marker: $(cat {LAUNCH_MARKER_PATH})\"; "
473
+ f"else echo 'csis-marker: absent'; fi\n")
474
+ for mp in mount_points or []:
475
+ probe += (f"if grep -q \" {mp} \" /proc/mounts; then echo 'csis-mount: {mp} mounted'; "
476
+ f"else echo 'csis-mount: {mp} NOT mounted'; fi\n")
477
+ rc, out = self.run_session_command(instance_name, probe, timeout=min(timeout, 300))
478
+ evidence = [ln for ln in out.splitlines() if ln.startswith("csis-")][-20:]
479
+ marker = next((ln for ln in evidence if ln.startswith("csis-marker:")), "")
480
+ applied = rc == 0 and bool(marker) and "absent" not in marker
481
+ checks.append({"name": "launch script", "ok": applied,
482
+ "detail": (f"applied ({marker.split(':', 1)[1].strip()})" if applied
483
+ else f"no completion marker at {LAUNCH_MARKER_PATH}" if rc == 0
484
+ else f"the session failed (exit {rc})")})
485
+ booted = self.query_instance_boot_image(instance_name)
486
+ if expected_build:
487
+ checks.append({"name": "booted image", "ok": booted == expected_build,
488
+ "detail": f"booted {booted}, expected {expected_build}"})
489
+ else:
490
+ checks.append({"name": "booted image", "ok": booted is not None, "detail": f"booted {booted}"})
491
+ if mount_points:
492
+ seen = [mp for mp in mount_points if f"csis-mount: {mp} mounted" in out]
493
+ missing = [mp for mp in mount_points if mp not in seen]
494
+ checks.append({"name": "data disks mounted", "ok": not missing,
495
+ "detail": f"mounted: {', '.join(seen) or 'none'}; missing: {', '.join(missing) or 'none'} "
496
+ "(each declared mount point in the container's mount table)"})
497
+ elif expect_mounts:
498
+ checks.append({"name": "data disks mounted", "ok": False,
499
+ "detail": f"{expect_mounts} declared and none named; a container's mounts are checked by name"})
500
+ return {"ok": all(c["ok"] for c in checks), "checks": checks, "evidence": evidence}
501
+
502
+ # --------------------------------------------------------- the bake
503
+ def session_mechanism(self) -> str | None:
504
+ return "exec"
505
+
506
+ def default_bake_user(self, family: str) -> str | None:
507
+ return "root"
508
+
509
+ def bake_ssh_username(self, image=None) -> str | None:
510
+ if image is None:
511
+ return self.default_bake_user("")
512
+ from cs_image_system.base.bake_user import resolve_bake_user
513
+ return resolve_bake_user(self._get_context(), image, self.get_name())
514
+
515
+ def bake_settle_commands(self, os_family: str | None = None) -> list[str]:
516
+ """A container has no first boot and no fleet management to wait
517
+ for, but a vendor image may lack what every later step uses: sudo
518
+ (the steps are written for a cloud image's unprivileged user) and,
519
+ on Debian, python3 for ansible."""
520
+ return [
521
+ "# a container settles at once; it may lack sudo and python3, which the steps that follow use",
522
+ "if ! command -v sudo >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then "
523
+ "if command -v apt-get >/dev/null 2>&1; then apt-get update -qq && "
524
+ "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo python3 procps >/dev/null; "
525
+ "elif command -v dnf >/dev/null 2>&1; then dnf -y -q install sudo python3 procps-ng >/dev/null; "
526
+ "elif command -v microdnf >/dev/null 2>&1; then microdnf -y install sudo python3 procps-ng >/dev/null; fi; fi",
527
+ ]
528
+
529
+ def bake_ansible_vars(self, image: Any) -> dict[str, str]:
530
+ """Ansible reaches the build container through the daemon, by name
531
+ (packer's ansible provisioner documents this for its docker
532
+ builder); the connection reads the same ``DOCKER_HOST``."""
533
+ from .docker_packer_source import bake_container_name
534
+ return {"ansible_connection": "docker",
535
+ "ansible_host": bake_container_name(str(image.get_name()), self.get_name())}
536
+
537
+ def packer_source_type(self) -> str:
538
+ from .docker_packer_source import DOCKER_SOURCE
539
+ return DOCKER_SOURCE
540
+
541
+ def packer_source_blocks(self, image: Any, *, runtime: str, source_type: str, subconfig: Any,
542
+ self_subconfig: Any, psi: Any, tags: dict[str, str],
543
+ final_name: str, pinned: str | None) -> list[str]:
544
+ from .docker_packer_source import docker_source
545
+ return docker_source(self.model, image, runtime=runtime, source_type=source_type, subconfig=subconfig,
546
+ self_subconfig=self_subconfig, psi=psi, tags=tags, final_name=final_name, pinned=pinned)
547
+
548
+ def packer_post_processor_blocks(self, image: Any, *, runtime: str, source_type: str, subconfig: Any,
549
+ final_name: str) -> list[str]:
550
+ from .docker_packer_source import docker_tag_post_processor
551
+ prefix = subconfig.get_image_output_name() if subconfig is not None else None
552
+ return docker_tag_post_processor(image, repository=self.model.get_repository(), source_type=source_type,
553
+ final_name=final_name, prefix=prefix)
554
+
555
+ def build_id_from_artifact(self, artifact_id: str) -> str:
556
+ """The docker builder's manifest artifact_id is the committed image
557
+ id (``sha256:...``), kept whole: a tag is a name, the id is the build."""
558
+ a = (artifact_id or "").strip()
559
+ return full_id(a) if is_image_id(a) else a
560
+
561
+
562
+ class DockerCLIVersionChecker(AbstractVersionChecker):
563
+ """``docker --version``: ``Docker version 29.8.2, build 7fc2dff``."""
564
+
565
+ @classmethod
566
+ def csis_name(cls) -> str:
567
+ return DOCKER_RUNTIME
568
+
569
+ @classmethod
570
+ def csis_classifier(cls) -> VCT:
571
+ return VCT.VERSION_CHECKER
572
+
573
+ def get_regex(self) -> str:
574
+ return r"[Dd]ocker version ([\d\.]+)"