Coverage for src/rhiza_task/tasks/docker.py: 100%
37 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-13 02:27 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-13 02:27 +0000
1"""The container tasks: docker.mk, as tasks.
3Three wrappers over the docker CLI, and the shortest of the five fragments. The only
4thing worth stating is what the image is called: docker.mk defaults it to
5``$(shell basename $(CURDIR))``, so an unset :attr:`~rhiza_task.config.Config.docker_image`
6resolves to the repository directory's name here too -- moving a checkout would rename the
7image, which is surprising but is the behaviour consumers already have.
9``docker-build`` skips rather than fails on a missing Dockerfile, as the fragment does.
10That is not the same judgement as the tool guard's: a repository with no ``docker/``
11folder has adopted the bundle and not used it yet, whereas a machine with no docker
12cannot answer the question at all. Both are a skip, and ``--strict`` fails both.
13"""
15from __future__ import annotations
17import os
19from ..config import Config
20from ..spec import Guard, Skip, task
21from ..uv import tool
23SECTION = "Docker"
25HAVE_DOCKER = Guard(tool="docker", reason="docker not found; install from https://docs.docker.com/get-docker/")
27BUILD_SECRETS = (("gh_pat", "GH_PAT"), ("uv_extra_index_url", "UV_EXTRA_INDEX_URL"))
28"""The BuildKit secrets ``docker-build`` forwards, as ``(secret id, environment variable)``.
30These are the two mounts the bundle's Dockerfile declares so that ``uv sync`` can resolve a
31private Git dependency or a private index *inside* the builder stage, and the ids and
32variable names are the ones ``rhiza_docker.yml`` passes -- so a project that builds in CI
33builds the same way here, with the same ``export``. Two rules about how they are passed:
35* **Only when set.** ``--secret id=x,env=X`` for an unset ``X`` makes buildx fail with
36 ``failed to read secret X from env``, whereas the Dockerfile's ``[ -s /run/secrets/x ]``
37 guard already treats a *missing* mount as a no-op. So the conditional lives here rather
38 than in the Dockerfile. A variable set to the empty string counts as unset for the same
39 reason: an empty mount is exactly what that guard skips.
40* **Never ``--build-arg``.** A build argument is recorded in the image and readable with
41 ``docker history``; a secret exists only for the instruction that mounts it.
43A Dockerfile that declares no such mount gets a warning from docker about an unused secret
44and nothing else, the same shape as the unconditional ``PYTHON_VERSION`` build arg.
45"""
48def image_name(cfg: Config) -> str:
49 """Return the tag to build and run.
51 Args:
52 cfg: The resolved config.
54 Returns:
55 The configured image name, or the repository directory's name.
56 """
57 return cfg.docker_image or cfg.root.name
60def secret_flags() -> list[str]:
61 """Return the ``--secret`` flags for every :data:`BUILD_SECRETS` variable that is set.
63 Returns:
64 ``["--secret", "id=<id>,env=<VAR>", ...]`` in :data:`BUILD_SECRETS` order, empty
65 when none of the variables is set.
66 """
67 flags: list[str] = []
68 for secret_id, var in BUILD_SECRETS:
69 if os.environ.get(var):
70 flags.extend(("--secret", f"id={secret_id},env={var}"))
71 return flags
74def has_dockerfile(cfg: Config) -> bool:
75 """Return whether the repository ships an image, for ``list``.
77 The same file :func:`docker_build` skips without, so ``list`` and the task agree on
78 what "has adopted the Docker bundle" means.
80 Args:
81 cfg: The resolved config.
83 Returns:
84 True when ``<docker_folder>/Dockerfile`` exists.
85 """
86 return (cfg.root / cfg.docker_folder / "Dockerfile").is_file()
89@task("docker-build", "build the Docker image", section=SECTION, guards=(HAVE_DOCKER,), applies=has_dockerfile)
90def docker_build(cfg: Config) -> None:
91 """Build ``<docker_folder>/Dockerfile`` with the repository root as the context.
93 ``PYTHON_VERSION`` is passed as a build argument whatever the layer, as docker.mk
94 does. A Dockerfile that declares no such ``ARG`` gets a warning from docker and
95 nothing else, which is cheaper than making the flag conditional on a language.
97 ``GH_PAT`` and ``UV_EXTRA_INDEX_URL`` are forwarded as BuildKit secrets when set, so a
98 private dependency the CI build resolves is resolved here too; :data:`BUILD_SECRETS`
99 says why they are conditional and why they are not build arguments.
101 Args:
102 cfg: The resolved config.
104 Raises:
105 Skip: When the folder holds no Dockerfile.
106 """
107 dockerfile = cfg.root / cfg.docker_folder / "Dockerfile"
108 if not dockerfile.is_file():
109 raise Skip(f"no {cfg.docker_folder}/Dockerfile")
111 tag = f"{image_name(cfg)}:latest"
112 print(f"[INFO] building {tag} with Python {cfg.python_version}")
113 tool(
114 "docker",
115 "buildx",
116 "build",
117 "--file",
118 f"{cfg.docker_folder}/Dockerfile",
119 "--build-arg",
120 f"PYTHON_VERSION={cfg.python_version}",
121 *secret_flags(),
122 "--tag",
123 tag,
124 "--load",
125 ".",
126 cwd=cfg.root,
127 )
130@task(
131 "docker-run",
132 "run the Docker container",
133 section=SECTION,
134 needs=("docker-build",),
135 guards=(HAVE_DOCKER,),
136 applies=has_dockerfile,
137)
138def docker_run(cfg: Config) -> None:
139 """Run the built image interactively, removing the container on exit.
141 Args:
142 cfg: The resolved config.
143 """
144 tag = f"{image_name(cfg)}:latest"
145 print(f"[INFO] running {tag}")
146 tool("docker", "run", "--rm", "-it", tag, cwd=cfg.root)
149@task("docker-clean", "remove the Docker image", section=SECTION, guards=(HAVE_DOCKER,), applies=has_dockerfile)
150def docker_clean(cfg: Config) -> None:
151 """Delete the image, tolerating its absence.
153 ``check=False`` is docker.mk's ``2>/dev/null || true``: removing an image that was
154 never built is the expected state of a clean target, not a failure.
156 Args:
157 cfg: The resolved config.
158 """
159 tag = f"{image_name(cfg)}:latest"
160 print(f"[INFO] removing {tag}")
161 tool("docker", "rmi", tag, cwd=cfg.root, check=False)