diff --git a/.packit.yaml b/.packit.yaml index 5908b020a5..d9e84677be 100644 --- a/.packit.yaml +++ b/.packit.yaml @@ -93,7 +93,7 @@ jobs: tf_extra_params: test: tmt: - name: /plans/provision/virtual + name: /plans/provision/(bootc|virtual) environments: - tmt: context: diff --git a/docs/releases.rst b/docs/releases.rst index e5b5075d88..821a2a2b57 100644 --- a/docs/releases.rst +++ b/docs/releases.rst @@ -20,6 +20,12 @@ environment files are found. The ``tmt try`` command now supports the new :ref:`/stories/cli/try/option/arch` option. +As a tech preview, a new :ref:`/plugins/provision/bootc` provision +plugin has been implemented. It takes a container image as input, +builds a bootc disk image from the container image, then uses the +:ref:`/plugins/provision/virtual.testcloud` plugin to create a +virtual machine using the bootc disk image. + tmt-1.38.0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/plans/provision/bootc.fmf b/plans/provision/bootc.fmf new file mode 100644 index 0000000000..e5fc819c56 --- /dev/null +++ b/plans/provision/bootc.fmf @@ -0,0 +1,34 @@ +summary: Bootc virtual machine via testcloud + +description: | + Verify functionality of the bootc provision plugin. + +discover: + how: fmf + filter: 'tag:provision-bootc' + +prepare+: + - name: start-libvirtd + script: | + systemctl start libvirtd + systemctl status libvirtd + +adjust+: + - enabled: true + when: how == provision + + - provision: + hardware: + virtualization: + is-supported: true + memory: ">= 4 GB" + when: trigger == commit + + - prepare+: + - name: Disable IPv6 + how: shell + script: + - sysctl -w net.ipv6.conf.all.disable_ipv6=1 + - sysctl -w net.ipv6.conf.default.disable_ipv6=1 + because: Disable IPv6 in CI to avoid IPv6 connections that are disabled in CI + when: trigger == commit diff --git a/pyproject.toml b/pyproject.toml index 0f125de355..8785f609f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ provision-virtual = [ "testcloud>=0.11.3", ] provision-container = [] +provision-bootc = [] report-junit = [ # Required to support XML parsing and checking the XSD schemas. "lxml>=4.6.5", @@ -75,6 +76,7 @@ all = [ "tmt[test-convert]", "tmt[export-polarion]", "tmt[provision-container]", + "tmt[provision-bootc]", "tmt[provision-virtual]", "tmt[provision-beaker]", "tmt[report-junit]", diff --git a/tests/provision/bootc/data/.fmf/version b/tests/provision/bootc/data/.fmf/version new file mode 100644 index 0000000000..d00491fd7e --- /dev/null +++ b/tests/provision/bootc/data/.fmf/version @@ -0,0 +1 @@ +1 diff --git a/tests/provision/bootc/data/includes-deps.containerfile b/tests/provision/bootc/data/includes-deps.containerfile new file mode 100644 index 0000000000..76f6694cba --- /dev/null +++ b/tests/provision/bootc/data/includes-deps.containerfile @@ -0,0 +1,6 @@ +FROM quay.io/centos-bootc/centos-bootc:stream9 + +RUN dnf -y install cloud-init rsync && \ + ln -s ../cloud-init.target /usr/lib/systemd/system/default.target.wants && \ + rm /usr/local -rf && ln -sr /var/usrlocal /usr/local && mkdir -p /var/usrlocal/bin && \ + dnf clean all diff --git a/tests/provision/bootc/data/needs-deps.containerfile b/tests/provision/bootc/data/needs-deps.containerfile new file mode 100644 index 0000000000..1498df8767 --- /dev/null +++ b/tests/provision/bootc/data/needs-deps.containerfile @@ -0,0 +1 @@ +FROM quay.io/centos-bootc/centos-bootc:stream9 diff --git a/tests/provision/bootc/data/plans.fmf b/tests/provision/bootc/data/plans.fmf new file mode 100644 index 0000000000..f5e1560a2f --- /dev/null +++ b/tests/provision/bootc/data/plans.fmf @@ -0,0 +1,45 @@ +discover: + how: fmf +provision: + how: bootc + disk: 20 +execute: + how: tmt + + +/image: + + /needs-deps: + summary: "Image that needs dependencies" + provision+: + add-tmt-dependencies: true + container-image: localhost/tmt-bootc-needs-deps + environment: + PATTERN: localhost/tmtmodified + + /includes-deps: + summary: "Image that already includes dependencies" + provision+: + add-tmt-dependencies: false + container-image: localhost/tmt-bootc-includes-deps + environment: + PATTERN: localhost/tmt-bootc-includes-deps + +/containerfile: + + /needs-deps: + summary: "Containerfile that needs dependencies" + provision+: + add-tmt-dependencies: true + container-file: needs-deps.containerfile + environment: + PATTERN: localhost/tmtmodified + + /includes-deps: + summary: "Containerfile that already includes dependencies" + provision: + how: bootc + add-tmt-dependencies: false + container-file: includes-deps.containerfile + environment: + PATTERN: localhost/tmtbase diff --git a/tests/provision/bootc/data/test.fmf b/tests/provision/bootc/data/test.fmf new file mode 100644 index 0000000000..62cb056140 --- /dev/null +++ b/tests/provision/bootc/data/test.fmf @@ -0,0 +1,2 @@ +summary: Check that booted image matches expected pattern +test: "bootc status && bootc status | grep $PATTERN" diff --git a/tests/provision/bootc/main.fmf b/tests/provision/bootc/main.fmf new file mode 100644 index 0000000000..a1d0ca3fd4 --- /dev/null +++ b/tests/provision/bootc/main.fmf @@ -0,0 +1,13 @@ +summary: Make sure that bootc provision method works +tag+: + - provision-only + - provision-bootc +require: + - tmt+provision-virtual +duration: 40m + +# As for now there is an expected AVC failure: +# https://github.com/osbuild/bootc-image-builder/issues/645 +check: + - how: avc + result: xfail diff --git a/tests/provision/bootc/test.sh b/tests/provision/bootc/test.sh new file mode 100755 index 0000000000..b856de7ac4 --- /dev/null +++ b/tests/provision/bootc/test.sh @@ -0,0 +1,47 @@ +#!/bin/bash +. /usr/share/beakerlib/beakerlib.sh || exit 1 + +IMAGE_NEEDS_DEPS="localhost/tmt-bootc-needs-deps" +IMAGE_INCLUDES_DEPS="localhost/tmt-bootc-includes-deps" + +TESTCLOUD_IMAGE="/var/tmp/tmt/testcloud/images/disk.qcow2" + + +rlJournalStart + rlPhaseStartSetup + # Use /var/tmp/tmt so the temp directories are accessible + # in the podman machine mount + rlRun "mkdir -p /var/tmp/tmt" + rlRun "run=\$(mktemp -d --tmpdir=/var/tmp/tmt)" 0 "Create run directory" + rlRun "pushd data" + rlPhaseEnd + + rlPhaseStartTest "Image that needs dependencies" + rlRun "podman build . -f needs-deps.containerfile -t $IMAGE_NEEDS_DEPS" + rlRun "tmt -vvv run --scratch -i $run plan --name /plans/image/needs-deps" + rlRun "rm -rf $TESTCLOUD_IMAGE" + rlPhaseEnd + + rlPhaseStartTest "Image that already includes dependencies" + rlRun "podman build . -f includes-deps.containerfile -t $IMAGE_INCLUDES_DEPS" + rlRun "tmt -vvv run --scratch -i $run plan --name /plans/image/includes-deps" + rlRun "rm -rf $TESTCLOUD_IMAGE" + rlPhaseEnd + + rlPhaseStartTest "Containerfile that needs dependencies" + rlRun "tmt -vvv run --scratch -i $run plan --name /plans/containerfile/needs-deps" + rlRun "rm -rf $TESTCLOUD_IMAGE" + rlPhaseEnd + + rlPhaseStartTest "Containerfile that already includes dependencies" + rlRun "tmt -vvv run --scratch -i $run plan --name /plans/containerfile/includes-deps" + rlRun "rm -rf $TESTCLOUD_IMAGE" + rlPhaseEnd + + rlPhaseStartCleanup + rlRun "popd" + rlRun "rm -r $run" 0 "Remove run directory" + rlRun "podman rmi $IMAGE_INCLUDES_DEPS" 0,1 + rlRun "podman rmi $IMAGE_NEEDS_DEPS" 0,1 + rlPhaseEnd +rlJournalEnd diff --git a/tmt.spec b/tmt.spec index 3f70fd53e1..6728990135 100644 --- a/tmt.spec +++ b/tmt.spec @@ -90,6 +90,16 @@ Recommends: qemu-system-x86-core %description -n tmt+provision-virtual %_metapackage_description +%package -n tmt+provision-bootc +Summary: Dependencies required for tmt bootc machine provisioner +Provides: tmt-provision-bootc == %{version}-%{release} +Requires: tmt == %{version}-%{release} +Requires: tmt+provision-virtual == %{version}-%{release} +Requires: podman +Recommends: podman-machine + +%description -n tmt+provision-bootc %_metapackage_description + %package -n tmt+provision-beaker Summary: Dependencies required for tmt beaker provisioner Provides: tmt-provision-beaker == %{version}-%{release} @@ -152,6 +162,7 @@ install -pm 644 %{name}/steps/provision/mrack/mrack* %{buildroot}/etc/%{name}/ %files -n tmt+provision-container -f %{_pyproject_ghost_distinfo} %files -n tmt+provision-virtual -f %{_pyproject_ghost_distinfo} +%files -n tmt+provision-bootc -f %{_pyproject_ghost_distinfo} %files -n tmt+test-convert -f %{_pyproject_ghost_distinfo} %files -n tmt+provision-beaker -f %{_pyproject_ghost_distinfo} %config(noreplace) %{_sysconfdir}/%{name}/mrack* diff --git a/tmt/schemas/provision/bootc.yaml b/tmt/schemas/provision/bootc.yaml new file mode 100644 index 0000000000..e7cafc3fe2 --- /dev/null +++ b/tmt/schemas/provision/bootc.yaml @@ -0,0 +1,71 @@ +--- + +# +# JSON Schema definition for `bootc` provision plugin +# +# https://tmt.readthedocs.io/en/stable/spec/plans.html#bootc +# + +$id: /schemas/provision/bootc +$schema: https://json-schema.org/draft-07/schema + +type: object +additionalProperties: false + +properties: + + how: + type: string + enum: + - bootc + + name: + type: string + + image: + type: string + + user: + type: string + + become: + type: boolean + + key: + $ref: "/schemas/common#/definitions/one_or_more_strings" + + memory: + type: integer + + disk: + type: integer + + connection: + type: string + enum: + - session + - system + + arch: + $ref: "/schemas/common#/definitions/arch" + + role: + $ref: "/schemas/common#/definitions/role" + + container-file: + type: string + + container-file-workdir: + type: string + + container-image: + type: string + + add-tmt-dependencies: + type: boolean + + image-builder: + type: string + +required: + - how diff --git a/tmt/steps/provision/bootc.py b/tmt/steps/provision/bootc.py new file mode 100644 index 0000000000..d1131e8d79 --- /dev/null +++ b/tmt/steps/provision/bootc.py @@ -0,0 +1,355 @@ +import dataclasses +import os +from typing import TYPE_CHECKING, Optional, cast + +import tmt +import tmt.base +import tmt.hardware +import tmt.log +import tmt.steps +import tmt.steps.provision +import tmt.steps.provision.testcloud +import tmt.utils +from tmt.steps.provision.testcloud import GuestTestcloud +from tmt.utils import field +from tmt.utils.templates import render_template + +if TYPE_CHECKING: + from tmt.hardware import Size + +DEFAULT_TMP_PATH = "/var/tmp/tmt" # noqa: S108 + +DEFAULT_IMAGE_BUILDER = "quay.io/centos-bootc/bootc-image-builder:latest" +CONTAINER_STORAGE_DIR = tmt.utils.Path("/var/lib/containers/storage") + +PODMAN_MACHINE_NAME = 'podman-machine-tmt' +PODMAN_ENV = tmt.utils.Environment.from_dict( + {"CONTAINER_CONNECTION": f'{PODMAN_MACHINE_NAME}-root'}) + +DEFAULT_PODMAN_MACHINE_CPU = 2 +DEFAULT_PODMAN_MACHINE_MEM: 'Size' = tmt.hardware.UNITS('2048 MB') +DEFAULT_PODMAN_MACHINE_DISK_SIZE: 'Size' = tmt.hardware.UNITS('50 GB') + +CONTAINER_TEMPLATE = """ +FROM {{ base_image }} + +RUN <> /etc/environment + +EOF +""" + + +class GuestBootc(GuestTestcloud): + containerimage: str + _rootless: bool + + def __init__(self, + *, + data: tmt.steps.provision.GuestData, + name: Optional[str] = None, + parent: Optional[tmt.utils.Common] = None, + logger: tmt.log.Logger, + containerimage: str, + rootless: bool) -> None: + super().__init__(data=data, logger=logger, parent=parent, name=name) + self.containerimage = containerimage + self._rootless = rootless + + def remove(self) -> None: + tmt.utils.Command( + "podman", + "rmi", + self.containerimage).run( + cwd=self.workdir, + stream_output=True, + logger=self._logger, + env=PODMAN_ENV if self._rootless else None) + + try: + tmt.utils.Command( + "podman", "machine", "rm", "-f", PODMAN_MACHINE_NAME + ).run(cwd=self.workdir, stream_output=True, logger=self._logger) + except BaseException: + self._logger.debug( + "Unable to remove podman machine '{PODMAN_MACHINE_NAME}', it might not exist.") + + super().remove() + + +@dataclasses.dataclass +class BootcData(tmt.steps.provision.testcloud.ProvisionTestcloudData): + container_file: Optional[str] = field( + default=None, + option='--container-file', + metavar='CONTAINER_FILE', + help=""" + Select container file to be used to build a container image + that is then used by bootc image builder to create a disk image. + + Cannot be used with container-image. + """) + + container_file_workdir: str = field( + default=".", + option=('--container-file-workdir'), + metavar='CONTAINER_FILE_WORKDIR', + help=""" + Select working directory for the podman build invocation. + """) + + container_image: Optional[str] = field( + default=None, + option=('--container-image'), + metavar='CONTAINER_IMAGE', + help=""" + Select container image to be used to build a bootc disk. + This takes priority over Containerfile. + """) + + add_tmt_dependencies: bool = field( + default=True, + is_flag=True, + option=('--add-tmt-dependencies/--no-add-tmt-dependencies'), + help=""" + Add tmt dependencies to the supplied container image or image built + from the supplied Containerfile. + This will cause a derived image to be built from the supplied image. + """) + + image_builder: str = field( + default=DEFAULT_IMAGE_BUILDER, + option=('--image-builder'), + metavar='IMAGE_BUILDER', + help=""" + The full repo:tag url of the bootc image builder image to use for + building the bootc disk image. + """) + + +@tmt.steps.provides_method('bootc') +class ProvisionBootc(tmt.steps.provision.ProvisionPlugin[BootcData]): + """ + Provision a local virtual machine using a bootc container image + + Minimal config which uses the CentOS Stream 9 bootc image: + + .. code-block:: yaml + + provision: + how: bootc + container-image: quay.io/centos-bootc/centos-bootc:stream9 + + Here's a config example using a Containerfile: + + .. code-block:: yaml + + provision: + how: bootc + container-file: "./my-custom-image.containerfile" + container-file-workdir: . + image-builder: quay.io/centos-bootc/bootc-image-builder:stream9 + disk: 100 + + Another config example using an image that already includes tmt + dependencies: + + .. code-block:: yaml + + provision: + how: bootc + add-tmt-dependencies: false + container-image: localhost/my-image-with-deps + + This plugin is an extension of the virtual.testcloud plugin. + Essentially, it takes a container image as input, builds a + bootc disk image from the container image, then uses the virtual.testcloud + plugin to create a virtual machine using the bootc disk image. + + The bootc disk creation requires running podman as root. The plugin will + automatically check if the current podman connection is rootless. If it is, + a podman machine will be spun up and used to build the bootc disk. + """ + + _data_class = BootcData + _guest_class = GuestTestcloud + _guest = None + _rootless = True + + def _get_id(self) -> str: + # FIXME: cast() - https://github.com/teemtee/tmt/issues/1372 + parent = cast(tmt.steps.provision.Provision, self.parent) + assert parent.plan is not None + assert parent.plan.my_run is not None + assert parent.plan.my_run.unique_id is not None + return parent.plan.my_run.unique_id + + def _expand_path(self, relative_path: str) -> str: + """ Expand the path to the full path relative to the current working dir """ + if relative_path.startswith("/"): + return relative_path + return f"{os.getcwd()}/{relative_path}" + + def _build_derived_image(self, base_image: str) -> str: + """ Build a "derived" container image from the base image with tmt dependencies added """ + assert self.workdir is not None # narrow type + + self._logger.debug("Build modified container image with necessary tmt packages/config.") + containerfile_template = ''' + FROM {{ base_image }} + + RUN \ + dnf -y install cloud-init rsync && \ + ln -s ../cloud-init.target /usr/lib/systemd/system/default.target.wants && \ + rm /usr/local -rf && ln -sr /var/usrlocal /usr/local && mkdir -p /var/usrlocal/bin && \ + dnf clean all + ''' + containerfile_parsed = render_template( + containerfile_template, + base_image=base_image) + (self.workdir / 'Containerfile').write_text(containerfile_parsed) + + image_tag = f'localhost/tmtmodified-{self._get_id()}' + tmt.utils.Command( + "podman", + "build", + f'{self.workdir}', + "-f", + f'{self.workdir}/Containerfile', + "-t", + image_tag).run( + cwd=self.workdir, + stream_output=True, + logger=self._logger, + env=PODMAN_ENV if self._rootless else None) + + return image_tag + + def _build_base_image(self, containerfile: str, workdir: str) -> str: + """ Build the "base" or user supplied container image """ + image_tag = f'localhost/tmtbase-{self._get_id()}' + self._logger.debug("Build container image.") + tmt.utils.Command( + "podman", + "build", + self._expand_path(workdir), + "-f", + self._expand_path(containerfile), + "-t", + image_tag).run( + cwd=self.workdir, + stream_output=True, + logger=self._logger, + env=PODMAN_ENV if self._rootless else None) + return image_tag + + def _build_bootc_disk(self, containerimage: str, image_builder: str) -> None: + """ Build the bootc disk from a container image using bootc image builder """ + self._logger.debug("Build bootc disk image.") + + tmt.utils.Command( + "podman", + "run", + "--rm", + "--privileged", + "-v", + f'{CONTAINER_STORAGE_DIR}:{CONTAINER_STORAGE_DIR}', + "--security-opt", + "label=type:unconfined_t", + "-v", + f"{self.workdir}:/output", + image_builder, + "build", + "--type", + "qcow2", + "--local", + containerimage).run( + cwd=self.workdir, + stream_output=True, + logger=self._logger, + env=PODMAN_ENV if self._rootless else None) + + def _init_podman_machine(self) -> None: + try: + tmt.utils.Command( + "podman", "machine", "rm", "-f", PODMAN_MACHINE_NAME + ).run(cwd=self.workdir, stream_output=True, logger=self._logger) + except BaseException: + self._logger.debug("Unable to remove existing podman machine (it might not exist).") + + self._logger.debug("Initialize podman machine.") + tmt.utils.Command( + "podman", "machine", "init", "--rootful", + "--disk-size", f"{DEFAULT_PODMAN_MACHINE_DISK_SIZE.magnitude}", + "--memory", f"{DEFAULT_PODMAN_MACHINE_MEM.magnitude}", + "--cpus", f"{DEFAULT_PODMAN_MACHINE_CPU}", + "-v", f"{DEFAULT_TMP_PATH}:{DEFAULT_TMP_PATH}", + "-v", "$HOME:$HOME", + PODMAN_MACHINE_NAME + ).run(cwd=self.workdir, stream_output=True, logger=self._logger) + + self._logger.debug("Start podman machine.") + tmt.utils.Command( + "podman", "machine", "start", PODMAN_MACHINE_NAME + ).run(cwd=self.workdir, stream_output=True, logger=self._logger) + + def _check_if_podman_is_rootless(self) -> None: + output = tmt.utils.Command( + "podman", "info", "--format", "{{.Host.Security.Rootless}}" + ).run(cwd=self.workdir, stream_output=True, logger=self._logger) + self._rootless = output.stdout == "true\n" + + def go(self, *, logger: Optional[tmt.log.Logger] = None) -> None: + """ Provision the bootc instance """ + super().go(logger=logger) + + self._check_if_podman_is_rootless() + + data = BootcData.from_plugin(self) + data.image = f"file://{self.workdir}/qcow2/disk.qcow2" + data.show(verbose=self.verbosity_level, logger=self._logger) + + if self._rootless: + self._init_podman_machine() + + # Use provided container image + if data.container_image is not None: + containerimage = data.container_image + if data.add_tmt_dependencies: + containerimage = self._build_derived_image(data.container_image) + self._build_bootc_disk(containerimage, data.image_builder) + + # Build image according to the container file + elif data.container_file is not None: + containerimage = self._build_base_image( + data.container_file, data.container_file_workdir) + if data.add_tmt_dependencies: + containerimage = self._build_derived_image(containerimage) + self._build_bootc_disk(containerimage, data.image_builder) + + # Image of file have to provided + else: + raise tmt.utils.ProvisionError( + "Either 'container-file' or 'container-image' must be specified.") + + self._guest = GuestBootc( + logger=self._logger, + data=data, + name=self.name, + parent=self.step, + containerimage=containerimage, + rootless=self._rootless) + self._guest.start() + self._guest.setup() + + def guest(self) -> Optional[tmt.steps.provision.Guest]: + return self._guest