fedora-image-tester: various fix-ups

Fixes up the Containerfile so it installs properly. This also ensures
the resource groups LISA creates are tagged so they can be easily
cleaned up asynchronously if LISA fails to clean up for whatever reason
(e.g. the container is restarted during a run).

Signed-off-by: Jeremy Cline <jeremycline@linux.microsoft.com>
This commit is contained in:
Jeremy Cline 2025-11-04 12:20:27 -05:00
commit 15b67101ef
No known key found for this signature in database
10 changed files with 381 additions and 717 deletions

55
Containerfile.fit Normal file
View file

@ -0,0 +1,55 @@
FROM quay.io/fedora/fedora:42 as builder
RUN dnf install -y \
git \
python3-pip \
python3-build \
python3-hatchling
RUN mkdir -p /srv/{fedora-image-tester,fedora-image-uploader-messages,dist}
COPY fedora-image-tester /srv/fedora-image-tester
COPY fedora-image-uploader-messages /srv/fedora-image-uploader-messages
RUN cd /srv/fedora-image-tester && hatchling build -d /srv/dist/ --target=wheel
RUN cd /srv/fedora-image-uploader-messages && hatchling build -d /srv/dist/ --target=wheel
# Currently LISA isn't on PyPI, but I'm working on it
RUN git clone https://github.com/microsoft/lisa.git /srv/lisa
WORKDIR /srv/lisa
RUN git checkout 20251028.1 && python -m build
FROM quay.io/fedora/fedora:42
LABEL org.opencontainers.image.authors="Fedora Cloud SIG <cloud@lists.fedoraproject.org>"
RUN mkdir -p /srv/fedora-image-tester
WORKDIR /srv/fedora-image-tester
COPY --from=builder /srv/dist /srv/dist
COPY --from=builder /srv/lisa/dist /srv/lisa/dist
# Use the system-provided libraries as much as we can here.
#
# We do need to commit a small crime so the system-provided fedora-messaging
# library uses our virtualenv
RUN dnf install -y \
python3-pip \
fedora-messaging \
python3-fedora-image-uploader-messages \
python3-gobject \
python3-paramiko \
python3-pillow \
python3-pyyaml \
python3-retry \
python3-requests
RUN python3 -m venv --system-site-packages venv && \
venv/bin/pip install --no-cache-dir '/srv/lisa/dist/mslisa-20251028.1-py3-none-any.whl' && \
venv/bin/pip install --no-cache-dir /srv/dist/*.whl
RUN cp /usr/bin/fedora-messaging /srv/fedora-image-tester/venv/bin/fedora-messaging && \
sed -i 's|/usr/bin/python3|/srv/fedora-image-tester/venv/bin/python3|g' \
/srv/fedora-image-tester/venv/bin/fedora-messaging
ENV PATH="/srv/fedora-image-tester/venv/bin:$PATH"
ENV VIRTUAL_ENV="/srv/fedora-image-tester/venv"
ENTRYPOINT ["/srv/fedora-image-tester/venv/bin/fedora-messaging"]
CMD ["consume"]

View file

@ -1,51 +0,0 @@
FROM registry.fedoraproject.org/fedora:43 as builder
RUN dnf install -y \
git \
python3-pip \
python3-build \
python3-hatchling
RUN mkdir -p /srv/fedora-image-upload-tester
COPY . /srv/fedora-image-upload-tester
RUN cd /srv/fedora-image-upload-tester && hatchling build --target=wheel
# Currently LISA isn't on PyPI, but I'm working on it
RUN git clone https://github.com/microsoft/lisa.git /srv/lisa
WORKDIR /srv/lisa
RUN git checkout 20251028.1 && python -m build
FROM registry.fedoraproject.org/fedora:43
LABEL org.opencontainers.image.authors="Fedora Cloud SIG <cloud@lists.fedoraproject.org>"
RUN mkdir -p /srv/fedora-image-upload-tester
WORKDIR /srv/fedora-image-upload-tester
COPY --from=builder /srv/fedora-image-upload-tester/dist /srv/fedora-cloud-testing/dist
COPY --from=builder /srv/lisa/dist/*whl /srv/fedora-image-upload-tester/dist/
# Use the system-provided libraries as much as we can here.
#
# We do need to commit a small crime so the system-provided fedora-messaging
# library uses our virtualenv
RUN dnf install -y \
python3-pip \
fedora-messaging \
python3-fedora-image-uploader-messages \
python3-gobject \
python3-paramiko \
python3-pillow \
python3-pyyaml \
python3-retry \
python3-requests
RUN python3 -m venv --system-site-packages venv && venv/bin/pip install --no-cache-dir dist/*
RUN cp /usr/bin/fedora-messaging /srv/fedora-image-upload-tester/venv/bin/fedora-messaging && \
sed -i 's|/usr/bin/python3|/srv/fedora-image-upload-tester/venv/bin/python3|g' \
/srv/fedora-image-upload-tester/venv/bin/fedora-messaging
ENV PATH="/srv/fedora-image-upload-tester/venv/bin:$PATH"
ENV VIRTUAL_ENV="/srv/fedora-image-upload-tester/venv"
ENTRYPOINT ["/srv/fedora-image-upload-tester/venv/bin/fedora-messaging"]
CMD ["consume"]

View file

@ -0,0 +1,46 @@
# fedora-image-tester
An AMQP consumer that runs the [LISA](https://github.com/microsoft/lisa) test suite against cloud images
uploaded by fedora-image-uploader.
## Configuring
The `fedora-messaging` service is the container entrypoint and is also used to provide most of the configuration. An example configuration file, `fedora-messaging-fit.toml.example` is included and the full list of options are in [fedora-messaging's configuration documentation](https://fedora-messaging.readthedocs.io/en/stable/user-guide/configuration.html). The `FEDORA_MESSAGING_CONF` environment variable should be set to the configuration file's location.
## Run Locally
First, build the container:
```
$ podman build -t fedora-image-tester:latest -f Containerfile.fit .
```
Copy `fedora-messaging-fit.toml.example` to `fit.toml` and edit it to fit your needs.
Next, set up any credentials. For example, to authenticate to Azure using a client certificate:
```
$ openssl req -x509 -new -nodes -sha256 -days 365 \
-addext "extendedKeyUsage = clientAuth" \
-subj "/CN=fedora-image-tester" \
-newkey rsa:4096 \
-keyout fedora-image-tester.key.pem \
-out fedora-image-tester.cert.pem
# Make note of the 'Id' and 'AppId' fields in the output as you'll need these later.
$ az ad app create --display-name fedora-image-tester-dev
# Add our certificate to use when authenticating
$ az ad app credential reset --id $APP_ID --append \
--display-name "Fedora Image Tester Certificate" \
--cert "@./fedora-image-tester.cert.pem"
$ az ad sp create --id $APP_ID
# Note that this is an absurdly broad permission set; don't do this for production
$ az role assignment create --assignee $APP_ID --role "Contributor" --scope "/subscriptions/<your sub>"
$ cat fedora-image-tester.key.pem fedora-image-tester.cert.pem > azure-creds
$ podman secret create fedora-image-tester-cert azure-creds
```
Finally, run the consumer:
```
podman run --rm -it -v "$(pwd)"/fit.toml:/etc/fedora-messaging/config.toml:ro,Z --secret source=fedora-image-tester-cert,type=mount --env 'AZURE_CLIENT_CERTIFICATE_PATH=/run/secrets/fedora-image-tester-cert' --env 'AZURE_TENANT_ID=cf237184-4b74-4d45-933d-e415c43f13d8' --env 'AZURE_CLIENT_ID=1571446d-5a0b-4784-83a3-cf4501afda69' --entrypoint=/bin/bash fedora-image-tester:latest
```

View file

@ -9,37 +9,30 @@ image with the results.
import asyncio
import logging
import os
import subprocess
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from tempfile import TemporaryDirectory
import lisa
import yaml
from azure import identity as az_identity
from azure.core.exceptions import AzureError
from azure.storage.blob import BlobServiceClient, ContentSettings
from fedora_image_uploader_messages.publish import AzurePublishedV1
from fedora_image_uploader_messages.test_results import AzureTestResults
from fedora_messaging import api, config
from fedora_messaging.exceptions import (
ConnectionException,
PublishTimeout,
ValidationError,
)
from fedora_messaging import config
from .trigger_lisa import LisaRunner
from .utils import trigger_lisa, fallible_publish
_log = logging.getLogger(__name__)
class AzurePublishedConsumer:
"""Consumer class for AzurePublishedV1 messages to trigger LISA tests."""
FEDORA_GALLERY = "Fedora-5e266ba4-2250-406d-adad-5d73860d958f"
RG_OWNER = "fedora-image-tester"
# Supported Fedora versions for testing
SUPPORTED_FEDORA_VERSIONS = [
"Fedora-Cloud-Rawhide-x64",
"Fedora-Cloud-41-x64",
"Fedora-Cloud-41-Arm64",
"Fedora-Cloud-Rawhide-Arm64",
"Fedora-Cloud-42-x64",
"Fedora-Cloud-42-Arm64",
]
class Consumer:
"""Consumer class for AzurePublishedV1 messages to trigger LISA tests."""
def __init__(self):
try:
@ -48,171 +41,114 @@ class AzurePublishedConsumer:
_log.error("The Azure consumer requires an 'azure' config section")
raise
def __call__(self, message):
self.azure_subscription = os.environ["AZURE_SUBSCRIPTION_ID"]
self.azure_credentials = az_identity.DefaultAzureCredential()
self.azure_blob_client = BlobServiceClient(
account_url=(f"https://{self.conf['results_storage_account']}.blob.core.windows.net"),
credential=self.azure_credentials,
)
def __call__(self, message: AzurePublishedV1):
"""Callback method to handle incoming messages."""
_log.info("Received message: %s", message)
self.azure_published_callback(message)
image_definition_name = message.body["image_definition_name"]
community_gallery_image = (
f"{self.conf['region']}/{FEDORA_GALLERY}/"
f"{image_definition_name}/{message.body['image_version_name']}"
)
def _get_image_definition_name(self, message):
"""Get image definition name from the message body.
Args:
message (AzurePublishedV1): The message containing image details.
Returns:
str: The image definition name if found, else None.
Eg: "Fedora-Cloud-Rawhide-x64", "Fedora-Cloud-41-x64", etc.
"""
try:
image_definition_name = message.body.get("image_definition_name")
if not isinstance(image_definition_name, str):
_log.error("image_definition_name is not a string: %s", image_definition_name)
return None
_log.info("Extracted image_definition_name: %s", image_definition_name)
return image_definition_name
except AttributeError:
_log.error("Message body does not have 'image_definition_name' field.")
return None
def get_community_gallery_image(self, message):
"""Extract community gallery image from the messages."""
_log.info("Extracting community gallery image from the message: %s", message.body)
try:
# Validate message.body is a dict
if not isinstance(message.body, dict):
_log.error("Message body is not a dictionary.")
return None
image_definition_name = self._get_image_definition_name(message)
# Run tests only for fedora rawhide, 41 and 42,
# include your Fedora versions in SUPPORTED_FEDORA_VERSIONS
if image_definition_name not in self.SUPPORTED_FEDORA_VERSIONS:
_log.info(
"image_definition_name '%s' not in supported Fedora" " versions, skipping.",
image_definition_name,
)
return None
image_version_name = message.body.get("image_version_name")
image_resource_id = message.body.get("image_resource_id")
# Check for missing fields
if not all([image_definition_name, image_version_name, image_resource_id]):
_log.error("Missing required image fields in message body.")
return None
# Defensive split and validation
parts = image_resource_id.split("/")
if len(parts) < 3:
_log.error("image_resource_id format is invalid: %s", image_resource_id)
return None
resource_id = parts[2]
community_gallery_image = (
f"{self.conf['region']}/{resource_id}/"
f"{image_definition_name}/{image_version_name}"
)
_log.info("Constructed community gallery image: %s", community_gallery_image)
return community_gallery_image
except AttributeError as e:
_log.error("Failed to extract image details from the message: %s", str(e))
return None
def azure_published_callback(self, message):
"""Handle Azure published messages"""
_log.info("Received message on topic: %s", message.topic)
_log.info("Message %s", message.body)
try:
if isinstance(message, AzurePublishedV1):
_log.info("Message properties match AzurePublishedV1 schema.")
except TypeError as e:
_log.error("Message properties do not match AzurePublishedV1 schema: %s", str(e))
community_gallery_image = self.get_community_gallery_image(message)
if not community_gallery_image:
_log.error("Unsupported or No community gallery image found in the message.")
return
image_definition_name = self._get_image_definition_name(message)
# Generate run name with UTC format
run_name = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")
run_name = f"{message.body['compose_id']}-{message.body['architecture']}"
_log.info("Run name generated: %s", run_name)
base_runbook = os.path.join(
os.path.dirname(lisa.__file__), "microsoft/runbook/tiers/tier.yml"
)
runbook = {
"name": "fedora azure",
"include": [
{"path": base_runbook},
],
"notifier": [
{"type": "html"},
{"type": "junit"},
{"type": "env_stats"},
],
"variable": [
{"name": "tier", "value": "1"},
],
"concurrency": 3,
"platform": [
{
"type": "azure",
"keep_environment": "no",
"azure": {
"deploy": True,
"wait_delete": False,
"subscription_id": self.azure_subscription,
"use_public_address": True,
"create_public_address": True,
"resource_group_tags": {"owner": RG_OWNER},
},
"requirement": {
"core_count": {"min": 2},
"azure": {
"community_gallery_image": community_gallery_image,
"location": self.conf["region"],
},
},
},
],
}
try:
# Use TemporaryDirectory context manager for auto cleanup at the end of
# the test run
with TemporaryDirectory(
prefix=f"lisa_results_{image_definition_name}_", suffix="_logs"
) as log_path:
with TemporaryDirectory(prefix=f"lisa_{image_definition_name}_") as workdir:
runbook_path = os.path.join(workdir, "runbook.yml")
log_path = os.path.join(workdir, "logs")
os.mkdir(log_path)
_log.info("Temporary log path created: %s", log_path)
with open(runbook_path, "w") as f:
yaml.safe_dump(runbook, f)
# Generate SSH key pair for authentication
private_key = self._generate_ssh_key_pair(log_path)
config_params = {
"subscription": self.conf["subscription_id"],
"private_key": private_key,
"log_path": log_path,
"run_name": run_name,
}
_log.info("LISA config parameters: %s", config_params)
_log.info("Triggering tests for image: %s", community_gallery_image)
runner = LisaRunner()
ret = asyncio.run(
runner.trigger_lisa(
region=self.conf["region"],
community_gallery_image=community_gallery_image,
config=config_params,
asyncio.run(
trigger_lisa(
runbook_path=runbook_path,
log_path=log_path,
run_name=run_name,
)
)
_log.info("LISA trigger completed with return code: %d", ret)
if ret == 0:
_log.info("LISA trigger executed successfully.")
test_results = self._parse_test_results(log_path, run_name)
if test_results is not None:
_log.info("Test execution completed with results: %s", test_results)
# To Do: Implement sending the results using publisher
self.publish_test_results(message, test_results)
else:
_log.error("Failed to parse test results, skipping image")
test_results = self._parse_test_results(log_path, run_name)
_log.info("Test execution completed with results: %s", test_results)
if test_results is not None:
body = self._build_result_message_body(message, test_results)
_log.debug("Publishing test results for image: %s", body["image_id"])
fallible_publish(AzureTestResults(body=body))
_log.info("Successfully published test results for %s", body["image_id"])
else:
_log.error("LISA trigger failed with return code: %d", ret)
# TemporaryDirectory automatically cleans up when exiting the context
_log.error("Failed to parse test results, skipping image")
html_results_path = os.path.join(log_path, run_name, "lisa.html")
junit_results_path = os.path.join(log_path, run_name, "lisa.junit.xml")
compose_id = message.body["compose_id"]
architecture = message.body["architecture"]
html_blob = self.azure_blob_client.get_blob_client(
container="$web", blob=f"{compose_id}/{architecture}/index.html"
)
junit_blob = self.azure_blob_client.get_blob_client(
container="$web", blob=f"{compose_id}/{architecture}/junit.xml"
)
try:
with open(html_results_path, "rb") as data:
content_settings = ContentSettings(content_type="text/html; charset=utf-8")
html_blob.upload_blob(data=data, content_settings=content_settings)
with open(junit_results_path, "rb") as data:
content_settings = ContentSettings(content_type="application/xml")
junit_blob.upload_blob(data=data, content_settings=content_settings)
except AzureError as e:
_log.warning("Failed to upload results to storage account: %s", str(e))
except OSError as e:
_log.exception("Failed to trigger LISA: %s", str(e))
def publish_test_results(self, message, test_results):
"""
Publish the test results using AzureTestResults publisher.
Following fedora-image-uploader patterns for message publishing.
"""
try:
# Extract metadata from original message
body = self._build_result_message_body(message, test_results)
# Create message instance with body (following fedora-messaging patterns)
result_message = AzureTestResults(body=body)
_log.info("Publishing test results for image: %s", body["image_id"])
_log.debug("Full message body: %s", body)
# Publish message using fedora-messaging API
api.publish(result_message)
_log.info("Successfully published test results for %s", body["image_id"])
except ValidationError as e:
_log.error("Message validation failed: %s", str(e))
_log.error("Invalid message body: %s", body)
except (PublishTimeout, ConnectionException) as e:
_log.error("Failed to publish test results due to connectivity: %s", str(e))
except (OSError, KeyError, TypeError) as e:
_log.error("Unexpected error during publishing: %s", str(e))
def _build_result_message_body(self, original_message, test_results):
"""
Build the message body for test results publication.
@ -224,17 +160,12 @@ class AzurePublishedConsumer:
Returns:
dict: Message body for AzureTestResults
"""
# Extract image metadata from original message
body = original_message.body
# Build the result message body following the schema
result_body = {
# Image identification
"architecture": body["architecture"],
"compose_id": body["compose_id"],
"image_id": body["image_definition_name"], # Use definition name as image ID
"image_id": body["image_definition_name"],
"image_resource_id": body["image_resource_id"],
# Detailed test lists
"failed_tests": test_results.get("failed_tests", {"count": 0, "tests": {}}),
"skipped_tests": test_results.get("skipped_tests", {"count": 0, "tests": {}}),
"passed_tests": test_results.get("passed_tests", {"count": 0, "tests": {}}),
@ -253,7 +184,6 @@ class AzurePublishedConsumer:
'total_tests', 'passed', 'failed', 'skipped', 'errors'
None: If parsing fails and results cannot be determined
"""
# Find and validate XML file
xml_file = self._find_xml_file(log_path, run_name)
if not xml_file or not os.path.exists(xml_file):
_log.error("No XML file found in the log path: %s", log_path)
@ -261,13 +191,11 @@ class AzurePublishedConsumer:
_log.info("Found XML file: %s", xml_file)
# Parse the XML file
try:
tree = ET.parse(xml_file)
root = tree.getroot()
_log.info("Parsing xml root element: %s", root.tag)
# Extract individual test details
test_details = self._extract_test_details(root)
results = self._format_for_schema(test_details)
@ -292,29 +220,23 @@ class AzurePublishedConsumer:
test_suites = root.findall("testsuite") if root.tag == "testsuites" else [root]
# Iterate through test suites and test cases
for suite in test_suites:
suite_name = suite.attrib.get("name")
for testcase in suite.findall("testcase"):
test_name = testcase.attrib.get("name")
# Create a descriptive test identifier
test_identifier = f"{suite_name}.{test_name}"
test_time = testcase.attrib.get("time", "0.000")
# Check test status and extract the message if available
failure_elem = testcase.find("failure")
error_elem = testcase.find("error")
skipped_elem = testcase.find("skipped")
# Log test details for failed, skipped and errored tests
if failure_elem is not None:
failure_msg = failure_elem.attrib.get("message", "Test case failed")
failure_msg = self._remove_html_tags(failure_msg)
traceback_msg = failure_elem.text or ""
# Combine failure_message and traceback if available
if traceback_msg.strip():
failure_msg = (
f"Summary: {failure_msg}\n Traceback: \n{traceback_msg.strip()}"
@ -332,10 +254,7 @@ class AzurePublishedConsumer:
elif skipped_elem is not None:
skip_msg = skipped_elem.attrib.get("message", "Test skipped")
skip_msg = self._remove_html_tags(skip_msg)
# As there won't be any traceback will return the entire message
test_details["skipped"].append((test_identifier, skip_msg))
else:
passed_msg = f"Test passed in {test_time} seconds."
test_details["passed"].append((test_identifier, passed_msg))
@ -403,39 +322,3 @@ class AzurePublishedConsumer:
_log.warning("No XML file with suffix 'lisa.junit.xml' found in %s", xml_path)
return None
def _generate_ssh_key_pair(self, temp_dir):
"""
Generate an SSH key pair for authentication.
Args:
temp_dir (str): Directory to store the generated key pair.
Returns:
str: Path to the private key file. or None if generation fails.
"""
private_key_path = os.path.join(temp_dir, "id_ed25519")
public_key_path = os.path.join(temp_dir, "id_ed25519.pub")
try:
# Generate SSH key pair using ssh-keygen
cmd = ["ssh-keygen", "-t", "ed25519", "-f", private_key_path, "-N", ""]
ret = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=60)
_log.info("SSH key pair generated at: %s and %s", private_key_path, public_key_path)
_log.debug("ssh-keygen output: %s", ret.stdout)
# Verify the private key file is created
if not os.path.exists(private_key_path):
_log.error(
"SSH key generation succeeded but private key file was not found at: %s",
private_key_path,
)
return None
# Set the permissions for the file
os.chmod(private_key_path, 0o600)
return private_key_path
except (subprocess.CalledProcessError, OSError) as e:
_log.error("Failed to generate SSH key pair: %s", str(e))
return None

View file

@ -1,108 +0,0 @@
"""Module to trigger LISA tests asynchronously."""
import asyncio
import logging
import subprocess
_log = logging.getLogger(__name__)
# pylint: disable=too-few-public-methods
class LisaRunner:
"""Class to run LISA tests asynchronously"""
def __init__(self):
pass
async def trigger_lisa(self, region, community_gallery_image, config):
# pylint: disable=too-many-return-statements,too-many-branches
"""Trigger LISA tier 1 tests with the provided parameters.
Args:
region (str): The Azure region to run the tests in.
community_gallery_image (str): The community gallery image to use for testing.
config (dict): A dictionary containing the configuration parameters.
- subscription (str): The Azure subscription ID.
- private_key (str): The path to the private key file for authentication.
- log_path (str): The path to the log file for the LISA tests.
- run_name (str): The name of the test run.
Returns:
bool: True if the LISA test completed successfully (return code 0),
False if the test failed, had errors, or if required parameters are missing.
"""
# Validate the input parameters
if not region or not isinstance(region, str):
_log.error("Invalid region parameter: must be a non-empty string")
return False
if not community_gallery_image or not isinstance(community_gallery_image, str):
_log.error("Invalid community_gallery_image parameter: must be a non-empty string")
return False
if not isinstance(config, dict):
_log.error("Invalid config parameter: must be a dictionary")
return False
if not config.get("subscription"):
_log.error("Missing required parameter: subscription")
return False
if not config.get("private_key"):
_log.error("Missing required parameter: private_key")
return False
try:
variables = [
f"region:{region}",
f"community_gallery_image:{community_gallery_image}",
f"subscription_id:{config.get('subscription')}",
f"admin_private_key_file:{config.get('private_key')}",
]
command = [
"lisa",
"-r",
"microsoft/runbook/azure_fedora.yml",
"-v",
"tier:1",
"-v",
"test_case_name:verify_dhcp_file_configuration",
]
for var in variables:
command.extend(["-v", var])
# Add optional parameters only if they are provided
log_path = config.get("log_path")
if log_path:
command.extend(["-l", log_path])
_log.debug("Added log path: %s", log_path)
else:
_log.debug("No log path provided, using LISA default")
run_name = config.get("run_name")
if run_name:
command.extend(["-i", run_name])
_log.debug("Added run name: %s", run_name)
else:
_log.debug("No run name provided, using LISA default")
_log.info("Starting LISA test with command: %s", " ".join(command))
process = await asyncio.create_subprocess_exec(
*command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
async for line in process.stdout:
line_content = line.decode().strip()
if line_content: # Only log non-empty lines
_log.info("LISA OUTPUT: %s ", line_content)
await process.wait()
# stderr = await process.communicate()
if process.returncode == 0:
_log.info("LISA test completed successfully")
return True
_log.error("LISA test failed with return code: %d", process.returncode)
return False
except Exception as e: # pylint: disable=broad-except
_log.error("An error occurred while running the tests: %s", str(e))
return False

View file

@ -0,0 +1,67 @@
"""Module to trigger LISA tests asynchronously."""
import asyncio
import logging
import subprocess
from fedora_messaging import api, exceptions as fm_exceptions
_log = logging.getLogger(__name__)
async def trigger_lisa(runbook_path: str, log_path: str, run_name: str) -> None:
"""
Trigger a LISA test run.
LISA exits with the number of failed tests, so it makes it difficult to use return codes.
Your runbook should, at a minimum, contains the "junit" notifier so you can examine results.
Args:
runbook_path: The absolute path to a valid LISA runbook.
log_path: The absolute path to a directory where LISA will store various logs and results.
run_name: The test run name; LISA will create a directory inside log_path with this name.
"""
command = [
"lisa",
"-r",
runbook_path,
"-l",
log_path,
"-i",
run_name,
]
_log.info("Starting LISA test with command: '%s'", " ".join(command))
process = await asyncio.create_subprocess_exec(
*command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
async for line in process.stdout:
line_content = line.decode().strip()
if line_content:
_log.info("LISA OUTPUT: %s ", line_content)
await process.wait()
if process.returncode != 0:
_log.warning("LISA test failed with return code: %d", process.returncode)
def fallible_publish(message):
"""
Helper to publish AMQP messages fallibly.
Rather than try really hard to publish every message, if the broker is unavailable it's
reasonable to just wait until the next image (which happens daily) to get built and try
again then.
"""
try:
api.publish(message=message)
_log.info("Published %s message to %s", message.__class__.__name__, message.topic)
except (fm_exceptions.PublishTimeout, fm_exceptions.PublishReturned) as e:
_log.warning("Unable to publish %s message: %s", message.__class__.__name__, str(e))
except fm_exceptions.PublishForbidden as e:
_log.error(
"Unable to publish message to topic %s, permission denied: %s",
message.topic,
str(e),
)

View file

@ -14,8 +14,7 @@ requires-python = ">=3.10"
dependencies = [
"fedora-messaging",
"fedora-image-uploader-messages",
# Until we get LISA on PyPI...
"mslisa[azure] @ git+https://github.com/microsoft/lisa.git",
"mslisa[azure,aws]",
]
[project.optional-dependencies]

View file

@ -1,4 +1,4 @@
"""Unit tests for the AzurePublishedConsumer class in azure.py."""
"""Unit tests for the Consumer class in azure.py."""
import os
import subprocess
@ -9,7 +9,7 @@ import pytest
from fedora_image_uploader_messages.publish import AzurePublishedV1
from fedora_messaging import config as fm_config
from fedora_image_tester.azure import AzurePublishedConsumer
from fedora_image_tester.azure import Consumer
@pytest.fixture(scope="module")
@ -29,8 +29,8 @@ def azure_conf():
@pytest.fixture
def consumer(azure_conf): # pylint: disable=unused-argument
"""Create an AzurePublishedConsumer instance for testing."""
return AzurePublishedConsumer()
"""Create an Consumer instance for testing."""
return Consumer()
@pytest.fixture
@ -49,22 +49,9 @@ def valid_message():
return message
class TestAzurePublishedConsumer:
class TestConsumer:
# pylint: disable=protected-access
"""Test class for AzurePublishedConsumer."""
def test_supported_fedora_versions_constant(self):
"""Test that SUPPORTED_FEDORA_VERSIONS contains expected versions."""
# Test that the constant is defined and is a list
assert hasattr(AzurePublishedConsumer, "SUPPORTED_FEDORA_VERSIONS")
assert isinstance(AzurePublishedConsumer.SUPPORTED_FEDORA_VERSIONS, list)
assert len(AzurePublishedConsumer.SUPPORTED_FEDORA_VERSIONS) > 0
# Test that all versions follow expected naming pattern
for version in AzurePublishedConsumer.SUPPORTED_FEDORA_VERSIONS:
assert isinstance(version, str)
assert version.startswith("Fedora-Cloud-")
assert version.endswith(("-x64", "-Arm64"))
"""Test class for Consumer."""
def test_get_image_definition_name_success(self, consumer, valid_message):
"""Test successful extraction of image definition name."""
@ -177,9 +164,14 @@ class TestAzurePublishedConsumer:
@patch("fedora_cloud_tests.azure.asyncio.run")
@patch("fedora_cloud_tests.azure.LisaRunner")
@patch.object(AzurePublishedConsumer, "_generate_ssh_key_pair")
@patch.object(Consumer, "_generate_ssh_key_pair")
def test_azure_published_callback_success(
self, mock_ssh_keygen, mock_lisa_runner, mock_asyncio_run, consumer, valid_message
self,
mock_ssh_keygen,
mock_lisa_runner,
mock_asyncio_run,
consumer,
valid_message,
): # pylint: disable=R0913,R0917
"""Test successful message processing and LISA trigger."""
mock_runner_instance = MagicMock()
@ -204,11 +196,19 @@ class TestAzurePublishedConsumer:
mock_lisa_runner.assert_not_called()
mock_asyncio_run.assert_not_called()
@patch("fedora_cloud_tests.azure.asyncio.run", side_effect=OSError("LISA execution failed"))
@patch(
"fedora_cloud_tests.azure.asyncio.run",
side_effect=OSError("LISA execution failed"),
)
@patch("fedora_cloud_tests.azure.LisaRunner")
@patch.object(AzurePublishedConsumer, "_generate_ssh_key_pair")
@patch.object(Consumer, "_generate_ssh_key_pair")
def test_azure_published_callback_lisa_exception(
self, mock_ssh_keygen, mock_lisa_runner, mock_asyncio_run, consumer, valid_message
self,
mock_ssh_keygen,
mock_lisa_runner,
mock_asyncio_run,
consumer,
valid_message,
): # pylint: disable=R0913,R0917
"""Test exception handling when LISA execution fails."""
mock_runner_instance = MagicMock()

View file

@ -1,310 +0,0 @@
"""Unit tests for the LisaRunner class in trigger_lisa.py."""
import subprocess
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fedora_image_tester import trigger_lisa
# pylint: disable=protected-access
@pytest.fixture
def runner():
"""Create a LisaRunner instance for testing."""
return trigger_lisa.LisaRunner()
@pytest.fixture
def test_setup(runner, region, community_gallery_image, config_params):
"""Create a test setup object combining common fixtures."""
return {
"runner": runner,
"region": region,
"community_gallery_image": community_gallery_image,
"config_params": config_params,
}
@pytest.fixture
def mock_process():
"""Create a properly mocked async subprocess for testing."""
process = MagicMock()
process.returncode = 0
process.wait = AsyncMock()
# Mock stdout as an async iterator
async def mock_stdout_lines():
lines = [b"LISA test output line 1\n", b"LISA test output line 2\n"]
for line in lines:
yield line
process.stdout = mock_stdout_lines()
return process
@pytest.fixture
def config_params():
"""Create test configuration parameters."""
return {
"subscription": "test-subscription-id",
"private_key": "/path/to/private/key",
"log_path": "/tmp/test_logs",
"run_name": "test-run-name",
}
@pytest.fixture
def region():
"""Create test region."""
return "westus2"
@pytest.fixture
def community_gallery_image():
"""Create test community gallery image."""
return "test/gallery/image"
class TestLisaRunner:
"""Test class for LisaRunner."""
@pytest.mark.asyncio
async def test_trigger_lisa_success(self, test_setup, mock_process):
"""Test successful execution of the trigger_lisa method."""
with patch("asyncio.create_subprocess_exec") as mock_subproc_exec:
mock_subproc_exec.return_value = mock_process
result = await test_setup["runner"].trigger_lisa(
test_setup["region"],
test_setup["community_gallery_image"],
test_setup["config_params"],
)
assert result is True
mock_subproc_exec.assert_called_once()
mock_process.wait.assert_called_once()
@pytest.mark.asyncio
async def test_trigger_lisa_success_with_warnings(self, test_setup, mock_process):
"""Test successful execution with output."""
with patch("asyncio.create_subprocess_exec") as mock_subproc_exec:
mock_subproc_exec.return_value = mock_process
with patch.object(trigger_lisa._log, "info") as mock_logger_info:
result = await test_setup["runner"].trigger_lisa(
test_setup["region"],
test_setup["community_gallery_image"],
test_setup["config_params"],
)
assert result is True
# Check that LISA output was logged
mock_logger_info.assert_any_call("LISA OUTPUT: %s ", "LISA test output line 1")
@pytest.mark.asyncio
async def test_trigger_lisa_failure_non_zero_return_code(
self, runner, region, community_gallery_image, config_params
):
"""Test failure when LISA returns non-zero exit code."""
with patch("asyncio.create_subprocess_exec") as mock_subproc_exec:
mock_process = MagicMock()
mock_process.returncode = 1
mock_process.wait = AsyncMock()
# Mock stdout as an async iterator with error output
async def mock_stdout_lines():
lines = [b"Error: LISA test failed\n", b"Additional error details\n"]
for line in lines:
yield line
mock_process.stdout = mock_stdout_lines()
mock_subproc_exec.return_value = mock_process
with patch.object(trigger_lisa._log, "error") as mock_logger_error:
result = await runner.trigger_lisa(region, community_gallery_image, config_params)
assert result is False
mock_logger_error.assert_any_call("LISA test failed with return code: %d", 1)
@pytest.mark.asyncio
async def test_trigger_lisa_exception_handling(
self, runner, region, community_gallery_image, config_params
):
"""Test error handling and logging when subprocess execution fails."""
with patch("asyncio.create_subprocess_exec", side_effect=Exception("Process failed")):
with patch.object(trigger_lisa._log, "error") as mock_logger_error:
result = await runner.trigger_lisa(region, community_gallery_image, config_params)
assert result is False
mock_logger_error.assert_called_with(
"An error occurred while running the tests: %s", "Process failed"
)
@pytest.mark.asyncio
async def test_trigger_lisa_missing_region(
self, runner, community_gallery_image, config_params
):
"""Test validation failure when region is missing."""
with patch.object(trigger_lisa._log, "error") as mock_logger_error:
result = await runner.trigger_lisa("", community_gallery_image, config_params)
assert result is False
mock_logger_error.assert_called_with(
"Invalid region parameter: must be a non-empty string"
)
@pytest.mark.asyncio
async def test_trigger_lisa_missing_community_gallery_image(
self, runner, region, config_params
):
"""Test validation failure when community_gallery_image is missing."""
with patch.object(trigger_lisa._log, "error") as mock_logger_error:
result = await runner.trigger_lisa(region, "", config_params)
assert result is False
mock_logger_error.assert_called_with(
"Invalid community_gallery_image parameter: must be a non-empty string"
)
@pytest.mark.asyncio
async def test_trigger_lisa_missing_subscription(
self, runner, region, community_gallery_image, config_params
):
"""Test validation failure when subscription is missing."""
config_without_subscription = config_params.copy()
del config_without_subscription["subscription"]
with patch.object(trigger_lisa._log, "error") as mock_logger_error:
result = await runner.trigger_lisa(
region, community_gallery_image, config_without_subscription
)
assert result is False
mock_logger_error.assert_called_with("Missing required parameter: subscription")
@pytest.mark.asyncio
async def test_trigger_lisa_missing_private_key(
self, runner, region, community_gallery_image, config_params
):
"""Test validation failure when private_key is missing."""
config_without_private_key = config_params.copy()
del config_without_private_key["private_key"]
with patch.object(trigger_lisa._log, "error") as mock_logger_error:
result = await runner.trigger_lisa(
region, community_gallery_image, config_without_private_key
)
assert result is False
mock_logger_error.assert_called_with("Missing required parameter: private_key")
@pytest.mark.asyncio
async def test_trigger_lisa_command_construction(self, test_setup, mock_process):
"""Test that the LISA command is constructed correctly."""
with patch("asyncio.create_subprocess_exec") as mock_subproc_exec:
mock_subproc_exec.return_value = mock_process
await test_setup["runner"].trigger_lisa(
test_setup["region"],
test_setup["community_gallery_image"],
test_setup["config_params"],
)
# Verify the command was called with correct arguments
expected_command = [
"lisa",
"-r",
"microsoft/runbook/azure_fedora.yml",
"-v",
"tier:1",
"-v",
"test_case_name:verify_dhcp_file_configuration",
"-v",
f"region:{test_setup['region']}",
"-v",
f"community_gallery_image:{test_setup['community_gallery_image']}",
"-v",
f"subscription_id:{test_setup['config_params']['subscription']}",
"-v",
f"admin_private_key_file:{test_setup['config_params']['private_key']}",
"-l",
test_setup["config_params"]["log_path"],
"-i",
test_setup["config_params"]["run_name"],
]
mock_subproc_exec.assert_called_once_with(
*expected_command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
@pytest.mark.asyncio
async def test_trigger_lisa_missing_optional_config_parameters(
self, runner, region, community_gallery_image, mock_process
):
"""Test successful execution when optional config parameters
(log_path, run_name) are missing."""
minimal_config = {
"subscription": "test-subscription",
"private_key": "/path/to/key",
# log_path and run_name are missing
}
with patch("asyncio.create_subprocess_exec") as mock_subproc_exec:
mock_subproc_exec.return_value = mock_process
result = await runner.trigger_lisa(region, community_gallery_image, minimal_config)
# Now the implementation should handle missing optional parameters gracefully
assert result is True
# Verify command is called but without the optional -l and -i flags
args, _ = mock_subproc_exec.call_args
command_list = list(args)
assert "-l" not in command_list
assert "-i" not in command_list
# But should still have the required arguments
assert "lisa" in command_list
assert "-r" in command_list
assert "microsoft/runbook/azure_fedora.yml" in command_list
@pytest.mark.asyncio
async def test_trigger_lisa_with_optional_config_parameters(
self, runner, region, community_gallery_image, mock_process
):
"""Test successful execution when optional config parameters are provided."""
config_with_optionals = {
"subscription": "test-subscription",
"private_key": "/path/to/key",
"log_path": "/custom/log/path",
"run_name": "custom-run-name",
}
with patch("asyncio.create_subprocess_exec") as mock_subproc_exec:
mock_subproc_exec.return_value = mock_process
result = await runner.trigger_lisa(
region, community_gallery_image, config_with_optionals
)
assert result is True
# Verify command includes the provided optional parameters
args, _ = mock_subproc_exec.call_args
command_list = list(args)
assert "-l" in command_list
assert "/custom/log/path" in command_list
assert "-i" in command_list
assert "custom-run-name" in command_list
@pytest.mark.asyncio
async def test_trigger_lisa_invalid_config_type(self, runner, region, community_gallery_image):
"""Test validation failure when config is not a dictionary."""
with patch.object(trigger_lisa._log, "error") as mock_logger_error:
result = await runner.trigger_lisa(
region, community_gallery_image, "not a dict" # Invalid type
)
assert result is False
mock_logger_error.assert_called_with("Invalid config parameter: must be a dictionary")

View file

@ -0,0 +1,83 @@
# A sample configuration for fedora-messaging. This file is in the TOML format.
amqp_url = "amqps://fedora:@rabbitmq.fedoraproject.org/%2Fpublic_pubsub"
callback = "fedora_image_tester.azure:Consumer"
[tls]
ca_cert = "/etc/fedora-messaging/cacert.pem"
keyfile = "/etc/fedora-messaging/fedora-key.pem"
certfile = "/etc/fedora-messaging/fedora-cert.pem"
# Queue names *must* be in the normal UUID format: run "uuidgen" and use the
# output as your queue name. If you don't define a queue here, the server will
# generate a queue name for you. This queue will be non-durable, auto-deleted and
# exclusive.
# If your queue is not exclusive, anyone can connect and consume from it, causing
# you to miss messages, so do not share your queue name. Any queues that are not
# auto-deleted on disconnect are garbage-collected after approximately one hour.
#
# If you require a stronger guarantee about delivery, please talk to Fedora's
# Infrastructure team.
#
# If you use the server-generated queue names, you can leave out the "queue"
# parameter in the bindings definition.
[[bindings]]
# queue = "00000000-0000-0000-0000-000000000000"
exchange = "amq.topic"
routing_keys = ["org.fedoraproject.*.fedora_image_uploader.published.v1.azure.*"]
[qos]
prefetch_size = 0
prefetch_count = 25
[client_properties]
app = "Fedora Image Tester"
app_url = "https://pagure.io/cloud-image-uploader"
app_contacts_email = "cloud@lists.fedoraproject.org"
# The Azure consumer namespaces its configuration under the "azure" key.
[consumer_config.azure]
# The region to use when creating test resources
region = "westus3"
results_storage_account = "fedoratestresults"
[log_config]
version = 1
disable_existing_loggers = true
[log_config.formatters.simple]
format = "%(asctime)s [%(name)s - %(levelname)s] - %(message)s"
[log_config.handlers.console]
class = "logging.StreamHandler"
formatter = "simple"
stream = "ext://sys.stdout"
[log_config.loggers.fedora_image_tester]
level = "INFO"
propagate = false
handlers = ["console"]
[log_config.loggers.fedora_messaging]
level = "INFO"
propagate = false
handlers = ["console"]
# Twisted is the asynchronous framework that manages the TCP/TLS connection, as well
# as the consumer event loop. When debugging you may want to lower this log level.
[log_config.loggers.twisted]
level = "INFO"
propagate = false
handlers = ["console"]
# Pika is the underlying AMQP client library. When debugging you may want to
# lower this log level.
[log_config.loggers.pika]
level = "WARNING"
propagate = false
handlers = ["console"]
[log_config.root]
level = "ERROR"
handlers = ["console"]