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,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,129 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """The container-host section of the bootstrap (stage 103 item 15).
6
+
7
+ A read-and-say section with no terraform module, because by decision
8
+ nothing in it can be created: a docker daemon is a host somebody runs, and
9
+ whoever reaches it is root on that host. What the section does instead is
10
+ read the tree's docker runtime from the raw ``cfg/`` (never loading it, and
11
+ never printing an address: a ``host`` may be an ``ENC[age:...]`` marker) and
12
+ ask the one question the rest of the bootstrap needs answered, *can CI reach
13
+ the daemon?*
14
+
15
+ * **Yes** (an ``ssh://`` host, or the environment's daemon with an address
16
+ in the workflow, the transports the starters' workflow reaches): the two
17
+ repository secrets that workflow reads to reach it, the ssh private key and
18
+ the host's ``known_hosts`` line (``DOCKER_HOST_SSH_KEY`` and
19
+ ``DOCKER_HOST_KNOWN_HOSTS``, in the GitHub section's script with the rest),
20
+ are set from files, and the section says that neither is made here. The key
21
+ belongs to a user on the host who may use the daemon; it is made on your
22
+ machine, never by the system.
23
+ * **No** (a local socket, a context, a ``tcp://`` host, or a host the YAML
24
+ keeps as a marker): CI never performs on the runtime, and the section says
25
+ to name it in ``GUARD_RUNTIME`` so a change to its declaration fails CI
26
+ loudly before anything runs (the GitHub section's default for that
27
+ variable already names a runtime CI cannot reach).
28
+
29
+ The section also records what it does NOT make, in the audit the README
30
+ tabulates: the daemon, the network it names, the docker CLI on the runner.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from typing import Any
36
+
37
+ from cs_image_system.base.bootstrap.facts import Facts, ci_can_reach, real, runtime_of_type
38
+ from cs_image_system.base.bootstrap.questions import BY_HAND, READ, Answers, Question, Rendered, Section
39
+
40
+ RUNTIME_TYPE = "docker"
41
+ GUIDE = "CI_SETUP.md 3.4a"
42
+
43
+ def _runtime(facts: Facts) -> dict[str, Any]:
44
+ return runtime_of_type(facts, RUNTIME_TYPE) or {}
45
+
46
+
47
+ def _runtime_default(facts: Facts, answers: Answers) -> str | None:
48
+ name = _runtime(facts).get("name")
49
+ return str(name) if name else None
50
+
51
+
52
+ def _transport(runtime: dict[str, Any]) -> str:
53
+ """How the runtime names its daemon, from the raw YAML: ``ssh``, ``tcp``,
54
+ ``unix``, ``context``, ``marker`` (an encrypted host, unreadable here) or
55
+ ``environment`` (neither: the daemon of the machine that runs the tool)."""
56
+ host = str(runtime.get("host") or "").strip()
57
+ if host.startswith("ENC["):
58
+ return "marker"
59
+ if host:
60
+ scheme = host.split("://", 1)[0].lower() if "://" in host else "unix"
61
+ return scheme if scheme in ("ssh", "tcp", "unix") else "unix"
62
+ return "context" if real(runtime.get("context")) else "environment"
63
+
64
+
65
+ def _reach_default(facts: Facts, answers: Answers) -> bool:
66
+ return ci_can_reach(_runtime(facts))
67
+
68
+
69
+ QUESTIONS: tuple[Question, ...] = (
70
+ Question("runtime", "The docker runtime this section is about (a `name:` from cfg/runtime-builders.yml)", "text",
71
+ default=_runtime_default,
72
+ help="no runtime of type docker could be read from cfg/"),
73
+ Question("ci_reaches", "Can CI reach the docker daemon (an ssh:// host the workflow can log in to)?", "bool",
74
+ default=_reach_default,
75
+ help="the starters' workflow reaches an ssh:// host only; say no for a socket, a context or a tcp:// host"),
76
+ )
77
+
78
+
79
+ def render(answers: Answers, facts: Facts | None = None) -> Rendered:
80
+ name = str(answers.get("runtime") or "the docker runtime")
81
+ reaches = bool(answers.get("ci_reaches"))
82
+ transport = _transport(_runtime(facts)) if facts is not None else "environment"
83
+ by_hand: list[str] = [
84
+ f"The daemon of `{name}` is the team's: install, patch and secure it on its host; whoever can reach it is "
85
+ "root on that host, and the system says so rather than hiding it (CONFIGURATION.md section 4).",
86
+ ]
87
+ if reaches:
88
+ by_hand.append(
89
+ "CI's way in: make an ed25519 key for a user on the docker host who may use the daemon "
90
+ f"(`ssh-keygen -t ed25519 -f DOCKER_HOST_SSH_KEY -N ''`), authorize its public half there, put the private "
91
+ "half in the secrets directory as `DOCKER_HOST_SSH_KEY`, and the host's key line (`ssh-keyscan -t ed25519 "
92
+ f"HOST`, checked against the host by another route) as `DOCKER_HOST_KNOWN_HOSTS` ({GUIDE}).")
93
+ else:
94
+ by_hand.append(
95
+ f"CI cannot reach `{name}`: name it in the `GUARD_RUNTIME` Actions variable (the GitHub section's default "
96
+ "does) so a change to its declaration fails CI before anything runs, and keep PERFORM_RUNTIME on a "
97
+ "runtime CI can reach, or leave the `perform` job unconfigured (it skips, and says so).")
98
+ return Rendered(
99
+ by_hand=tuple(by_hand),
100
+ notes=(
101
+ f"the docker runtime `{name}`: its daemon is named by "
102
+ + {"ssh": "an ssh:// host", "tcp": "a tcp:// host", "unix": "a local socket", "context": "a docker context",
103
+ "marker": "an encrypted host", "environment": "the environment (DOCKER_HOST, else the local socket)"}.get(
104
+ transport, "the environment"),
105
+ "CI " + ("reaches it over ssh with DOCKER_HOST_SSH_KEY and DOCKER_HOST_KNOWN_HOSTS"
106
+ if reaches else "does not reach it: it is a runtime CI must never bake on"),
107
+ "nothing here is terraform: the section reads the declaration and says what remains",
108
+ ),
109
+ coverage=(
110
+ (f"the daemon of `{name}`", BY_HAND, "a host somebody runs; the system never installs or configures a daemon"),
111
+ ("the network the runtime names", BY_HAND, "referenced by name and never created or changed: the team's, by decision"),
112
+ ("the docker CLI on the runner", READ, "the runner image carries it (CI_RUNNER.md); the workflow only checks it is there"),
113
+ ("CI's key to the daemon's host" if reaches else "CI's performing run on the runtime",
114
+ BY_HAND,
115
+ "made on your machine and set by the script from a file; the system never makes a key" if reaches
116
+ else "not built: CI has no way into the daemon, so the runtime is guarded instead"),
117
+ ),
118
+ )
119
+
120
+
121
+ def wanted(facts: Facts) -> bool:
122
+ """The tree declares a docker runtime."""
123
+ return runtime_of_type(facts, RUNTIME_TYPE) is not None
124
+
125
+
126
+ def docker_section() -> Section:
127
+ return Section(name="docker", title="Container host", questions=QUESTIONS, render=render, wanted=wanted,
128
+ wants_facts=True,
129
+ description="the docker daemon CI can or cannot reach, the secrets that reach it, the runtime to guard")
@@ -0,0 +1,136 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """The spellings a container daemon accepts (stage 103), mapped ONCE here.
6
+
7
+ * a **repository** name is lowercase: ``[a-z0-9]`` with single ``.``, ``_``,
8
+ ``__`` or ``-`` separators, path components joined by ``/``;
9
+ * a **tag** is ``[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}``: at most 128
10
+ characters, and a build's final name (``<prefix>-<timestamp>``) is written
11
+ as one, so a name that would be cut is refused rather than cut (the cut
12
+ would fall where the timestamp is);
13
+ * a **label** key is a string without whitespace or ``=``; the OCI convention
14
+ is lowercase reverse-DNS, but the system's ``csis_*`` keys and a tree's
15
+ own are accepted as written.
16
+
17
+ A build is identified by its IMAGE ID (``sha256:<64 hex>``), which tags
18
+ move around; the lineage build id on this runtime is that id.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ TAG_MAX = 128
25
+ _TAG_BAD = re.compile(r"[^A-Za-z0-9_.-]+")
26
+ _REPO_BAD = re.compile(r"[^a-z0-9._/-]+")
27
+ _IMAGE_ID = re.compile(r"^(sha256:)?[0-9a-f]{64}$")
28
+ _DIGEST_REF = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
29
+
30
+
31
+ def tag_name(value: str) -> str:
32
+ """A valid docker tag derived deterministically from ``value``: what it
33
+ cannot keep becomes ``-``, a separator may not lead. Not shortened."""
34
+ v = _TAG_BAD.sub("-", str(value)).strip(".-")
35
+ if not v:
36
+ return "i"
37
+ if not (v[0].isalnum() or v[0] == "_"):
38
+ v = f"i{v}"
39
+ return v
40
+
41
+
42
+ def tag_problem(name: str) -> str | None:
43
+ """Why ``name``, once made a tag, cannot be one: only its length."""
44
+ made = tag_name(name)
45
+ if len(made) > TAG_MAX:
46
+ return (f"a docker tag is at most {TAG_MAX} characters; {name!r} becomes {made!r} ({len(made)}) "
47
+ f"and would be cut where the timestamp is")
48
+ return None
49
+
50
+
51
+ def repository_name(value: str) -> str:
52
+ """A valid repository name derived from ``value``: lowercase, what it
53
+ cannot keep becomes ``-``, separators never doubled or leading."""
54
+ v = _REPO_BAD.sub("-", str(value).lower())
55
+ v = re.sub(r"[._-]{2,}", "-", v).strip("._-/")
56
+ return v or "csis"
57
+
58
+
59
+ def label_key_problem(key: str) -> str | None:
60
+ """Why ``key`` cannot be a docker label key, or None."""
61
+ k = str(key)
62
+ if not k.strip():
63
+ return "a docker label key cannot be empty"
64
+ if any(c.isspace() for c in k):
65
+ return f"a docker label key carries no whitespace; {key!r} does"
66
+ if "=" in k:
67
+ return f"a docker label key carries no '='; {key!r} does"
68
+ return None
69
+
70
+
71
+ def is_image_id(value: str) -> bool:
72
+ return bool(_IMAGE_ID.match(str(value).strip()))
73
+
74
+
75
+ def is_digest_reference(value: str) -> bool:
76
+ """``<repository>@sha256:<digest>``: the form ``image_id`` pins."""
77
+ return bool(_DIGEST_REF.match(str(value).strip()))
78
+
79
+
80
+ #: The registry a daemon may put in front of a repository it was given
81
+ #: unqualified: Podman's API spells ``csis/x:t`` as ``docker.io/csis/x:t``
82
+ #: (or ``localhost/csis/x:t``) in ``RepoTags``, where Docker keeps it as
83
+ #: written. Found by the Podman leg (stage 103, 2026-11-01).
84
+ IMPLIED_REGISTRIES = ("docker.io/", "index.docker.io/", "localhost/")
85
+
86
+
87
+ def same_repository(reported: str, repository: str) -> bool:
88
+ """Whether a ``RepoTags`` entry's repository part IS the runtime's
89
+ repository, however the daemon spells the registry it implied."""
90
+ if reported == repository:
91
+ return True
92
+ return any(reported.startswith(p) and reported[len(p):] == repository for p in IMPLIED_REGISTRIES)
93
+
94
+
95
+ def short_id(image_id: str) -> str:
96
+ """The twelve hex characters the CLI shows for an image id."""
97
+ v = str(image_id).strip()
98
+ v = v.split(":", 1)[1] if v.startswith("sha256:") else v
99
+ return v[:12]
100
+
101
+
102
+ def full_id(image_id: str) -> str:
103
+ """The ``sha256:``-prefixed form of an image id the CLI printed."""
104
+ v = str(image_id).strip()
105
+ return v if v.startswith("sha256:") else f"sha256:{v}"
106
+
107
+
108
+ # tags that carry what a label cannot change after the build (stage 103,
109
+ # decision 4: labels are fixed when an image is built, so release and
110
+ # relabel MOVE TAGS): `<repository>:<short id>--<key>--<value as tag>`
111
+ MOVABLE_SEP = "--"
112
+
113
+
114
+ def movable_tag(image_id: str, key: str, value: str) -> str:
115
+ return f"{short_id(image_id)}{MOVABLE_SEP}{tag_name(key)}{MOVABLE_SEP}{tag_name(value)}"
116
+
117
+
118
+ _ID_AS_TAG = re.compile(r"^sha256-([0-9a-f]{64})$")
119
+
120
+
121
+ def movable_value_decoded(value: str) -> str:
122
+ """A movable tag's value as it was given: an image id written as a tag
123
+ lost its colon (``sha256-<hex>``), and gets it back; anything else is
124
+ returned as it stands (found live: the state query compared the
125
+ lineage's ``sha256:...`` parent with the tag's ``sha256-...``)."""
126
+ m = _ID_AS_TAG.match(str(value))
127
+ return f"sha256:{m.group(1)}" if m else str(value)
128
+
129
+
130
+ def parse_movable_tag(tag: str) -> tuple[str, str, str] | None:
131
+ """``(short id, key, value)`` of a movable tag, or None for any other;
132
+ the value is decoded (``movable_value_decoded``)."""
133
+ parts = str(tag).split(MOVABLE_SEP, 2)
134
+ if len(parts) != 3 or not re.fullmatch(r"[0-9a-f]{12}", parts[0]) or not parts[1] or not parts[2]:
135
+ return None
136
+ return parts[0], parts[1], movable_value_decoded(parts[2])
@@ -0,0 +1,140 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """The ``docker`` packer source for an image baked on a docker runtime
6
+ (stage 103), from HashiCorp's docker plugin.
7
+
8
+ A bake starts a container from the parent image, provisions it through the
9
+ daemon (no SSH: the plugin's docker communicator runs ``docker exec`` and
10
+ ``docker cp``) and COMMITS it. The committed image carries the lineage
11
+ labels and the keepalive command as Dockerfile instructions (``changes``);
12
+ the build's names are tags, put on by the ``docker-tag`` post-processor the
13
+ runtime adds to the build block: the final name and the series' floating
14
+ tag, which a child image bakes from when its parent is deferred.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import shlex
20
+ from typing import Any
21
+
22
+ import hcl2
23
+ from hcl2 import Builder
24
+
25
+ from cs_image_system.contract.constants import OOPS_DEFAULTS, SELF
26
+ from cs_image_system.hashicorp_utils.hashicorp import FO
27
+
28
+ from .docker_names import is_image_id, tag_name
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ DOCKER_SOURCE = "docker"
33
+ #: PID 1's command in every image this runtime bakes (decision 2: no init
34
+ #: system; the launcher runs it under ``--init``)
35
+ KEEPALIVE_CMD = ["sleep", "infinity"]
36
+ #: where the bake writes what must not land in the image (the step runner)
37
+ BAKE_TMPFS = ["/run"]
38
+
39
+ _ARCH_PLATFORM = {"x86_64": "linux/amd64", "amd64": "linux/amd64", "arm64": "linux/arm64", "aarch64": "linux/arm64"}
40
+
41
+
42
+ def bake_container_name(image_name: str, runtime: str) -> str:
43
+ """The build container's name: what ``ansible_host`` names for the
44
+ docker connection, so one per image and runtime."""
45
+ return tag_name(f"csis-bake-{runtime}-{image_name}").lower()
46
+
47
+
48
+ def _label_change(key: str, value: str) -> str:
49
+ v = str(value).replace("\\", "\\\\").replace('"', '\\"')
50
+ return f'LABEL {key}="{v}"'
51
+
52
+
53
+ def docker_source(model: Any, image: Any, *, runtime: str, source_type: str, subconfig: Any,
54
+ self_subconfig: Any, psi: Any, tags: dict[str, str], final_name: str,
55
+ pinned: str | None) -> list[str]:
56
+ c: dict[str, Any] = {}
57
+ # --- the bake's parent image
58
+ if pinned:
59
+ c["image"] = pinned if is_image_id(pinned) else str(pinned)
60
+ elif psi.is_resolved():
61
+ c["image"] = str(psi.identifier)
62
+ else:
63
+ # deferred: the parent series' floating tag, moved by every bake of
64
+ # the series (the daemon's own "newest of the family")
65
+ tag = psi.floating_tag() if hasattr(psi, "floating_tag") else None
66
+ if not tag:
67
+ raise ValueError(f"image {image.get_name()} on runtime {runtime}: no floating tag for its deferred "
68
+ f"parent {psi.source_name!r}")
69
+ c["image"] = tag
70
+ # the query pulled every vendor image it resolved; our own builds are
71
+ # local and no registry has them
72
+ c["pull"] = False
73
+ c["commit"] = True
74
+ arch = getattr(image, "architecture", None) or getattr(psi, "architecture", None)
75
+ if arch and str(arch) not in OOPS_DEFAULTS and str(arch) in _ARCH_PLATFORM:
76
+ c["platform"] = _ARCH_PLATFORM[str(arch)]
77
+ # --- the build container: named (ansible reaches it by name), under an
78
+ # init, on the runtime's network, with its machine type's limits
79
+ run: list[str] = ["-d", "-i", "-t", "--init", "--name", bake_container_name(image.get_name(), runtime)]
80
+ networking = getattr(model, "networking", None)
81
+ network = getattr(networking, "network", None) if networking is not None else None
82
+ if network and network not in OOPS_DEFAULTS:
83
+ run += ["--network", str(network)]
84
+ mt_name = None
85
+ for cand in (getattr(subconfig, "machine_type", None) if subconfig else None,
86
+ getattr(subconfig, "default_machine_type", None) if subconfig else None,
87
+ model.get_default_machine_type()):
88
+ if cand and cand not in OOPS_DEFAULTS:
89
+ mt_name = str(cand)
90
+ break
91
+ mt = (model.get_machine_types() or {}).get(mt_name) if mt_name else None
92
+ if mt is not None:
93
+ if getattr(mt, "cpus", None):
94
+ run += ["--cpus", str(mt.cpus)]
95
+ if getattr(mt, "memory", None):
96
+ run += ["--memory", str(mt.memory)]
97
+ # the shell the bake provisions through is the CONTAINER's command, not an
98
+ # entrypoint: packer's default `--entrypoint=/bin/sh` becomes the committed
99
+ # image's entrypoint, and `ENTRYPOINT []` in `changes` does not clear it
100
+ # (found live: every later `docker run <image> <cmd>` ran `/bin/sh <cmd>`)
101
+ run += ["--", "{{.Image}}", "/bin/sh"]
102
+ c["run_command"] = [f'"{a}"' for a in run]
103
+ c["tmpfs"] = [f'"{t}"' for t in BAKE_TMPFS]
104
+ # --- what the committed image carries
105
+ changes = [_label_change(k, v) for k, v in sorted(tags.items())]
106
+ changes.append("CMD " + "[" + ", ".join(f'"{a}"' for a in KEEPALIVE_CMD) + "]")
107
+ c["changes"] = [f'"{ch.replace(chr(92), chr(92) * 2).replace(chr(34), chr(92) + chr(34))}"' for ch in changes]
108
+ for k, v in c.items():
109
+ if isinstance(v, str):
110
+ c[k] = f'"{v}"'
111
+ doc = Builder()
112
+ doc.block("source", labels=[f'"{source_type}"', f'"{image.get_name()}"'], **c)
113
+ return hcl2.dumps(doc.build(), formatter_options=FO).splitlines()
114
+
115
+
116
+ def docker_tag_post_processor(image: Any, *, repository: str, source_type: str, final_name: str,
117
+ prefix: str | None) -> list[str]:
118
+ """The ``docker-tag`` post-processor of one image's build: the final name
119
+ and the series' floating tag, in the runtime's repository."""
120
+ names = [tag_name(final_name)] if final_name else []
121
+ if prefix and tag_name(prefix) not in names:
122
+ names.append(tag_name(prefix))
123
+ if not names:
124
+ return []
125
+ return [
126
+ f" # stage 103: the build's names are tags ({image.get_name()}); the floating tag is the series' newest",
127
+ ' post-processor "docker-tag" {',
128
+ f' only = ["{source_type}.{image.get_name()}"]',
129
+ f' repository = "{repository}"',
130
+ " tags = [" + ", ".join(f'"{n}"' for n in names) + "]",
131
+ " }",
132
+ ]
133
+
134
+
135
+ def quoted(parts: list[str]) -> str:
136
+ return " ".join(shlex.quote(p) for p in parts)
137
+
138
+
139
+ __all__ = ["DOCKER_SOURCE", "KEEPALIVE_CMD", "SELF", "bake_container_name", "docker_source",
140
+ "docker_tag_post_processor"]
@@ -0,0 +1,40 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from cs_image_system.base.models.provider_specific_image import ProviderSpecificImage
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ class DockerProviderSpecificImage(ProviderSpecificImage):
14
+ """An image as a container daemon knows it. Resolved: a vendor image by
15
+ digest (``<repository>@sha256:...``, what the query recorded) or one of
16
+ our own builds by image id (``sha256:...``). Deferred: the series'
17
+ floating tag ``<repository>:<prefix>``, which every bake of the series
18
+ moves to its newest build -- the daemon has no "newest image matching a
19
+ name pattern" query, so the tag IS that query."""
20
+
21
+ @classmethod
22
+ def csis_name(cls) -> str:
23
+ return "docker"
24
+
25
+ def floating_tag(self) -> str | None:
26
+ """``<repository>:<prefix as a tag>`` for a deferred image."""
27
+ if not self.deferred_name_pattern:
28
+ return None
29
+ from cs_image_system.base.global_context import GlobalTypeContext
30
+ from .docker_names import tag_name
31
+ rtb = GlobalTypeContext().runtime_builders.get(self.runtime)
32
+ model = getattr(rtb, "model", None)
33
+ repo = model.get_repository() if model is not None and hasattr(model, "get_repository") else "csis"
34
+ return f"{repo}:{tag_name(self.deferred_name_pattern)}"
35
+
36
+ def get_query_assets(self) -> dict[str, Any] | None:
37
+ if self.is_resolved():
38
+ return {"image": str(self.identifier)}
39
+ tag = self.floating_tag()
40
+ return {"image": tag} if tag else None