Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add vmimage dependency runner #6112

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions avocado/plugins/runners/vmimage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import multiprocessing
import signal
import sys
import time
import traceback
from multiprocessing import set_start_method

from avocado.core.exceptions import TestInterrupt
from avocado.core.nrunner.app import BaseRunnerApp
from avocado.core.nrunner.runner import RUNNER_RUN_CHECK_INTERVAL, BaseRunner
from avocado.core.utils import messages
from avocado.core.utils.messages import start_logging
from avocado.plugins.vmimage import download_image
from avocado.utils import vmimage


class VMImageRunner(BaseRunner):
"""
Runner for dependencies of type vmimage.
This runner uses the vmimage plugin's download_image function which handles:
1. Checking if the image exists in cache
2. Downloading the image if not in cache
3. Storing the image in the configured cache directory
4. Returning the cached image path
"""

name = "vmimage"
description = "Runner for dependencies of type vmimage"

@staticmethod
def signal_handler(signum, frame): # pylint: disable=W0613
if signum == signal.SIGTERM.value:
raise TestInterrupt("VM image operation interrupted: Timeout reached")

@staticmethod
def _run_vmimage_operation(runnable, queue):
try:
signal.signal(signal.SIGTERM, VMImageRunner.signal_handler)
start_logging(runnable.config, queue)
provider = runnable.kwargs.get("provider")
version = runnable.kwargs.get("version")
arch = runnable.kwargs.get("arch")

if not all([provider, version, arch]):
stderr = "Missing required parameters: provider, version, and arch"
queue.put(messages.StderrMessage.get(stderr.encode()))
queue.put(messages.FinishedMessage.get("error"))
return

queue.put(
messages.StdoutMessage.get(
f"Getting VM image for {provider} {version} {arch}".encode()
)
)

try:
# download_image will use cache if available, otherwise download
# It will raise AttributeError if provider is not found
provider_normalized = provider.lower()
image = download_image(provider_normalized, version, arch)
if not image:
raise RuntimeError("Failed to get image")

queue.put(
messages.StdoutMessage.get(
f"Successfully retrieved VM image from cache or downloaded to: {image['file']}".encode()
)
)
queue.put(messages.FinishedMessage.get("pass"))

except (AttributeError, RuntimeError, vmimage.ImageProviderError) as e:
# AttributeError: provider not found
# RuntimeError: failed to get image
# ImageProviderError: provider-specific errors
queue.put(
messages.StderrMessage.get(
f"Failed to download image: {str(e)}".encode()
)
)
queue.put(
messages.FinishedMessage.get(
"error",
fail_reason=str(e),
fail_class=e.__class__.__name__,
traceback=traceback.format_exc(),
)
)

except (TestInterrupt, multiprocessing.TimeoutError) as e:
queue.put(messages.StderrMessage.get(traceback.format_exc().encode()))
queue.put(
messages.FinishedMessage.get(
"error",
fail_reason=str(e),
fail_class=e.__class__.__name__,
traceback=traceback.format_exc(),
)
)

@staticmethod
def _monitor(queue):
while True:
time.sleep(RUNNER_RUN_CHECK_INTERVAL)
if queue.empty():
yield messages.RunningMessage.get()
else:
message = queue.get()
yield message
if message.get("status") == "finished":
break

def run(self, runnable):
signal.signal(signal.SIGTERM, VMImageRunner.signal_handler)
yield messages.StartedMessage.get()
try:
queue = multiprocessing.SimpleQueue()
process = multiprocessing.Process(
target=self._run_vmimage_operation, args=(runnable, queue)
)

process.start()

for message in self._monitor(queue):
yield message

except TestInterrupt:
process.terminate()
for message in self._monitor(queue):
yield message
except (multiprocessing.ProcessError, OSError) as e:
# ProcessError: Issues with process management
# OSError: System-level errors (e.g. resource limits)
yield messages.StderrMessage.get(traceback.format_exc().encode())
yield messages.FinishedMessage.get(
"error",
fail_reason=str(e),
fail_class=e.__class__.__name__,
traceback=traceback.format_exc(),
)


class RunnerApp(BaseRunnerApp):
PROG_NAME = "avocado-runner-vmimage"
PROG_DESCRIPTION = "nrunner application for dependencies of type vmimage"
RUNNABLE_KINDS_CAPABLE = ["vmimage"]


def main():
if sys.platform == "darwin":
set_start_method("fork")
app = RunnerApp(print)
app.run()


if __name__ == "__main__":
main()
39 changes: 39 additions & 0 deletions docs/source/guides/user/chapters/dependencies.rst
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,45 @@ effect on the spawner.
* `uri`: the image reference, in any format supported by ``podman
pull`` itself.

VM Image
++++++++

Support downloading virtual machine images ahead of test execution time.
This allows tests to have their required VM images downloaded and cached
before the test execution begins, preventing timeout issues during the
actual test run.

* `type`: `vmimage`
* `provider`: the VM image provider (e.g., 'Fedora')
* `version`: version of the image
* `arch`: architecture of the image

Following is an example of tests using the VM Image dependency that demonstrates
different use cases including multiple dependencies and different providers:

.. literalinclude:: ../../../../../examples/tests/dependency_vmimage.py

To test the VM Image dependency:
harvey0100 marked this conversation as resolved.
Show resolved Hide resolved

1. Clear the cache first::

$ avocado cache clear

2. Run the tests::

$ avocado run examples/tests/dependency_vmimage.py

3. Check the cache to see downloaded images::

$ avocado cache list

4. Run again to verify cache is used::

$ avocado run examples/tests/dependency_vmimage.py

The vmimage runner will download required images on first run and use cached
images on subsequent runs.

Ansible Module
++++++++++++++

Expand Down
1 change: 1 addition & 0 deletions examples/nrunner/recipes/runnable/vmimage_fedora.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"kind": "vmimage", "kwargs": {"provider": "fedora", "version": "41", "arch": "x86_64"}}
125 changes: 125 additions & 0 deletions examples/tests/dependency_vmimage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import os

from avocado import Test
from avocado.core.settings import settings


class VmimageTest(Test):
"""
Test demonstrating VM image dependency usage.
The vmimage dependency runner will ensure the required VM image
is downloaded and cached before the test execution begins.

:avocado: dependency={"type": "vmimage", "provider": "fedora", "version": "41", "arch": "s390x"}
"""

def test_vmimage_exists(self):
"""
Verify that the VM image was downloaded by the vmimage runner.
"""
# Get cache directory from settings
cache_dir = settings.as_dict().get("datadir.paths.cache_dirs")[0]
cache_base = os.path.join(cache_dir, "by_location")

# The image should be in the cache since the runner downloaded it
self.assertTrue(
os.path.exists(cache_base), f"Cache directory {cache_base} does not existz"
)

# Log cache contents for debugging
self.log.info("Cache directory contents:")
for root, _, files in os.walk(cache_base):
for f in files:
if f.endswith((".qcow2", ".raw", ".img")):
self.log.info("Found image: %s", os.path.join(root, f))


class MultiArchVmimageTest(Test):
"""
Test demonstrating multiple VM image dependencies with different architectures.

:avocado: dependency={"type": "vmimage", "provider": "fedora", "version": "41", "arch": "s390x"}
:avocado: dependency={"type": "vmimage", "provider": "fedora", "version": "41", "arch": "x86_64"}
"""

def test_multiple_images(self):
"""
Verify that multiple VM images can be handled by the runner.
Checks that both s390x and x86_64 images exist in the cache
and have the expected properties.
"""
# Get cache directory from settings
cache_dir = settings.as_dict().get("datadir.paths.cache_dirs")[0]
cache_base = os.path.join(cache_dir, "by_location")

# The cache directory should exist
self.assertTrue(
os.path.exists(cache_base), f"Cache directory {cache_base} does not exist"
)

# Track if we found both architectures
found_s390x = False
found_x86_64 = False

# Search for both architecture images
self.log.info("Searching for Fedora 41 images (s390x and x86_64):")
for root, _, files in os.walk(cache_base):
for f in files:
if not f.endswith((".qcow2", ".raw", ".img")):
continue

filepath = os.path.join(root, f)
self.log.info("Found image: %s", filepath)

# Check for architecture markers in path/filename
if "s390x" in filepath:
found_s390x = True
if "x86_64" in filepath:
found_x86_64 = True

# Verify both architectures were found
self.assertTrue(found_s390x, "s390x Fedora 41 image not found in cache")
self.assertTrue(found_x86_64, "x86_64 Fedora 41 image not found in cache")


class UbuntuVmimageTest(Test):
"""
Test demonstrating VM image dependency with a different provider.

:avocado: dependency={"type": "vmimage", "provider": "ubuntu", "version": "22.04", "arch": "x86_64"}
"""

def test_ubuntu_image(self):
"""
Verify that Ubuntu images can be handled by the runner.
Checks that the Ubuntu x86_64 image exists in the cache
and has the expected properties.
"""
# Get cache directory from settings
cache_dir = settings.as_dict().get("datadir.paths.cache_dirs")[0]
cache_base = os.path.join(cache_dir, "by_location")

# The cache directory should exist
self.assertTrue(
os.path.exists(cache_base), f"Cache directory {cache_base} does not exist"
)

# Track if we found the Ubuntu image
found_ubuntu = False

# Search for Ubuntu x86_64 image
self.log.info("Searching for Ubuntu 22.04 x86_64 image:")
for root, _, files in os.walk(cache_base):
for f in files:
if not f.endswith((".qcow2", ".raw", ".img")):
continue

filepath = os.path.join(root, f)

# Check for Ubuntu cloud image filename pattern
if "ubuntu-22.04-server-cloudimg-amd64.img" in filepath.lower():
self.log.info("Found Ubuntu image: %s", filepath)
found_ubuntu = True

# Verify Ubuntu image was found
self.assertTrue(found_ubuntu, "Ubuntu 22.04 x86_64 image not found in cache")
1 change: 1 addition & 0 deletions python-avocado.spec
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ PATH=%{buildroot}%{_bindir}:%{buildroot}%{_libexecdir}/avocado:$PATH \
%{_bindir}/avocado-runner-pip
%{_bindir}/avocado-runner-podman-image
%{_bindir}/avocado-runner-sysinfo
%{_bindir}/avocado-runner-vmimage
%{_bindir}/avocado-software-manager
%{_bindir}/avocado-external-runner
%{python3_sitelib}/avocado*
Expand Down
9 changes: 6 additions & 3 deletions selftests/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@
"job-api-check-file-exists": 11,
"job-api-check-output-file": 4,
"job-api-check-tmp-directory-exists": 1,
"nrunner-interface": 80,
"nrunner-interface": 90,
"nrunner-requirement": 28,
"unit": 682,
"jobs": 11,
"functional-parallel": 318,
"functional-parallel": 325,
"functional-serial": 7,
"optional-plugins": 0,
"optional-plugins-golang": 2,
Expand Down Expand Up @@ -587,7 +587,6 @@ def get_ref(method_short_name):
def create_suites(args): # pylint: disable=W0621
suites = []
config_check = {"run.ignore_missing_references": True}

if args.dict_tests["static-checks"]:
config_check_static = copy.copy(config_check)
config_check_static["resolver.references"] = glob.glob("selftests/*.sh")
Expand Down Expand Up @@ -630,6 +629,10 @@ def create_suites(args): # pylint: disable=W0621
{
"runner": "avocado-runner-pip",
},
{
"runner": "avocado-runner-vmimage",
"max_parallel_tasks": 1,
},
],
}

Expand Down
3 changes: 2 additions & 1 deletion selftests/functional/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ def test_runnables_recipe(self):
package: 1
pip: 1
python-unittest: 1
sysinfo: 1"""
sysinfo: 1
vmimage: 1"""
cmd_line = f"{AVOCADO} -V list {runnables_recipe_path}"
result = process.run(cmd_line)
self.assertIn(
Expand Down
Loading
Loading