|
21 | 21 | from datetime import datetime
|
22 | 22 | from datetime import timedelta
|
23 | 23 | from hashlib import sha3_256
|
24 |
| -from os.path import exists |
25 |
| -from os.path import isabs |
26 | 24 | from os.path import join
|
27 | 25 | from pathlib import Path
|
28 | 26 | from subprocess import call
|
|
55 | 53 | from pytest_container.logging import _logger
|
56 | 54 | from pytest_container.runtime import get_selected_runtime
|
57 | 55 | from pytest_container.runtime import OciRuntimeBase
|
| 56 | +from pytest_container.volume import BindMount |
| 57 | +from pytest_container.volume import ContainerVolume |
| 58 | +from pytest_container.volume import get_volume_creator |
58 | 59 |
|
59 | 60 | if sys.version_info >= (3, 8):
|
60 | 61 | from importlib import metadata
|
@@ -128,289 +129,6 @@ def create_host_port_port_forward(
|
128 | 129 | return finished_forwards
|
129 | 130 |
|
130 | 131 |
|
131 |
| -@enum.unique |
132 |
| -class VolumeFlag(enum.Enum): |
133 |
| - """Supported flags for mounting container volumes.""" |
134 |
| - |
135 |
| - #: The volume is mounted read-only |
136 |
| - READ_ONLY = "ro" |
137 |
| - #: The volume is mounted read-write (default) |
138 |
| - READ_WRITE = "rw" |
139 |
| - |
140 |
| - #: The volume is relabeled so that it can be shared by two containers |
141 |
| - SELINUX_SHARED = "z" |
142 |
| - #: The volume is relabeled so that only a single container can access it |
143 |
| - SELINUX_PRIVATE = "Z" |
144 |
| - |
145 |
| - #: chown the content of the volume for rootless runs |
146 |
| - CHOWN_USER = "U" |
147 |
| - |
148 |
| - #: ensure the volume is mounted as noexec (data only) |
149 |
| - NOEXEC = "noexec" |
150 |
| - |
151 |
| - #: The volume is mounted as a temporary storage using overlay-fs (only |
152 |
| - #: supported by :command:`podman`) |
153 |
| - OVERLAY = "O" |
154 |
| - |
155 |
| - def __str__(self) -> str: |
156 |
| - assert isinstance(self.value, str) |
157 |
| - return self.value |
158 |
| - |
159 |
| - |
160 |
| -if sys.version_info >= (3, 9): |
161 |
| - TEMPDIR_T = tempfile.TemporaryDirectory[str] |
162 |
| -else: |
163 |
| - TEMPDIR_T = tempfile.TemporaryDirectory |
164 |
| - |
165 |
| - |
166 |
| -@dataclass |
167 |
| -class ContainerVolumeBase: |
168 |
| - """Base class for container volumes.""" |
169 |
| - |
170 |
| - #: Path inside the container where this volume will be mounted |
171 |
| - container_path: str |
172 |
| - |
173 |
| - #: Flags for mounting this volume. |
174 |
| - #: |
175 |
| - #: Note that some flags are mutually exclusive and potentially not supported |
176 |
| - #: by all container runtimes. |
177 |
| - #: |
178 |
| - #: The :py:attr:`VolumeFlag.SELINUX_PRIVATE` flag will be added by default |
179 |
| - #: if flags is ``None``, unless :py:attr:`ContainerVolumeBase.shared` is |
180 |
| - #: ``True``, then :py:attr:`VolumeFlag.SELINUX_SHARED` is added. |
181 |
| - #: |
182 |
| - #: If flags is a list (even an empty one), then no flags are added. |
183 |
| - flags: Optional[List[VolumeFlag]] = None |
184 |
| - |
185 |
| - #: Define whether this volume should can be shared between |
186 |
| - #: containers. Defaults to ``False``. |
187 |
| - #: |
188 |
| - #: This affects only the addition of SELinux flags to |
189 |
| - #: :py:attr:`~ContainerVolumeBase.flags`. |
190 |
| - shared: bool = False |
191 |
| - |
192 |
| - #: internal volume name via which it can be mounted, e.g. the volume's ID or |
193 |
| - #: the path on the host |
194 |
| - _vol_name: str = "" |
195 |
| - |
196 |
| - def __post_init__(self) -> None: |
197 |
| - if self.flags is None: |
198 |
| - self.flags = [ |
199 |
| - VolumeFlag.SELINUX_SHARED |
200 |
| - if self.shared |
201 |
| - else VolumeFlag.SELINUX_PRIVATE |
202 |
| - ] |
203 |
| - |
204 |
| - for mutually_exclusive_flags in ( |
205 |
| - (VolumeFlag.READ_ONLY, VolumeFlag.READ_WRITE), |
206 |
| - (VolumeFlag.SELINUX_SHARED, VolumeFlag.SELINUX_PRIVATE), |
207 |
| - ): |
208 |
| - if ( |
209 |
| - mutually_exclusive_flags[0] in self.flags |
210 |
| - and mutually_exclusive_flags[1] in self.flags |
211 |
| - ): |
212 |
| - raise ValueError( |
213 |
| - f"Invalid container volume flags: {', '.join(str(f) for f in self.flags)}; " |
214 |
| - f"flags {mutually_exclusive_flags[0]} and {mutually_exclusive_flags[1]} " |
215 |
| - "are mutually exclusive" |
216 |
| - ) |
217 |
| - |
218 |
| - @property |
219 |
| - def cli_arg(self) -> str: |
220 |
| - """Command line argument to mount this volume.""" |
221 |
| - assert self._vol_name |
222 |
| - res = f"-v={self._vol_name}:{self.container_path}" |
223 |
| - if self.flags: |
224 |
| - res += ":" + ",".join(str(f) for f in self.flags) |
225 |
| - return res |
226 |
| - |
227 |
| - |
228 |
| -@dataclass |
229 |
| -class ContainerVolume(ContainerVolumeBase): |
230 |
| - """A container volume created by the container runtime for persisting files |
231 |
| - outside of (ephemeral) containers. |
232 |
| -
|
233 |
| - """ |
234 |
| - |
235 |
| - @property |
236 |
| - def volume_id(self) -> str: |
237 |
| - """Unique ID of the volume. It is automatically set when the volume is |
238 |
| - created by :py:class:`VolumeCreator`. |
239 |
| -
|
240 |
| - """ |
241 |
| - return self._vol_name |
242 |
| - |
243 |
| - |
244 |
| -@dataclass |
245 |
| -class BindMount(ContainerVolumeBase): |
246 |
| - """A volume mounted into a container from the host using bind mounts. |
247 |
| -
|
248 |
| - This class describes a bind mount of a host directory into a container. In |
249 |
| - the most minimal configuration, all you need to specify is the path in the |
250 |
| - container via :py:attr:`~ContainerVolumeBase.container_path`. The |
251 |
| - ``container*`` fixtures will then create a temporary directory on the host |
252 |
| - for you that will be used as the mount point. Alternatively, you can also |
253 |
| - specify the path on the host yourself via :py:attr:`host_path`. |
254 |
| -
|
255 |
| - """ |
256 |
| - |
257 |
| - #: Path on the host that will be mounted if absolute. if relative, |
258 |
| - #: it refers to a volume to be auto-created. When omitted, a temporary |
259 |
| - #: directory will be created and the path will be saved in this attribute. |
260 |
| - host_path: Optional[str] = None |
261 |
| - |
262 |
| - def __post_init__(self) -> None: |
263 |
| - super().__post_init__() |
264 |
| - if self.host_path: |
265 |
| - self._vol_name = self.host_path |
266 |
| - |
267 |
| - |
268 |
| -@dataclass |
269 |
| -class VolumeCreator: |
270 |
| - """Context Manager to create and remove a :py:class:`ContainerVolume`. |
271 |
| -
|
272 |
| - This context manager creates a volume using the supplied |
273 |
| - :py:attr:`container_runtime` When the ``with`` block is entered and removes |
274 |
| - it once it is exited. |
275 |
| - """ |
276 |
| - |
277 |
| - #: The volume to be created |
278 |
| - volume: ContainerVolume |
279 |
| - |
280 |
| - #: The container runtime, via which the volume is created & destroyed |
281 |
| - container_runtime: OciRuntimeBase |
282 |
| - |
283 |
| - def __enter__(self) -> "VolumeCreator": |
284 |
| - """Creates the container volume""" |
285 |
| - vol_id = ( |
286 |
| - check_output( |
287 |
| - [self.container_runtime.runner_binary, "volume", "create"] |
288 |
| - ) |
289 |
| - .decode() |
290 |
| - .strip() |
291 |
| - ) |
292 |
| - self.volume._vol_name = vol_id |
293 |
| - return self |
294 |
| - |
295 |
| - def __exit__( |
296 |
| - self, |
297 |
| - __exc_type: Optional[Type[BaseException]], |
298 |
| - __exc_value: Optional[BaseException], |
299 |
| - __traceback: Optional[TracebackType], |
300 |
| - ) -> None: |
301 |
| - """Cleans up the container volume.""" |
302 |
| - assert self.volume.volume_id |
303 |
| - |
304 |
| - _logger.debug( |
305 |
| - "cleaning up volume %s via %s", |
306 |
| - self.volume.volume_id, |
307 |
| - self.container_runtime.runner_binary, |
308 |
| - ) |
309 |
| - |
310 |
| - # Clean up container volume |
311 |
| - check_output( |
312 |
| - [ |
313 |
| - self.container_runtime.runner_binary, |
314 |
| - "volume", |
315 |
| - "rm", |
316 |
| - "-f", |
317 |
| - self.volume.volume_id, |
318 |
| - ], |
319 |
| - ) |
320 |
| - self.volume._vol_name = "" |
321 |
| - |
322 |
| - |
323 |
| -@dataclass |
324 |
| -class BindMountCreator: |
325 |
| - """Context Manager that creates temporary directories for bind mounts (if |
326 |
| - necessary, i.e. when :py:attr:`BindMount.host_path` is ``None``). |
327 |
| -
|
328 |
| - """ |
329 |
| - |
330 |
| - #: The bind mount which host path should be created |
331 |
| - volume: BindMount |
332 |
| - |
333 |
| - #: internal temporary directory |
334 |
| - _tmpdir: Optional[TEMPDIR_T] = None |
335 |
| - |
336 |
| - def __post__init__(self) -> None: |
337 |
| - # the tempdir must not be set accidentally by the user |
338 |
| - assert self._tmpdir is None, "_tmpdir must only be set in __enter__()" |
339 |
| - |
340 |
| - def __enter__(self) -> "BindMountCreator": |
341 |
| - """Creates the temporary host path if necessary.""" |
342 |
| - if not self.volume.host_path: |
343 |
| - # we don't want to use a with statement, as the temporary directory |
344 |
| - # must survive this function |
345 |
| - # pylint: disable=consider-using-with |
346 |
| - self._tmpdir = tempfile.TemporaryDirectory() |
347 |
| - self.volume.host_path = self._tmpdir.name |
348 |
| - |
349 |
| - _logger.debug( |
350 |
| - "created temporary directory %s for the container volume %s", |
351 |
| - self._tmpdir.name, |
352 |
| - self.volume.container_path, |
353 |
| - ) |
354 |
| - |
355 |
| - assert self.volume.host_path |
356 |
| - self.volume._vol_name = self.volume.host_path |
357 |
| - if isabs(self.volume.host_path) and not exists(self.volume.host_path): |
358 |
| - raise RuntimeError( |
359 |
| - f"Volume with the host path '{self.volume.host_path}' " |
360 |
| - "was requested but the directory does not exist" |
361 |
| - ) |
362 |
| - return self |
363 |
| - |
364 |
| - def __exit__( |
365 |
| - self, |
366 |
| - __exc_type: Optional[Type[BaseException]], |
367 |
| - __exc_value: Optional[BaseException], |
368 |
| - __traceback: Optional[TracebackType], |
369 |
| - ) -> None: |
370 |
| - """Cleans up the temporary host directory or the container volume.""" |
371 |
| - assert self.volume.host_path |
372 |
| - |
373 |
| - if self._tmpdir: |
374 |
| - _logger.debug( |
375 |
| - "cleaning up directory %s for the container volume %s", |
376 |
| - self.volume.host_path, |
377 |
| - self.volume.container_path, |
378 |
| - ) |
379 |
| - self._tmpdir.cleanup() |
380 |
| - self.volume.host_path = None |
381 |
| - self.volume._vol_name = "" |
382 |
| - |
383 |
| - |
384 |
| -@overload |
385 |
| -def get_volume_creator( |
386 |
| - volume: ContainerVolume, runtime: OciRuntimeBase |
387 |
| -) -> VolumeCreator: |
388 |
| - ... # pragma: no cover |
389 |
| - |
390 |
| - |
391 |
| -@overload |
392 |
| -def get_volume_creator( |
393 |
| - volume: BindMount, runtime: OciRuntimeBase |
394 |
| -) -> BindMountCreator: |
395 |
| - ... # pragma: no cover |
396 |
| - |
397 |
| - |
398 |
| -def get_volume_creator( |
399 |
| - volume: Union[ContainerVolume, BindMount], runtime: OciRuntimeBase |
400 |
| -) -> Union[VolumeCreator, BindMountCreator]: |
401 |
| - """Returns the appropriate volume creation context manager for the given |
402 |
| - volume. |
403 |
| -
|
404 |
| - """ |
405 |
| - if isinstance(volume, ContainerVolume): |
406 |
| - return VolumeCreator(volume, runtime) |
407 |
| - |
408 |
| - if isinstance(volume, BindMount): |
409 |
| - return BindMountCreator(volume) |
410 |
| - |
411 |
| - assert False, f"invalid volume type {type(volume)}" # pragma: no cover |
412 |
| - |
413 |
| - |
414 | 132 | _CONTAINER_ENTRYPOINT = "/bin/bash"
|
415 | 133 | _CONTAINER_STOPSIGNAL = ("--stop-signal", "SIGTERM")
|
416 | 134 |
|
|
0 commit comments