diff --git a/doc/source/admin/image-building.rst b/doc/source/admin/image-building.rst index ff0a4164b6..842198470b 100644 --- a/doc/source/admin/image-building.rst +++ b/doc/source/admin/image-building.rst @@ -741,6 +741,29 @@ variables that will be picked up from the user env: Also these variables could be overwritten using ``--build-args``, which have precedence. +Docker BuildKit +--------------- + +When using ``--engine docker``, ``kolla-build`` builds images via +``docker buildx build`` (Docker BuildKit) by default. This requires the +``docker-buildx-plugin`` package to be installed. + +To disable BuildKit and fall back to the legacy docker-py SDK, set +``buildkit = False`` in ``kolla-build.conf`` or pass ``--nobuildkit`` on the +command line. + +To use a specific buildx builder instance (e.g. a ``docker-container`` or +remote driver), pass ``--buildkit-builder``: + +.. code-block:: console + + kolla-build --buildkit-builder mybuilder + +.. note:: + + ``--buildkit`` and ``--squash`` are mutually exclusive. Use one or the + other. + Cross-compiling --------------- @@ -756,7 +779,11 @@ To build ``ARM`` images on ``x86_64`` platform, pass the ``--base-arch`` and .. note:: - To make this work on x86_64 platform you can use tools like: `qemu-user-static + Cross-compilation is natively handled by Docker BuildKit; using BuildKit + (the default) is recommended for multi-platform builds. + + To make this work on x86_64 platform with the docker-py based builder + (``--nobuildkit``) you can use tools like: `qemu-user-static `_ or `binfmt `_. diff --git a/docker/base/Dockerfile.j2 b/docker/base/Dockerfile.j2 index a4255f98c1..3825a2f79d 100644 --- a/docker/base/Dockerfile.j2 +++ b/docker/base/Dockerfile.j2 @@ -138,6 +138,7 @@ RUN {{ macros.install_packages(base_centos_yum_repo_packages | customizable("cen 'procps-ng', 'python3', 'python3-pip', + 'python3-pyyaml', 'socat', 'sudo', 'tar', @@ -230,6 +231,7 @@ COPY apt_preferences /etc/apt/preferences.d/kolla-custom 'procps', 'python3', 'python3-pip', + 'python3-yaml', 'socat', 'sudo', 'tgt', diff --git a/docker/base/set_configs.py b/docker/base/set_configs.py index 1a80bd8771..542537e4c9 100644 --- a/docker/base/set_configs.py +++ b/docker/base/set_configs.py @@ -23,6 +23,7 @@ import shutil import stat import sys +import yaml # TODO(rhallisey): add docstring. @@ -298,13 +299,33 @@ def validate_source(data): def load_config(): def load_from_file(): - config_file = '/var/lib/kolla/config_files/config.json' - LOG.info("Loading config file at %s", config_file) + yaml_config_file = '/var/lib/kolla/config_files/config.yaml' + json_config_file = '/var/lib/kolla/config_files/config.json' - # Attempt to read config file - with open(config_file) as f: + if os.path.exists(yaml_config_file): + config_file = yaml_config_file + LOG.info("Loading config file at %s", config_file) try: - return json.load(f) + with open(config_file) as f: + config = yaml.safe_load(f) + + if config is None: + raise InvalidConfig( + "Invalid yaml file found at %s: " + "file is empty" % config_file) + return config + except yaml.YAMLError: + raise InvalidConfig( + "Invalid yaml file found at %s" % config_file) + except IOError as e: + raise InvalidConfig( + "Could not read file %s: %r" % (config_file, e)) + else: + config_file = json_config_file + LOG.info("Loading config file at %s", config_file) + try: + with open(config_file) as f: + return json.load(f) except ValueError: raise InvalidConfig( "Invalid json file found at %s" % config_file) @@ -631,7 +652,7 @@ def execute_config_check(config): """ state = get_defaults_state() - # Build a set of all current destination paths from config.json + # Build a set of all current destination paths from the config # If the destination is a directory, we append the # basename of the source current_dests = { @@ -642,7 +663,7 @@ def execute_config_check(config): } # Detect any paths that are present in the state file but - # missing from config.json. + # missing from the config file. # These would be either restored (if state[dest] has a backup) # or removed (if dest is null) removed_dests = [ @@ -653,7 +674,7 @@ def execute_config_check(config): if removed_dests: raise StateMismatch( f"The following config files are tracked in state but missing " - f"from config.json. " + f"from the config file. " f"They would be restored or removed: {sorted(removed_dests)}" ) diff --git a/docker/gnocchi/gnocchi-base/Dockerfile.j2 b/docker/gnocchi/gnocchi-base/Dockerfile.j2 index b4a210a136..a050bce8d6 100644 --- a/docker/gnocchi/gnocchi-base/Dockerfile.j2 +++ b/docker/gnocchi/gnocchi-base/Dockerfile.j2 @@ -18,7 +18,6 @@ LABEL maintainer="{{ maintainer }}" name="{{ image_name }}" build-date="{{ build 'python3-rados', ] %} -RUN mkdir -p /var/www/cgi-bin/gnocchi {% elif base_package_type == 'deb' %} {% set gnocchi_base_packages = [ diff --git a/docker/horizon/Dockerfile.j2 b/docker/horizon/Dockerfile.j2 index bd661fcad7..86a9f8058b 100644 --- a/docker/horizon/Dockerfile.j2 +++ b/docker/horizon/Dockerfile.j2 @@ -46,13 +46,9 @@ COPY extend_start.sh /usr/local/bin/kolla_extend_start # NOTE(kevko): This dance with local settings python paths below is needed # because we are using different distros with different python version and we need to # know to which path symlink should point to. -# NOTE(bbezak): pin setuptools for pip build isolation due to -# https://bugs.launchpad.net/horizon/+bug/2141293 RUN ln -s horizon-source/* horizon \ && {{ macros.upper_constraints_remove("horizon") }} \ - && echo "setuptools<82" > /tmp/horizon-build-constraints.txt \ - && PIP_BUILD_CONSTRAINT=/tmp/horizon-build-constraints.txt \ - {{ macros.install_pip(horizon_pip_packages | customizable("pip_packages")) }} \ + && {{ macros.install_pip(horizon_pip_packages | customizable("pip_packages")) }} \ && mkdir -p /etc/openstack-dashboard \ && cp -r /horizon/openstack_dashboard/conf/* /etc/openstack-dashboard/ \ && cp /horizon/openstack_dashboard/local/local_settings.py.example /etc/openstack-dashboard/local_settings.py \ diff --git a/docker/masakari/masakari-base/Dockerfile.j2 b/docker/masakari/masakari-base/Dockerfile.j2 index 9b5e117bca..7ec23e4e11 100644 --- a/docker/masakari/masakari-base/Dockerfile.j2 +++ b/docker/masakari/masakari-base/Dockerfile.j2 @@ -33,9 +33,8 @@ COPY extend_start.sh /usr/local/bin/kolla_extend_start RUN ln -s masakari-base-source/* masakari \ && {{ macros.install_pip(masakari_base_pip_packages | customizable("pip_packages")) }} \ - && mkdir -p /etc/masakari /var/www/cgi-bin/masakari \ + && mkdir -p /etc/masakari \ && cp -r /masakari/etc/masakari/* /etc/masakari/ \ - && chmod 755 /var/www/cgi-bin/masakari \ && touch /usr/local/bin/kolla_masakari_extend_start \ && chmod 644 /usr/local/bin/kolla_extend_start /usr/local/bin/kolla_masakari_extend_start diff --git a/docker/nova/nova-libvirt/Dockerfile.j2 b/docker/nova/nova-libvirt/Dockerfile.j2 index 88e4a52d6e..b866917eae 100644 --- a/docker/nova/nova-libvirt/Dockerfile.j2 +++ b/docker/nova/nova-libvirt/Dockerfile.j2 @@ -20,6 +20,7 @@ LABEL maintainer="{{ maintainer }}" name="{{ image_name }}" build-date="{{ build 'libguestfs', 'libvirt-client', 'libvirt-daemon', + 'libvirt-daemon-log', 'libvirt-daemon-config-nwfilter', 'libvirt-daemon-driver-nwfilter', 'libvirt-daemon-driver-nodedev', @@ -75,6 +76,7 @@ LABEL maintainer="{{ maintainer }}" name="{{ image_name }}" build-date="{{ build {% if base_distro in ['debian'] %} {% set nova_libvirt_packages = nova_libvirt_packages + [ + 'libvirt-daemon-log', 'usermode' ] %} {% endif %} @@ -89,7 +91,8 @@ RUN rm -f /etc/libvirt/qemu/networks/default.xml /etc/libvirt/qemu/networks/auto {% endif %} COPY extend_start.sh /usr/local/bin/kolla_extend_start -RUN chmod 644 /usr/local/bin/kolla_extend_start +COPY kolla_nova_libvirt_start.sh /usr/local/bin/kolla_nova_libvirt_start +RUN chmod 644 /usr/local/bin/kolla_extend_start && chmod 744 /usr/local/bin/kolla_nova_libvirt_start {{ macros.kolla_patch_sources() }} diff --git a/docker/nova/nova-libvirt/kolla_nova_libvirt_start.sh b/docker/nova/nova-libvirt/kolla_nova_libvirt_start.sh new file mode 100644 index 0000000000..f06a63d9ed --- /dev/null +++ b/docker/nova/nova-libvirt/kolla_nova_libvirt_start.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail + +# NOTE(mikal): Start virtlogd as a sidecar. We need this for +# nova instance console logs to rotate. Otherwise they will +# consume unbounded amounts of disk. +# +# No exec here: libvirtd will be the "main" process. +/usr/sbin/virtlogd & + +# Now hand over PID 1's direct child role to libvirtd. +exec /usr/sbin/libvirtd --listen diff --git a/kolla/common/config.py b/kolla/common/config.py index c4ebcbf3bb..d46de40953 100644 --- a/kolla/common/config.py +++ b/kolla/common/config.py @@ -171,6 +171,14 @@ help='The Docker namespace name'), cfg.StrOpt('network_mode', default='host', help='The network mode for Docker build. Example: host'), + cfg.BoolOpt('buildkit', default=True, + help='Use Docker BuildKit (docker buildx build) when building ' + 'images. Requires the docker-buildx-plugin to be ' + 'installed. Only valid with --engine docker.'), + cfg.StrOpt('buildkit-builder', default=None, + help='Name of the docker buildx builder instance to use. ' + 'If unset the currently active builder is used. ' + 'Only valid with --buildkit.'), cfg.BoolOpt('cache', default=True, help='Use the container engine cache when building'), cfg.StrOpt('patches-path', default=None, @@ -256,7 +264,7 @@ cfg.StrOpt('image-name-prefix', default='', help='Prefix prepended to image names'), cfg.StrOpt('repos-yaml', default='', - help='Path to alternative repos.yaml file'), + help='Path to repos.yaml override file'), cfg.StrOpt('engine', default='docker', choices=['docker', 'podman'], help='Container engine to build images on.'), cfg.StrOpt('podman_base_url', default='unix:///run/podman/podman.sock', diff --git a/kolla/common/sources.py b/kolla/common/sources.py index 08c4a4639b..248e0697d8 100644 --- a/kolla/common/sources.py +++ b/kolla/common/sources.py @@ -388,11 +388,11 @@ 'openstack-network-exporter' '-linux-${debian_arch}')}, 'prometheus-server': { - 'version': '3.5.3', + 'version': '3.5.4', 'type': 'url', 'sha256': { - 'amd64': '8c30b9d99664e39b0363c0ba54fab30a7958e9d3de27246bf26ed85e6cfb8946', # noqa: E501 - 'arm64': '11457bc76cab34f5ac05ba05fb80cfca1e8be7e4b31ae7c054879ce1066cb9a5'}, # noqa: E501 + 'amd64': 'be64e1cf657e6e7d132aca022f6135abedd660db053952e3f69e8affa3b9cc9e', # noqa: E501 + 'arm64': '97634b9b6ed9ef18689e3b3572f1b277714650657516494fd164dcc53c1d3f48'}, # noqa: E501 'location': ('https://github.com/' 'prometheus/prometheus/' 'releases/download/v${version}/' diff --git a/kolla/common/utils.py b/kolla/common/utils.py index 4ee80768f2..485848ba27 100644 --- a/kolla/common/utils.py +++ b/kolla/common/utils.py @@ -54,6 +54,21 @@ def make_a_logger(conf=None, image_name=None): LOG = make_a_logger() +def check_docker_buildx(): + try: + subprocess.check_output( # nosec + ['docker', 'buildx', 'version'], stderr=subprocess.STDOUT) + except OSError as ex: + if ex.errno == 2: + LOG.error('"docker" command is not found.') + raise + except subprocess.CalledProcessError: + LOG.error('"docker buildx" is not available. ' + 'Install the docker-buildx-plugin or disable BuildKit ' + 'with "--nobuildkit".') + raise + + def get_docker_squash_version(): try: diff --git a/kolla/image/build.py b/kolla/image/build.py index 9b6571d363..a0513b22a9 100644 --- a/kolla/image/build.py +++ b/kolla/image/build.py @@ -120,6 +120,10 @@ def run_build(): "Try running 'pip install docker'\n" "Python error: %s", e) sys.exit(1) + if conf.buildkit and conf.squash: + LOG.error('--buildkit and --squash are mutually exclusive: ' + 'docker buildx build does not support squashing.') + sys.exit(1) if conf.squash: squash_version = utils.get_docker_squash_version() LOG.info('Image squash is enabled and "docker-squash" version ' @@ -171,6 +175,12 @@ def run_build(): kolla.list_dependencies() return + if (conf.engine == engine.Engine.DOCKER.value and conf.buildkit): + try: + utils.check_docker_buildx() + except Exception: + sys.exit(1) + push_queue = queue.Queue() build_queue = kolla.build_queue(push_queue) workers = [] diff --git a/kolla/image/tasks.py b/kolla/image/tasks.py index 6bee905f7c..757197ef45 100644 --- a/kolla/image/tasks.py +++ b/kolla/image/tasks.py @@ -16,6 +16,7 @@ import json import os import shutil +import subprocess # nosec import tarfile try: @@ -156,6 +157,11 @@ def __init__(self, conf, image, push_queue): def name(self): return 'BuildTask(%s)' % self.image.name + @property + def _buildkit_active(self): + return (self.conf.engine == engine.Engine.DOCKER.value + and self.conf.buildkit) + def run(self): self.builder(self.image) if self.image.status in (Status.BUILT, Status.SKIPPED): @@ -164,7 +170,7 @@ def run(self): @property def followups(self): followups = [] - if self.conf.push and self.success: + if self.conf.push and self.success and not self._buildkit_active: followups.extend([ # If we are supposed to push the image into a # container image repository, @@ -406,6 +412,10 @@ def reset_userinfo(tarinfo): buildargs = self.update_buildargs() + if self._buildkit_active: + self._build_buildkit(image, pull, buildargs) + return + kwargs = {} if hasattr(image, 'labels') and image.labels: kwargs['labels'] = image.labels @@ -478,6 +488,88 @@ def reset_userinfo(tarinfo): self.logger.info('Built at %s (took %s)' % (now, now - image.start)) + def _build_buildkit(self, image, pull, buildargs): + platform = self.conf.platform or '' + # Multi-platform (comma-separated): buildx pushes a manifest list + # directly to the registry. --load is not supported for multi-platform + # output, so --push is required. + # + # Single platform: load into the local daemon so child FROM lines + # resolve without a registry round-trip, then push the canonical tag. + # + # No platform: use --push or --load based on conf.push. + multi_platform = ',' in platform + single_platform = bool(platform and not multi_platform) + + if multi_platform and not self.conf.push: + self.logger.error( + 'Multi-platform buildkit build requires --push: ' + 'docker buildx does not support --load for multi-platform ' + 'output.') + image.status = Status.ERROR + return + + cmd = ['docker', 'buildx', 'build', '--progress=plain'] + if self.conf.buildkit_builder: + cmd.extend(['--builder', self.conf.buildkit_builder]) + if pull: + cmd.append('--pull') + if not self.conf.cache: + cmd.append('--no-cache') + if self.conf.network_mode: + cmd.extend(['--network', self.conf.network_mode]) + if platform: + cmd.extend(['--platform', platform]) + + cmd.extend(['-t', image.canonical_name]) + + if self.conf.push and not single_platform: + # Multi-platform or no-platform with push: let buildx push directly + cmd.append('--push') + else: + # Load into the local daemon so child image FROM lines resolve + # locally. For single-platform builds with --push, the canonical + # tag is pushed explicitly after the build. + cmd.append('--load') + + if buildargs: + for k, v in buildargs.items(): + cmd.extend(['--build-arg', '%s=%s' % (k, v)]) + if hasattr(image, 'labels') and image.labels: + for k, v in image.labels.items(): + cmd.extend(['--label', '%s=%s' % (k, v)]) + cmd.append(image.path) + + try: + self._run_cmd(cmd) + if single_platform and self.conf.push: + self._run_cmd(['docker', 'push', image.canonical_name]) + except Exception: + image.status = Status.ERROR + self.logger.exception('Unknown error when building') + else: + image.status = Status.BUILT + now = datetime.datetime.now() + self.logger.info('Built at %s (took %s)' % + (now, now - image.start)) + + def _run_cmd(self, cmd): + env = os.environ.copy() + env['NO_COLOR'] = '1' + with subprocess.Popen( # nosec + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + bufsize=1, + env=env, + ) as proc: + for line in proc.stdout: + self.logger.info(line.rstrip()) + proc.wait() + if proc.returncode != 0: + raise subprocess.CalledProcessError(proc.returncode, cmd) + def squash(self): image_tag = self.image.canonical_name image_id = self.engine_client.images.get(image_tag).id diff --git a/kolla/template/methods.py b/kolla/template/methods.py index 42b7399bbc..5dbf8da1bb 100644 --- a/kolla/template/methods.py +++ b/kolla/template/methods.py @@ -108,15 +108,18 @@ def handle_repos(context, reponames, mode): if not isinstance(reponames, list): raise TypeError("First argument should be a list of repositories") + default_repofile = os.path.dirname( + os.path.realpath(__file__)) + '/repos.yaml' + with open(default_repofile, 'r') as repos_file: + repo_data = yaml.safe_load(repos_file) + if context.get('repos_yaml'): - repofile = context.get('repos_yaml') - else: - repofile = os.path.dirname(os.path.realpath(__file__)) + '/repos.yaml' - - with open(repofile, 'r') as repos_file: - repo_data = {} - for name, params in yaml.safe_load(repos_file).items(): - repo_data[name] = params + with open(context.get('repos_yaml'), 'r') as repos_file: + for section, repos in yaml.safe_load(repos_file).items(): + if section in repo_data: + repo_data[section].update(repos) + else: + repo_data[section] = repos base_package_type = context.get('base_package_type') base_distro = context.get('base_distro') diff --git a/kolla/tests/test_build.py b/kolla/tests/test_build.py index 82bcb7d6d5..e072860ab4 100644 --- a/kolla/tests/test_build.py +++ b/kolla/tests/test_build.py @@ -10,6 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import fixtures import jinja2 import os @@ -20,6 +21,7 @@ from unittest import mock from kolla.cmd import build as build_cmd +from kolla.common import utils as common_utils from kolla.image import build from kolla.image.kolla_worker import Image from kolla.image import tasks @@ -77,6 +79,9 @@ def setUp(self): self.build_kwargs["squash"] = False else: self.build_kwargs = {} + # Existing tests exercise the docker-py SDK path; disable BuildKit so + # they are not routed through _build_buildkit. + self.conf.set_override('buildkit', False) @mock.patch.dict(os.environ, clear=True) @mock.patch(engine_client) @@ -479,6 +484,253 @@ def test_followups_docker_image(self, mock_client): get_result = builder.followups self.assertEqual(1, len(get_result)) + @mock.patch(engine_client) + def test_followups_buildkit_skips_push(self, mock_client): + """PushTask must not be added when buildkit is active (docker).""" + self.conf.set_override('buildkit', True) + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.imageChild, push_queue) + builder.success = True + self.conf.push = True + followups = builder.followups + push_tasks = [f for f in followups if + isinstance(f, tasks.PushIntoQueueTask)] + self.assertEqual(0, len(push_tasks)) + + @mock.patch(engine_client) + def test_followups_buildkit_true_podman_includes_push(self, mock_client): + """PushTask must be added for Podman even when buildkit=True.""" + self.conf.set_override('buildkit', True) + self.conf.set_override('engine', 'podman') + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.imageChild, push_queue) + builder.success = True + self.conf.push = True + followups = builder.followups + push_tasks = [f for f in followups if + isinstance(f, tasks.PushIntoQueueTask)] + self.assertEqual(1, len(push_tasks)) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_no_platform_no_push(self, mock_run_cmd): + """No --platform and no push: command uses --load.""" + self.conf.set_override('buildkit', True) + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + builder._build_buildkit(self.image, pull=True, buildargs=None) + + mock_run_cmd.assert_called_once() + cmd = mock_run_cmd.call_args[0][0] + self.assertIn('--load', cmd) + self.assertNotIn('--push', cmd) + self.assertNotIn('--platform', cmd) + self.assertEqual(utils.Status.BUILT, self.image.status) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_no_platform_with_push(self, mock_run_cmd): + """No platform + push=True: buildx pushes directly.""" + self.conf.set_override('buildkit', True) + self.conf.set_override('push', True) + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + builder._build_buildkit(self.image, pull=False, buildargs=None) + + mock_run_cmd.assert_called_once() + cmd = mock_run_cmd.call_args[0][0] + self.assertIn('--push', cmd) + self.assertNotIn('--load', cmd) + self.assertEqual(utils.Status.BUILT, self.image.status) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_single_platform_no_push(self, mock_run_cmd): + """Single platform without push: --load, no push_tag, no extra push.""" + self.conf.set_override('buildkit', True) + self.conf.set_override('platform', 'linux/amd64') + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + builder._build_buildkit(self.image, pull=False, buildargs=None) + + mock_run_cmd.assert_called_once() + cmd = mock_run_cmd.call_args[0][0] + self.assertIn('--platform', cmd) + self.assertIn('linux/amd64', cmd) + self.assertIn('--load', cmd) + self.assertNotIn('--push', cmd) + self.assertEqual(utils.Status.BUILT, self.image.status) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_single_platform_with_push(self, mock_run_cmd): + """Single platform + push: --load then push canonical tag.""" + self.conf.set_override('buildkit', True) + self.conf.set_override('platform', 'linux/amd64') + self.conf.set_override('push', True) + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + builder._build_buildkit(self.image, pull=False, buildargs=None) + + self.assertEqual(2, mock_run_cmd.call_count) + build_cmd = mock_run_cmd.call_args_list[0][0][0] + push_cmd = mock_run_cmd.call_args_list[1][0][0] + + self.assertIn('--load', build_cmd) + self.assertNotIn('--push', build_cmd) + self.assertNotIn('image-base:latest-amd64', build_cmd) + self.assertEqual( + ['docker', 'push', self.image.canonical_name], push_cmd) + self.assertEqual(utils.Status.BUILT, self.image.status) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_multi_platform_with_push(self, mock_run_cmd): + """Multi-platform + push: single --push invocation, no separate push""" + self.conf.set_override('buildkit', True) + self.conf.set_override('platform', 'linux/amd64,linux/arm64') + self.conf.set_override('push', True) + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + builder._build_buildkit(self.image, pull=False, buildargs=None) + + mock_run_cmd.assert_called_once() + cmd = mock_run_cmd.call_args[0][0] + self.assertIn('--push', cmd) + self.assertNotIn('--load', cmd) + self.assertIn('linux/amd64,linux/arm64', cmd) + self.assertEqual(utils.Status.BUILT, self.image.status) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_multi_platform_no_push(self, mock_run_cmd): + """Multi-platform without push: fails fast (--load unsupported).""" + self.conf.set_override('buildkit', True) + self.conf.set_override('platform', 'linux/amd64,linux/arm64') + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + builder._build_buildkit(self.image, pull=False, buildargs=None) + + mock_run_cmd.assert_not_called() + self.assertEqual(utils.Status.ERROR, self.image.status) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_with_named_builder(self, mock_run_cmd): + """--buildkit-builder is forwarded as --builder to the command.""" + self.conf.set_override('buildkit', True) + self.conf.set_override('buildkit_builder', 'mybuilder') + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + builder._build_buildkit(self.image, pull=False, buildargs=None) + + cmd = mock_run_cmd.call_args[0][0] + self.assertIn('--builder', cmd) + idx = cmd.index('--builder') + self.assertEqual('mybuilder', cmd[idx + 1]) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_no_cache(self, mock_run_cmd): + """cache=False adds --no-cache to the command.""" + self.conf.set_override('buildkit', True) + self.conf.set_override('cache', False) + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + builder._build_buildkit(self.image, pull=False, buildargs=None) + + cmd = mock_run_cmd.call_args[0][0] + self.assertIn('--no-cache', cmd) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_with_buildargs(self, mock_run_cmd): + """Build args are forwarded as --build-arg KEY=VALUE pairs.""" + self.conf.set_override('buildkit', True) + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + buildargs = {'HTTP_PROXY': 'http://proxy:8080', + 'NO_PROXY': '127.0.0.1'} + builder._build_buildkit(self.image, pull=False, buildargs=buildargs) + + cmd = mock_run_cmd.call_args[0][0] + self.assertIn('--build-arg', cmd) + self.assertIn('HTTP_PROXY=http://proxy:8080', cmd) + self.assertIn('NO_PROXY=127.0.0.1', cmd) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_with_labels(self, mock_run_cmd): + """Image labels are forwarded as --label key=value pairs.""" + self.conf.set_override('buildkit', True) + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + self.image.labels = {'maintainer': 'kolla', 'version': '1.0'} + builder._build_buildkit(self.image, pull=False, buildargs=None) + + cmd = mock_run_cmd.call_args[0][0] + self.assertIn('--label', cmd) + self.assertIn('maintainer=kolla', cmd) + self.assertIn('version=1.0', cmd) + + @mock.patch.object(tasks.BuildTask, '_run_cmd') + def test_build_buildkit_error_sets_status(self, mock_run_cmd): + """An exception from _run_cmd marks the image as ERROR.""" + self.conf.set_override('buildkit', True) + mock_run_cmd.side_effect = Exception('build failed') + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + self.image.start = datetime.datetime.now() + builder._build_buildkit(self.image, pull=False, buildargs=None) + + self.assertEqual(utils.Status.ERROR, self.image.status) + + @mock.patch('subprocess.Popen') + def test_run_cmd_success(self, mock_popen): + """Zero exit code does not raise.""" + mock_proc = mock.MagicMock() + mock_proc.__enter__.return_value = mock_proc + mock_proc.stdout = [] + mock_proc.returncode = 0 + mock_popen.return_value = mock_proc + + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + builder._run_cmd(['docker', 'buildx', 'build', self.image.path]) + + @mock.patch('subprocess.Popen') + def test_run_cmd_failure_raises(self, mock_popen): + """Non-zero exit code raises CalledProcessError.""" + import subprocess as sp # nosec + mock_proc = mock.MagicMock() + mock_proc.__enter__.return_value = mock_proc + mock_proc.stdout = [] + mock_proc.returncode = 1 + mock_popen.return_value = mock_proc + + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + cmd = ['docker', 'buildx', 'build', self.image.path] + self.assertRaises(sp.CalledProcessError, builder._run_cmd, cmd) + + @mock.patch(engine_client) + def test_build_image_buildkit_dispatches(self, mock_client): + """When buildkit=True and engine=docker, calls _build_buildkit.""" + self.conf.set_override('buildkit', True) + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + with mock.patch.object(builder, '_build_buildkit') as mock_bk: + builder.run() + mock_bk.assert_called_once() + + @mock.patch(engine_client) + def test_build_image_no_buildkit_uses_sdk(self, mock_client): + """When buildkit=False, builder uses docker-py SDK (images.build).""" + push_queue = mock.Mock() + builder = tasks.BuildTask(self.conf, self.image, push_queue) + builder.run() + mock_client().images.build.assert_called_once() + class KollaWorkerTest(base.TestCase): @@ -869,8 +1121,49 @@ def test_work_dir(self, copytree_mock): self.assertEqual('tmp/foo/docker', kolla.working_dir) +class BuildKitCheckTest(base.TestCase): + + @mock.patch('subprocess.check_output') + def test_check_docker_buildx_success(self, mock_check_output): + mock_check_output.return_value = b'github.com/docker/buildx v0.21.0' + common_utils.check_docker_buildx() + mock_check_output.assert_called_once_with( + ['docker', 'buildx', 'version'], stderr=mock.ANY) + + @mock.patch('subprocess.check_output') + def test_check_docker_buildx_not_installed(self, mock_check_output): + import subprocess as sp # nosec + mock_check_output.side_effect = sp.CalledProcessError(1, 'docker') + self.assertRaises(sp.CalledProcessError, + common_utils.check_docker_buildx) + + @mock.patch('subprocess.check_output') + def test_check_docker_buildx_docker_missing(self, mock_check_output): + mock_check_output.side_effect = OSError(2, 'No such file or directory') + self.assertRaises(OSError, common_utils.check_docker_buildx) + + class MainTest(base.TestCase): + def setUp(self): + super(MainTest, self).setUp() + self.conf.set_override('buildkit', False) + # Mock subprocess so that _build_buildkit and check_docker_buildx + # succeed when the full build pipeline is exercised (buildkit=True is + # the default for a fresh conf). + mock_proc = mock.MagicMock() + mock_proc.__enter__.return_value = mock_proc + mock_proc.stdout = [] + mock_proc.returncode = 0 + popen_patcher = mock.patch('subprocess.Popen', return_value=mock_proc) + popen_patcher.start() + self.addCleanup(popen_patcher.stop) + check_output_patcher = mock.patch( + 'subprocess.check_output', + return_value=b'github.com/docker/buildx v0.21.0') + check_output_patcher.start() + self.addCleanup(check_output_patcher.stop) + @mock.patch.object(build, 'run_build') def test_images_built(self, mock_run_build): image_statuses = ({}, {'img': 'built'}, {}, {}, {}, {}) @@ -904,6 +1197,15 @@ def test_run_build(self, mock_client, mock_sys): result = build.run_build() self.assertTrue(result) + @mock.patch('sys.argv') + @mock.patch('kolla.common.utils.check_docker_buildx') + @mock.patch(engine_client) + def test_run_build_exits_when_buildx_missing( + self, mock_client, mock_check_buildx, mock_sys): + import subprocess as sp # nosec + mock_check_buildx.side_effect = sp.CalledProcessError(1, 'docker') + self.assertRaises(SystemExit, build.run_build) + @mock.patch.object(build, 'run_build') def test_skipped_images(self, mock_run_build): image_statuses = ({}, {}, {}, {'img': 'skipped'}, {}, {}) diff --git a/releasenotes/notes/add-buildkit-support-via-buildx-c4fda2286c2d7d1e.yaml b/releasenotes/notes/add-buildkit-support-via-buildx-c4fda2286c2d7d1e.yaml new file mode 100644 index 0000000000..1fe5febde7 --- /dev/null +++ b/releasenotes/notes/add-buildkit-support-via-buildx-c4fda2286c2d7d1e.yaml @@ -0,0 +1,22 @@ +--- +features: + - | + Added support for building images using Docker BuildKit via + ``docker buildx build``. It is enabled by default, users can + disable it with the ``--nobuildkit`` flag or by + setting ``buildkit = False`` in the ``[DEFAULT]`` section of + ``kolla-build.conf``. Requires the ``docker-buildx-plugin`` to be + installed and only applies when ``--engine docker`` is used. An optional + ``--buildkit-builder`` flag selects a named buildx builder instance, + enabling docker-container or remote drivers for multi-arch builds. +upgrade: + - | + Docker BuildKit (``docker buildx build``) is now enabled by default when + using ``--engine docker``. Set ``buildkit = False`` in + ``kolla-build.conf`` or pass ``--nobuildkit`` on the CLI to revert to + the legacy docker-py SDK build path. + - | + Multi-platform BuildKit builds (comma-separated ``--platform`` values) + require ``--push``, as ``docker buildx`` does not support ``--load`` for + multi-platform output. Omitting ``--push`` will result in an immediate + error. diff --git a/releasenotes/notes/switch-to-yaml-configs-32802ccb894c0295.yaml b/releasenotes/notes/switch-to-yaml-configs-32802ccb894c0295.yaml new file mode 100644 index 0000000000..bfc798906e --- /dev/null +++ b/releasenotes/notes/switch-to-yaml-configs-32802ccb894c0295.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + The ``kolla_set_configs`` tool now supports YAML as a native + configuration format. It can now load ``config.yaml`` files in + addition to the legacy ``config.json``. Existing JSON + configurations remain fully supported. diff --git a/releasenotes/notes/update-prometheus-server-3.5.4-70f6b76854015b51.yaml b/releasenotes/notes/update-prometheus-server-3.5.4-70f6b76854015b51.yaml new file mode 100644 index 0000000000..80bbb5091e --- /dev/null +++ b/releasenotes/notes/update-prometheus-server-3.5.4-70f6b76854015b51.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Updates Prometheus to version 3.5.4. diff --git a/roles/kolla-build-deps/tasks/main.yml b/roles/kolla-build-deps/tasks/main.yml index 490f13edf6..399aa4380b 100644 --- a/roles/kolla-build-deps/tasks/main.yml +++ b/roles/kolla-build-deps/tasks/main.yml @@ -51,6 +51,12 @@ ansible.builtin.include_role: name: "{{ container_engine }}_sdk" +- name: Install docker-buildx-plugin + ansible.builtin.package: + name: docker-buildx-plugin + become: true + when: container_engine == 'docker' + - name: Ensure container engine socket is world-writable ansible.builtin.file: path: "{{ '/run/docker.sock' if container_engine == 'docker' else '/run/podman/podman.sock' }}" diff --git a/tests/test_set_config.py b/tests/test_set_config.py index 6e3ddf5e3e..89b71fca8a 100644 --- a/tests/test_set_config.py +++ b/tests/test_set_config.py @@ -75,6 +75,67 @@ def test_load_ok(self): self.assertEqual(calls, mo.mock_calls) + @mock.patch('os.path.exists', return_value=True) + def test_load_yaml_ok(self, mock_exists): + in_config = ( + 'command: /bin/true\n' + 'config_files: {}\n' + ) + + mo = mock.mock_open(read_data=in_config) + with mock.patch.object(set_configs, 'open', mo): + config = set_configs.load_config() + self.assertEqual(config['command'], '/bin/true') + + mock_exists.assert_called_with( + '/var/lib/kolla/config_files/config.yaml') + + @mock.patch('os.path.exists', return_value=True) + def test_load_yaml_invalid(self, mock_exists): + in_config = '{' + + mo = mock.mock_open(read_data=in_config) + with mock.patch.object(set_configs, 'open', mo): + self.assertRaises(set_configs.InvalidConfig, + set_configs.load_config) + + @mock.patch('os.path.exists', return_value=False) + def test_load_json_when_no_yaml(self, mock_exists): + in_config = json.dumps({'command': '/bin/true', + 'config_files': {}}) + + mo = mock.mock_open(read_data=in_config) + with mock.patch.object(set_configs, 'open', mo): + config = set_configs.load_config() + self.assertEqual(config['command'], '/bin/true') + + mock_exists.assert_called_with( + '/var/lib/kolla/config_files/config.yaml') + + @mock.patch('os.path.exists', return_value=True) + def test_load_yaml_full_config(self, mock_exists): + in_config = ( + 'command: kolla_toolbox\n' + 'config_files:\n' + '- dest: /etc/rabbitmq/rabbitmq-env.conf\n' + ' source: /var/lib/kolla/config_files/rabbitmq-env.conf\n' + ' owner: rabbitmq\n' + ' perm: \'0600\'\n' + 'permissions:\n' + '- path: /var/log/kolla/ansible.log\n' + ' owner: ansible:kolla\n' + ' perm: \'0664\'\n' + ) + + mo = mock.mock_open(read_data=in_config) + with mock.patch.object(set_configs, 'open', mo): + config = set_configs.load_config() + + self.assertEqual(config['command'], 'kolla_toolbox') + self.assertEqual(len(config['config_files']), 1) + self.assertEqual(config['config_files'][0]['owner'], 'rabbitmq') + self.assertEqual(config['permissions'][0]['perm'], '0664') + FAKE_CONFIG_FILES = [ set_configs.ConfigFile( diff --git a/zuul.d/debian.yaml b/zuul.d/debian.yaml index 473db20b8b..1e7cda822a 100644 --- a/zuul.d/debian.yaml +++ b/zuul.d/debian.yaml @@ -65,4 +65,6 @@ periodic: jobs: - kolla-publish-debian-trixie-quay + periodic-weekly: + jobs: - kolla-publish-debian-trixie-arm64-quay diff --git a/zuul.d/rocky.yaml b/zuul.d/rocky.yaml index 816dddccc7..45c37793ea 100644 --- a/zuul.d/rocky.yaml +++ b/zuul.d/rocky.yaml @@ -65,4 +65,6 @@ periodic: jobs: - kolla-publish-rocky-10-quay + periodic-weekly: + jobs: - kolla-publish-rocky-10-arm64-quay diff --git a/zuul.d/ubuntu.yaml b/zuul.d/ubuntu.yaml index 78a86964a2..301c1f3ce0 100644 --- a/zuul.d/ubuntu.yaml +++ b/zuul.d/ubuntu.yaml @@ -65,4 +65,6 @@ periodic: jobs: - kolla-publish-ubuntu-noble-quay + periodic-weekly: + jobs: - kolla-publish-ubuntu-noble-arm64-quay