xref: /linux/scripts/container (revision 5d7f3375cd574fc83f0cd30839b21455c0e79ae2)
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