diff --git a/plans/provision/bootc.fmf b/plans/provision/bootc.fmf new file mode 100644 index 0000000000..15885d415e --- /dev/null +++ b/plans/provision/bootc.fmf @@ -0,0 +1,34 @@ +summary: Bootc virtual machine via testcloud + +description: | + bootc tests + +discover: + how: fmf + filter: 'tag:provision-bootc' + + +environment: + PROVISION_HOW: virtual + +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 + +enabled: true 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/containerfile_includes_deps.fmf b/tests/provision/bootc/data/containerfile_includes_deps.fmf new file mode 100644 index 0000000000..2e5e9bcef7 --- /dev/null +++ b/tests/provision/bootc/data/containerfile_includes_deps.fmf @@ -0,0 +1,13 @@ +execute: + how: tmt +discover: + how: shell + tests: + - name: booted image + test: bootc status && bootc status | grep localhost/tmtbase +provision: + how: bootc + add-deps: false + containerfile: "$TMT_BOOTC_CONTAINERFILE_RUNDIR/includes_deps.containerfile" + containerfile-workdir: . + disk: 20 diff --git a/tests/provision/bootc/data/containerfile_needs_deps.fmf b/tests/provision/bootc/data/containerfile_needs_deps.fmf new file mode 100644 index 0000000000..d188cc82f1 --- /dev/null +++ b/tests/provision/bootc/data/containerfile_needs_deps.fmf @@ -0,0 +1,13 @@ +execute: + how: tmt +discover: + how: shell + tests: + - name: booted image + test: bootc status | grep localhost/tmtmodified +provision: + how: bootc + add-deps: true + containerfile: "$TMT_BOOTC_CONTAINERFILE_RUNDIR/needs_deps.containerfile" + containerfile-workdir: . + disk: 20 diff --git a/tests/provision/bootc/data/image_includes_deps.fmf b/tests/provision/bootc/data/image_includes_deps.fmf new file mode 100644 index 0000000000..981b946709 --- /dev/null +++ b/tests/provision/bootc/data/image_includes_deps.fmf @@ -0,0 +1,12 @@ +execute: + how: tmt +discover: + how: shell + tests: + - name: booted image + test: bootc status | grep localhost/tmt-bootc-includes-deps +provision: + how: bootc + add-deps: false + containerimage: localhost/tmt-bootc-includes-deps + disk: 20 diff --git a/tests/provision/bootc/data/image_needs_deps.fmf b/tests/provision/bootc/data/image_needs_deps.fmf new file mode 100644 index 0000000000..af992ba9ba --- /dev/null +++ b/tests/provision/bootc/data/image_needs_deps.fmf @@ -0,0 +1,12 @@ +execute: + how: tmt +discover: + how: shell + tests: + - name: booted image + test: bootc status | grep localhost/tmtmodified +provision: + how: bootc + add-deps: true + containerimage: localhost/tmt-bootc-needs-deps + disk: 20 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/main.fmf b/tests/provision/bootc/main.fmf new file mode 100644 index 0000000000..82b71bbcb4 --- /dev/null +++ b/tests/provision/bootc/main.fmf @@ -0,0 +1,6 @@ +summary: Make sure that bootc provision method works +tag+: + - provision-only + - provision-bootc +require: + - tmt+provision-virtual diff --git a/tests/provision/bootc/test.sh b/tests/provision/bootc/test.sh new file mode 100755 index 0000000000..9ad8556446 --- /dev/null +++ b/tests/provision/bootc/test.sh @@ -0,0 +1,63 @@ +#!/bin/bash +. /usr/share/beakerlib/beakerlib.sh || exit 1 + +IMAGE_NEEDS_DEPS="localhost/tmt-bootc-needs-deps" +IMAGE_NEEDS_DEPS_PLAN="$(pwd)/data/image_needs_deps.fmf" +IMAGE_INCLUDES_DEPS="localhost/tmt-bootc-includes-deps" +IMAGE_INCLUDES_DEPS_PLAN="$(pwd)/data/image_includes_deps.fmf" + +CONTAINERFILE_NEEDS_DEPS="$(pwd)/data/needs_deps.containerfile" +CONTAINERFILE_NEEDS_DEPS_PLAN="$(pwd)/data/containerfile_needs_deps.fmf" +CONTAINERFILE_INCLUDES_DEPS="$(pwd)/data/includes_deps.containerfile" +CONTAINERFILE_INCLUDES_DEPS_PLAN="$(pwd)/data/containerfile_includes_deps.fmf" + + +rlJournalStart + rlPhaseStartSetup + # cleanup previous runs + test -d /var/tmp/tmt/testcloud && rlRun "rm -rf /var/tmp/tmt/testcloud" + + # use /var/tmp/tmt so the temp directories are accessible + # in the podman machine mount + rlRun "tmp=\$(mktemp -d --tmpdir=/var/tmp/tmt)" 0 "Create tmp directory" + rlRun "run=\$(mktemp -d --tmpdir=/var/tmp/tmt)" 0 "Create run directory" + rlRun "pushd $tmp" + rlRun "set -o pipefail" + rlRun "tmt init" + rlPhaseEnd + + rlPhaseStartTest "Image that needs dependencies" + rlRun "podman build . -f $CONTAINERFILE_NEEDS_DEPS -t $IMAGE_NEEDS_DEPS" + rlRun "cp $IMAGE_NEEDS_DEPS_PLAN ." + rlRun "tmt -vvvvv run -i $run" + rlPhaseEnd + + rlPhaseStartTest "Image that already includes dependencies" + rlRun "podman build . -f $CONTAINERFILE_INCLUDES_DEPS -t $IMAGE_INCLUDES_DEPS" + rlRun "cp $IMAGE_INCLUDES_DEPS_PLAN ." + rlRun "tmt -vvvvv run -i $run" + rlPhaseEnd + + rlPhaseStartTest "Containerfile that needs dependencies" + rlRun "cp $CONTAINERFILE_NEEDS_DEPS_PLAN ." + rlRun "cp $CONTAINERFILE_NEEDS_DEPS $run" + rlRun "tmt -vvvvv run --environment TMT_BOOTC_CONTAINERFILE_RUNDIR=$run -i $run" + rlPhaseEnd + + rlPhaseStartTest "Containerfile that already includes dependencies" + rlRun "cp $CONTAINERFILE_INCLUDES_DEPS_PLAN ." + rlRun "cp $CONTAINERFILE_INCLUDES_DEPS $run" + rlRun "tmt -vvvvv run --environment TMT_BOOTC_CONTAINERFILE_RUNDIR=$run -i $run" + rlPhaseEnd + + rlPhaseStartCleanup + rlRun "popd" + rlRun "rm -r $tmp" 0 "Remove tmp directory" + rlRun "rm -r $run" 0 "Remove run directory" + + rlRun "podman rmi $IMAGE_INCLUDES_DEPS" 0,1 + rlRun "podman rmi $IMAGE_NEEDS_DEPS" 0,1 + + test -d /var/tmp/tmt/testcloud && rlRun "rm -rf /var/tmp/tmt/testcloud" + rlPhaseEnd +rlJournalEnd diff --git a/tmt/steps/provision/bootc.py b/tmt/steps/provision/bootc.py new file mode 100644 index 0000000000..f6bb35f34f --- /dev/null +++ b/tmt/steps/provision/bootc.py @@ -0,0 +1,232 @@ +import dataclasses +import uuid +from string import Template +from typing import Optional + +import tmt +import tmt.base +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 + +DEFAULT_IMAGE_BUILDER = "quay.io/centos-bootc/bootc-image-builder:latest" + + +class GuestBootc(GuestTestcloud): + containerimage: str + + def __init__(self, + *, + data: tmt.steps.provision.GuestData, + name: Optional[str] = None, + parent: Optional[tmt.utils.Common] = None, + logger: tmt.log.Logger, + containerimage: Optional[str]) -> None: + super().__init__(data=data, logger=logger, parent=parent, name=name) + + if containerimage: + self.containerimage = containerimage + + def remove(self) -> None: + if self.containerimage: + tmt.utils.Command( + "podman", "rmi", self.containerimage + ).run(cwd=self.workdir, stream_output=True, logger=self._logger) + + super().remove() + + +@dataclasses.dataclass +class BootcData(tmt.steps.provision.testcloud.ProvisionTestcloudData): + containerfile: Optional[str] = field( + default=None, + option=('--containerfile'), + metavar='CONTAINERFILE', + 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 containerimage. + """) + + containerfile_workdir: str = field( + default=".", + option=('--containerfile-workdir'), + metavar='CONTAINERFILE_WORKDIR', + help=""" + Select working directory for the podman build invocation. + """) + + containerimage: Optional[str] = field( + default=None, + option=('--containerimage'), + metavar='CONTAINERIMAGE', + help=""" + Select container image to be used to build a bootc disk. + This takes priority over containerfile. + """) + + add_deps: bool = field( + default=True, + is_flag=True, + option=('--add-deps'), + 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='IMAGEBUILDER', + help=""" + The full repo:tag url of the bootc image builder image to use for + building the bootc disk image. + """) + + def set_image(self, image: str) -> None: + self.image = 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 Fedora bootc image: + + .. code-block:: yaml + + provision: + how: bootc + containerimage: quay.io/fedora/fedora-bootc:40 + + Here's a config example using a containerfile: + + .. code-block:: yaml + + provision: + how: bootc + containerfile: "./my-custom-image.containerfile" + containerfile-workdir: . + image_builder: quay.io/centos-bootc/bootc-image-builder:stream9 + disk: 100 + + Another config example using an image that includes tmt dependencies: + + .. code-block:: yaml + + provision: + how: bootc + add_deps: false + containerimage: 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, this is typically + done by running the command in a rootful podman-machine. The podman-machine + also needs access to ``/var/tmp/tmt``. An example command to initialize the + machine: + + .. code-block:: shell + + podman machine init --rootful --disk-size 200 --memory 8192 \ + --cpus 8 -v /var/tmp/tmt:/var/tmp/tmt -v $HOME:$HOME + """ + + _data_class = BootcData + _guest_class = GuestTestcloud + _guest = None + _id = str(uuid.uuid4())[:8] + + # build a "modified" container image from the base image with tmt dependencies added + def _build_derived_image(self, base_image: str) -> str: + self._logger.debug("Building modified container image with necessary tmt packages/config") + containerfile_template = 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 = containerfile_template.substitute({"base_image": base_image}) + with open(f'{self.workdir}/Containerfile', 'w') as file: + file.write(containerfile) + + image_tag = f'localhost/tmtmodified-{self._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) + + return image_tag + + # build the "base" or user supplied container image + def _build_base_image(self, containerfile: str, workdir: str) -> str: + image_tag = f'localhost/tmtbase-{self._id}' + self._logger.debug("Building container image") + tmt.utils.Command( + "podman", "build", workdir, + "-f", containerfile, + "-t", image_tag + ).run(cwd=self.workdir, stream_output=True, logger=self._logger) + return image_tag + + # build the bootc disk + def _build_bootc_disk(self, containerimage: str, image_builder: str) -> None: + self._logger.debug("Building bootc disk image") + tmt.utils.Command( + "podman", "run", "--rm", "--privileged", + "-v", "/var/lib/containers/storage:/var/lib/containers/storage", + "--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) + + def go(self, *, logger: Optional[tmt.log.Logger] = None) -> None: + """ Provision the bootc instance """ + super().go(logger=logger) + + data = BootcData.from_plugin(self) + data.set_image(f"file://{self.workdir}/qcow2/disk.qcow2") + data.show(verbose=self.verbosity_level, logger=self._logger) + + containerimage = "" + if data.containerimage is not None: + containerimage = data.containerimage + if data.add_deps: + containerimage = self._build_derived_image(data.containerimage) + self._build_bootc_disk(containerimage, data.image_builder) + elif data.containerfile is not None: + containerimage = self._build_base_image(data.containerfile, data.containerfile_workdir) + if data.add_deps: + containerimage = self._build_derived_image(containerimage) + self._build_bootc_disk(containerimage, data.image_builder) + else: + self._logger.fail("Either containerfile or containerimage must be specified.") + raise SystemExit(1) + + self._guest = GuestBootc( + logger=self._logger, + data=data, + name=self.name, + parent=self.step, + containerimage=containerimage) + self._guest.start() + self._guest.setup() + + def guest(self) -> Optional[tmt.steps.provision.Guest]: + return self._guest