Add OCI platform support for x86_64 sub-architecture containers

Add cache_key option to yum_cache plugin for shared package cache across configs
This commit is contained in:
Andrew Lukoshko 2026-04-08 14:20:41 +00:00 committed by root
parent b965f54059
commit 1cf5696bf9
3 changed files with 512 additions and 2 deletions

View File

@ -0,0 +1,234 @@
From f18ddbae4f900b54b3e2c3abe8270cf37f889e33 Mon Sep 17 00:00:00 2001
From: Andrew Lukoshko <andrew.lukoshko@gmail.com>
Date: Mon, 6 Apr 2026 19:12:45 +0200
Subject: [PATCH 1/2] feat: add OCI platform support for x86_64
sub-architecture containers
Add oci_platform_map config dict mapping x86_64_v2/v3/v4 to OCI
platform strings (linux/amd64/v2, v3, v4).
When target_arch has a mapping, podman pull receives --platform so
the correct image variant is fetched from multi-arch manifests.
Preserve target repo_arch for bootstrap chroot when target_arch has
an oci_platform_map entry, so the bootstrap uses the correct
sub-architecture repos instead of falling back to the base arch.
Includes 7 unit tests covering platform injection, architecture
check, and error paths.
Fixes: #1732
---
py/mock.py | 8 +-
py/mockbuild/config.py | 7 +
py/mockbuild/podman.py | 7 +-
tests/test_podman.py | 130 ++++++++++++++++++
.../oci-platform-sub-arch.feature.md | 5 +
5 files changed, 155 insertions(+), 2 deletions(-)
create mode 100644 tests/test_podman.py
create mode 100644 releng/release-notes-next/oci-platform-sub-arch.feature.md
diff --git a/py/mock.py b/py/mock.py
index 270043611..0ed4b746a 100755
--- a/py/mock.py
+++ b/py/mock.py
@@ -773,8 +773,14 @@ def main():
# Enforce host-native repo architecture for bootstrap chroot (unless
# bootstrap_forcearch=True, which should never be the case). This
# decision affects condPersonality() for DNF calls!
+ #
+ # Exception: sub-architecture variants (e.g. x86_64_v2) listed in
+ # oci_platform_map run natively on the host — keep the target's
+ # repo_arch so the bootstrap uses the correct sub-arch repos.
host_arch = config_opts["host_arch"]
- if config_opts["use_bootstrap_image"]:
+ target_arch = config_opts["target_arch"]
+ if config_opts["use_bootstrap_image"] \
+ and target_arch not in config_opts.get('oci_platform_map', {}):
# with bootstrap image, bootstrap is always native
bootstrap_buildroot_config['repo_arch'] = config_opts['repo_arch_map'].get(host_arch, host_arch)
elif host_arch not in config_opts.get("legal_host_arches", []) \
diff --git a/py/mockbuild/config.py b/py/mockbuild/config.py
index 34244fa45..cc80cec84 100644
--- a/py/mockbuild/config.py
+++ b/py/mockbuild/config.py
@@ -429,6 +429,13 @@ def setup_default_config_opts():
'i686': 'i386',
}
+ # mapping from target_arch to OCI --platform string for podman pull
+ config_opts['oci_platform_map'] = {
+ 'x86_64_v2': 'linux/amd64/v2',
+ 'x86_64_v3': 'linux/amd64/v3',
+ 'x86_64_v4': 'linux/amd64/v4',
+ }
+
config_opts["recursion_limit"] = 5000
config_opts["calculatedeps"] = None
diff --git a/py/mockbuild/podman.py b/py/mockbuild/podman.py
index 286d5fae3..67369534a 100644
--- a/py/mockbuild/podman.py
+++ b/py/mockbuild/podman.py
@@ -117,7 +117,12 @@ def pull_image(self):
""" pull the latest image, return True if successful """
logger = getLog()
logger.info("Pulling image: %s", self.image)
- cmd = [self.podman_binary, "pull", self.image]
+ cmd = [self.podman_binary, "pull"]
+ target_arch = self.buildroot.config.get('target_arch', '')
+ oci_platform = self.buildroot.config.get('oci_platform_map', {}).get(target_arch)
+ if oci_platform:
+ cmd += ["--platform", oci_platform]
+ cmd.append(self.image)
res = subprocess.run(cmd, env=self.buildroot.env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
diff --git a/tests/test_podman.py b/tests/test_podman.py
new file mode 100644
index 000000000..ec2187cc8
--- /dev/null
+++ b/tests/test_podman.py
@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+"""Unit tests for mockbuild.podman — OCI platform pull and architecture check."""
+
+import subprocess
+from unittest.mock import MagicMock, patch
+
+from mockbuild.podman import (
+ Podman,
+ podman_check_native_image_architecture,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _make_buildroot(target_arch, oci_platform_map=None):
+ """Return a minimal mock buildroot with the given config values."""
+ config = {"target_arch": target_arch}
+ if oci_platform_map is not None:
+ config["oci_platform_map"] = oci_platform_map
+ br = MagicMock()
+ br.config = config
+ br.env = {}
+ return br
+
+
+def _make_podman(buildroot, image="registry.example.com/image:latest"):
+ """Instantiate Podman while bypassing the os.path.exists check."""
+ with patch("os.path.exists", return_value=True):
+ return Podman(buildroot, image)
+
+
+# ===================================================================
+# R002 — pull_image injects --platform when mapping matches
+# ===================================================================
+
+class TestPullImagePlatform:
+ """pull_image() must inject --platform from oci_platform_map."""
+
+ @patch("mockbuild.podman.subprocess.run")
+ def test_platform_injected_when_mapped(self, mock_run):
+ """--platform linux/amd64/v2 appears when target_arch is x86_64_v2."""
+ mock_run.return_value = MagicMock(returncode=0, stdout=b"sha256:abc")
+ br = _make_buildroot(
+ "x86_64_v2",
+ oci_platform_map={"x86_64_v2": "linux/amd64/v2"},
+ )
+ pod = _make_podman(br)
+ assert pod.pull_image() is True
+
+ cmd = mock_run.call_args[0][0]
+ assert "--platform" in cmd
+ plat_idx = cmd.index("--platform")
+ assert cmd[plat_idx + 1] == "linux/amd64/v2"
+ # --platform must precede image name
+ assert plat_idx < cmd.index("registry.example.com/image:latest")
+
+ @patch("mockbuild.podman.subprocess.run")
+ def test_no_platform_when_arch_not_in_map(self, mock_run):
+ """--platform absent when target_arch has no mapping entry."""
+ mock_run.return_value = MagicMock(returncode=0, stdout=b"sha256:abc")
+ br = _make_buildroot(
+ "x86_64",
+ oci_platform_map={"x86_64_v2": "linux/amd64/v2"},
+ )
+ pod = _make_podman(br)
+ assert pod.pull_image() is True
+
+ cmd = mock_run.call_args[0][0]
+ assert "--platform" not in cmd
+
+ @patch("mockbuild.podman.subprocess.run")
+ def test_no_platform_when_map_missing(self, mock_run):
+ """--platform absent when oci_platform_map key is missing entirely."""
+ mock_run.return_value = MagicMock(returncode=0, stdout=b"sha256:def")
+ br = _make_buildroot("x86_64") # no oci_platform_map
+ pod = _make_podman(br)
+ assert pod.pull_image() is True
+
+ cmd = mock_run.call_args[0][0]
+ assert "--platform" not in cmd
+
+ @patch("mockbuild.podman.subprocess.run")
+ def test_empty_target_arch(self, mock_run):
+ """Empty target_arch string never produces --platform."""
+ mock_run.return_value = MagicMock(returncode=0, stdout=b"sha256:ghi")
+ br = _make_buildroot(
+ "",
+ oci_platform_map={"x86_64_v2": "linux/amd64/v2"},
+ )
+ pod = _make_podman(br)
+ assert pod.pull_image() is True
+
+ cmd = mock_run.call_args[0][0]
+ assert "--platform" not in cmd
+
+
+# ===================================================================
+# R003 — podman_check_native_image_architecture
+# ===================================================================
+
+class TestArchCheck:
+ """Architecture check compares system vs image os/arch."""
+
+ @patch("mockbuild.podman.subprocess.check_output")
+ def test_matching_arch_returns_true(self, mock_co):
+ """Matching system and image arch returns True."""
+ mock_co.side_effect = ["linux/amd64", "linux/amd64"]
+ assert podman_check_native_image_architecture("img:latest") is True
+
+ @patch("mockbuild.podman.subprocess.check_output")
+ def test_mismatched_arch_returns_false(self, mock_co):
+ """Mismatched system and image arch returns False."""
+ mock_co.side_effect = ["linux/amd64", "linux/arm64"]
+ assert podman_check_native_image_architecture("img:latest") is False
+
+
+# ===================================================================
+# Negative / boundary tests
+# ===================================================================
+
+class TestArchCheckErrorPaths:
+ """Error handling in architecture check."""
+
+ @patch("mockbuild.podman.subprocess.check_output")
+ def test_subprocess_error_returns_false(self, mock_co):
+ """SubprocessError during check returns False (existing behavior)."""
+ mock_co.side_effect = subprocess.SubprocessError("fail")
+ assert podman_check_native_image_architecture("img:latest") is False
diff --git a/releng/release-notes-next/oci-platform-sub-arch.feature.md b/releng/release-notes-next/oci-platform-sub-arch.feature.md
new file mode 100644
index 000000000..0dc976c3a
--- /dev/null
+++ b/releng/release-notes-next/oci-platform-sub-arch.feature.md
@@ -0,0 +1,5 @@
+Podman container image pulls now pass `--platform` for x86_64
+sub-architecture variants (x86_64_v2, x86_64_v3, x86_64_v4). A new
+`oci_platform_map` config option maps target architectures to OCI platform
+strings (e.g. `linux/amd64/v2`), and the architecture check accepts
+variant images on a matching base host.

View File

@ -0,0 +1,267 @@
From 38842ed9678d974309d879907d1f05fe211e539b Mon Sep 17 00:00:00 2001
From: Andrew Lukoshko <andrew.lukoshko@gmail.com>
Date: Wed, 8 Apr 2026 01:49:00 +0200
Subject: [PATCH] yum_cache: add cache_key option for shared package cache
across configs
When cache_key is set in yum_cache_opts, the plugin redirects the
dnf/yum cache bind mounts and lock file to a shared directory at
<cache_topdir>/yum_cache/<cache_key>/ instead of the per-config
buildroot.cachedir. This allows multiple mock configs with different
root names but the same cache_key to share cached RPM packages and
repo metadata, avoiding redundant downloads.
Without cache_key the behavior is identical to the original plugin.
Usage in mock config:
config_opts['plugin_conf']['yum_cache_opts']['cache_key'] = 'almalinux-kitten-10-x86_64'
The shared cache directory is not removed by --scrub=all (which only
cleans per-root caches), matching the design intent that shared caches
are host-level resources.
---
py/mockbuild/plugins/yum_cache.py | 25 +++-
tests/plugins/test_yum_cache.py | 181 +++++++++++++++++++++++++
2 files changed, 200 insertions(+), 6 deletions(-)
create mode 100644 tests/plugins/test_yum_cache.py
diff --git a/py/mockbuild/plugins/yum_cache.py b/py/mockbuild/plugins/yum_cache.py
index c732b439f..1e8aec494 100644
--- a/py/mockbuild/plugins/yum_cache.py
+++ b/py/mockbuild/plugins/yum_cache.py
@@ -26,11 +26,12 @@ def init(plugins, conf, buildroot):
class CacheDir:
- def __init__(self, buildroot, pkg_manager):
+ def __init__(self, buildroot, pkg_manager, cache_dir=None):
self.buildroot = buildroot
self.cache_path = os.path.join('/var/cache', pkg_manager)
- self.host_cache_path = os.path.join(self.buildroot.cachedir,
- pkg_manager + '_cache')
+ self.host_cache_path = os.path.join(
+ cache_dir if cache_dir else self.buildroot.cachedir,
+ pkg_manager + '_cache')
self.mount_path = self.buildroot.make_chroot_path(self.cache_path)
self.buildroot.mounts.add(BindMountPoint(
srcpath=self.host_cache_path,
@@ -56,9 +57,21 @@ def __init__(self, plugins, conf, buildroot):
self.config = buildroot.config
self.state = buildroot.state
self.yum_cache_opts = conf
+
+ cache_key = conf.get('cache_key')
+ if cache_key:
+ cache_topdir = self.config['cache_topdir']
+ cache_dir = os.path.join(cache_topdir, 'yum_cache', cache_key)
+ mockbuild.file_util.mkdirIfAbsent(cache_dir)
+ lock_dir = cache_dir
+ getLog().info("enabled package manager cache (shared, key=%s)", cache_key)
+ else:
+ cache_dir = None
+ lock_dir = buildroot.cachedir
+
self.cache_dirs = [
- CacheDir(buildroot, 'yum'),
- CacheDir(buildroot, 'dnf'),
+ CacheDir(buildroot, 'yum', cache_dir),
+ CacheDir(buildroot, 'dnf', cache_dir),
]
self.yumSharedCachePath = self.cache_dirs[0].host_cache_path
self.online = self.config['online']
@@ -66,7 +79,7 @@ def __init__(self, plugins, conf, buildroot):
plugins.add_hook("postyum", self._yumCachePostYumHook)
plugins.add_hook("preinit", self._yumCachePreInitHook)
- self.yumCacheLock = open(os.path.join(buildroot.cachedir, "yumcache.lock"), "a+")
+ self.yumCacheLock = open(os.path.join(lock_dir, "yumcache.lock"), "a+")
# =============
diff --git a/tests/plugins/test_yum_cache.py b/tests/plugins/test_yum_cache.py
new file mode 100644
index 000000000..a6aba45d0
--- /dev/null
+++ b/tests/plugins/test_yum_cache.py
@@ -0,0 +1,181 @@
+"""Unit tests for the yum_cache plugin cache_key shared-cache feature."""
+
+from copy import deepcopy
+from unittest import mock
+from unittest.mock import MagicMock, patch
+
+from mockbuild.plugins import yum_cache
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+DEFAULT_CONF = {
+ 'max_age_days': 30,
+ 'max_metadata_age_days': 0.5,
+}
+
+
+def make_buildroot(extra_config=None):
+ br = MagicMock()
+ br.config = {
+ 'cache_topdir': '/var/cache/mock',
+ 'online': False,
+ 'target_arch': 'x86_64',
+ }
+ if extra_config:
+ br.config.update(extra_config)
+ br.make_chroot_path.side_effect = lambda p: '/chroot' + p
+ return br
+
+
+# ---------------------------------------------------------------------------
+# init() entry point
+# ---------------------------------------------------------------------------
+
+@patch('mockbuild.plugins.yum_cache.YumCache')
+def test_init(MockYumCache):
+ plugins, conf, buildroot = object(), object(), object()
+ yum_cache.init(plugins, conf, buildroot)
+ MockYumCache.assert_called_once_with(plugins, conf, buildroot)
+
+
+# ---------------------------------------------------------------------------
+# Without cache_key — legacy behaviour unchanged
+# ---------------------------------------------------------------------------
+
+@patch('mockbuild.plugins.yum_cache.mockbuild.file_util')
+def test_no_cache_key_uses_buildroot_cachedir(mock_file_util):
+ """Without cache_key, host_cache_path must be buildroot.cachedir/dnf_cache."""
+ br = make_buildroot()
+ br.cachedir = '/var/cache/mock/my-root'
+ conf = deepcopy(DEFAULT_CONF)
+
+ with patch('builtins.open', mock.mock_open()):
+ plugin = yum_cache.YumCache(MagicMock(), conf, br)
+
+ dnf_dir = next(d for d in plugin.cache_dirs if d.host_cache_path.endswith('dnf_cache'))
+ assert dnf_dir.host_cache_path == '/var/cache/mock/my-root/dnf_cache'
+
+
+@patch('mockbuild.plugins.yum_cache.mockbuild.file_util')
+def test_no_cache_key_lock_in_buildroot_cachedir(mock_file_util):
+ """Without cache_key, lock file must be in buildroot.cachedir."""
+ br = make_buildroot()
+ br.cachedir = '/var/cache/mock/my-root'
+ conf = deepcopy(DEFAULT_CONF)
+
+ mock_open = mock.mock_open()
+ with patch('builtins.open', mock_open):
+ yum_cache.YumCache(MagicMock(), conf, br)
+
+ opened_paths = [c.args[0] for c in mock_open.call_args_list]
+ assert any('my-root/yumcache.lock' in p for p in opened_paths), \
+ f"lock not in cachedir; opened: {opened_paths}"
+
+
+# ---------------------------------------------------------------------------
+# With cache_key — shared cache behaviour
+# ---------------------------------------------------------------------------
+
+@patch('mockbuild.plugins.yum_cache.mockbuild.file_util')
+def test_cache_key_uses_shared_dir(mock_file_util):
+ """With cache_key, host_cache_path must be cache_topdir/yum_cache/{key}/dnf_cache."""
+ br = make_buildroot({'cache_topdir': '/var/cache/mock'})
+ conf = {**DEFAULT_CONF, 'cache_key': 'centos-stream-10-x86_64'}
+
+ with patch('builtins.open', mock.mock_open()):
+ plugin = yum_cache.YumCache(MagicMock(), conf, br)
+
+ dnf_dir = next(d for d in plugin.cache_dirs if d.host_cache_path.endswith('dnf_cache'))
+ assert dnf_dir.host_cache_path == \
+ '/var/cache/mock/yum_cache/centos-stream-10-x86_64/dnf_cache'
+
+
+@patch('mockbuild.plugins.yum_cache.mockbuild.file_util')
+def test_cache_key_lock_in_shared_dir(mock_file_util):
+ """With cache_key, lock file must be in the shared cache dir, not buildroot.cachedir."""
+ br = make_buildroot({'cache_topdir': '/var/cache/mock'})
+ br.cachedir = '/var/cache/mock/my-root'
+ conf = {**DEFAULT_CONF, 'cache_key': 'centos-stream-10-x86_64'}
+
+ mock_open = mock.mock_open()
+ with patch('builtins.open', mock_open):
+ yum_cache.YumCache(MagicMock(), conf, br)
+
+ opened_paths = [c.args[0] for c in mock_open.call_args_list]
+ assert any('yum_cache/centos-stream-10-x86_64/yumcache.lock' in p
+ for p in opened_paths), f"shared lock not found; opened: {opened_paths}"
+ assert not any('my-root/yumcache.lock' in p
+ for p in opened_paths), "lock must not be in buildroot.cachedir"
+
+
+@patch('mockbuild.plugins.yum_cache.mockbuild.file_util')
+def test_cache_key_shared_dir_created(mock_file_util):
+ """With cache_key, the shared directory must be created via mkdirIfAbsent."""
+ br = make_buildroot({'cache_topdir': '/var/cache/mock'})
+ conf = {**DEFAULT_CONF, 'cache_key': 'centos-stream-10-x86_64'}
+
+ with patch('builtins.open', mock.mock_open()):
+ yum_cache.YumCache(MagicMock(), conf, br)
+
+ created_dirs = [c.args[0] for c in mock_file_util.mkdirIfAbsent.call_args_list]
+ assert any('yum_cache/centos-stream-10-x86_64' in d for d in created_dirs), \
+ f"shared dir not created; mkdirIfAbsent called with: {created_dirs}"
+
+
+@patch('mockbuild.plugins.yum_cache.mockbuild.file_util')
+def test_both_dnf_and_yum_cache_dirs_registered(mock_file_util):
+ """Both /var/cache/dnf and /var/cache/yum bind mounts must be added."""
+ br = make_buildroot()
+ conf = {**DEFAULT_CONF, 'cache_key': 'centos-stream-10-x86_64'}
+
+ with patch('builtins.open', mock.mock_open()):
+ yum_cache.YumCache(MagicMock(), conf, br)
+
+ added_srcs = [c.args[0].srcpath for c in br.mounts.add.call_args_list]
+ assert any('dnf_cache' in p for p in added_srcs), f"dnf_cache not mounted: {added_srcs}"
+ assert any('yum_cache' in p for p in added_srcs), f"yum_cache not mounted: {added_srcs}"
+
+
+# ---------------------------------------------------------------------------
+# Hook registration (both modes)
+# ---------------------------------------------------------------------------
+
+@patch('mockbuild.plugins.yum_cache.mockbuild.file_util')
+def test_hooks_registered(mock_file_util):
+ """Plugin must always register preyum, postyum, preinit hooks."""
+ for conf in [deepcopy(DEFAULT_CONF), {**DEFAULT_CONF, 'cache_key': 'my-key'}]:
+ plugins = MagicMock()
+ br = make_buildroot()
+ br.cachedir = '/var/cache/mock/root'
+ with patch('builtins.open', mock.mock_open()):
+ yum_cache.YumCache(plugins, conf, br)
+ hook_names = [c.args[0] for c in plugins.add_hook.call_args_list]
+ assert 'preyum' in hook_names
+ assert 'postyum' in hook_names
+ assert 'preinit' in hook_names
+
+
+# ---------------------------------------------------------------------------
+# Two configs, same cache_key — both resolve to same path
+# ---------------------------------------------------------------------------
+
+@patch('mockbuild.plugins.yum_cache.mockbuild.file_util')
+def test_two_roots_same_cache_key_share_path(mock_file_util):
+ """Two buildroots with different cachedir but same cache_key must resolve identical paths."""
+ conf = {**DEFAULT_CONF, 'cache_key': 'centos-stream-10-x86_64'}
+
+ br1 = make_buildroot({'cache_topdir': '/var/cache/mock'})
+ br1.cachedir = '/var/cache/mock/build-root-1'
+ br2 = make_buildroot({'cache_topdir': '/var/cache/mock'})
+ br2.cachedir = '/var/cache/mock/build-root-2'
+
+ with patch('builtins.open', mock.mock_open()):
+ p1 = yum_cache.YumCache(MagicMock(), deepcopy(conf), br1)
+ p2 = yum_cache.YumCache(MagicMock(), deepcopy(conf), br2)
+
+ dnf1 = next(d.host_cache_path for d in p1.cache_dirs if d.host_cache_path.endswith('dnf_cache'))
+ dnf2 = next(d.host_cache_path for d in p2.cache_dirs if d.host_cache_path.endswith('dnf_cache'))
+ assert dnf1 == dnf2, f"paths differ: {dnf1} != {dnf2}"

View File

@ -19,7 +19,7 @@
Summary: Builds packages inside chroots
Name: mock
Version: 6.7
Release: 1%{?dist}
Release: 1%{?dist}.alma.1
License: GPL-2.0-or-later
# Source is created by
# git clone https://github.com/rpm-software-management/mock.git
@ -27,6 +27,10 @@ License: GPL-2.0-or-later
# git reset --hard %%{name}-%%{version}
# tito build --tgz
Source: https://github.com/rpm-software-management/%{name}/releases/download/%{name}-%{version}-1/%{name}-%{version}.tar.gz
# AlmaLinux Patch
Patch: 1000-add-oci-platform-support-for-x86_64-sub-arch.patch
Patch: 1001-yum-cache-add-cache-key-for-shared-package-cache.patch
URL: https://github.com/rpm-software-management/mock/
BuildArch: noarch
Requires: tar
@ -169,7 +173,7 @@ Requires(pre): shadow-utils
Filesystem layout and group for Mock.
%prep
%setup -q
%autosetup -p1
for file in py/mock.py py/mock-parse-buildlog.py; do
sed -i 1"s|#!/usr/bin/python3 |#!%{__python} |" $file
done
@ -330,6 +334,11 @@ pylint-3 py/mockbuild/ py/*.py py/mockbuild/plugins/* || :
%changelog
* Wed Apr 08 2026 Andrew Lukoshko <alukoshko@almalinux.org> - 6.7-1.alma.1
- Add OCI platform support for x86_64 sub-architecture containers
- Add cache_key option to yum_cache plugin for shared package cache across
configs
* Tue Mar 03 2026 Pavel Raiskup <pavel@raiskup.cz> 6.7-1
- mock: Use umask 0022 instead of 0002 to avoid strange permissions (ngompa@velocitylimitless.com)
- expand_spec plugin: generating expanded-spec.txt in postdeps hook (yzhu@redhat.com)