linux/scripts/container
Guillaume Tucker 69cb84e259
scripts: add TOML config to container tool
Add support for a TOML configuration file to the scripts/container
tool.  This improves user experience by not having to keep passing the
same command line options all the time or overly relying on built-in
default values.  Include the concept of 'profiles' with different
named sections in the file to cover various use cases.

Command line options take precedence over the config file, and values
defined in profile sections take precedence over the default one.

Add a -c option to override the location of the .container.toml config
file which should otherwise be located in the current working
directory.  If not found, the file is silently ignored as it is not
strictly required unless the -c option is used.

Add a -p option to choose a particular profile section in the config
file rather than the default.

Signed-off-by: Guillaume Tucker <gtucker@gtucker.io>
Acked-by: Nicolas Schier <n.schier@fritz.com>
Link: https://patch.msgid.link/68b14a76a4cb2e6437c869bab948983a98acffbd.1788779245.git.gtucker@gtucker.io
Signed-off-by: Nathan Chancellor <nathan@kernel.org>
2026-10-02 22:17:21 +02:00

262 lines
8.0 KiB
Python
Executable 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:])))