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