diff --git a/osbuild/formats/v2.py b/osbuild/formats/v2.py index 0fd5bf7313..f7fee5445b 100644 --- a/osbuild/formats/v2.py +++ b/osbuild/formats/v2.py @@ -391,7 +391,7 @@ def load(description: Dict, index: Index) -> Manifest: } for pipeline in pipelines: - if not pipeline.build: + if not pipeline.build or pipeline.build.startswith("container:"): pipeline.runner = host_runner continue diff --git a/osbuild/objectstore.py b/osbuild/objectstore.py index 4536e4a1c1..69d46fbfb3 100644 --- a/osbuild/objectstore.py +++ b/osbuild/objectstore.py @@ -318,6 +318,52 @@ def __fspath__(self) -> os.PathLike: return self.tree +class ContainerMountTree: + """Access to a container based root filesystem. + + An object that provides a similar interface to + `objectstore.Object` and provides access to a container + """ + + def __init__(self, from_container: str) -> None: + self._from_container = None + self._root = None + self.init(from_container) + + def init(self, from_container: str) -> None: + self._from_container = from_container + result = subprocess.run( + ["podman", "image", "mount", from_container], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + check=False, + ) + if result.returncode != 0: + code = result.returncode + msg = result.stderr.strip() + raise RuntimeError(f"Failed to mount image ({code}): {msg}") + self._root = result.stdout.strip() + + @property + def tree(self) -> os.PathLike: + if not self._root: + raise AssertionError(f"ContainerMountTree for {self._from_container} not initialized") + return self._root + + def cleanup(self): + if self._root: + subprocess.run( + ["podman", "image", "umount", self._from_container], + stdout=subprocess.DEVNULL, + check=True, + ) + self._root = None + + def __fspath__(self) -> os.PathLike: + return self.tree + + class ObjectStore(contextlib.AbstractContextManager): def __init__(self, store: PathLike): self.cache = FsCache("osbuild", store) diff --git a/osbuild/pipeline.py b/osbuild/pipeline.py index c955033b40..cac44465d7 100644 --- a/osbuild/pipeline.py +++ b/osbuild/pipeline.py @@ -11,7 +11,7 @@ from .devices import Device, DeviceManager from .inputs import Input, InputManager from .mounts import Mount, MountManager -from .objectstore import ObjectStore +from .objectstore import ContainerMountTree, ObjectStore from .sources import Source from .util import osrelease @@ -313,6 +313,9 @@ def build_stages(self, object_store, monitor, libdir, debug_break="", stage_time if not self.build: build_tree = object_store.host_tree + elif self.build.startswith("container:"): + cnt_id = self.build.removeprefix("container:") + build_tree = ContainerMountTree(cnt_id) else: build_tree = object_store.get(self.build) @@ -365,6 +368,10 @@ def build_stages(self, object_store, monitor, libdir, debug_break="", stage_time if stage.checkpoint: object_store.commit(tree, stage.id) + # XXX: needs a test but pretty sure we need this (i.e. this is + # a pre-existing leak) as AFAICT HostTree is never umounted + # otherwise (and ContainerMountTree now of course) + build_tree.cleanup() tree.finalize() return results @@ -457,7 +464,7 @@ def depsolve(self, store: ObjectStore, targets: Iterable[str]) -> List[str]: # Add all dependencies to the stack of things to check, # starting with the build pipeline, if there is one - if pl.build: + if pl.build and not pl.build.startswith("container:"): check.append(self.get(pl.build)) # Stages depend on other pipeline via pipeline inputs. diff --git a/test/run/test_exports.py b/test/run/test_exports.py index c2c1fab825..cad7dfb9a9 100644 --- a/test/run/test_exports.py +++ b/test/run/test_exports.py @@ -91,3 +91,36 @@ def _delenv(): assert expected_export.exists() assert expected_export.stat().st_uid == 0 assert k not in os.environ + + +# XXX: put into a more fitting place +@pytest.mark.skipif(os.getuid() != 0, reason="root-only") +def test_build_root_from_container_registry(osb, tmp_path, testing_libdir): + # XXX: this test uses the fact that /usr/bin/subscription-manager is + # avaialble as an indicator that the buildroot was constructed from + # the ubi9:latest container. This is not a great detection, instead + # we should just provide our own testing container that puts some + # canary data into /usr (note that /etc is not available in a buildroot) + cnt_ref = "registry.access.redhat.com/ubi9:latest" + subprocess.run(["podman", "pull", cnt_ref], check=True) + jsondata = json.dumps({ + "version": "2", + "pipelines": [ + { + "name": "image", + "build": f"container:{cnt_ref}", + "stages": [ + { + "type": "org.osbuild.testing.injectpy", + "options": { + "code": [ + 'import os.path', + 'assert os.path.exists("/usr/bin/subscription-manager")', + ], + }, + }, + ] + } + ] + }) + osb.compile(jsondata, output_dir=tmp_path, exports=["image"], libdir=testing_libdir)