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

1"""The container tasks: docker.mk, as tasks. 

2 

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. 

8 

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

14 

15from __future__ import annotations 

16 

17import os 

18 

19from ..config import Config 

20from ..spec import Guard, Skip, task 

21from ..uv import tool 

22 

23SECTION = "Docker" 

24 

25HAVE_DOCKER = Guard(tool="docker", reason="docker not found; install from https://docs.docker.com/get-docker/") 

26 

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)``. 

29 

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: 

34 

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. 

42 

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

46 

47 

48def image_name(cfg: Config) -> str: 

49 """Return the tag to build and run. 

50 

51 Args: 

52 cfg: The resolved config. 

53 

54 Returns: 

55 The configured image name, or the repository directory's name. 

56 """ 

57 return cfg.docker_image or cfg.root.name 

58 

59 

60def secret_flags() -> list[str]: 

61 """Return the ``--secret`` flags for every :data:`BUILD_SECRETS` variable that is set. 

62 

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 

72 

73 

74def has_dockerfile(cfg: Config) -> bool: 

75 """Return whether the repository ships an image, for ``list``. 

76 

77 The same file :func:`docker_build` skips without, so ``list`` and the task agree on 

78 what "has adopted the Docker bundle" means. 

79 

80 Args: 

81 cfg: The resolved config. 

82 

83 Returns: 

84 True when ``<docker_folder>/Dockerfile`` exists. 

85 """ 

86 return (cfg.root / cfg.docker_folder / "Dockerfile").is_file() 

87 

88 

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. 

92 

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. 

96 

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. 

100 

101 Args: 

102 cfg: The resolved config. 

103 

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

110 

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 ) 

128 

129 

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. 

140 

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) 

147 

148 

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. 

152 

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. 

155 

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)