Skip to content

Commit 5618ef9

Browse files
committed
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 <hlynden@redhat.com>
1 parent 594bec5 commit 5618ef9

File tree

11 files changed

+441
-6
lines changed

11 files changed

+441
-6
lines changed

avocado/plugins/runners/vmimage.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import multiprocessing
2+
import signal
3+
import sys
4+
import time
5+
import traceback
6+
from multiprocessing import set_start_method
7+
8+
from avocado.core.exceptions import TestInterrupt
9+
from avocado.core.nrunner.app import BaseRunnerApp
10+
from avocado.core.nrunner.runner import (
11+
RUNNER_RUN_CHECK_INTERVAL,
12+
RUNNER_RUN_STATUS_INTERVAL,
13+
BaseRunner,
14+
)
15+
from avocado.core.utils import messages
16+
from avocado.core.utils.messages import start_logging
17+
from avocado.plugins.vmimage import download_image
18+
from avocado.utils import vmimage
19+
20+
21+
class VMImageRunner(BaseRunner):
22+
"""
23+
Runner for dependencies of type vmimage.
24+
This runner uses the vmimage plugin's download_image function which handles:
25+
1. Checking if the image exists in cache
26+
2. Downloading the image if not in cache
27+
3. Storing the image in the configured cache directory
28+
4. Returning the cached image path
29+
"""
30+
31+
name = "vmimage"
32+
description = "Runner for dependencies of type vmimage"
33+
34+
@staticmethod
35+
def signal_handler(signum, frame): # pylint: disable=W0613
36+
if signum == signal.SIGTERM.value:
37+
raise TestInterrupt("VM image operation interrupted: Timeout reached")
38+
39+
@staticmethod
40+
def _run_vmimage_operation(runnable, queue):
41+
try:
42+
signal.signal(signal.SIGTERM, VMImageRunner.signal_handler)
43+
start_logging(runnable.config, queue)
44+
# Get parameters from runnable.kwargs
45+
provider = runnable.kwargs.get("provider")
46+
version = runnable.kwargs.get("version")
47+
arch = runnable.kwargs.get("arch")
48+
49+
if not provider:
50+
stderr = "Missing required parameter: provider"
51+
queue.put(messages.StderrMessage.get(stderr.encode()))
52+
queue.put(messages.FinishedMessage.get("error"))
53+
return
54+
55+
message = f"Getting VM image for {provider}"
56+
if version:
57+
message += f" {version}"
58+
if arch:
59+
message += f" {arch}"
60+
queue.put(messages.StdoutMessage.get(message.encode()))
61+
62+
queue.put(
63+
messages.StdoutMessage.get(
64+
f"Attempting to download image for {provider} (version: {version or 'latest'}, arch: {arch})".encode()
65+
)
66+
)
67+
68+
image = download_image(provider, version, arch)
69+
if not image:
70+
raise RuntimeError("Failed to get image")
71+
72+
queue.put(
73+
messages.StdoutMessage.get(
74+
f"Successfully retrieved VM image from cache or downloaded to: {image['file']}".encode()
75+
)
76+
)
77+
queue.put(messages.FinishedMessage.get("pass"))
78+
79+
except (AttributeError, RuntimeError, vmimage.ImageProviderError) as e:
80+
# AttributeError: provider not found
81+
# RuntimeError: failed to get image
82+
# ImageProviderError: provider-specific errors
83+
queue.put(
84+
messages.StderrMessage.get(
85+
f"Failed to download image: {str(e)}".encode()
86+
)
87+
)
88+
queue.put(
89+
messages.FinishedMessage.get(
90+
"error",
91+
fail_reason=str(e),
92+
fail_class=e.__class__.__name__,
93+
traceback=traceback.format_exc(),
94+
)
95+
)
96+
except (TestInterrupt, multiprocessing.TimeoutError) as e:
97+
queue.put(
98+
messages.StderrMessage.get(f"Operation interrupted: {str(e)}".encode())
99+
)
100+
queue.put(
101+
messages.FinishedMessage.get(
102+
"error",
103+
fail_reason=str(e),
104+
fail_class=e.__class__.__name__,
105+
)
106+
)
107+
108+
@staticmethod
109+
def _monitor(queue):
110+
most_recent_status_time = None
111+
while True:
112+
time.sleep(RUNNER_RUN_CHECK_INTERVAL)
113+
114+
if queue.empty():
115+
now = time.monotonic()
116+
if (
117+
most_recent_status_time is None
118+
or now >= most_recent_status_time + RUNNER_RUN_STATUS_INTERVAL
119+
):
120+
most_recent_status_time = now
121+
yield messages.RunningMessage.get()
122+
continue
123+
124+
message = queue.get()
125+
yield message
126+
if message.get("status") == "finished":
127+
break
128+
129+
def run(self, runnable):
130+
signal.signal(signal.SIGTERM, VMImageRunner.signal_handler)
131+
yield messages.StartedMessage.get()
132+
133+
queue = multiprocessing.SimpleQueue()
134+
process = multiprocessing.Process(
135+
target=self._run_vmimage_operation, args=(runnable, queue)
136+
)
137+
138+
try:
139+
process.start()
140+
141+
for message in self._monitor(queue):
142+
yield message
143+
144+
except TestInterrupt:
145+
process.terminate()
146+
for message in self._monitor(queue):
147+
yield message
148+
except (multiprocessing.ProcessError, OSError) as e:
149+
# ProcessError: Issues with process management
150+
# OSError: System-level errors (e.g. resource limits)
151+
yield messages.StderrMessage.get(f"Process error: {str(e)}".encode())
152+
yield messages.FinishedMessage.get(
153+
"error",
154+
fail_reason=str(e),
155+
fail_class=e.__class__.__name__,
156+
)
157+
158+
159+
class RunnerApp(BaseRunnerApp):
160+
PROG_NAME = "avocado-runner-vmimage"
161+
PROG_DESCRIPTION = "nrunner application for dependencies of type vmimage"
162+
RUNNABLE_KINDS_CAPABLE = ["vmimage"]
163+
164+
165+
def main():
166+
if sys.platform == "darwin":
167+
set_start_method("fork")
168+
app = RunnerApp(print)
169+
app.run()
170+
171+
172+
if __name__ == "__main__":
173+
main()

docs/source/guides/user/chapters/dependencies.rst

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,41 @@ effect on the spawner.
196196
* `uri`: the image reference, in any format supported by ``podman
197197
pull`` itself.
198198

199+
VM Image
200+
++++++++
201+
202+
Support downloading virtual machine images ahead of test execution time.
203+
This allows tests to have their required VM images downloaded and cached
204+
before the test execution begins, preventing timeout issues during the
205+
actual test run.
206+
207+
* `type`: `vmimage`
208+
* `provider`: the VM image provider (e.g., 'Fedora')
209+
* `version`: version of the image (Optional)
210+
* `arch`: architecture of the image (Optional)
211+
212+
Following is an example of tests using the VM Image dependency that demonstrates
213+
different use cases including multiple dependencies and different providers:
214+
215+
.. literalinclude:: ../../../../../examples/tests/dependency_vmimage.py
216+
217+
To test the VM Image dependency:
218+
219+
1. Run the tests::
220+
221+
$ avocado run examples/tests/dependency_vmimage.py
222+
223+
2. Check the cache to see downloaded images::
224+
225+
$ avocado cache list
226+
227+
3. Run again to verify cache is used::
228+
229+
$ avocado run examples/tests/dependency_vmimage.py
230+
231+
The vmimage runner will automatically download required images if they're not in the cache
232+
and use cached images when available, without needing to clear the cache first.
233+
199234
Ansible Module
200235
++++++++++++++
201236

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"kind": "vmimage", "kwargs": {"provider": "fedora", "version": "41", "arch": "x86_64"}}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import unittest
2+
3+
from avocado import Test
4+
from avocado.utils import vmimage
5+
from selftests.utils import missing_binary
6+
7+
8+
@unittest.skipIf(
9+
missing_binary("qemu-img"),
10+
"QEMU disk image utility is required by the vmimage utility ",
11+
)
12+
class VmimageTest(Test):
13+
"""
14+
Example demonstrating VM image dependency.
15+
The vmimage dependency runner will ensure the required VM image
16+
is downloaded and cached before the test execution begins.
17+
18+
This example uses Fedora, but the same approach works for other providers
19+
such as CentOS, Debian, Ubuntu, etc. Just change the provider, version,
20+
and architecture parameters as needed.
21+
22+
:avocado: dependency={"type": "vmimage", "provider": "fedora", "version": "41", "arch": "x86_64"}
23+
"""
24+
25+
def test_vmimage(self):
26+
"""
27+
Simple example showing how to use vmimage.get to verify the image exists.
28+
"""
29+
# Get the VM image based on the dependency parameters
30+
image = vmimage.get(name="fedora", version="41", arch="x86_64")
31+
32+
# Log the image path
33+
self.log.info("VM image path: %s", image.path)
34+
35+
# Verify the image exists
36+
self.assertTrue(image.path, "VM image not found")

python-avocado.spec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ PATH=%{buildroot}%{_bindir}:%{buildroot}%{_libexecdir}/avocado:$PATH \
239239
%{_bindir}/avocado-runner-pip
240240
%{_bindir}/avocado-runner-podman-image
241241
%{_bindir}/avocado-runner-sysinfo
242+
%{_bindir}/avocado-runner-vmimage
242243
%{_bindir}/avocado-software-manager
243244
%{_bindir}/avocado-external-runner
244245
%{python3_sitelib}/avocado*

selftests/check.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"job-api-check-file-exists": 11,
2626
"job-api-check-output-file": 4,
2727
"job-api-check-tmp-directory-exists": 1,
28-
"nrunner-interface": 80,
28+
"nrunner-interface": 90,
2929
"nrunner-requirement": 28,
3030
"unit": 661,
3131
"jobs": 11,
@@ -38,7 +38,7 @@
3838
"optional-plugins-varianter_cit": 40,
3939
"optional-plugins-varianter_yaml_to_mux": 50,
4040
"vmimage-variants": 248,
41-
"vmimage-tests": 28,
41+
"vmimage-tests": 35,
4242
"pre-release": 19,
4343
}
4444

@@ -634,6 +634,9 @@ def create_suites(args): # pylint: disable=W0621
634634
{
635635
"runner": "avocado-runner-pip",
636636
},
637+
{
638+
"runner": "avocado-runner-vmimage",
639+
},
637640
],
638641
}
639642

@@ -764,9 +767,7 @@ def create_suites(args): # pylint: disable=W0621
764767
os.path.join(test_dir, "variants", "vmimage.py"),
765768
],
766769
"yaml_to_mux.files": [
767-
os.path.join(
768-
test_dir, "variants", "vmimage.py.data", "variants.yml"
769-
)
770+
os.path.join(test_dir, "variants", "vmimage.py.data", "variants.yml")
770771
],
771772
"run.max_parallel_tasks": 1,
772773
}

selftests/functional/resolver.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,8 @@ def test_runnables_recipe(self):
252252
package: 1
253253
pip: 1
254254
python-unittest: 1
255-
sysinfo: 1"""
255+
sysinfo: 1
256+
vmimage: 1"""
256257
cmd_line = f"{AVOCADO} -V list {runnables_recipe_path}"
257258
result = process.run(cmd_line)
258259
self.assertIn(

0 commit comments

Comments
 (0)