Skip to content

Commit fd1d54b

Browse files
committed
Merge remote-tracking branch 'harvey0100/vmimageplugin'
Signed-off-by: Cleber Rosa <crosa@redhat.com>
2 parents 921987f + 5618ef9 commit fd1d54b

File tree

19 files changed

+503
-33
lines changed

19 files changed

+503
-33
lines changed

.github/workflows/vmimage.yml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,19 @@ name: "VMImage CI"
33
on:
44
push:
55
branches: [ "master", "103lts" ]
6-
paths:
6+
paths:
77
- 'avocado/utils/vmimage.py'
8-
- 'selftests/utils/vmimage.py'
8+
- 'selftests/vmimage/**'
9+
- 'avocado/plugins/runners/vmimage.py'
910
pull_request:
1011
branches: [ "master", "103lts" ]
11-
paths:
12+
paths:
1213
- 'avocado/utils/vmimage.py'
13-
- 'selftests/utils/vmimage.py'
14+
- 'selftests/vmimage/**'
15+
- 'avocado/plugins/runners/vmimage.py'
1416

1517
jobs:
16-
vmimge-test-coverage:
18+
vmimage-test-coverage:
1719

1820
name: Code Coverage
1921
runs-on: ubuntu-22.04
@@ -30,6 +32,10 @@ jobs:
3032
uses: actions/setup-python@v5
3133
with:
3234
python-version: ${{ matrix.python-version }}
35+
- name: Install system dependencies
36+
run: |
37+
sudo apt-get update
38+
sudo apt-get install -y qemu-utils
3339
- name: Install
3440
run: pip install -r requirements-dev.txt
3541
- name: Avocado build

avocado/core/utils/messages.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ def start_logging(config, queue):
277277
stderr_logger.propagate = False
278278

279279
# store custom test loggers
280-
enabled_loggers = config.get("job.run.store_logging_stream")
280+
enabled_loggers = config.get("job.run.store_logging_stream", [])
281281
for enabled_logger, level in split_loggers_and_levels(enabled_loggers):
282282
log_path = f"{enabled_logger}.{logging.getLevelName(level)}.log"
283283
if not level:

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: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,20 @@
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,
30-
"unit": 685,
30+
"unit": 661,
3131
"jobs": 11,
32-
"functional-parallel": 318,
32+
"functional-parallel": 314,
3333
"functional-serial": 7,
3434
"optional-plugins": 0,
3535
"optional-plugins-golang": 2,
3636
"optional-plugins-html": 3,
3737
"optional-plugins-robot": 3,
3838
"optional-plugins-varianter_cit": 40,
3939
"optional-plugins-varianter_yaml_to_mux": 50,
40-
"vmimage": 248,
40+
"vmimage-variants": 248,
41+
"vmimage-tests": 35,
4142
"pre-release": 19,
4243
}
4344

@@ -238,6 +239,7 @@ def parse_args():
238239
jobs Run selftests/jobs/
239240
functional Run selftests/functional/
240241
optional-plugins Run optional_plugins/*/tests/
242+
vmimage Run selftests/vmimage/ tests (tests first, then variants)
241243
""",
242244
)
243245
group = parser.add_mutually_exclusive_group()
@@ -632,6 +634,9 @@ def create_suites(args): # pylint: disable=W0621
632634
{
633635
"runner": "avocado-runner-pip",
634636
},
637+
{
638+
"runner": "avocado-runner-vmimage",
639+
},
635640
],
636641
}
637642

@@ -743,16 +748,33 @@ def create_suites(args): # pylint: disable=W0621
743748

744749
suites.append(TestSuite.from_config(config_check_optional, "optional-plugins"))
745750

751+
test_dir = os.path.join("selftests", "vmimage")
752+
753+
# Combined vmimage option - tests first, then variants
746754
if args.dict_tests.get("vmimage"):
747-
test_dir = os.path.join("selftests", "pre_release", "tests")
748-
vmimage_config = {
749-
"resolver.references": [os.path.join(test_dir, "vmimage.py")],
755+
# First suite: vmimage tests
756+
vmimage_tests_config = {
757+
"resolver.references": [
758+
os.path.join(test_dir, "tests"),
759+
],
760+
"run.max_parallel_tasks": 1,
761+
}
762+
suites.append(TestSuite.from_config(vmimage_tests_config, "vmimage-tests"))
763+
764+
# Second suite: vmimage variants
765+
vmimage_variants_config = {
766+
"resolver.references": [
767+
os.path.join(test_dir, "variants", "vmimage.py"),
768+
],
750769
"yaml_to_mux.files": [
751-
os.path.join(test_dir, "vmimage.py.data", "variants.yml")
770+
os.path.join(test_dir, "variants", "vmimage.py.data", "variants.yml")
752771
],
753772
"run.max_parallel_tasks": 1,
754773
}
755-
suites.append(TestSuite.from_config(vmimage_config, "vmimage"))
774+
suites.append(
775+
TestSuite.from_config(vmimage_variants_config, "vmimage-variants")
776+
)
777+
756778
if args.dict_tests.get("pre-release"):
757779
os.environ["AVOCADO_CHECK_LEVEL"] = "3"
758780
pre_release_config = {

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)