blob: a3a55180d6ed79b9c624373d4f931c1da3936454 [file]
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
# Copyright (C) 2025-2026 Guillaume Tucker
"""Containerized builds"""
import abc
import argparse
import dataclasses
import logging
import os
import pathlib
import shutil
import subprocess
import sys
import tomllib
import uuid
class ContainerRuntime(abc.ABC):
"""Base class for a container runtime implementation"""
name = None # Property defined in each implementation class
def __init__(self, args, config, logger):
def _first_not_none(*args):
return next((item for item in args if item is not None))
self._uid = _first_not_none(
args.uid, config.uid, os.getuid()
)
self._gid = _first_not_none(
args.gid, config.gid, args.uid, config.uid, os.getgid()
)
self._env_file = args.env_file or config.env_file
self._shell = args.shell
self._logger = logger
@classmethod
def is_present(cls):
"""Determine whether the runtime is present on the system"""
return shutil.which(cls.name) is not None
@abc.abstractmethod
def _do_run(self, image, cmd, container_name):
"""Runtime-specific handler to run a command in a container"""
@abc.abstractmethod
def _do_abort(self, container_name):
"""Runtime-specific handler to abort a running container"""
def run(self, image, cmd):
"""Run a command in a runtime container"""
container_name = str(uuid.uuid4())
self._logger.debug("container: %s", container_name)
try:
return self._do_run(image, cmd, container_name)
except KeyboardInterrupt:
self._logger.error("user aborted")
self._do_abort(container_name)
return 1
class CommonRuntime(ContainerRuntime):
"""Common logic for Docker and Podman"""
def _do_run(self, image, cmd, container_name):
cmdline = [self.name, 'run']
cmdline += self._get_opts(container_name)
cmdline.append(image)
cmdline += cmd
self._logger.debug('command: %s', ' '.join(cmdline))
return subprocess.call(cmdline)
def _get_opts(self, container_name):
opts = [
'--name', container_name,
'--rm',
'--volume', f'{pathlib.Path.cwd()}:/src',
'--workdir', '/src',
]
if self._env_file:
opts += ['--env-file', self._env_file]
if self._shell:
opts += ['--interactive', '--tty']
return opts
def _do_abort(self, container_name):
subprocess.call([self.name, 'kill', container_name])
class DockerRuntime(CommonRuntime):
"""Run a command in a Docker container"""
name = 'docker'
def _get_opts(self, container_name):
return super()._get_opts(container_name) + [
'--user', f'{self._uid}:{self._gid}'
]
class PodmanRuntime(CommonRuntime):
"""Run a command in a Podman container"""
name = 'podman'
def _get_opts(self, container_name):
return super()._get_opts(container_name) + [
'--userns', f'keep-id:uid={self._uid},gid={self._gid}',
]
class Runtimes:
"""List of all supported runtimes"""
runtimes = [PodmanRuntime, DockerRuntime]
@classmethod
def get_names(cls):
"""Get a list of all the runtime names"""
return list(runtime.name for runtime in cls.runtimes)
@classmethod
def get(cls, name):
"""Get a single runtime class matching the given name"""
for runtime in cls.runtimes:
if runtime.name == name:
if not runtime.is_present():
raise ValueError(f"runtime not found: {name}")
return runtime
raise ValueError(f"unknown runtime: {name}")
@classmethod
def find(cls):
"""Find the first runtime present on the system"""
for runtime in cls.runtimes:
if runtime.is_present():
return runtime
raise ValueError("no runtime found")
@dataclasses.dataclass
class Config:
"""Container configuration"""
image: str = None
runtime: str = None
env_file: str = None
uid: int = None
gid: int = None
@classmethod
def from_toml(cls, config_file_path, profile_name):
"""Create a config object from a TOML file"""
if not config_file_path:
config_file_path = '.container.toml'
if not os.path.exists(config_file_path):
return cls()
elif not os.path.exists(config_file_path):
raise ValueError(f"config file not found: {config_file_path}")
with open(config_file_path, 'rb') as config_file:
config = tomllib.load(config_file)
default = config.get('DEFAULT', {})
if not profile_name:
profile = {}
else:
profile = config.get(profile_name)
if profile is None:
raise ValueError(f"unknown profile: {profile_name}")
kwargs = {
name: type(value) for (name, type, value) in (
(op.name, op.type, profile.get(op.name, default.get(op.name)))
for op in dataclasses.fields(cls)
) if value is not None
}
return cls(**kwargs)
def _get_logger(verbose):
"""Set up a logger with the appropriate level"""
logger = logging.getLogger('container')
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(
fmt='[container {levelname}] {message}', style='{'
))
logger.addHandler(handler)
logger.setLevel(logging.DEBUG if verbose is True else logging.INFO)
return logger
def main(args):
"""Main entry point for the container tool"""
logger = _get_logger(args.verbose)
try:
config = Config.from_toml(args.config_file, args.config_profile)
runtime = args.runtime or config.runtime
cls = Runtimes.get(runtime) if runtime else Runtimes.find()
except ValueError as ex:
logger.error(ex)
return 1
logger.debug("runtime: %s", cls.name)
image = args.image or config.image
if not image:
logger.error("no image specified")
return 1
logger.debug("image: %s", image)
return cls(args, config, logger).run(image, args.cmd)
if __name__ == '__main__':
parser = argparse.ArgumentParser(
'container',
description="See the documentation for more details: "
"https://docs.kernel.org/dev-tools/container.html"
)
parser.add_argument(
'-c', '--config-file',
help="Path to the config file. If not specified, the default is to "
"look for .container.toml in the current working directory."
)
parser.add_argument(
'-e', '--env-file',
help="Path to an environment file to load in the container."
)
parser.add_argument(
'-g', '--gid', type=int,
help="Group ID to use inside the container."
)
parser.add_argument(
'-i', '--image',
help="Container image name."
)
parser.add_argument(
'-p', '--config-profile',
help="Profile section to use in the config file. This will override "
"any values defined in the DEFAULT section."
)
parser.add_argument(
'-r', '--runtime', choices=Runtimes.get_names(),
help="Container runtime name. If not specified, the first one found "
"on the system will be used i.e. Podman if present, otherwise Docker."
)
parser.add_argument(
'-s', '--shell', action='store_true',
help="Run the container in an interactive shell."
)
parser.add_argument(
'-u', '--uid', type=int,
help="User ID to use inside the container. If the -g option is not "
"specified and no group ID is defined in the configuration file, the "
"user ID will also be set as the group ID."
)
parser.add_argument(
'-v', '--verbose', action='store_true',
help="Enable verbose output."
)
parser.add_argument(
'cmd', nargs='+',
help="Command to run in the container"
)
sys.exit(main(parser.parse_args(sys.argv[1:])))