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