1#!/usr/bin/env python3 2# SPDX-License-Identifier: GPL-2.0-only 3# Copyright (C) 2025 Guillaume Tucker 4 5"""Containerized builds""" 6 7import abc 8import argparse 9import logging 10import os 11import pathlib 12import shutil 13import subprocess 14import sys 15import uuid 16 17 18class ContainerRuntime(abc.ABC): 19 """Base class for a container runtime implementation""" 20 21 name = None # Property defined in each implementation class 22 23 def __init__(self, args, logger): 24 self._uid = args.uid or os.getuid() 25 self._gid = args.gid or args.uid or os.getgid() 26 self._env_file = args.env_file 27 self._shell = args.shell 28 self._logger = logger 29 30 @classmethod 31 def is_present(cls): 32 """Determine whether the runtime is present on the system""" 33 return shutil.which(cls.name) is not None 34 35 @abc.abstractmethod 36 def _do_run(self, image, cmd, container_name): 37 """Runtime-specific handler to run a command in a container""" 38 39 @abc.abstractmethod 40 def _do_abort(self, container_name): 41 """Runtime-specific handler to abort a running container""" 42 43 def run(self, image, cmd): 44 """Run a command in a runtime container""" 45 container_name = str(uuid.uuid4()) 46 self._logger.debug("container: %s", container_name) 47 try: 48 return self._do_run(image, cmd, container_name) 49 except KeyboardInterrupt: 50 self._logger.error("user aborted") 51 self._do_abort(container_name) 52 return 1 53 54 55class CommonRuntime(ContainerRuntime): 56 """Common logic for Docker and Podman""" 57 58 def _do_run(self, image, cmd, container_name): 59 cmdline = [self.name, 'run'] 60 cmdline += self._get_opts(container_name) 61 cmdline.append(image) 62 cmdline += cmd 63 self._logger.debug('command: %s', ' '.join(cmdline)) 64 return subprocess.call(cmdline) 65 66 def _get_opts(self, container_name): 67 opts = [ 68 '--name', container_name, 69 '--rm', 70 '--volume', f'{pathlib.Path.cwd()}:/src', 71 '--workdir', '/src', 72 ] 73 if self._env_file: 74 opts += ['--env-file', self._env_file] 75 if self._shell: 76 opts += ['--interactive', '--tty'] 77 return opts 78 79 def _do_abort(self, container_name): 80 subprocess.call([self.name, 'kill', container_name]) 81 82 83class DockerRuntime(CommonRuntime): 84 """Run a command in a Docker container""" 85 86 name = 'docker' 87 88 def _get_opts(self, container_name): 89 return super()._get_opts(container_name) + [ 90 '--user', f'{self._uid}:{self._gid}' 91 ] 92 93 94class PodmanRuntime(CommonRuntime): 95 """Run a command in a Podman container""" 96 97 name = 'podman' 98 99 def _get_opts(self, container_name): 100 return super()._get_opts(container_name) + [ 101 '--userns', f'keep-id:uid={self._uid},gid={self._gid}', 102 ] 103 104 105class Runtimes: 106 """List of all supported runtimes""" 107 108 runtimes = [PodmanRuntime, DockerRuntime] 109 110 @classmethod 111 def get_names(cls): 112 """Get a list of all the runtime names""" 113 return list(runtime.name for runtime in cls.runtimes) 114 115 @classmethod 116 def get(cls, name): 117 """Get a single runtime class matching the given name""" 118 for runtime in cls.runtimes: 119 if runtime.name == name: 120 if not runtime.is_present(): 121 raise ValueError(f"runtime not found: {name}") 122 return runtime 123 raise ValueError(f"unknown runtime: {name}") 124 125 @classmethod 126 def find(cls): 127 """Find the first runtime present on the system""" 128 for runtime in cls.runtimes: 129 if runtime.is_present(): 130 return runtime 131 raise ValueError("no runtime found") 132 133 134def _get_logger(verbose): 135 """Set up a logger with the appropriate level""" 136 logger = logging.getLogger('container') 137 handler = logging.StreamHandler() 138 handler.setFormatter(logging.Formatter( 139 fmt='[container {levelname}] {message}', style='{' 140 )) 141 logger.addHandler(handler) 142 logger.setLevel(logging.DEBUG if verbose is True else logging.INFO) 143 return logger 144 145 146def main(args): 147 """Main entry point for the container tool""" 148 logger = _get_logger(args.verbose) 149 try: 150 cls = Runtimes.get(args.runtime) if args.runtime else Runtimes.find() 151 except ValueError as ex: 152 logger.error(ex) 153 return 1 154 logger.debug("runtime: %s", cls.name) 155 logger.debug("image: %s", args.image) 156 return cls(args, logger).run(args.image, args.cmd) 157 158 159if __name__ == '__main__': 160 parser = argparse.ArgumentParser( 161 'container', 162 description="See the documentation for more details: " 163 "https://docs.kernel.org/dev-tools/container.html" 164 ) 165 parser.add_argument( 166 '-e', '--env-file', 167 help="Path to an environment file to load in the container." 168 ) 169 parser.add_argument( 170 '-g', '--gid', 171 help="Group ID to use inside the container." 172 ) 173 parser.add_argument( 174 '-i', '--image', required=True, 175 help="Container image name." 176 ) 177 parser.add_argument( 178 '-r', '--runtime', choices=Runtimes.get_names(), 179 help="Container runtime name. If not specified, the first one found " 180 "on the system will be used i.e. Podman if present, otherwise Docker." 181 ) 182 parser.add_argument( 183 '-s', '--shell', action='store_true', 184 help="Run the container in an interactive shell." 185 ) 186 parser.add_argument( 187 '-u', '--uid', 188 help="User ID to use inside the container. If the -g option is not " 189 "specified, the user ID will also be set as the group ID." 190 ) 191 parser.add_argument( 192 '-v', '--verbose', action='store_true', 193 help="Enable verbose output." 194 ) 195 parser.add_argument( 196 'cmd', nargs='+', 197 help="Command to run in the container" 198 ) 199 sys.exit(main(parser.parse_args(sys.argv[1:]))) 200