diff --git a/.github/workflows/basic-ci.yaml b/.github/workflows/basic-ci.yaml index 1bff4f2f..49db856f 100644 --- a/.github/workflows/basic-ci.yaml +++ b/.github/workflows/basic-ci.yaml @@ -4,26 +4,23 @@ on: [push, pull_request] jobs: basic_ci: name: Basic CI - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 strategy: matrix: - python-version: [3.8] + python-version: [3.8, '3.10'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install Dependencies And Self run: | python -m pip install --upgrade pip setuptools wheel - # Workaround for https://github.com/docker/docker-py/issues/2807 - python -m pip install six - pip install codecov coverage nose - pip install . + python -m pip install -e .[test] codecov pytest-cov - name: Run headless tests - uses: GabrielBB/xvfb-action@v1 + uses: coactions/setup-xvfb@v1 with: - run: nosetests -s -v --with-coverage --cover-package rocker --exclude test_nvidia_glmark2 + run: python -m pytest -s -v --cov=rocker -m "not nvidia" - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 diff --git a/README.md b/README.md index 668feaa7..f851f0f6 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,53 @@ A tool to run docker images with customized local support injected for things like nvidia support. And user id specific files for cleaner mounting file permissions. +## Difference from docker-compose + +A common question about rocker is how is it different than `docker-compose`. +`rocker` is designed to solve a similar but different problem than `docker-compose`. +The primary goal of `rocker` is to support the use of Docker in use cases where the containers will be effected by the local environment. +A primary example of this is setting up file permissions inside the container to match the users outside of the container so that mounted files inside the container have the same UID as the host. +Doing this enables quickly going in and out of different containrs while leverating the same workspace on your host for testing on different platforms etc. +This is done by dynamically generating overlays on the same core image after detecting the local conditions required. + +The secondary feature that `rocker` provides that docker-compose does not address is the ability to inject extra use case specific capabilities into a container before running. +A common example is the ability to use NVIDIA drivers on a standard published image. +`rocker` will take that standard published image and both inject the necessary drivers into the container which will match your host driver and simultaneously set the correct runtime flags. +This is possible to do with docker-compose or straight docker. +But the drawbacks are that you have to build and publish the combinatoric images of all possible drivers, and in addition you need to manually make sure to pass all the correct runtime arguments. +This is especially true if you want to combine multiple possible additional features, such that the number of images starts scaling in a polynomic manner and maintenance of the number of images becomes unmanagable quickly. +Whereas with `rocker` you can invoke your specific plugins and it will use multi-stage builds of docker images to customize the container for your specific use case, which lets you use official upstream docker images without requiring you to maintain a plethora of parallel built images. + + + ## Know extensions -Rocker supports extensions via entry points there are some built in but you can add your own. Here's a list of public repositories with extensions. +Rocker supports extensions via entry points there are some built in but you can add your own. + +### Integrated Extensions + +There are a number of integrated extensions here's some of the highlights. +You can get full details on the extensions from the main `rocker --help` command. + +- x11 -- Enable the use of X11 inside the container via the host X instance. +- nvidia -- Enable NVIDIA graphics cards for rendering +- cuda -- Enable NVIDIA CUDA in the container +- user -- Create a user inside the container with the same settings as the host and run commands inside the container as that user. +- home -- Mount the user's home directory into the container +- pulse -- Mount pulse audio into the container +- ssh -- Pass through ssh access to the container. + +As well as access to many of the docker arguments as well such as `device`, `env`, `volume`, `name`, `network`, and `privileged`. + +### Externally maintained extensions + +Here's a list of public repositories with extensions. - Off-your-rocker: https://github.com/sloretz/off-your-rocker +- mp_rocker: https://github.com/miguelprada/mp_rocker +- ghrocker: https://github.com/tfoote/ghrocker + + # Prerequisites @@ -18,11 +60,12 @@ Docker installation instructions: https://docs.docker.com/install/ For the NVIDIA option this has been tested on the following systems using nvidia docker2: -| Ubuntu distribution | Linux Kernel | Nvidia drivers | -| -------------------- | ------------ | ------------------------- | -| 16.04 | 4.15 | nvidia-384 (works)
nvidia-340 (doesn't work) | -| 18.04 | | nvidia-390 (works) | -| 20.04 | 5.4.0 | nvidia-driver-460 (works) | +| Ubuntu distribution | Linux Kernel | Nvidia drivers | +| ------------------- | ------------ | ------------------------------------------------- | +| 16.04 | 4.15 | nvidia-384 (works)
nvidia-340 (doesn't work) | +| 18.04 | | nvidia-390 (works) | +| 20.04 | 5.4.0 | nvidia-driver-460 (works) | +| 22.04 | 5.13.0 | nvidia-driver-470 (works) | Install nvidia-docker 2: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#docker @@ -41,13 +84,12 @@ Note, that changing this setting will lead to a `Failed to initialize NVML: Unkn ## Intel integrated graphics support -For intel integrated graphics support you will need to mount through a specific device +For [Intel integrated graphics support](https://www.intel.com/content/www/us/en/develop/documentation/get-started-with-ai-linux/top/using-containers/using-containers-with-the-command-line.html) you will need to mount the `/dev/dri` directory as follows: ``` ---devices /dev/dri/card0 +--devices /dev/dri ``` - # Installation ## Debians (Recommended) @@ -63,7 +105,19 @@ Rocker is available via pip you can install it via pip using `pip install rocker` +## Archlinux ([AUR](https://aur.archlinux.org/)) + +Using any AUR helper, for example, with `paru` + +```bash +paru -S python-rocker +``` + +or +```bash +paru -S python-rocker-git +``` ## Development To set things up in a virtual environment for isolation is a good way. If you don't already have it install python3's venv module. @@ -87,19 +141,18 @@ For any new terminal re activate the venv before trying to use it. ### Testing -To run tests install nose and coverage in the venv +To run tests install the 'test' extra and pytest-cov in the venv . ~/rocker_venv/bin/activate - pip install nose - pip install coverage + pip install -e .[test] pytest-cov -Then you can run nosetests. +Then you can run pytest. - nosetests-3.4 --with-coverage --cover-package rocker + python3 -m pytest --cov=rocker Notes: -- Make sure to use the python3 instance of nosetest from inside the environment. +- Make sure to use the python3 instance of pytest from inside the environment. - The tests include an nvidia test which assumes you're using a machine with an nvidia gpu. @@ -142,9 +195,11 @@ On Bionic ## Volume mount -For arguments with one element not colon separated. +### For arguments with one element not colon separated -`--volume` adds paths as docker volumes. The last path must be terminated with two dashes `--`. +`--volume` adds paths as docker volumes. + +**The last path must be terminated with two dashes `--`**. rocker --volume ~/.vimrc ~/.bashrc -- ubuntu:18.04 @@ -152,7 +207,9 @@ The above example of the volume option will be expanded via absolute paths for ` --volume /home//.vimrc:/home//.vimrc --volume /home//.bashrc:/home//.bashrc -For arguments with colon separation it will process the same as `docker`'s `--volume` option, `rocker --volume` takes 3 fields. +### For arguments with colon separation + +It will process the same as `docker`'s `--volume` option, `rocker --volume` takes 3 fields. - 1st field: the path to the file or directory on the host machine. - 2nd field: (optional) the path where the file or directory is mounted in the container. - If only the 1st field is supplied, same value as the 1st field will be populated as the 2nd field. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..252003bf --- /dev/null +++ b/codecov.yml @@ -0,0 +1,3 @@ +comment: + layout: "reach, diff, flags, files" + behavior: default diff --git a/setup.cfg b/setup.cfg index 12181301..fb52d277 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,12 @@ [sdist_dsc] epoch: 1 -debian-version: 2intermodalics +debian-version: 1intermodalics + +[tool:pytest] +markers = + # Tests which require a docker engine + docker + # Tests which require an NVIDIA GPU and drivers + nvidia + # Tests which require an X11 display of some kind + x11 diff --git a/setup.py b/setup.py index 95a505eb..17cb2813 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,7 @@ 'empy', 'pexpect', 'packaging', + 'urllib3', ] # docker API used to be in a package called `docker-py` before the 2.0 release @@ -31,7 +32,7 @@ kwargs = { 'name': 'rocker', - 'version': '0.2.6', + 'version': '0.2.12', 'packages': ['rocker'], 'package_dir': {'': 'src'}, 'package_data': {'rocker': ['templates/*.em']}, @@ -41,20 +42,25 @@ 'detect_docker_image_os = rocker.cli:detect_image_os', ], 'rocker.extensions': [ + 'cuda = rocker.nvidia_extension:Cuda', 'devices = rocker.extensions:Devices', 'dev_helpers = rocker.extensions:DevHelpers', 'env = rocker.extensions:Environment', + 'expose = rocker.extensions:Expose', 'git = rocker.git_extension:Git', + 'group_add = rocker.extensions:GroupAdd', 'home = rocker.extensions:HomeDir', - 'volume = rocker.volume_extension:Volume', + 'hostname = rocker.extensions:Hostname', 'name = rocker.extensions:Name', 'network = rocker.extensions:Network', 'nvidia = rocker.nvidia_extension:Nvidia', + 'port = rocker.extensions:Port', 'privileged = rocker.extensions:Privileged', 'pulse = rocker.extensions:PulseAudio', 'ssh = rocker.ssh_extension:Ssh', 'ssh_server = rocker.ssh_server_extension:SshServer', 'user = rocker.extensions:User', + 'volume = rocker.volume_extension:Volume', 'x11 = rocker.nvidia_extension:X11', ] }, @@ -72,6 +78,11 @@ 'python_requires': '>=3.0', 'install_requires': install_requires, + 'extras_require': { + 'test': [ + 'pytest' + ] + }, 'url': 'https://github.com/osrf/rocker' } diff --git a/src/rocker/cli.py b/src/rocker/cli.py old mode 100755 new mode 100644 index da6b3811..27aeda0c --- a/src/rocker/cli.py +++ b/src/rocker/cli.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - # Copyright 2019 Open Source Robotics Foundation # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/src/rocker/core.py b/src/rocker/core.py old mode 100755 new mode 100644 index 3a211484..23322e9d --- a/src/rocker/core.py +++ b/src/rocker/core.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - # Copyright 2019 Open Source Robotics Foundation # Licensed under the Apache License, Version 2.0 (the "License"); @@ -127,11 +125,12 @@ def get_docker_client(): # Validate that the server is available docker_client.ping() return docker_client - except (docker.errors.APIError, ConnectionError) as ex: + except (docker.errors.DockerException, docker.errors.APIError, ConnectionError) as ex: raise DependencyMissing('Docker Client failed to connect to docker daemon.' ' Please verify that docker is installed and running.' ' As well as that you have permission to access the docker daemon.' - ' This is usually by being a member of the docker group.') + ' This is usually by being a member of the docker group.' + ' The underlying error was:\n"""\n%s\n"""\n' % ex) def docker_build(docker_client = None, output_callback = None, **kwargs): diff --git a/src/rocker/extensions.py b/src/rocker/extensions.py index e85e9aa8..c789ce40 100644 --- a/src/rocker/extensions.py +++ b/src/rocker/extensions.py @@ -92,6 +92,29 @@ def register_arguments(parser, defaults={}): help="add development tools emacs and byobu to your environment") +class Hostname(RockerExtension): + @staticmethod + def get_name(): + return 'hostname' + + def __init__(self): + self.name = Hostname.get_name() + + def get_preamble(self, cliargs): + return '' + + def get_docker_args(self, cliargs): + args = '' + hostname = cliargs.get('hostname', None) + if hostname: + args += ' --hostname %s ' % hostname + return args + + @staticmethod + def register_arguments(parser, defaults={}): + parser.add_argument('--hostname', default=defaults.get('hostname', ''), + help='Hostname of the container.') + class Name(RockerExtension): @staticmethod def get_name(): @@ -140,6 +163,59 @@ def register_arguments(parser, defaults={}): default=defaults.get('network', argparse.SUPPRESS), help="What network configuration to use.") + +class Expose(RockerExtension): + @staticmethod + def get_name(): + return 'expose' + + def __init__(self): + self.name = Expose.get_name() + + def get_preamble(self, cliargs): + return '' + + def get_docker_args(self, cliargs): + args = [''] + ports = cliargs.get('expose', []) + for port in ports: + args.append(' --expose {0}'.format(port)) + return ' '.join(args) + + @staticmethod + def register_arguments(parser, defaults={}): + parser.add_argument('--expose', + default=defaults.get('expose', None), + action='append', + help="Exposes a port from the container to host machine.") + + +class Port(RockerExtension): + @staticmethod + def get_name(): + return 'port' + + def __init__(self): + self.name = Port.get_name() + + def get_preamble(self, cliargs): + return '' + + def get_docker_args(self, cliargs): + args = [''] + ports = cliargs.get('port', []) + for port in ports: + args.append(' -p {0}'.format(port)) + return ' '.join(args) + + @staticmethod + def register_arguments(parser, defaults={}): + parser.add_argument('--port', + default=defaults.get('port', None), + action='append', + help="Binds port from the container to host machine.") + + class PulseAudio(RockerExtension): @staticmethod def get_name(): @@ -228,7 +304,28 @@ def get_snippet(self, cliargs): if 'user_override_name' in cliargs and cliargs['user_override_name']: substitutions['name'] = cliargs['user_override_name'] substitutions['dir'] = os.path.join('/home/', cliargs['user_override_name']) + substitutions['user_preserve_home'] = True if 'user_preserve_home' in cliargs and cliargs['user_preserve_home'] else False + if 'user_preserve_groups' in cliargs and isinstance(cliargs['user_preserve_groups'], list): + query_groups = cliargs['user_preserve_groups'] + all_groups = grp.getgrall() + if query_groups: + matched_groups = [g for g in all_groups if g.gr_name in query_groups] + matched_group_names = [g.gr_name for g in matched_groups] + unmatched_groups = [n for n in cliargs['user_preserve_groups'] if n not in matched_group_names] + if unmatched_groups: + print('Warning skipping groups %s because they do not exist on the host.' % unmatched_groups) + substitutions['user_groups'] = ' '.join(['{};{}'.format(g.gr_name, g.gr_gid) for g in matched_groups]) + else: + substitutions['user_groups'] = ' '.join(['{};{}'.format(g.gr_name, g.gr_gid) for g in all_groups if substitutions['name'] in g.gr_mem]) + else: + substitutions['user_groups'] = '' + substitutions['user_preserve_groups_permissive'] = True if 'user_preserve_groups_permissive' in cliargs and cliargs['user_preserve_groups_permissive'] else False substitutions['home_extension_active'] = True if 'home' in cliargs and cliargs['home'] else False + if 'user_override_shell' in cliargs and cliargs['user_override_shell'] is not None: + if cliargs['user_override_shell'] == '': + substitutions['shell'] = None + else: + substitutions['shell'] = cliargs['user_override_shell'] return em.expand(snippet, substitutions) @staticmethod @@ -241,6 +338,24 @@ def register_arguments(parser, defaults={}): action='store', default=defaults.get('user-override-username', argparse.SUPPRESS), help="override the current user's name") + parser.add_argument('--user-preserve-home', + action='store_true', + default=defaults.get('user-preserve-home', False), + help="Do not delete home directory if it exists when making a new user.") + parser.add_argument('--user-preserve-groups', + action='store', + nargs='*', + default=defaults.get('user-preserve-groups', False), + help="Assign user to same groups as he belongs in host. If arguments provided they are the explicit list of groups.") + parser.add_argument('--user-preserve-groups-permissive', + action='store_true', + default=defaults.get('user-preserve-groups-permissive', False), + help="If using user-preserve-groups allow failures in assignment." + "This is important if the host and target have different rules. https://unix.stackexchange.com/a/11481/83370" ) + parser.add_argument('--user-override-shell', + action='store', + default=defaults.get('user-override-shell', None), + help="Override the current user's shell. Set to empty string to use container default shell") class Environment(RockerExtension): @@ -312,3 +427,30 @@ def register_arguments(parser, defaults={}): action='store_true', default=defaults.get(Privileged.get_name(), argparse.SUPPRESS), help="give extended privileges to the container") + + +class GroupAdd(RockerExtension): + """Add additional groups to running container.""" + @staticmethod + def get_name(): + return 'group_add' + + def __init__(self): + self.name = GroupAdd.get_name() + + def get_preamble(self, cliargs): + return '' + + def get_docker_args(self, cliargs): + args = [''] + groups = cliargs.get('group_add', []) + for group in groups: + args.append(' --group-add {0}'.format(group)) + return ' '.join(args) + + @staticmethod + def register_arguments(parser, defaults={}): + parser.add_argument(name_to_argument(GroupAdd.get_name()), + default=defaults.get(GroupAdd.get_name(), None), + action='append', + help="Add additional groups to join.") diff --git a/src/rocker/nvidia_extension.py b/src/rocker/nvidia_extension.py index eec0c00a..1ecae5f4 100644 --- a/src/rocker/nvidia_extension.py +++ b/src/rocker/nvidia_extension.py @@ -43,9 +43,10 @@ def get_name(): def __init__(self): self.name = X11.get_name() self._env_subs = None - self._xauth = tempfile.NamedTemporaryFile(prefix='.docker', suffix='.xauth') + self._xauth = None def get_docker_args(self, cliargs): + assert self._xauth, 'xauth not initialized, get_docker_args must be called after precodition_environment' xauth = self._xauth.name return " -e DISPLAY -e TERM \ -e QT_X11_NO_MITSHM=1 \ @@ -54,6 +55,7 @@ def get_docker_args(self, cliargs): -v /etc/localtime:/etc/localtime:ro " % locals() def precondition_environment(self, cliargs): + self._xauth = tempfile.NamedTemporaryFile(prefix='.docker', suffix='.xauth', delete=not cliargs.get('nocleanup')) xauth = self._xauth.name display = os.getenv('DISPLAY') # Make sure processes in the container can connect to the x server @@ -86,7 +88,7 @@ def __init__(self): self._env_subs = None self.name = Nvidia.get_name() self.supported_distros = ['Ubuntu', 'Debian GNU/Linux'] - self.supported_versions = ['16.04', '18.04', '20.04', '10'] + self.supported_versions = ['16.04', '18.04', '20.04', '10', '22.04'] def get_environment_subs(self, cliargs={}): @@ -123,6 +125,11 @@ def get_snippet(self, cliargs): return em.expand(snippet, self.get_environment_subs(cliargs)) def get_docker_args(self, cliargs): + force_flag = cliargs.get('nvidia', None) + if force_flag == 'runtime': + return " --runtime=nvidia" + if force_flag == 'gpus': + return " --gpus all" if get_docker_version() >= Version("19.03"): return " --gpus all" return " --runtime=nvidia" @@ -132,6 +139,66 @@ def register_arguments(parser, defaults={}): parser.add_argument(name_to_argument(Nvidia.get_name()), action='store_true', default=defaults.get(Nvidia.get_name(), argparse.SUPPRESS), - help="Enable nvidia") + help="Enable nvidia. Default behavior is to pick flag based on docker version.") + +class Cuda(RockerExtension): + @staticmethod + def get_name(): + return 'cuda' + + def __init__(self): + self._env_subs = None + self.name = Cuda.get_name() + self.supported_distros = ['Ubuntu', 'Debian GNU/Linux'] + self.supported_versions = ['20.04', '22.04', '18.04', '11'] # Debian 11 + + def get_environment_subs(self, cliargs={}): + if not self._env_subs: + self._env_subs = {} + self._env_subs['user_id'] = os.getuid() + self._env_subs['username'] = getpass.getuser() + + # non static elements test every time + detected_os = detect_os(cliargs['base_image'], print, nocache=cliargs.get('nocache', False)) + if detected_os is None: + print("WARNING unable to detect os for base image '%s', maybe the base image does not exist" % cliargs['base_image']) + sys.exit(1) + dist, ver, codename = detected_os + + self._env_subs['download_osstring'] = dist.split()[0].lower() + self._env_subs['download_verstring'] = ver.replace('.', '') + self._env_subs['download_keyid'] = '3bf863cc' + + self._env_subs['image_distro_id'] = dist + if self._env_subs['image_distro_id'] not in self.supported_distros: + print("WARNING distro id %s not supported by Cuda supported " % self._env_subs['image_distro_id'], self.supported_distros) + sys.exit(1) + self._env_subs['image_distro_version'] = ver + if self._env_subs['image_distro_version'] not in self.supported_versions: + print("WARNING distro %s version %s not in supported list by Nvidia supported versions" % (dist, ver), self.supported_versions) + sys.exit(1) + # TODO(tfoote) add a standard mechanism for checking preconditions and disabling plugins + + return self._env_subs + + def get_preamble(self, cliargs): + return '' + # preamble = pkgutil.get_data('rocker', 'templates/%s_preamble.Dockerfile.em' % self.name).decode('utf-8') + # return em.expand(preamble, self.get_environment_subs(cliargs)) + + def get_snippet(self, cliargs): + snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % self.name).decode('utf-8') + return em.expand(snippet, self.get_environment_subs(cliargs)) + + def get_docker_args(self, cliargs): + return "" + # Runtime requires --nvidia option too + + @staticmethod + def register_arguments(parser, defaults={}): + parser.add_argument(name_to_argument(Cuda.get_name()), + action='store_true', + default=defaults.get('cuda', None), + help="Install cuda and nvidia-cuda-dev into the container") diff --git a/src/rocker/os_detector.py b/src/rocker/os_detector.py index 65b041bb..c9ca877a 100644 --- a/src/rocker/os_detector.py +++ b/src/rocker/os_detector.py @@ -1,4 +1,4 @@ -# Copyright 2019 Open Source Robotics Foundation +# Copyright 2019-2022 Arm Ltd., Open Source Robotics Foundation # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,37 +12,27 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import pexpect -from ast import literal_eval from io import BytesIO as StringIO -from .core import docker_build +from .core import docker_build, get_docker_client DETECTION_TEMPLATE=""" -FROM python:3-slim-stretch as detector -# Force the older version of debian for detector. -# GLIBC is forwards compatible but not necessarily backwards compatible for pyinstaller -# https://github.com/pyinstaller/pyinstaller/wiki/FAQ#gnulinux -# StaticX is supposed to take care of this but there appears to be an issue when using subprocess +FROM golang:1.19 as detector -RUN mkdir -p /tmp/distrovenv -RUN python3 -m venv /tmp/distrovenv -# patchelf needed for staticx -# binutils provides objdump needed by pyinstaller -RUN apt-get update && apt-get install -qy patchelf binutils -RUN . /tmp/distrovenv/bin/activate && pip install distro pyinstaller==4.0 staticx==0.12.3 - -RUN echo 'import distro; import sys; output = (distro.name(), distro.version(), distro.codename()); print(output) if distro.name() else sys.exit(1)' > /tmp/distrovenv/detect_os.py -RUN . /tmp/distrovenv/bin/activate && pyinstaller --onefile /tmp/distrovenv/detect_os.py - -RUN . /tmp/distrovenv/bin/activate && staticx /dist/detect_os /dist/detect_os_static && chmod go+xr /dist/detect_os_static +# For reliability, pin a distro-detect commit instead of targeting a branch. +RUN git clone -q https://github.com/dekobon/distro-detect.git && \ + cd distro-detect && \ + git checkout -q 5f5b9c724b9d9a117732d2a4292e6288905734e1 && \ + CGO_ENABLED=0 go build . FROM %(image_name)s -COPY --from=detector /dist/detect_os_static /tmp/detect_os -ENTRYPOINT [ "/tmp/detect_os" ] +COPY --from=detector /go/distro-detect/distro-detect /tmp/detect_os +ENTRYPOINT [ "/tmp/detect_os", "-format", "json-one-line" ] CMD [ "" ] """ @@ -54,11 +44,12 @@ def detect_os(image_name, output_callback=None, nocache=False): return _detect_os_cache[image_name] iof = StringIO((DETECTION_TEMPLATE % locals()).encode()) + tag_name = "rocker:" + f"os_detect_{image_name}".replace(':', '_').replace('/', '_') image_id = docker_build( fileobj=iof, output_callback=output_callback, nocache=nocache, - forcerm=True, # Remove intermediate containers from RUN commands in DETECTION_TEMPLATE + forcerm=True # Remove intermediate containers from RUN commands in DETECTION_TEMPLATE ) if not image_id: if output_callback: @@ -73,8 +64,25 @@ def detect_os(image_name, output_callback=None, nocache=False): if output_callback: output_callback("output: ", output) p.terminate() + + # Clean up the image + client = get_docker_client() + #client.remove_image(image=tag_name) # Can be prunned, since there is no tag + if p.exitstatus == 0: - _detect_os_cache[image_name] = literal_eval(output.strip()) + try: + detect_dict = json.loads(output.strip()) + except ValueError: + if output_callback: + output_callback('Failed to parse JSON') + return None + + dist = detect_dict.get('name', '') + os_release = detect_dict.get('os_release', {}) + ver = os_release.get('VERSION_ID', '') + codename = os_release.get('VERSION_CODENAME', '') + + _detect_os_cache[image_name] = (dist, ver, codename) return _detect_os_cache[image_name] else: if output_callback: diff --git a/src/rocker/templates/cuda_snippet.Dockerfile.em b/src/rocker/templates/cuda_snippet.Dockerfile.em new file mode 100644 index 00000000..92a12d4f --- /dev/null +++ b/src/rocker/templates/cuda_snippet.Dockerfile.em @@ -0,0 +1,41 @@ +# Installation instructions from NVIDIA: +# https://developer.nvidia.com/cuda-downloads?target_os=Linux&target_arch=x86_64&Distribution=Debian&target_version=11&target_type=deb_network +# https://developer.nvidia.com/cuda-downloads?target_os=Linux&target_arch=x86_64&Distribution=Ubuntu&target_version=22.04&target_type=deb_network + +# Keep the dockerfile non-interactive +# TODO(tfoote) make this more generic/shared across instances +ARG DEBIAN_FRONTEND=noninteractive + +# Prerequisites +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget software-properties-common gnupg2 \ + && rm -rf /var/lib/apt/lists/* + +# Enable contrib on debian to get required +# https://packages.debian.org/bullseye/glx-alternative-nvidia +# Enable non-free for nvidia-cuda-dev +# https://packages.debian.org/bullseye/nvidia-cuda-dev + +RUN \ + @[if download_osstring == 'ubuntu']@ + wget https://developer.download.nvidia.com/compute/cuda/repos/@(download_osstring)@(download_verstring)/x86_64/cuda-@(download_osstring)@(download_verstring).pin \ + && mv cuda-@(download_osstring)@(download_verstring).pin /etc/apt/preferences.d/cuda-repository-pin-600 && \ + add-apt-repository restricted && \ + @[else]@ + add-apt-repository contrib && \ + add-apt-repository non-free && \ + @[end if]@ + apt-key adv --fetch-keys https://developer.download.nvidia.com/compute/cuda/repos/@(download_osstring)@(download_verstring)/x86_64/@(download_keyid).pub \ + && add-apt-repository "deb https://developer.download.nvidia.com/compute/cuda/repos/@(download_osstring)@(download_verstring)/x86_64/ /" \ + && apt-get update \ + && apt-get -y install cuda \ + && rm -rf /var/lib/apt/lists/* + +# File conflict problem with libnvidia-ml.so.1 and libcuda.so.1 +# https://github.com/NVIDIA/nvidia-docker/issues/1551 +RUN rm -rf /usr/lib/x86_64-linux-gnu/libnv* +RUN rm -rf /usr/lib/x86_64-linux-gnu/libcuda* + +# TODO(tfoote) Add documentation of why these are required +ENV PATH /usr/local/cuda/bin${PATH:+:${PATH}} +ENV LD_LIBRARY_PATH /usr/local/cuda/lib64/stubs:/usr/local/cuda/lib64${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}} \ No newline at end of file diff --git a/src/rocker/templates/nvidia_snippet.Dockerfile.em b/src/rocker/templates/nvidia_snippet.Dockerfile.em index 69e9a09c..2393263a 100644 --- a/src/rocker/templates/nvidia_snippet.Dockerfile.em +++ b/src/rocker/templates/nvidia_snippet.Dockerfile.em @@ -25,4 +25,4 @@ COPY --from=glvnd /usr/share/glvnd/egl_vendor.d/10_nvidia.json /usr/share/glvnd/ ENV NVIDIA_VISIBLE_DEVICES ${NVIDIA_VISIBLE_DEVICES:-all} -ENV NVIDIA_DRIVER_CAPABILITIES ${NVIDIA_DRIVER_CAPABILITIES:+$NVIDIA_DRIVER_CAPABILITIES,}graphics +ENV NVIDIA_DRIVER_CAPABILITIES ${NVIDIA_DRIVER_CAPABILITIES:-all} diff --git a/src/rocker/templates/user_snippet.Dockerfile.em b/src/rocker/templates/user_snippet.Dockerfile.em index 0882700e..a00541f8 100644 --- a/src/rocker/templates/user_snippet.Dockerfile.em +++ b/src/rocker/templates/user_snippet.Dockerfile.em @@ -7,14 +7,28 @@ RUN if ! command -v sudo >/dev/null; then \ @[if name != 'root']@ RUN existing_user_by_uid=`getent passwd "@(uid)" | cut -f1 -d: || true` && \ - if [ -n "${existing_user_by_uid}" ]; then userdel -r "${existing_user_by_uid}"; fi && \ + if [ -n "${existing_user_by_uid}" ]; then userdel @('' if user_preserve_home else '-r') "${existing_user_by_uid}"; fi && \ existing_user_by_name=`getent passwd "@(name)" | cut -f1 -d: || true` && \ - if [ -n "${existing_user_by_name}" ]; then userdel -r "${existing_user_by_name}"; fi && \ + existing_user_uid=`getent passwd "@(name)" | cut -f3 -d: || true` && \ + if [ -n "${existing_user_by_name}" ]; then find / -uid ${existing_user_uid} -exec chown -h @(uid) {} + || true ; find / -gid ${existing_user_uid} -exec chgrp -h @(uid) {} + || true ; fi && \ + if [ -n "${existing_user_by_name}" ]; then userdel @('' if user_preserve_home else '-r') "${existing_user_by_name}"; fi && \ existing_group_by_gid=`getent group "@(gid)" | cut -f1 -d: || true` && \ if [ -z "${existing_group_by_gid}" ]; then \ groupadd -g "@(gid)" "@name"; \ fi && \ - useradd --no-log-init --no-create-home --uid "@(uid)" -s "@(shell)" -c "@(gecos)" -g "@(gid)" -d "@(dir)" "@(name)" && \ + useradd --no-log-init --no-create-home --uid "@(uid)" @(str('-s ' + shell) if shell else '') -c "@(gecos)" -g "@(gid)" -d "@(dir)" "@(name)" && \ +@[if user_groups != '']@ + user_groups="@(user_groups)" && \ + for groupinfo in ${user_groups}; do \ + existing_group_by_name=`getent group ${groupinfo%;*} || true`; \ + existing_group_by_gid=`getent group ${groupinfo#*;} || true`; \ + if [ -z "${existing_group_by_name}" ] && [ -z "${existing_group_by_gid}" ]; then \ + groupadd -g "${groupinfo#*;}" "${groupinfo%;*}" && usermod -aG "${groupinfo%;*}" "@(name)" @(('|| (true && echo "user-preserve-group-permissive Enabled, continuing without processing group $groupinfo" )') if user_preserve_groups_permissive else '') || (echo "Failed to add group ${groupinfo%;*}, consider option --user-preserve-group-permissive" && exit 2); \ + elif [ "${existing_group_by_name}" = "${existing_group_by_gid}" ]; then \ + usermod -aG "${groupinfo%;*}" "@(name)" @(('|| (true && echo "user-preserve-group-permissive Enabled, continuing without processing group $groupinfo" )') if user_preserve_groups_permissive else '') || (echo "Failed to adjust group ${groupinfo%;*}, consider option --user-preserve-group-permissive" && exit 2); \ + fi; \ + done && \ +@[end if]@ echo "@(name) ALL=NOPASSWD: ALL" >> /etc/sudoers.d/rocker @[if not home_extension_active ]@ diff --git a/src/rocker/volume_extension.py b/src/rocker/volume_extension.py index 1f7d4265..c6f58794 100644 --- a/src/rocker/volume_extension.py +++ b/src/rocker/volume_extension.py @@ -67,4 +67,4 @@ def register_arguments(parser): type=str, nargs='+', action='append', - help='volume volumes in container') + help='volume(s) to map into the container. The last path must be followed by two dashes "--"') diff --git a/stdeb.cfg b/stdeb.cfg index 40482429..9e1fc94c 100644 --- a/stdeb.cfg +++ b/stdeb.cfg @@ -3,5 +3,5 @@ Debian-Version: 100 No-Python2: Depends3: python3-docker, python3-empy, python3-pexpect, python3-packaging Conflicts3: python-rocker -Suite: xenial yakkety zesty artful bionic cosmic disco eoan focal stretch buster +Suite: bionic focal jammy stretch buster bullseye bookworm X-Python3-Version: >= 3.2 diff --git a/test/test_core.py b/test/test_core.py index 914d8437..f57e659b 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -17,6 +17,7 @@ import argparse import em +import pytest import unittest from itertools import chain @@ -56,18 +57,21 @@ def test_get_rocker_version(self): # Check that it can be cast to an int i = int(p) + @pytest.mark.docker def test_run_before_build(self): dig = DockerImageGenerator([], {}, 'ubuntu:bionic') self.assertEqual(dig.run('true'), 1) self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true'), 0) + @pytest.mark.docker def test_return_code_no_extensions(self): dig = DockerImageGenerator([], {}, 'ubuntu:bionic') self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true'), 0) self.assertEqual(dig.run('false'), 1) + @pytest.mark.docker def test_return_code_multiple_extensions(self): plugins = list_plugins() desired_plugins = ['home', 'user'] @@ -77,29 +81,34 @@ def test_return_code_multiple_extensions(self): self.assertEqual(dig.run('true'), 0) self.assertEqual(dig.run('false'), 1) + @pytest.mark.docker def test_noexecute(self): dig = DockerImageGenerator([], {}, 'ubuntu:bionic') self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true', noexecute=True), 0) + @pytest.mark.docker def test_dry_run(self): dig = DockerImageGenerator([], {}, 'ubuntu:bionic') self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true', mode='dry-run'), 0) self.assertEqual(dig.run('false', mode='dry-run'), 0) + @pytest.mark.docker def test_non_interactive(self): dig = DockerImageGenerator([], {}, 'ubuntu:bionic') self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true', mode='non-interactive'), 0) self.assertEqual(dig.run('false', mode='non-interactive'), 1) + @pytest.mark.docker def test_device(self): dig = DockerImageGenerator([], {}, 'ubuntu:bionic') self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true', devices=['/dev/random']), 0) self.assertEqual(dig.run('true', devices=['/dev/does_not_exist']), 0) + @pytest.mark.docker def test_network(self): dig = DockerImageGenerator([], {}, 'ubuntu:bionic') self.assertEqual(dig.build(), 0) @@ -107,6 +116,7 @@ def test_network(self): for n in networks: self.assertEqual(dig.run('true', network=n), 0) + @pytest.mark.docker def test_extension_manager(self): parser = argparse.ArgumentParser() extension_manager = RockerExtensionManager() @@ -146,4 +156,4 @@ def test_docker_cmd_nocleanup(self): self.assertIn('--rm', dig.generate_docker_cmd(mode='dry-run')) self.assertIn('--rm', dig.generate_docker_cmd(nocleanup='')) - self.assertNotIn('--rm', dig.generate_docker_cmd(nocleanup='true')) \ No newline at end of file + self.assertNotIn('--rm', dig.generate_docker_cmd(nocleanup='true')) diff --git a/test/test_extension.py b/test/test_extension.py index 300aa79b..a2c41a46 100644 --- a/test/test_extension.py +++ b/test/test_extension.py @@ -22,8 +22,12 @@ import unittest from pathlib import Path import pwd +import pytest +from io import BytesIO as StringIO +from rocker.core import DockerImageGenerator +from rocker.core import docker_build from rocker.core import list_plugins from rocker.extensions import name_to_argument @@ -123,6 +127,7 @@ def setUp(self): # "em.Error: interpreter stdout proxy lost" em.Interpreter._wasProxyInstalled = False + @pytest.mark.docker def test_network_extension(self): plugins = list_plugins() network_plugin = plugins['network'] @@ -141,6 +146,71 @@ def test_network_extension(self): args = p.get_docker_args(mock_cliargs) self.assertTrue('--network host' in args) +class ExposeExtensionTest(unittest.TestCase): + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + @pytest.mark.docker + def test_expose_extension(self): + plugins = list_plugins() + expose_plugin = plugins['expose'] + self.assertEqual(expose_plugin.get_name(), 'expose') + + p = expose_plugin() + self.assertTrue(plugin_load_parser_correctly(expose_plugin)) + + mock_cliargs = {} + self.assertEqual(p.get_snippet(mock_cliargs), '') + self.assertEqual(p.get_preamble(mock_cliargs), '') + args = p.get_docker_args(mock_cliargs) + self.assertNotIn('--expose', args) + + mock_cliargs = {'expose': ['80', '8080']} + args = p.get_docker_args(mock_cliargs) + self.assertIn('--expose 80', args) + self.assertIn('--expose 8080', args) + +class PortExtensionTest(unittest.TestCase): + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + @pytest.mark.docker + def test_port_extension(self): + plugins = list_plugins() + port_plugin = plugins['port'] + self.assertEqual(port_plugin.get_name(), 'port') + + p = port_plugin() + self.assertTrue(plugin_load_parser_correctly(port_plugin)) + + mock_cliargs = {} + self.assertEqual(p.get_snippet(mock_cliargs), '') + self.assertEqual(p.get_preamble(mock_cliargs), '') + args = p.get_docker_args(mock_cliargs) + self.assertNotIn('-p', args) + + mock_cliargs = {'port': ['80:8080', '81:8081']} + args = p.get_docker_args(mock_cliargs) + self.assertIn('-p 80:8080', args) + self.assertIn('-p 81:8081', args) class NameExtensionTest(unittest.TestCase): @@ -173,6 +243,37 @@ def test_name_extension(self): args = p.get_docker_args(mock_cliargs) self.assertTrue('--name docker_name' in args) +class HostnameExtensionTest(unittest.TestCase): + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + def test_name_extension(self): + plugins = list_plugins() + name_plugin = plugins['hostname'] + self.assertEqual(name_plugin.get_name(), 'hostname') + + p = name_plugin() + self.assertTrue(plugin_load_parser_correctly(name_plugin)) + + mock_cliargs = {'hostname': 'none'} + self.assertEqual(p.get_snippet(mock_cliargs), '') + self.assertEqual(p.get_preamble(mock_cliargs), '') + args = p.get_docker_args(mock_cliargs) + self.assertTrue('--hostname none' in args) + + mock_cliargs = {'hostname': 'docker-hostname'} + args = p.get_docker_args(mock_cliargs) + self.assertTrue('--hostname docker-hostname' in args) + class PrivilegedExtensionTest(unittest.TestCase): @@ -247,11 +348,89 @@ def test_user_extension(self): self.assertFalse('mkhomedir_helper' in p.get_snippet(home_active_cliargs)) user_override_active_cliargs = mock_cliargs + user_override_active_cliargs['user_preserve_groups'] = [] + snippet_result = p.get_snippet(user_override_active_cliargs) + self.assertTrue('usermod -aG' in snippet_result) + + user_override_active_cliargs = mock_cliargs + user_override_active_cliargs['user_preserve_groups'] = ['cdrom', 'audio'] + snippet_result = p.get_snippet(user_override_active_cliargs) + self.assertTrue('cdrom' in snippet_result) + self.assertTrue('audio' in snippet_result) + + user_override_active_cliargs = mock_cliargs + user_override_active_cliargs['user_preserve_groups'] = [] + user_override_active_cliargs['user_preserve_groups_permissive'] = True + snippet_result = p.get_snippet(user_override_active_cliargs) + self.assertTrue('usermod -aG' in snippet_result) + self.assertTrue('user-preserve-group-permissive Enabled' in snippet_result) + user_override_active_cliargs['user_override_name'] = 'testusername' - print(p.get_snippet(user_override_active_cliargs)) snippet_result = p.get_snippet(user_override_active_cliargs) self.assertTrue('USER testusername' in snippet_result) self.assertTrue('WORKDIR /home/testusername' in snippet_result) + self.assertTrue('userdel -r' in snippet_result) + + user_override_active_cliargs['user_preserve_home'] = True + snippet_result = p.get_snippet(user_override_active_cliargs) + self.assertFalse('userdel -r' in snippet_result) + + snippet_result = p.get_snippet(user_override_active_cliargs) + self.assertTrue(('-s ' + pwd.getpwuid(os.getuid()).pw_shell) in snippet_result) + + user_override_active_cliargs['user_override_shell'] = 'testshell' + snippet_result = p.get_snippet(user_override_active_cliargs) + self.assertTrue('-s testshell' in snippet_result) + + user_override_active_cliargs['user_override_shell'] = '' + snippet_result = p.get_snippet(user_override_active_cliargs) + self.assertFalse('-s' in snippet_result) + + @pytest.mark.docker + def test_user_collisions(self): + plugins = list_plugins() + user_plugin = plugins['user'] + self.assertEqual(user_plugin.get_name(), 'user') + + uid = os.getuid()+1 + COLLIDING_UID_DOCKERFILE = f"""FROM ubuntu:jammy +RUN useradd test -u{uid} + +""" + iof = StringIO(COLLIDING_UID_DOCKERFILE.encode()) + image_id = docker_build( + fileobj=iof, + #output_callback=output_callback, + nocache=True, + forcerm=True, + tag="rocker:" + f"user_extension_test_uid_collision" + ) + print(f'Image id is {image_id}') + self.assertTrue(image_id, f"Image failed to build >>>{COLLIDING_UID_DOCKERFILE}<<<") + + # Test Colliding UID but not name + build_args = { + 'user': True, + 'user_override_name': 'test2', + 'user_preserve_home': True, + # 'command': 'ls -l && touch /home/test2/home_directory_access_verification', + 'command': 'touch /home/test2/testwrite', + } + dig = DockerImageGenerator([user_plugin()], build_args, image_id) + exit_code = dig.build(**build_args) + self.assertTrue(exit_code == 0, f"Build failed with exit code {exit_code}") + run_exit_code = dig.run(**build_args) + self.assertTrue(run_exit_code == 0, f"Run failed with exit code {run_exit_code}") + + + # Test colliding UID and name + build_args['user_override_name'] = 'test' + build_args['command'] = 'touch /home/test/testwrite' + dig = DockerImageGenerator([user_plugin()], build_args, image_id) + exit_code = dig.build(**build_args) + self.assertTrue(exit_code == 0, f"Build failed with exit code {exit_code}") + run_exit_code = dig.run(**build_args) + self.assertTrue(run_exit_code == 0, f"Run failed with exit code {run_exit_code}") class PulseExtensionTest(unittest.TestCase): @@ -369,3 +548,37 @@ def test_env_file_extension(self): self.assertEqual(p.get_snippet(mock_cliargs), '') self.assertEqual(p.get_preamble(mock_cliargs), '') self.assertEqual(p.get_docker_args(mock_cliargs), ' --env-file foo --env-file bar') + + +class GroupAddExtensionTest(unittest.TestCase): + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + @pytest.mark.docker + def test_group_add_extension(self): + plugins = list_plugins() + group_add_plugin = plugins['group_add'] + self.assertEqual(group_add_plugin.get_name(), 'group_add') + + p = group_add_plugin() + self.assertTrue(plugin_load_parser_correctly(group_add_plugin)) + + mock_cliargs = {} + self.assertEqual(p.get_snippet(mock_cliargs), '') + self.assertEqual(p.get_preamble(mock_cliargs), '') + args = p.get_docker_args(mock_cliargs) + self.assertNotIn('--group_add', args) + + mock_cliargs = {'group_add': ['sudo', 'docker']} + args = p.get_docker_args(mock_cliargs) + self.assertIn('--group-add sudo', args) + self.assertIn('--group-add docker', args) diff --git a/test/test_nvidia.py b/test/test_nvidia.py index 01bf2d4d..d63d9794 100644 --- a/test/test_nvidia.py +++ b/test/test_nvidia.py @@ -19,6 +19,7 @@ import em import unittest import pexpect +import pytest from io import BytesIO as StringIO @@ -31,6 +32,7 @@ from test_extension import plugin_load_parser_correctly +@pytest.mark.docker class X11Test(unittest.TestCase): @classmethod def setUpClass(self): @@ -72,6 +74,9 @@ def test_x11_extension_basic(self): p = x11_plugin() mock_cliargs = {'base_image': 'ubuntu:xenial'} + # Must be called before get_docker_args + docker_args = p.precondition_environment(mock_cliargs) + docker_args = p.get_docker_args(mock_cliargs) self.assertIn(' -e DISPLAY -e TERM', docker_args) self.assertIn(' -e QT_X11_NO_MITSHM=1', docker_args) @@ -79,6 +84,15 @@ def test_x11_extension_basic(self): self.assertIn(' -v /tmp/.X11-unix:/tmp/.X11-unix ', docker_args) self.assertIn(' -v /etc/localtime:/etc/localtime:ro ', docker_args) + def test_x11_extension_nocleanup(self): + plugins = list_plugins() + x11_plugin = plugins['x11'] + p = x11_plugin() + mock_cliargs = {'base_image': 'ubuntu:xenial', 'nocleanup': True} + docker_args = p.precondition_environment(mock_cliargs) + # TODO(tfoote) do more to check that it doesn't actually clean up. + # This is more of a smoke test + def test_no_x11_xpdyinfo(self): for tag in self.dockerfile_tags: @@ -86,6 +100,7 @@ def test_no_x11_xpdyinfo(self): self.assertEqual(dig.build(), 0) self.assertNotEqual(dig.run(), 0) + @pytest.mark.x11 def test_x11_xpdyinfo(self): plugins = list_plugins() desired_plugins = ['x11'] @@ -96,6 +111,7 @@ def test_x11_xpdyinfo(self): self.assertEqual(dig.run(), 0) +@pytest.mark.docker class NvidiaTest(unittest.TestCase): @classmethod def setUpClass(self): @@ -169,6 +185,21 @@ def test_nvidia_extension_basic(self): else: self.assertIn(' --runtime=nvidia', docker_args) + mock_cliargs = {'nvidia': 'auto'} + docker_args = p.get_docker_args(mock_cliargs) + if get_docker_version() >= Version("19.03"): + self.assertIn(' --gpus all', docker_args) + else: + self.assertIn(' --runtime=nvidia', docker_args) + + mock_cliargs = {'nvidia': 'gpus'} + docker_args = p.get_docker_args(mock_cliargs) + self.assertIn(' --gpus all', docker_args) + + mock_cliargs = {'nvidia': 'runtime'} + docker_args = p.get_docker_args(mock_cliargs) + self.assertIn(' --runtime=nvidia', docker_args) + def test_no_nvidia_glmark2(self): for tag in self.dockerfile_tags: @@ -176,6 +207,8 @@ def test_no_nvidia_glmark2(self): self.assertEqual(dig.build(), 0) self.assertNotEqual(dig.run(), 0) + @pytest.mark.nvidia + @pytest.mark.x11 def test_nvidia_glmark2(self): plugins = list_plugins() desired_plugins = ['x11', 'nvidia', 'user'] #TODO(Tfoote) encode the x11 dependency into the plugin and remove from test here @@ -208,3 +241,76 @@ def test_nvidia_env_subs(self): with self.assertRaises(SystemExit) as cm: p.get_environment_subs(mock_cliargs) self.assertEqual(cm.exception.code, 1) + +class CudaTest(unittest.TestCase): + @classmethod + def setUpClass(self): + client = get_docker_client() + self.dockerfile_tags = [] + for distro_version in ['focal', 'jammy']: + dockerfile = """ +FROM ubuntu:%(distro_version)s +CMD dpkg -s cuda +""" + dockerfile_tag = 'testfixture_%s_cuda' % distro_version + iof = StringIO((dockerfile % locals()).encode()) + im = client.build(fileobj = iof, tag=dockerfile_tag) + for e in im: + pass + #print(e) + self.dockerfile_tags.append(dockerfile_tag) + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + + @pytest.mark.docker + def test_no_cuda(self): + for tag in self.dockerfile_tags: + dig = DockerImageGenerator([], {}, tag) + self.assertEqual(dig.build(), 0) + self.assertNotEqual(dig.run(), 0) + + @pytest.mark.nvidia + @pytest.mark.x11 + @pytest.mark.docker + def test_cuda(self): + plugins = list_plugins() + desired_plugins = ['x11', 'nvidia', 'cuda'] #TODO(Tfoote) encode the x11 dependency into the plugin and remove from test here + active_extensions = [e() for e in plugins.values() if e.get_name() in desired_plugins] + for tag in self.dockerfile_tags: + dig = DockerImageGenerator(active_extensions, {}, tag) + self.assertEqual(dig.build(), 0) + self.assertEqual(dig.run(), 0) + + def test_cuda_env_subs(self): + plugins = list_plugins() + cuda_plugin = plugins['cuda'] + + p = cuda_plugin() + + # base image doesn't exist + mock_cliargs = {'base_image': 'ros:does-not-exist'} + with self.assertRaises(SystemExit) as cm: + p.get_environment_subs(mock_cliargs) + self.assertEqual(cm.exception.code, 1) + + # unsupported version + mock_cliargs = {'base_image': 'ubuntu:17.04'} + with self.assertRaises(SystemExit) as cm: + p.get_environment_subs(mock_cliargs) + self.assertEqual(cm.exception.code, 1) + + # unsupported os + mock_cliargs = {'base_image': 'fedora'} + with self.assertRaises(SystemExit) as cm: + p.get_environment_subs(mock_cliargs) + self.assertEqual(cm.exception.code, 1) diff --git a/test/test_os_detect.py b/test/test_os_detect.py index dd26ba7f..2099f3ee 100644 --- a/test/test_os_detect.py +++ b/test/test_os_detect.py @@ -16,6 +16,7 @@ # under the License. import docker +import pytest import unittest @@ -23,6 +24,7 @@ class RockerOSDetectorTest(unittest.TestCase): + @pytest.mark.docker def test_ubuntu(self): result = detect_os("ubuntu:xenial") self.assertEqual(result[0], 'Ubuntu') @@ -37,15 +39,18 @@ def test_ubuntu(self): self.assertEqual(result[0], 'Ubuntu') self.assertEqual(result[1], '18.04') + @pytest.mark.docker def test_fedora(self): result = detect_os("fedora:29") self.assertEqual(result[0], 'Fedora') self.assertEqual(result[1], '29') + @pytest.mark.docker def test_does_not_exist(self): result = detect_os("osrf/ros:does-not-exist") self.assertEqual(result, None) + @pytest.mark.docker def test_cannot_detect_os(self): # Test with output callback too get coverage of error reporting result = detect_os("scratch", output_callback=print)