Skip to content

Commit

Permalink
Add vmimage dependency runner
Browse files Browse the repository at this point in the history
 VM image dependencies in tests

 * Comprehensive functional tests in `runner_vmimage.py`
 * Documentation section in dependencies guide
 * Example test and recipe JSON
 * Integration with resolver and check systems
 * Setup.py entry points for plugin discovery

Reference: avocado-framework#6043
Signed-off-by: Harvey Lynden <[email protected]>
  • Loading branch information
harvey0100 committed Jan 24, 2025
1 parent 2337945 commit 6c63dbd
Show file tree
Hide file tree
Showing 9 changed files with 363 additions and 1 deletion.
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 hashlib
import os
import sys
import traceback
from multiprocessing import set_start_method

from avocado.core.nrunner.app import BaseRunnerApp
from avocado.core.nrunner.runner import BaseRunner
from avocado.core.utils import messages
from avocado.utils import vmimage


class VMImageRunner(BaseRunner):
"""Runner for dependencies of type vmimage
This runner handles downloading and caching of VM images.
Runnable attributes usage:
* kind: 'vmimage'
* uri: not used
* args: not used
* kwargs:
- provider: VM image provider (e.g., 'Fedora')
- version: version of the image
- arch: architecture of the image
"""

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

def run(self, runnable):
try:
yield messages.StartedMessage.get()

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"
yield messages.StderrMessage.get(stderr.encode())
yield messages.FinishedMessage.get("error")
return

try:
# First get the image object
yield messages.StdoutMessage.get(
f"Getting VM image for {provider} {version} {arch}".encode()
)

# Download the image
yield messages.StdoutMessage.get(
f"Downloading VM image for: distro={provider}, version={version}, arch={arch}".encode()
)
try:
# Get the image using vmimage utility with configured cache dir
from avocado.core.settings import settings

cache_dir = settings.as_dict().get("datadir.paths.cache_dirs")[0]

# Validate provider exists
provider = provider.lower() # Normalize case
available_providers = [
p.name.lower() for p in vmimage.IMAGE_PROVIDERS
]
if provider not in available_providers:
raise ValueError(
f"Provider '{provider}' not found. Available providers: {', '.join(available_providers)}"
)

image = vmimage.Image.from_parameters(
name=provider, version=version, arch=arch, cache_dir=cache_dir
)

# Download and get the image path
image_path = image.get()
if not image_path or not os.path.exists(image_path):
raise RuntimeError(
f"Downloaded image not found at {image_path}"
)

# Verify the downloaded image
if not os.path.isfile(image_path):
raise RuntimeError(f"Image path is not a file: {image_path}")
if os.path.getsize(image_path) == 0:
raise RuntimeError(f"Image file is empty: {image_path}")
if not image_path.endswith((".qcow2", ".raw")):
raise RuntimeError(f"Unexpected image format: {image_path}")

# Verify checksum if available
checksum_file = image_path + ".CHECKSUM"
if os.path.exists(checksum_file):
with open(checksum_file, "r", encoding="utf-8") as f:
expected_checksum = (
f.read().strip().split()[0]
) # Handle 'sha256:...' format
hasher = hashlib.sha256()
with open(image_path, "rb") as f:
while chunk := f.read(4096):
hasher.update(chunk)
actual_checksum = hasher.hexdigest()
if actual_checksum != expected_checksum:
raise RuntimeError(
f"Checksum mismatch for {image_path}\n"
f"Expected: {expected_checksum}\n"
f"Actual: {actual_checksum}"
)

yield messages.StdoutMessage.get(
f"Successfully downloaded VM image to: {image_path}".encode()
)
yield messages.FinishedMessage.get("pass")

except Exception as e:
raise RuntimeError(f"Failed to download image: {str(e)}")

except Exception as e:
yield messages.StderrMessage.get(
f"Failed to get/download VM image: {str(e)}".encode()
)
yield messages.FinishedMessage.get(
"error",
fail_reason=str(e),
fail_class=e.__class__.__name__,
traceback=traceback.format_exc(),
)

except Exception as e:
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()
38 changes: 38 additions & 0 deletions docs/source/guides/user/chapters/dependencies.rst
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@ Following is an example of a test using the Package dependency:

.. literalinclude:: ../../../../../examples/tests/passtest_with_dependency.py

VM Image
++++++++

Support downloading virtual machine images from cloud providers. The
parameters available to use the vmimage `type` of dependencies are:

* `type`: `vmimage`
* `provider`: The distribution name (e.g. 'Fedora')
* `version`: The OS version
* `arch`: The architecture

Pip
+++

Expand Down Expand Up @@ -196,6 +207,33 @@ 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 a test using the VM Image dependency::

from avocado import Test

class VmimageTest(Test):
"""
:avocado: dependency={"type": "vmimage", "provider": "fedora", "version": "41", "arch": "s390x"}
"""
def test(self):
"""
A test that requires a Fedora VM image
"""
self.log.info("Test that uses a VM image")

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"}}
58 changes: 58 additions & 0 deletions examples/tests/dependency_vmimage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env python3

import glob
import os

from avocado import Test


class VmimageTest(Test):
"""
Example test using vmimage dependency
:avocado: dependency={"type": "vmimage", "provider": "Fedora", "version": "41", "arch": "s390x"}
"""

def test(self):
"""
A test that requires a Fedora VM image
"""
# Reconstruct cache path using same logic as vmimage runner
from avocado.core.settings import settings

# Get settings
cache_dir = settings.as_dict().get("datadir.paths.cache_dirs")[0]
# Match runner's normalization exactly
version = "41"
arch = "x86_64"

# Match actual filename pattern with build number
image_filename = f"Fedora-Cloud-Base-Generic-{version}-*.{arch}.qcow2"

# Search all hash directories for matching image
cache_base = os.path.join(cache_dir, "by_location")
found_files = []
searched_dirs = []

if os.path.exists(cache_base):
for dir_name in os.listdir(cache_base):
dir_path = os.path.join(cache_base, dir_name)
if os.path.isdir(dir_path):
pattern = os.path.join(dir_path, image_filename)
matches = glob.glob(pattern)
found_files.extend(matches)
searched_dirs.append(dir_path)

self.log.info(
"Searched %d cache directories: %s",
len(searched_dirs),
", ".join(searched_dirs),
)

self.assertTrue(
len(found_files) > 0,
f"No VM images found matching pattern: {image_filename}\n"
f"Searched directories: {searched_dirs}\n"
f"Cache base path: {cache_base}",
)
self.log.info("VM image validation successful")
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
3 changes: 3 additions & 0 deletions selftests/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,9 @@ def create_suites(args): # pylint: disable=W0621
{
"runner": "avocado-runner-pip",
},
{
"runner": "avocado-runner-vmimage",
},
],
}

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

0 comments on commit 6c63dbd

Please sign in to comment.