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.
- cs_image_system/docker_runtime/__init__.py +3 -0
- cs_image_system/docker_runtime/bootstrap.py +129 -0
- cs_image_system/docker_runtime/docker_names.py +136 -0
- cs_image_system/docker_runtime/docker_packer_source.py +140 -0
- cs_image_system/docker_runtime/docker_provider_specific_image.py +40 -0
- cs_image_system/docker_runtime/docker_runtime_builders.py +574 -0
- cs_image_system/docker_runtime/docker_runtime_models.py +110 -0
- cs_image_system/docker_runtime/main.py +27 -0
- cs_image_system_docker_runtime_plugin-0.1.6.dist-info/METADATA +268 -0
- cs_image_system_docker_runtime_plugin-0.1.6.dist-info/RECORD +13 -0
- cs_image_system_docker_runtime_plugin-0.1.6.dist-info/WHEEL +4 -0
- cs_image_system_docker_runtime_plugin-0.1.6.dist-info/entry_points.txt +5 -0
- cs_image_system_docker_runtime_plugin-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -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\.]+)"
|