From 25c603dd0642f84aa937c599ded2d6be96c925fa Mon Sep 17 00:00:00 2001 From: Bala Konda Reddy M Date: Oct 31 2025 16:00:55 +0000 Subject: [PATCH 1/6] Create a consumer to run tests via LISA This commit introduces a new fedora-messaging consumer which runs LISA tests against uploaded Azure images. Signed-off-by: Bala Konda Reddy M Signed-off-by: Jeremy Cline --- diff --git a/fedora-image-upload-tester-messages/LICENSE b/fedora-image-upload-tester-messages/LICENSE new file mode 100644 index 0000000..479cdd0 --- /dev/null +++ b/fedora-image-upload-tester-messages/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Bala Konda Reddy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fedora-image-upload-tester-messages/README.md b/fedora-image-upload-tester-messages/README.md new file mode 100644 index 0000000..2faa224 --- /dev/null +++ b/fedora-image-upload-tester-messages/README.md @@ -0,0 +1,4 @@ +# fedora_cloud_tests_messages + +Message schema definition for fedora_cloud_tests Azure Image validation results +This package defines fedora-messaging message classes for publishing Azure Image Test Results \ No newline at end of file diff --git a/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/__init__.py b/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/__init__.py new file mode 100644 index 0000000..c89a59d --- /dev/null +++ b/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/__init__.py @@ -0,0 +1,3 @@ +"""Message schema definitions for fedora_cloud_tests.""" + +__version__ = "0.1.0" diff --git a/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/publish.py b/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/publish.py new file mode 100644 index 0000000..66092b0 --- /dev/null +++ b/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/publish.py @@ -0,0 +1,93 @@ +""" +Message schema definitions for fedora_cloud_tests Azure image test results. + +This module defines fedora-messaging message classes for publishing +Azure image test results after LISA validation. +""" + +from fedora_messaging import message + +SCHEMA_URL = "http://fedoraproject.org/message-schema/v1" + + +class BaseTestResults(message.Message): + """Base class for fedora_cloud_tests published messages.""" + + topic = "fedora_cloud_tests.test_results.v1" + + @property + def app_name(self): + """Return the application name.""" + return "fedora_cloud_tests" + + +class AzureTestResults(BaseTestResults): + """ + Published when an image is tested with LISA and results are available. + """ + topic = ".".join([BaseTestResults.topic, "azure"]) + + body_schema = { + "id": f"{SCHEMA_URL}/{topic}.json", + "$schema": "http://json-schema.org/draft-07/schema#", + + # Using $defs and $ref for reusability of test results(passed, failed, skipped) + "$defs": { + "testResults": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "Number of tests in this category", + }, + "tests": { + "type": "object", + "patternProperties": { + ".*": { + "type": "string", + "description": "Name of the test" + } + }, + "additionalProperties": False, + "description": "Explanation for the test result (e.g., reason for skip or failure)" + } + }, + "required": ["count", "tests"], + "additionalProperties": False + } + }, + "description": "Schema for Azure image test results published by fedora_cloud_tests against LISA", + "type": "object", + "properties": { + "architecture": {"type": "string"}, + "compose_id": {"type": "string"}, + "image_id": {"type": "string"}, + "image_resource_id": {"type": "string"}, + # References to reusable test results schema + "failed_tests": {"$ref": "#/$defs/testResults"}, + "skipped_tests": {"$ref": "#/$defs/testResults"}, + "passed_tests": {"$ref": "#/$defs/testResults"} + }, + "required": [ + "architecture", + "compose_id", + "image_id", + "image_resource_id", + "failed_tests", + "skipped_tests", + "passed_tests", + ], + } + + @property + def summary(self): + return ( + f"Azure Cloud image {self.body['image_id']} tested successfully" + f" ({self.body['passed_tests']['count']} tests passed, " + f"{self.body['failed_tests']['count']} tests failed, " + f"{self.body['skipped_tests']['count']} tests skipped)" + ) + + def __str__(self): + """Return string representation of the message.""" + return f"AzureImageTestResults for {self.body.get('image_id')}" diff --git a/fedora-image-upload-tester-messages/pyproject.toml b/fedora-image-upload-tester-messages/pyproject.toml new file mode 100644 index 0000000..8147b02 --- /dev/null +++ b/fedora-image-upload-tester-messages/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "fedora-image-upload-tester-messages" +description = "A schema package for messages sent by fedora-image-upload-tester" +readme ="README.md" +license = "MIT" +license-files = { paths = ["LICENSE"] } +requires-python = ">=3.10" +dynamic = ["version"] + +dependencies = [ + "fedora_messaging", +] + +[project.optional-dependencies] +test = [ + "pytest", +] + +[tool.hatch.version] +path = "fedora_image_upload_tester_messages/__init__.py" + +[tool.black] +line-length = 100 + +[tool.coverage.run] +source = [ + "fedora_image_upload_tester_messages", +] + +[project.entry-points."fedora.messages"] +"fedora_image_upload_tester.azure.test_results" = "fedora_image_upload_tester_messages.publish:AzureTestResults" diff --git a/fedora-image-upload-tester-messages/tests/test_publish.py b/fedora-image-upload-tester-messages/tests/test_publish.py new file mode 100644 index 0000000..108a6e9 --- /dev/null +++ b/fedora-image-upload-tester-messages/tests/test_publish.py @@ -0,0 +1,330 @@ +""" +Tests for fedora_cloud_tests.publish module schema validation. + +This module tests the message schema definitions and validation +for Azure image test results publishing without requiring full +fedora-messaging registration. +""" + +import pytest +import jsonschema +from fedora_cloud_tests_messages.publish import BaseTestResults, AzureTestResults + + +class TestBaseTestResults: + """Test cases for BaseTestResults base class.""" + + def test_topic_format(self): + """Test that topic follows expected format.""" + expected_topic = "fedora_cloud_tests.test_results.v1" + assert BaseTestResults.topic == expected_topic + + def test_app_name_property(self): + """Test that app_name property returns correct value.""" + # Test the class property directly - it's a property that returns a string + class_instance = BaseTestResults.__new__(BaseTestResults) + assert class_instance.app_name == "fedora_cloud_tests" + + +class TestAzureTestResults: + """Test cases for AzureTestResults message class.""" + + def test_topic_inheritance(self): + """Test that Azure message inherits and extends base topic.""" + expected_topic = "fedora_cloud_tests.test_results.v1.azure" + assert AzureTestResults.topic == expected_topic + + def test_schema_validation_missing_required_fields(self): + """Test schema validation fails with missing required fields.""" + incomplete_body = { + "architecture": "x86_64", + # Missing other required fields + } + + with pytest.raises(jsonschema.ValidationError): + message = AzureTestResults(body=incomplete_body) + message.validate() + + def test_schema_validation_wrong_data_types(self): + """Test schema validation fails with wrong data types.""" + invalid_body = { + "architecture": "x86_64", + "compose_id": "Fedora-Rawhide-20250922.n.0", + "image_id": "Fedora-Cloud-Rawhide-x64", + "image_resource_id": "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/test-gallery", + "passed_tests": "not_an_object", # Should be object with count and tests + "failed_tests": { + "count": 1, + "tests": {"Dhcp.verify_dhcp_client_timeout": "DHCP client timeout configuration issue"} + }, + "skipped_tests": { + "count": 1, + "tests": {"GpuTestSuite.verify_load_gpu_driver": "No available quota found on 'westus3'"} + } + } + + with pytest.raises(jsonschema.ValidationError): + message = AzureTestResults(body=invalid_body) + message.validate() + + def test_schema_validation_invalid_test_objects(self): + """Test schema validation fails with invalid test result objects.""" + invalid_body = { + "architecture": "x86_64", + "compose_id": "Fedora-Rawhide-20250922.n.0", + "image_id": "Fedora-Cloud-Rawhide-x64", + "image_resource_id": "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/test-gallery", + "passed_tests": { + "count": 1, + "tests": {"AzureImageStandard.verify_grub": "Test passed in 5.494 seconds"} + }, + "failed_tests": { + "count": "not_a_number", # Should be integer + "tests": {"Storage.verify_swap": "Swap configuration from waagent.conf and distro should match"} + }, + "skipped_tests": { + "count": 1, + "tests": {"ACCBasicTest.verify_sgx": "No available quota found on 'westus3'"} + } + } + + with pytest.raises(jsonschema.ValidationError): + message = AzureTestResults(body=invalid_body) + message.validate() + + def test_message_string_representation(self): + """Test the __str__ method of AzureTestResults.""" + valid_body = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", + "failed_tests": { + "count": 0, + "tests": {} + }, + "skipped_tests": { + "count": 0, + "tests": {} + }, + "passed_tests": { + "count": 1, + "tests": {"Provisioning.smoke_test": "Test passed in 46.198 seconds"} + } + } + + message = AzureTestResults(body=valid_body) + str_repr = str(message) + assert "AzureImageTestResults for Fedora-Cloud-41-x64" == str_repr + + def test_message_summary_property(self): + """Test the summary property of AzureTestResults.""" + # Using realistic test counts from LISA XML data (91 total: 58 passed, 8 failed, 25 skipped) + valid_body = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", + "failed_tests": { + "count": 8, + "tests": { + "Dhcp.verify_dhcp_client_timeout": "DHCP client timeout should be set equal or more than 300 seconds", + "AzureDiskEncryption.verify_azure_disk_encryption_provisioned": "Azure authentication token expired", + "HvModule.verify_initrd_modules": "Required Hyper-V modules are missing from initrd", + "Storage.verify_swap": "Swap configuration from waagent.conf and distro should match", + "Provisioning.verify_deployment_provision_premiumv2_disk": "VM size Standard_DS2_v2 not available in WestUS3", + "Provisioning.verify_deployment_provision_ultra_datadisk": "VM size Standard_B2als_v2 not available in WestUS3", + "VMAccessTests.verify_valid_password_run": "Password not set as intended for user vmaccessuser", + "Vdso.verify_vdso": "Current distro Fedora doesn't support vdsotest" + } + }, + "skipped_tests": { + "count": 25, + "tests": { + "ACCBasicTest.verify_sgx": "No available quota found on 'westus3'", + "CVMSuite.verify_lsvmbus": "Security profile requirement not supported in capability", + "GpuTestSuite.verify_load_gpu_driver": "No available quota found on 'westus3'", + "ApplicationHealthExtension.verify_application_health_extension": "Fedora release 41 is not supported" + } + }, + "passed_tests": { + "count": 58, + "tests": { + "LsVmBus.verify_vmbus_devices_channels": "Test passed in 20.126 seconds", + "Floppy.verify_floppy_module_is_blacklisted": "Test passed in 3.309 seconds", + "Dns.verify_dns_name_resolution": "Test passed in 8.379 seconds", + "AzureImageStandard.verify_default_targetpw": "Test passed in 2.992 seconds", + "AzureImageStandard.verify_grub": "Test passed in 5.494 seconds" + } + } + } + + message = AzureTestResults(body=valid_body) + summary = message.summary + + # Check that summary contains test counts matching LISA results + assert "58 tests passed" in summary + assert "8 tests failed" in summary + assert "25 tests skipped" in summary + assert "Fedora-Cloud-41-x64" in summary + + +class TestSchemaIntegration: + """Integration tests for schema functionality.""" + + def test_create_valid_message_body_example(self): + """Example of how to create a properly formatted message body with real LISA test names.""" + valid_message_body = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", + "failed_tests": { + "count": 3, + "tests": { + "Dhcp.verify_dhcp_client_timeout": "DHCP client timeout should be set equal or more than 300 seconds", + "Storage.verify_swap": "Swap configuration from waagent.conf and distro should match", + "VMAccessTests.verify_valid_password_run": "Password not set as intended for user vmaccessuser" + } + }, + "skipped_tests": { + "count": 4, + "tests": { + "ACCBasicTest.verify_sgx": "No available quota found on 'westus3'", + "GpuTestSuite.verify_load_gpu_driver": "No available quota found on 'westus3'", + "ApplicationHealthExtension.verify_application_health_extension": "Fedora release 41 is not supported", + "NetworkWatcherExtension.verify_azure_network_watcher": "Fedora release 41 is not supported" + } + }, + "passed_tests": { + "count": 5, + "tests": { + "LsVmBus.verify_vmbus_devices_channels": "Test passed in 20.126 seconds", + "Dns.verify_dns_name_resolution": "Test passed in 8.379 seconds", + "AzureImageStandard.verify_grub": "Test passed in 5.494 seconds", + "Storage.verify_resource_disk_mounted": "Test passed in 5.923 seconds", + "TimeSync.verify_timedrift_corrected": "Test passed in 54.743 seconds" + } + } + } + + # Validate against schema + message = AzureTestResults(body=valid_message_body) + message.validate() + + # Verify count consistency + assert valid_message_body["failed_tests"]["count"] == len(valid_message_body["failed_tests"]["tests"]) + assert valid_message_body["skipped_tests"]["count"] == len(valid_message_body["skipped_tests"]["tests"]) + assert valid_message_body["passed_tests"]["count"] == len(valid_message_body["passed_tests"]["tests"]) + + # Test message methods + assert "Fedora-Cloud-41-x64" in str(message) + summary = message.summary + assert "5 tests passed" in summary + assert "3 tests failed" in summary + assert "4 tests skipped" in summary + + def test_test_results_object_validation(self): + """Test validation of test results object structure.""" + # Test missing required fields in test results object + invalid_body_missing_count = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", + "failed_tests": { + # Missing "count" field + "tests": {"Vdso.verify_vdso": "Current distro Fedora doesn't support vdsotest"} + }, + "skipped_tests": { + "count": 0, + "tests": {} + }, + "passed_tests": { + "count": 0, + "tests": {} + } + } + + with pytest.raises(jsonschema.ValidationError): + message = AzureTestResults(body=invalid_body_missing_count) + message.validate() + + # Test missing required fields in test results object + invalid_body_missing_tests = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", + "failed_tests": { + "count": 1, + # Missing "tests" field + }, + "skipped_tests": { + "count": 0, + "tests": {} + }, + "passed_tests": { + "count": 0, + "tests": {} + } + } + + with pytest.raises(jsonschema.ValidationError): + message = AzureTestResults(body=invalid_body_missing_tests) + message.validate() + + def test_valid_edge_cases(self): + """Test valid edge cases that should pass validation.""" + # Test with only passed tests + only_passed_body = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", + "failed_tests": { + "count": 0, + "tests": {} + }, + "skipped_tests": { + "count": 0, + "tests": {} + }, + "passed_tests": { + "count": 3, + "tests": { + "Provisioning.smoke_test": "Test passed in 46.198 seconds", + "Dns.verify_dns_name_resolution": "Test passed in 8.379 seconds", + "KernelDebug.verify_enable_kprobe": "Test passed in 10.013 seconds" + } + } + } + + message = AzureTestResults(body=only_passed_body) + message.validate() # Should not raise + + # Test with only failed tests + only_failed_body = { + "architecture": "aarch64", + "compose_id": "Fedora-Rawhide-20241015.n.0", + "image_id": "Fedora-Cloud-Rawhide-Arm64", + "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", + "failed_tests": { + "count": 2, + "tests": { + "Dhcp.verify_dhcp_client_timeout": "DHCP client timeout should be set equal or more than 300 seconds", + "Storage.verify_swap": "Swap configuration from waagent.conf and distro should match" + } + }, + "skipped_tests": { + "count": 0, + "tests": {} + }, + "passed_tests": { + "count": 0, + "tests": {} + } + } + + message = AzureTestResults(body=only_failed_body) + message.validate() # Should not raise diff --git a/fedora-image-upload-tester/LICENSE b/fedora-image-upload-tester/LICENSE new file mode 100644 index 0000000..479cdd0 --- /dev/null +++ b/fedora-image-upload-tester/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Bala Konda Reddy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fedora-image-upload-tester/README.md b/fedora-image-upload-tester/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/fedora-image-upload-tester/README.md diff --git a/fedora-image-upload-tester/fedora_image_upload_tester/__init__.py b/fedora-image-upload-tester/fedora_image_upload_tester/__init__.py new file mode 100644 index 0000000..f30eb5e --- /dev/null +++ b/fedora-image-upload-tester/fedora_image_upload_tester/__init__.py @@ -0,0 +1,8 @@ +""" +Fedora Image Upload Tester. + +This defines a set of AMQP consumers that asynchronously test images uploaded +by fedora-image-uploader, emits test results via AMQP, and tags the images with +the results. +""" +__version__ = "0.1.0" diff --git a/fedora-image-upload-tester/fedora_image_upload_tester/azure.py b/fedora-image-upload-tester/fedora_image_upload_tester/azure.py new file mode 100644 index 0000000..481ab13 --- /dev/null +++ b/fedora-image-upload-tester/fedora_image_upload_tester/azure.py @@ -0,0 +1,455 @@ +""" +AMQP consumer that processes messages from fedora-image-uploader when it uploads +a new Cloud image to Azure. + +This consumer is responsible for testing the new image via LISA and annotating the +image with the results. +""" + +import asyncio +import logging +import os +from datetime import datetime, timezone +import subprocess +from tempfile import TemporaryDirectory +import xml.etree.ElementTree as ET + +from fedora_image_uploader_messages.publish import AzurePublishedV1 +from fedora_messaging import config, api +from fedora_messaging.exceptions import ValidationError, PublishTimeout, ConnectionException + +from fedora_cloud_tests_messages.publish import AzureTestResults + +from .trigger_lisa import LisaRunner + +_log = logging.getLogger(__name__) + + +class AzurePublishedConsumer: + """Consumer class for AzurePublishedV1 messages to trigger LISA tests.""" + + # 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", + ] + + def __init__(self): + try: + self.conf = config.conf["consumer_config"]["azure"] + except KeyError: + _log.error("The Azure consumer requires an 'azure' config section") + raise + + def __call__(self, message): + """Callback method to handle incoming messages.""" + _log.info("Received message: %s", message) + self.azure_published_callback(message) + + 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") + _log.info("Run name generated: %s", run_name) + + 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: + _log.info("Temporary log path created: %s", log_path) + + # 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 + ) + ) + _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") + else: + _log.error("LISA trigger failed with return code: %d", ret) + # TemporaryDirectory automatically cleans up when exiting the context + + 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. + + Args: + original_message: The original AzurePublishedV1 message + test_results: Parsed test results dictionary + + 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_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": {}}) + } + + return result_body + + def _parse_test_results(self, log_path, run_name): + """ + Parse the test results from the LISA runner output. + 1. Find the xml file in the log_path + 2. Read the xml file and extract number of tests run, tests passed, failed and skipped + + Returns: + dict: Dictionary containing test results with keys: + '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) + return None + + _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) + + return results + + except ET.ParseError as e: + _log.error("Failed to parse XML file %s: %s", xml_file, str(e)) + return None + + def _extract_test_details(self, root): + """ + Extract individual test case details from XML. + + Args: + root: XML root element (either 'testsuites' or 'testsuite') + + Returns: + dict: Dictionary with lists of test names categorized by status: + {'passed': [...], 'failed': [...], 'skipped': [...]} + """ + test_details = { + 'passed': [], + 'failed': [], + 'skipped': [] + } + + 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()}" + test_details['failed'].append((test_identifier, failure_msg)) + + elif error_elem is not None: + error_msg = error_elem.attrib.get('message', 'Test error') + error_msg = self._remove_html_tags(error_msg) + traceback_msg = error_elem.text or '' + if traceback_msg.strip(): + error_msg = f"Summary: {error_msg}\n Traceback: \n{traceback_msg.strip()}" + test_details['failed'].append((test_identifier, error_msg)) + + 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)) + + _log.info("Extracted test details - Passed: %d, Failed: %d, Skipped: %d", + len(test_details['passed']), + len(test_details['failed']), + len(test_details['skipped'])) + + return test_details + + def _remove_html_tags(self, msg): + "Remove HTML tags from the message." + return msg.replace("<", "<").replace(">", ">").replace("&", "&") + + def _format_for_schema(self, test_details=None): + """ + Format the test details into the schema required for publishing. + Args: + test_details (dict): Dictionary with test name lists + + Returns: + dict: Formatted test results for schema compliance + """ + results = {} + if test_details: + for each_category in ['passed', 'failed', 'skipped']: + test_list = test_details.get(each_category, []) + tests_dict = {} + for test_name, message in test_list: + tests_dict[test_name] = message + + results[f"{each_category}_tests"] = { + 'count': len(test_list), + 'tests': tests_dict + } + + return results + + def _find_xml_file(self, log_path, run_name): + """ + Find the XML file in the directory with the run name. + + Args: + log_path (str): Base log directory path + run_name (str): Specific run name subdirectory + + Returns: + str: Path to the XML file if found, None otherwise + """ + xml_path = os.path.join(log_path, run_name) + + if not os.path.exists(xml_path): + _log.error("XML path does not exist: %s", xml_path) + return None + + try: + for root, _, files in os.walk(xml_path): + for filename in files: + if filename.endswith("lisa.junit.xml"): + xml_file_path = os.path.join(root, filename) + _log.info("Found XML file at: %s", xml_file_path) + return xml_file_path + except OSError as e: + _log.error("Error while searching for XML file in %s: %s ", xml_path, str(e)) + + _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 diff --git a/fedora-image-upload-tester/fedora_image_upload_tester/trigger_lisa.py b/fedora-image-upload-tester/fedora_image_upload_tester/trigger_lisa.py new file mode 100644 index 0000000..e1a0334 --- /dev/null +++ b/fedora-image-upload-tester/fedora_image_upload_tester/trigger_lisa.py @@ -0,0 +1,110 @@ +"""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 diff --git a/fedora-image-upload-tester/pyproject.toml b/fedora-image-upload-tester/pyproject.toml new file mode 100644 index 0000000..9ca85b9 --- /dev/null +++ b/fedora-image-upload-tester/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "fedora-image-upload-tester" +description = "An AMQP message consumer that automates the validation of Fedora nightly cloud images." +readme = "README.md" +license = "MIT" +license-files = { paths = ["LICENSE"] } +dynamic = ["version"] +requires-python = ">=3.10" + +dependencies = [ + "fedora-messaging", + "fedora-image-uploader-messages", + "lisa[azure]", + "fedora-cloud-tests-messages" +] + +test = [ + "pytest", + "pytest-asyncio" +] + +[tool.hatch.version] +path = "fedora__image_upload_tester/__init__.py" + +#[tool.hatch.metadata] +# Required for LISA since they don't publish releases to PyPI +#allow-direct-references = true + +[tool.black] +line-length = 100 + +[tool.isort] +profile = "black" + +[tool.coverage.run] +source = [ + "fedora_cloud_tests/", +] diff --git a/fedora-image-upload-tester/tests/test_azure.py b/fedora-image-upload-tester/tests/test_azure.py new file mode 100644 index 0000000..ca6cc0f --- /dev/null +++ b/fedora-image-upload-tester/tests/test_azure.py @@ -0,0 +1,223 @@ +"""Unit tests for the AzurePublishedConsumer class in azure.py.""" + +import os +import subprocess +from tempfile import TemporaryDirectory +from unittest.mock import patch, MagicMock, Mock + +import pytest +from fedora_image_uploader_messages.publish import AzurePublishedV1 +from fedora_messaging import config as fm_config + +from fedora_cloud_tests.azure import AzurePublishedConsumer + +@pytest.fixture(scope="module") +def azure_conf(): + """Provide a minimal config for Azure in the fedora-messaging configuration dictionary.""" + with patch.dict( + fm_config.conf["consumer_config"], + { + "azure": { + "region": "westus3", + "subscription_id": "00000000-0000-0000-0000-000000000000", + } + }, + ): + yield + +@pytest.fixture +def consumer(azure_conf): # pylint: disable=unused-argument + """Create an AzurePublishedConsumer instance for testing.""" + return AzurePublishedConsumer() + + +@pytest.fixture +def valid_message(): + """Create a valid mock AzurePublishedV1 message.""" + message = Mock(spec=AzurePublishedV1) + message.topic = "org.fedoraproject.prod.fedora_image_uploader.published.v1.azure.test" + message.body = { + "image_definition_name": "Fedora-Cloud-Rawhide-x64", + "image_version_name": "20250101.0", + "image_resource_id": "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/test-gallery" + } + return message + + +class TestAzurePublishedConsumer: + # 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")) + + def test_get_image_definition_name_success(self, consumer, valid_message): + """Test successful extraction of image definition name.""" + result = consumer._get_image_definition_name(valid_message) + assert result == "Fedora-Cloud-Rawhide-x64" + + def test_get_image_definition_name_invalid_data(self, consumer): + """Test handling of invalid image definition name data.""" + # Test missing field + message = Mock() + message.body = {} + assert consumer._get_image_definition_name(message) is None + + # Test non-string value + message.body = {"image_definition_name": 123} + assert consumer._get_image_definition_name(message) is None + + # Test missing body attribute + del message.body + assert consumer._get_image_definition_name(message) is None + + @patch('fedora_cloud_tests.azure.subprocess.run') + @patch('os.chmod') + def test_generate_ssh_key_pair_success(self, mock_chmod, mock_subprocess, consumer): + """Test successful SSH key pair generation.""" + # Mock subprocess.run to simulate successful ssh-keygen + mock_subprocess.return_value = MagicMock(stdout="Key generated successfully") + + with TemporaryDirectory() as temp_dir: + with patch('os.path.exists', return_value=True): + result = consumer._generate_ssh_key_pair(temp_dir) + + # Verify the method returns the expected private key path + expected_path = os.path.join(temp_dir, "id_ed25519") + assert result == expected_path + + # Verify ssh-keygen was called with correct parameters + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args[0][0] + assert "ssh-keygen" in call_args + assert "-t" in call_args and "ed25519" in call_args + assert "-f" in call_args + + # Verify file permissions were set + mock_chmod.assert_called_once_with(expected_path, 0o600) + + @patch('fedora_cloud_tests.azure.subprocess.run') + def test_generate_ssh_key_pair_failures(self, mock_subprocess, consumer): + """Test SSH key pair generation failure cases.""" + with TemporaryDirectory() as temp_dir: + # Test subprocess failure + mock_subprocess.side_effect = subprocess.CalledProcessError(1, 'ssh-keygen') + result = consumer._generate_ssh_key_pair(temp_dir) + assert result is None + + # Reset mock for next test + mock_subprocess.side_effect = None + mock_subprocess.return_value = MagicMock(stdout="Key generated") + + # Test file not created scenario + with patch('os.path.exists', return_value=False): + result = consumer._generate_ssh_key_pair(temp_dir) + assert result is None + + def test_get_community_gallery_image_success(self, consumer, valid_message): + """Test successful community gallery image construction.""" + result = consumer.get_community_gallery_image(valid_message) + expected = "westus3/test-sub/Fedora-Cloud-Rawhide-x64/20250101.0" + assert result == expected + + def test_get_community_gallery_image_invalid_cases(self, consumer): + """Test community gallery image extraction with invalid inputs.""" + # Test unsupported Fedora version + message = Mock() + message.body = { + "image_definition_name": "Fedora-Cloud-Unsupported-x64", + "image_version_name": "20250101.0", + "image_resource_id": "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/test-gallery" + } + assert consumer.get_community_gallery_image(message) is None + + # Test invalid message body type + message.body = "not_a_dict" + assert consumer.get_community_gallery_image(message) is None + + # Test missing required fields + message.body = {"image_definition_name": "Fedora-Cloud-Rawhide-x64"} + assert consumer.get_community_gallery_image(message) is None + + # Test invalid resource ID format + message.body = { + "image_definition_name": "Fedora-Cloud-Rawhide-x64", + "image_version_name": "20250101.0", + "image_resource_id": "invalid/format" + } + assert consumer.get_community_gallery_image(message) is None + + # Test resource_id with insufficient parts (code allows empty parts[2]) + message.body = { + "image_definition_name": "Fedora-Cloud-Rawhide-x64", + "image_version_name": "20250101.0", + "image_resource_id": "//" # Results in empty parts[2] but still valid per current logic + } + result = consumer.get_community_gallery_image(message) + # The current code allows this and creates: "westus3//Fedora-Cloud-Rawhide-x64/20250101.0" + assert result == "westus3//Fedora-Cloud-Rawhide-x64/20250101.0" + + @patch('fedora_cloud_tests.azure.asyncio.run') + @patch('fedora_cloud_tests.azure.LisaRunner') + @patch.object(AzurePublishedConsumer, '_generate_ssh_key_pair') + def test_azure_published_callback_success(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() + mock_lisa_runner.return_value = mock_runner_instance + mock_ssh_keygen.return_value = "/tmp/test_key" + + consumer.azure_published_callback(valid_message) + mock_lisa_runner.assert_called_once_with() + mock_asyncio_run.assert_called_once() + + @patch('fedora_cloud_tests.azure.asyncio.run') + @patch('fedora_cloud_tests.azure.LisaRunner') + def test_azure_published_callback_unsupported_image(self, mock_lisa_runner, mock_asyncio_run, consumer): + """Test handling when community gallery image cannot be processed.""" + message = Mock() + message.topic = "test.topic" + message.body = {"image_definition_name": "Fedora-Cloud-Unsupported-x64"} + + consumer.azure_published_callback(message) + 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.LisaRunner') + @patch.object(AzurePublishedConsumer, '_generate_ssh_key_pair') + def test_azure_published_callback_lisa_exception(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() + mock_lisa_runner.return_value = mock_runner_instance + mock_ssh_keygen.return_value = "/tmp/test_key" + + # Should not raise exception, just log it + consumer.azure_published_callback(valid_message) + mock_asyncio_run.assert_called_once() + + def test_azure_published_callback_message_validation_exception(self, consumer): + """Test exception handling during message type validation.""" + # Create a message that will cause a TypeError during isinstance check + # by making it not a proper message type + invalid_message = Mock() + invalid_message.topic = "test.topic" + invalid_message.body = "not_a_dict" # This will cause isinstance issues + + # This should not crash but should log errors and return early + consumer.azure_published_callback(invalid_message) + + def test_call_method_delegates_to_callback(self, consumer, valid_message): + """Test that __call__ method properly delegates to azure_published_callback.""" + with patch.object(consumer, 'azure_published_callback') as mock_callback: + consumer(valid_message) + mock_callback.assert_called_once_with(valid_message) diff --git a/fedora-image-upload-tester/tests/test_trigger_lisa.py b/fedora-image-upload-tester/tests/test_trigger_lisa.py new file mode 100644 index 0000000..50eae83 --- /dev/null +++ b/fedora-image-upload-tester/tests/test_trigger_lisa.py @@ -0,0 +1,301 @@ +"""Unit tests for the LisaRunner class in trigger_lisa.py.""" + +import subprocess +from unittest.mock import patch, MagicMock, AsyncMock +import pytest +from fedora_cloud_tests 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" + ) From 17a4823811ebf4c20257f27401d9f931bf7d6910 Mon Sep 17 00:00:00 2001 From: Jeremy Cline Date: Oct 31 2025 18:10:12 +0000 Subject: [PATCH 2/6] Combine the messaging packages Rather than distributing the two message schema separately, just keep them in one package. The name is not quite as accurate now, but it's a lot less work. Signed-off-by: Jeremy Cline --- diff --git a/fedora-image-upload-tester-messages/LICENSE b/fedora-image-upload-tester-messages/LICENSE deleted file mode 100644 index 479cdd0..0000000 --- a/fedora-image-upload-tester-messages/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Bala Konda Reddy - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/fedora-image-upload-tester-messages/README.md b/fedora-image-upload-tester-messages/README.md deleted file mode 100644 index 2faa224..0000000 --- a/fedora-image-upload-tester-messages/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# fedora_cloud_tests_messages - -Message schema definition for fedora_cloud_tests Azure Image validation results -This package defines fedora-messaging message classes for publishing Azure Image Test Results \ No newline at end of file diff --git a/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/__init__.py b/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/__init__.py deleted file mode 100644 index c89a59d..0000000 --- a/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Message schema definitions for fedora_cloud_tests.""" - -__version__ = "0.1.0" diff --git a/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/publish.py b/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/publish.py deleted file mode 100644 index 66092b0..0000000 --- a/fedora-image-upload-tester-messages/fedora_cloud_tests_messages/publish.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Message schema definitions for fedora_cloud_tests Azure image test results. - -This module defines fedora-messaging message classes for publishing -Azure image test results after LISA validation. -""" - -from fedora_messaging import message - -SCHEMA_URL = "http://fedoraproject.org/message-schema/v1" - - -class BaseTestResults(message.Message): - """Base class for fedora_cloud_tests published messages.""" - - topic = "fedora_cloud_tests.test_results.v1" - - @property - def app_name(self): - """Return the application name.""" - return "fedora_cloud_tests" - - -class AzureTestResults(BaseTestResults): - """ - Published when an image is tested with LISA and results are available. - """ - topic = ".".join([BaseTestResults.topic, "azure"]) - - body_schema = { - "id": f"{SCHEMA_URL}/{topic}.json", - "$schema": "http://json-schema.org/draft-07/schema#", - - # Using $defs and $ref for reusability of test results(passed, failed, skipped) - "$defs": { - "testResults": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "description": "Number of tests in this category", - }, - "tests": { - "type": "object", - "patternProperties": { - ".*": { - "type": "string", - "description": "Name of the test" - } - }, - "additionalProperties": False, - "description": "Explanation for the test result (e.g., reason for skip or failure)" - } - }, - "required": ["count", "tests"], - "additionalProperties": False - } - }, - "description": "Schema for Azure image test results published by fedora_cloud_tests against LISA", - "type": "object", - "properties": { - "architecture": {"type": "string"}, - "compose_id": {"type": "string"}, - "image_id": {"type": "string"}, - "image_resource_id": {"type": "string"}, - # References to reusable test results schema - "failed_tests": {"$ref": "#/$defs/testResults"}, - "skipped_tests": {"$ref": "#/$defs/testResults"}, - "passed_tests": {"$ref": "#/$defs/testResults"} - }, - "required": [ - "architecture", - "compose_id", - "image_id", - "image_resource_id", - "failed_tests", - "skipped_tests", - "passed_tests", - ], - } - - @property - def summary(self): - return ( - f"Azure Cloud image {self.body['image_id']} tested successfully" - f" ({self.body['passed_tests']['count']} tests passed, " - f"{self.body['failed_tests']['count']} tests failed, " - f"{self.body['skipped_tests']['count']} tests skipped)" - ) - - def __str__(self): - """Return string representation of the message.""" - return f"AzureImageTestResults for {self.body.get('image_id')}" diff --git a/fedora-image-upload-tester-messages/pyproject.toml b/fedora-image-upload-tester-messages/pyproject.toml deleted file mode 100644 index 8147b02..0000000 --- a/fedora-image-upload-tester-messages/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "fedora-image-upload-tester-messages" -description = "A schema package for messages sent by fedora-image-upload-tester" -readme ="README.md" -license = "MIT" -license-files = { paths = ["LICENSE"] } -requires-python = ">=3.10" -dynamic = ["version"] - -dependencies = [ - "fedora_messaging", -] - -[project.optional-dependencies] -test = [ - "pytest", -] - -[tool.hatch.version] -path = "fedora_image_upload_tester_messages/__init__.py" - -[tool.black] -line-length = 100 - -[tool.coverage.run] -source = [ - "fedora_image_upload_tester_messages", -] - -[project.entry-points."fedora.messages"] -"fedora_image_upload_tester.azure.test_results" = "fedora_image_upload_tester_messages.publish:AzureTestResults" diff --git a/fedora-image-upload-tester-messages/tests/test_publish.py b/fedora-image-upload-tester-messages/tests/test_publish.py deleted file mode 100644 index 108a6e9..0000000 --- a/fedora-image-upload-tester-messages/tests/test_publish.py +++ /dev/null @@ -1,330 +0,0 @@ -""" -Tests for fedora_cloud_tests.publish module schema validation. - -This module tests the message schema definitions and validation -for Azure image test results publishing without requiring full -fedora-messaging registration. -""" - -import pytest -import jsonschema -from fedora_cloud_tests_messages.publish import BaseTestResults, AzureTestResults - - -class TestBaseTestResults: - """Test cases for BaseTestResults base class.""" - - def test_topic_format(self): - """Test that topic follows expected format.""" - expected_topic = "fedora_cloud_tests.test_results.v1" - assert BaseTestResults.topic == expected_topic - - def test_app_name_property(self): - """Test that app_name property returns correct value.""" - # Test the class property directly - it's a property that returns a string - class_instance = BaseTestResults.__new__(BaseTestResults) - assert class_instance.app_name == "fedora_cloud_tests" - - -class TestAzureTestResults: - """Test cases for AzureTestResults message class.""" - - def test_topic_inheritance(self): - """Test that Azure message inherits and extends base topic.""" - expected_topic = "fedora_cloud_tests.test_results.v1.azure" - assert AzureTestResults.topic == expected_topic - - def test_schema_validation_missing_required_fields(self): - """Test schema validation fails with missing required fields.""" - incomplete_body = { - "architecture": "x86_64", - # Missing other required fields - } - - with pytest.raises(jsonschema.ValidationError): - message = AzureTestResults(body=incomplete_body) - message.validate() - - def test_schema_validation_wrong_data_types(self): - """Test schema validation fails with wrong data types.""" - invalid_body = { - "architecture": "x86_64", - "compose_id": "Fedora-Rawhide-20250922.n.0", - "image_id": "Fedora-Cloud-Rawhide-x64", - "image_resource_id": "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/test-gallery", - "passed_tests": "not_an_object", # Should be object with count and tests - "failed_tests": { - "count": 1, - "tests": {"Dhcp.verify_dhcp_client_timeout": "DHCP client timeout configuration issue"} - }, - "skipped_tests": { - "count": 1, - "tests": {"GpuTestSuite.verify_load_gpu_driver": "No available quota found on 'westus3'"} - } - } - - with pytest.raises(jsonschema.ValidationError): - message = AzureTestResults(body=invalid_body) - message.validate() - - def test_schema_validation_invalid_test_objects(self): - """Test schema validation fails with invalid test result objects.""" - invalid_body = { - "architecture": "x86_64", - "compose_id": "Fedora-Rawhide-20250922.n.0", - "image_id": "Fedora-Cloud-Rawhide-x64", - "image_resource_id": "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/test-gallery", - "passed_tests": { - "count": 1, - "tests": {"AzureImageStandard.verify_grub": "Test passed in 5.494 seconds"} - }, - "failed_tests": { - "count": "not_a_number", # Should be integer - "tests": {"Storage.verify_swap": "Swap configuration from waagent.conf and distro should match"} - }, - "skipped_tests": { - "count": 1, - "tests": {"ACCBasicTest.verify_sgx": "No available quota found on 'westus3'"} - } - } - - with pytest.raises(jsonschema.ValidationError): - message = AzureTestResults(body=invalid_body) - message.validate() - - def test_message_string_representation(self): - """Test the __str__ method of AzureTestResults.""" - valid_body = { - "architecture": "x86_64", - "compose_id": "Fedora-41-20241001.n.0", - "image_id": "Fedora-Cloud-41-x64", - "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", - "failed_tests": { - "count": 0, - "tests": {} - }, - "skipped_tests": { - "count": 0, - "tests": {} - }, - "passed_tests": { - "count": 1, - "tests": {"Provisioning.smoke_test": "Test passed in 46.198 seconds"} - } - } - - message = AzureTestResults(body=valid_body) - str_repr = str(message) - assert "AzureImageTestResults for Fedora-Cloud-41-x64" == str_repr - - def test_message_summary_property(self): - """Test the summary property of AzureTestResults.""" - # Using realistic test counts from LISA XML data (91 total: 58 passed, 8 failed, 25 skipped) - valid_body = { - "architecture": "x86_64", - "compose_id": "Fedora-41-20241001.n.0", - "image_id": "Fedora-Cloud-41-x64", - "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", - "failed_tests": { - "count": 8, - "tests": { - "Dhcp.verify_dhcp_client_timeout": "DHCP client timeout should be set equal or more than 300 seconds", - "AzureDiskEncryption.verify_azure_disk_encryption_provisioned": "Azure authentication token expired", - "HvModule.verify_initrd_modules": "Required Hyper-V modules are missing from initrd", - "Storage.verify_swap": "Swap configuration from waagent.conf and distro should match", - "Provisioning.verify_deployment_provision_premiumv2_disk": "VM size Standard_DS2_v2 not available in WestUS3", - "Provisioning.verify_deployment_provision_ultra_datadisk": "VM size Standard_B2als_v2 not available in WestUS3", - "VMAccessTests.verify_valid_password_run": "Password not set as intended for user vmaccessuser", - "Vdso.verify_vdso": "Current distro Fedora doesn't support vdsotest" - } - }, - "skipped_tests": { - "count": 25, - "tests": { - "ACCBasicTest.verify_sgx": "No available quota found on 'westus3'", - "CVMSuite.verify_lsvmbus": "Security profile requirement not supported in capability", - "GpuTestSuite.verify_load_gpu_driver": "No available quota found on 'westus3'", - "ApplicationHealthExtension.verify_application_health_extension": "Fedora release 41 is not supported" - } - }, - "passed_tests": { - "count": 58, - "tests": { - "LsVmBus.verify_vmbus_devices_channels": "Test passed in 20.126 seconds", - "Floppy.verify_floppy_module_is_blacklisted": "Test passed in 3.309 seconds", - "Dns.verify_dns_name_resolution": "Test passed in 8.379 seconds", - "AzureImageStandard.verify_default_targetpw": "Test passed in 2.992 seconds", - "AzureImageStandard.verify_grub": "Test passed in 5.494 seconds" - } - } - } - - message = AzureTestResults(body=valid_body) - summary = message.summary - - # Check that summary contains test counts matching LISA results - assert "58 tests passed" in summary - assert "8 tests failed" in summary - assert "25 tests skipped" in summary - assert "Fedora-Cloud-41-x64" in summary - - -class TestSchemaIntegration: - """Integration tests for schema functionality.""" - - def test_create_valid_message_body_example(self): - """Example of how to create a properly formatted message body with real LISA test names.""" - valid_message_body = { - "architecture": "x86_64", - "compose_id": "Fedora-41-20241001.n.0", - "image_id": "Fedora-Cloud-41-x64", - "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", - "failed_tests": { - "count": 3, - "tests": { - "Dhcp.verify_dhcp_client_timeout": "DHCP client timeout should be set equal or more than 300 seconds", - "Storage.verify_swap": "Swap configuration from waagent.conf and distro should match", - "VMAccessTests.verify_valid_password_run": "Password not set as intended for user vmaccessuser" - } - }, - "skipped_tests": { - "count": 4, - "tests": { - "ACCBasicTest.verify_sgx": "No available quota found on 'westus3'", - "GpuTestSuite.verify_load_gpu_driver": "No available quota found on 'westus3'", - "ApplicationHealthExtension.verify_application_health_extension": "Fedora release 41 is not supported", - "NetworkWatcherExtension.verify_azure_network_watcher": "Fedora release 41 is not supported" - } - }, - "passed_tests": { - "count": 5, - "tests": { - "LsVmBus.verify_vmbus_devices_channels": "Test passed in 20.126 seconds", - "Dns.verify_dns_name_resolution": "Test passed in 8.379 seconds", - "AzureImageStandard.verify_grub": "Test passed in 5.494 seconds", - "Storage.verify_resource_disk_mounted": "Test passed in 5.923 seconds", - "TimeSync.verify_timedrift_corrected": "Test passed in 54.743 seconds" - } - } - } - - # Validate against schema - message = AzureTestResults(body=valid_message_body) - message.validate() - - # Verify count consistency - assert valid_message_body["failed_tests"]["count"] == len(valid_message_body["failed_tests"]["tests"]) - assert valid_message_body["skipped_tests"]["count"] == len(valid_message_body["skipped_tests"]["tests"]) - assert valid_message_body["passed_tests"]["count"] == len(valid_message_body["passed_tests"]["tests"]) - - # Test message methods - assert "Fedora-Cloud-41-x64" in str(message) - summary = message.summary - assert "5 tests passed" in summary - assert "3 tests failed" in summary - assert "4 tests skipped" in summary - - def test_test_results_object_validation(self): - """Test validation of test results object structure.""" - # Test missing required fields in test results object - invalid_body_missing_count = { - "architecture": "x86_64", - "compose_id": "Fedora-41-20241001.n.0", - "image_id": "Fedora-Cloud-41-x64", - "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", - "failed_tests": { - # Missing "count" field - "tests": {"Vdso.verify_vdso": "Current distro Fedora doesn't support vdsotest"} - }, - "skipped_tests": { - "count": 0, - "tests": {} - }, - "passed_tests": { - "count": 0, - "tests": {} - } - } - - with pytest.raises(jsonschema.ValidationError): - message = AzureTestResults(body=invalid_body_missing_count) - message.validate() - - # Test missing required fields in test results object - invalid_body_missing_tests = { - "architecture": "x86_64", - "compose_id": "Fedora-41-20241001.n.0", - "image_id": "Fedora-Cloud-41-x64", - "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", - "failed_tests": { - "count": 1, - # Missing "tests" field - }, - "skipped_tests": { - "count": 0, - "tests": {} - }, - "passed_tests": { - "count": 0, - "tests": {} - } - } - - with pytest.raises(jsonschema.ValidationError): - message = AzureTestResults(body=invalid_body_missing_tests) - message.validate() - - def test_valid_edge_cases(self): - """Test valid edge cases that should pass validation.""" - # Test with only passed tests - only_passed_body = { - "architecture": "x86_64", - "compose_id": "Fedora-41-20241001.n.0", - "image_id": "Fedora-Cloud-41-x64", - "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", - "failed_tests": { - "count": 0, - "tests": {} - }, - "skipped_tests": { - "count": 0, - "tests": {} - }, - "passed_tests": { - "count": 3, - "tests": { - "Provisioning.smoke_test": "Test passed in 46.198 seconds", - "Dns.verify_dns_name_resolution": "Test passed in 8.379 seconds", - "KernelDebug.verify_enable_kprobe": "Test passed in 10.013 seconds" - } - } - } - - message = AzureTestResults(body=only_passed_body) - message.validate() # Should not raise - - # Test with only failed tests - only_failed_body = { - "architecture": "aarch64", - "compose_id": "Fedora-Rawhide-20241015.n.0", - "image_id": "Fedora-Cloud-Rawhide-Arm64", - "image_resource_id": "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/galleries/test", - "failed_tests": { - "count": 2, - "tests": { - "Dhcp.verify_dhcp_client_timeout": "DHCP client timeout should be set equal or more than 300 seconds", - "Storage.verify_swap": "Swap configuration from waagent.conf and distro should match" - } - }, - "skipped_tests": { - "count": 0, - "tests": {} - }, - "passed_tests": { - "count": 0, - "tests": {} - } - } - - message = AzureTestResults(body=only_failed_body) - message.validate() # Should not raise diff --git a/fedora-image-upload-tester/pyproject.toml b/fedora-image-upload-tester/pyproject.toml index 9ca85b9..02f3d0e 100644 --- a/fedora-image-upload-tester/pyproject.toml +++ b/fedora-image-upload-tester/pyproject.toml @@ -14,21 +14,22 @@ requires-python = ">=3.10" dependencies = [ "fedora-messaging", "fedora-image-uploader-messages", - "lisa[azure]", - "fedora-cloud-tests-messages" + # Until we get LISA on PyPI... + "mslisa[azure] @ git+https://github.com/microsoft/lisa.git", ] test = [ + "coverage", "pytest", "pytest-asyncio" ] [tool.hatch.version] -path = "fedora__image_upload_tester/__init__.py" +path = "fedora_image_upload_tester/__init__.py" -#[tool.hatch.metadata] -# Required for LISA since they don't publish releases to PyPI -#allow-direct-references = true +[tool.hatch.metadata] +# Required for LISA since they don't publish releases to PyPI yet +allow-direct-references = true [tool.black] line-length = 100 @@ -38,5 +39,5 @@ profile = "black" [tool.coverage.run] source = [ - "fedora_cloud_tests/", + "fedora_image_upload_tester/", ] diff --git a/fedora-image-uploader-messages/LICENSE b/fedora-image-uploader-messages/LICENSE deleted file mode 100644 index d159169..0000000 --- a/fedora-image-uploader-messages/LICENSE +++ /dev/null @@ -1,339 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. diff --git a/fedora-image-uploader-messages/LICENSE.LGPLv3 b/fedora-image-uploader-messages/LICENSE.LGPLv3 new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/fedora-image-uploader-messages/LICENSE.LGPLv3 @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/fedora-image-uploader-messages/LICENSE.MIT b/fedora-image-uploader-messages/LICENSE.MIT new file mode 100644 index 0000000..479cdd0 --- /dev/null +++ b/fedora-image-uploader-messages/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Bala Konda Reddy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fedora-image-uploader-messages/fedora_image_uploader_messages/test_results.py b/fedora-image-uploader-messages/fedora_image_uploader_messages/test_results.py new file mode 100644 index 0000000..0707162 --- /dev/null +++ b/fedora-image-uploader-messages/fedora_image_uploader_messages/test_results.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2025 Bala Konda Reddy +# +# SPDX-License-Identifier: MIT +""" +Message schema definitions for fedora_cloud_tests Azure image test results. + +This module defines fedora-messaging message classes for publishing +Azure image test results after LISA validation. +""" + +from fedora_messaging import message + +SCHEMA_URL = "http://fedoraproject.org/message-schema/v1" + + +class BaseTestResults(message.Message): + """Base class for fedora_cloud_tests published messages.""" + + topic = "fedora_cloud_tests.test_results.v1" + + @property + def app_name(self): + """Return the application name.""" + return "fedora_cloud_tests" + + +class AzureTestResults(BaseTestResults): + """ + Published when an image is tested with LISA and results are available. + """ + + topic = ".".join([BaseTestResults.topic, "azure"]) + + body_schema = { + "id": f"{SCHEMA_URL}/{topic}.json", + "$schema": "http://json-schema.org/draft-07/schema#", + # Using $defs and $ref for reusability of test results(passed, failed, skipped) + "$defs": { + "testResults": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "Number of tests in this category", + }, + "tests": { + "type": "object", + "patternProperties": { + ".*": {"type": "string", "description": "Name of the test"} + }, + "additionalProperties": False, + "description": ( + "Explanation for the test result (e.g., reason for skip or failure)" + ), + }, + }, + "required": ["count", "tests"], + "additionalProperties": False, + } + }, + "description": ( + "Schema for Azure image test results published by fedora_cloud_tests against LISA" + ), + "type": "object", + "properties": { + "architecture": {"type": "string"}, + "compose_id": {"type": "string"}, + "image_id": {"type": "string"}, + "image_resource_id": {"type": "string"}, + # References to reusable test results schema + "failed_tests": {"$ref": "#/$defs/testResults"}, + "skipped_tests": {"$ref": "#/$defs/testResults"}, + "passed_tests": {"$ref": "#/$defs/testResults"}, + }, + "required": [ + "architecture", + "compose_id", + "image_id", + "image_resource_id", + "failed_tests", + "skipped_tests", + "passed_tests", + ], + } + + @property + def summary(self): + return ( + f"Azure Cloud image {self.body['image_id']} tested successfully" + f" ({self.body['passed_tests']['count']} tests passed, " + f"{self.body['failed_tests']['count']} tests failed, " + f"{self.body['skipped_tests']['count']} tests skipped)" + ) + + def __str__(self): + """Return string representation of the message.""" + return f"AzureImageTestResults for {self.body.get('image_id')}" diff --git a/fedora-image-uploader-messages/pyproject.toml b/fedora-image-uploader-messages/pyproject.toml index 8cd71a3..117af74 100644 --- a/fedora-image-uploader-messages/pyproject.toml +++ b/fedora-image-uploader-messages/pyproject.toml @@ -6,8 +6,8 @@ build-backend = "hatchling.build" name = "fedora-image-uploader-messages" description = "A schema package for messages sent by fedora-image-uploader" readme = "README.md" -license = "GPL-2.0-or-later" -license-files = { paths = ["LICENSE"] } +license = "LGPL-3.0-or-later AND MIT" +license-files = { paths = ["LICENSE.LGPLv3", "LICENSE.MIT"] } dynamic = ["version"] requires-python = ">=3.8" keywords = ["fedora-messaging"] @@ -47,6 +47,7 @@ test = [ "fedora_image_uploader.published.v1.azure" = "fedora_image_uploader_messages.publish:AzurePublishedV1" "fedora_image_uploader.published.v1.container" = "fedora_image_uploader_messages.publish:ContainerPublishedV1" "fedora_image_uploader.published.v1.gcp" = "fedora_image_uploader_messages.publish:GcpPublishedV1" +"fedora_image_uploader.test_results.v1.azure" = "fedora_image_uploader_messages.test_results:AzureTestResults" [tool.hatch.version] path = "fedora_image_uploader_messages/__init__.py" diff --git a/fedora-image-uploader-messages/tests/test_test_results.py b/fedora-image-uploader-messages/tests/test_test_results.py new file mode 100644 index 0000000..7769546 --- /dev/null +++ b/fedora-image-uploader-messages/tests/test_test_results.py @@ -0,0 +1,378 @@ +# SPDX-FileCopyrightText: 2025 Bala Konda Reddy +# +# SPDX-License-Identifier: MIT +""" +Tests for fedora_cloud_tests.publish module schema validation. + +This module tests the message schema definitions and validation +for Azure image test results publishing without requiring full +fedora-messaging registration. +""" + +import jsonschema +import pytest + +from fedora_image_uploader_messages.test_results import ( + AzureTestResults, + BaseTestResults, +) + + +class TestBaseTestResults: + """Test cases for BaseTestResults base class.""" + + def test_topic_format(self): + """Test that topic follows expected format.""" + expected_topic = "fedora_cloud_tests.test_results.v1" + assert BaseTestResults.topic == expected_topic + + def test_app_name_property(self): + """Test that app_name property returns correct value.""" + # Test the class property directly - it's a property that returns a string + class_instance = BaseTestResults.__new__(BaseTestResults) + assert class_instance.app_name == "fedora_cloud_tests" + + +class TestAzureTestResults: + """Test cases for AzureTestResults message class.""" + + def test_topic_inheritance(self): + """Test that Azure message inherits and extends base topic.""" + expected_topic = "fedora_cloud_tests.test_results.v1.azure" + assert AzureTestResults.topic == expected_topic + + def test_schema_validation_missing_required_fields(self): + """Test schema validation fails with missing required fields.""" + incomplete_body = { + "architecture": "x86_64", + # Missing other required fields + } + + with pytest.raises(jsonschema.ValidationError): + message = AzureTestResults(body=incomplete_body) + message.validate() + + def test_schema_validation_wrong_data_types(self): + """Test schema validation fails with wrong data types.""" + invalid_body = { + "architecture": "x86_64", + "compose_id": "Fedora-Rawhide-20250922.n.0", + "image_id": "Fedora-Cloud-Rawhide-x64", + "image_resource_id": ( + "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/" + "galleries/test-gallery" + ), + "passed_tests": "not_an_object", # Should be object with count and tests + "failed_tests": { + "count": 1, + "tests": { + "Dhcp.verify_dhcp_client_timeout": "DHCP client timeout configuration issue" + }, + }, + "skipped_tests": { + "count": 1, + "tests": { + "GpuTestSuite.verify_load_gpu_driver": "No available quota found on 'westus3'" + }, + }, + } + + with pytest.raises(jsonschema.ValidationError): + message = AzureTestResults(body=invalid_body) + message.validate() + + def test_schema_validation_invalid_test_objects(self): + """Test schema validation fails with invalid test result objects.""" + invalid_body = { + "architecture": "x86_64", + "compose_id": "Fedora-Rawhide-20250922.n.0", + "image_id": "Fedora-Cloud-Rawhide-x64", + "image_resource_id": ( + "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/" + "galleries/test-gallery" + ), + "passed_tests": { + "count": 1, + "tests": {"AzureImageStandard.verify_grub": "Test passed in 5.494 seconds"}, + }, + "failed_tests": { + "count": "not_a_number", # Should be integer + "tests": {"Storage.verify_swap": "Swap configuration should match"}, + }, + "skipped_tests": { + "count": 1, + "tests": {"ACCBasicTest.verify_sgx": "No available quota found on 'westus3'"}, + }, + } + + with pytest.raises(jsonschema.ValidationError): + message = AzureTestResults(body=invalid_body) + message.validate() + + def test_message_string_representation(self): + """Test the __str__ method of AzureTestResults.""" + valid_body = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": ( + "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/" + "galleries/test" + ), + "failed_tests": {"count": 0, "tests": {}}, + "skipped_tests": {"count": 0, "tests": {}}, + "passed_tests": { + "count": 1, + "tests": {"Provisioning.smoke_test": "Test passed in 46.198 seconds"}, + }, + } + + message = AzureTestResults(body=valid_body) + str_repr = str(message) + assert "AzureImageTestResults for Fedora-Cloud-41-x64" == str_repr + + def test_message_summary_property(self): + """Test the summary property of AzureTestResults.""" + # Using realistic test counts from LISA XML data (91 total: 58 passed, 8 failed, 25 skipped) + valid_body = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": ( + "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/" + "galleries/test" + ), + "failed_tests": { + "count": 8, + "tests": { + "Dhcp.verify_dhcp_client_timeout": ( + "DHCP client timeout should be set equal or more than 300 seconds" + ), + "AzureDiskEncryption.verify_azure_disk_encryption_provisioned": ( + "Azure authentication token expired" + ), + "HvModule.verify_initrd_modules": ( + "Required Hyper-V modules are missing from initrd" + ), + "Storage.verify_swap": ( + "Swap configuration from waagent.conf and distro should match" + ), + "Provisioning.verify_deployment_provision_premiumv2_disk": ( + "VM size Standard_DS2_v2 not available in WestUS3" + ), + "Provisioning.verify_deployment_provision_ultra_datadisk": ( + "VM size Standard_B2als_v2 not available in WestUS3" + ), + "VMAccessTests.verify_valid_password_run": ( + "Password not set as intended for user vmaccessuser" + ), + "Vdso.verify_vdso": "Current distro Fedora doesn't support vdsotest", + }, + }, + "skipped_tests": { + "count": 25, + "tests": { + "ACCBasicTest.verify_sgx": "No available quota found on 'westus3'", + "CVMSuite.verify_lsvmbus": ( + "Security profile requirement not supported in capability" + ), + "GpuTestSuite.verify_load_gpu_driver": ( + "No available quota found on 'westus3'" + ), + "ApplicationHealthExtension.verify_application_health_extension": ( + "Fedora release 41 is not supported" + ), + }, + }, + "passed_tests": { + "count": 58, + "tests": { + "LsVmBus.verify_vmbus_devices_channels": "Test passed in 20.126 seconds", + "Floppy.verify_floppy_module_is_blacklisted": "Test passed in 3.309 seconds", + "Dns.verify_dns_name_resolution": "Test passed in 8.379 seconds", + "AzureImageStandard.verify_default_targetpw": "Test passed in 2.992 seconds", + "AzureImageStandard.verify_grub": "Test passed in 5.494 seconds", + }, + }, + } + + message = AzureTestResults(body=valid_body) + summary = message.summary + + # Check that summary contains test counts matching LISA results + assert "58 tests passed" in summary + assert "8 tests failed" in summary + assert "25 tests skipped" in summary + assert "Fedora-Cloud-41-x64" in summary + + +class TestSchemaIntegration: + """Integration tests for schema functionality.""" + + def test_create_valid_message_body_example(self): + """Example of how to create a properly formatted message body with real LISA test names.""" + valid_message_body = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": ( + "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/" + "galleries/test" + ), + "failed_tests": { + "count": 3, + "tests": { + "Dhcp.verify_dhcp_client_timeout": ( + "DHCP client timeout should be set equal or more than 300 seconds" + ), + "Storage.verify_swap": ( + "Swap configuration from waagent.conf and distro should match" + ), + "VMAccessTests.verify_valid_password_run": ( + "Password not set as intended for user vmaccessuser" + ), + }, + }, + "skipped_tests": { + "count": 4, + "tests": { + "ACCBasicTest.verify_sgx": "No available quota found on 'westus3'", + "GpuTestSuite.verify_load_gpu_driver": "No available quota found on 'westus3'", + "ApplicationHealthExtension.verify_application_health_extension": ( + "Fedora release 41 is not supported" + ), + "NetworkWatcherExtension.verify_azure_network_watcher": ( + "Fedora release 41 is not supported" + ), + }, + }, + "passed_tests": { + "count": 5, + "tests": { + "LsVmBus.verify_vmbus_devices_channels": "Test passed in 20.126 seconds", + "Dns.verify_dns_name_resolution": "Test passed in 8.379 seconds", + "AzureImageStandard.verify_grub": "Test passed in 5.494 seconds", + "Storage.verify_resource_disk_mounted": "Test passed in 5.923 seconds", + "TimeSync.verify_timedrift_corrected": "Test passed in 54.743 seconds", + }, + }, + } + + # Validate against schema + message = AzureTestResults(body=valid_message_body) + message.validate() + + # Verify count consistency + assert valid_message_body["failed_tests"]["count"] == len( + valid_message_body["failed_tests"]["tests"] + ) + assert valid_message_body["skipped_tests"]["count"] == len( + valid_message_body["skipped_tests"]["tests"] + ) + assert valid_message_body["passed_tests"]["count"] == len( + valid_message_body["passed_tests"]["tests"] + ) + + # Test message methods + assert "Fedora-Cloud-41-x64" in str(message) + summary = message.summary + assert "5 tests passed" in summary + assert "3 tests failed" in summary + assert "4 tests skipped" in summary + + def test_test_results_object_validation(self): + """Test validation of test results object structure.""" + # Test missing required fields in test results object + invalid_body_missing_count = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": ( + "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/" + "galleries/test" + ), + "failed_tests": { + # Missing "count" field + "tests": {"Vdso.verify_vdso": "Current distro Fedora doesn't support vdsotest"} + }, + "skipped_tests": {"count": 0, "tests": {}}, + "passed_tests": {"count": 0, "tests": {}}, + } + + with pytest.raises(jsonschema.ValidationError): + message = AzureTestResults(body=invalid_body_missing_count) + message.validate() + + # Test missing required fields in test results object + invalid_body_missing_tests = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": ( + "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/" + "galleries/test" + ), + "failed_tests": { + "count": 1, + # Missing "tests" field + }, + "skipped_tests": {"count": 0, "tests": {}}, + "passed_tests": {"count": 0, "tests": {}}, + } + + with pytest.raises(jsonschema.ValidationError): + message = AzureTestResults(body=invalid_body_missing_tests) + message.validate() + + def test_valid_edge_cases(self): + """Test valid edge cases that should pass validation.""" + # Test with only passed tests + only_passed_body = { + "architecture": "x86_64", + "compose_id": "Fedora-41-20241001.n.0", + "image_id": "Fedora-Cloud-41-x64", + "image_resource_id": ( + "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/" + "galleries/test" + ), + "failed_tests": {"count": 0, "tests": {}}, + "skipped_tests": {"count": 0, "tests": {}}, + "passed_tests": { + "count": 3, + "tests": { + "Provisioning.smoke_test": "Test passed in 46.198 seconds", + "Dns.verify_dns_name_resolution": "Test passed in 8.379 seconds", + "KernelDebug.verify_enable_kprobe": "Test passed in 10.013 seconds", + }, + }, + } + + message = AzureTestResults(body=only_passed_body) + message.validate() # Should not raise + + # Test with only failed tests + only_failed_body = { + "architecture": "aarch64", + "compose_id": "Fedora-Rawhide-20241015.n.0", + "image_id": "Fedora-Cloud-Rawhide-Arm64", + "image_resource_id": ( + "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/" + "galleries/test" + ), + "failed_tests": { + "count": 2, + "tests": { + "Dhcp.verify_dhcp_client_timeout": ( + "DHCP client timeout should be set equal or more than 300 seconds" + ), + "Storage.verify_swap": ( + "Swap configuration from waagent.conf and distro should match" + ), + }, + }, + "skipped_tests": {"count": 0, "tests": {}}, + "passed_tests": {"count": 0, "tests": {}}, + } + + message = AzureTestResults(body=only_failed_body) + message.validate() # Should not raise From 30495f452b38d9fa7c75d766642bee3a789a3037 Mon Sep 17 00:00:00 2001 From: Jeremy Cline Date: Oct 31 2025 19:35:05 +0000 Subject: [PATCH 3/6] Apply project formatting Signed-off-by: Jeremy Cline --- diff --git a/fedora-image-upload-tester/fedora_image_upload_tester/__init__.py b/fedora-image-upload-tester/fedora_image_upload_tester/__init__.py index f30eb5e..5af73fd 100644 --- a/fedora-image-upload-tester/fedora_image_upload_tester/__init__.py +++ b/fedora-image-upload-tester/fedora_image_upload_tester/__init__.py @@ -5,4 +5,5 @@ This defines a set of AMQP consumers that asynchronously test images uploaded by fedora-image-uploader, emits test results via AMQP, and tags the images with the results. """ + __version__ = "0.1.0" diff --git a/fedora-image-upload-tester/fedora_image_upload_tester/azure.py b/fedora-image-upload-tester/fedora_image_upload_tester/azure.py index 481ab13..34ca955 100644 --- a/fedora-image-upload-tester/fedora_image_upload_tester/azure.py +++ b/fedora-image-upload-tester/fedora_image_upload_tester/azure.py @@ -9,16 +9,19 @@ image with the results. import asyncio import logging import os -from datetime import datetime, timezone import subprocess -from tempfile import TemporaryDirectory import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from tempfile import TemporaryDirectory from fedora_image_uploader_messages.publish import AzurePublishedV1 -from fedora_messaging import config, api -from fedora_messaging.exceptions import ValidationError, PublishTimeout, ConnectionException - -from fedora_cloud_tests_messages.publish import AzureTestResults +from fedora_image_uploader_messages.test_results import AzureTestResults +from fedora_messaging import api, config +from fedora_messaging.exceptions import ( + ConnectionException, + PublishTimeout, + ValidationError, +) from .trigger_lisa import LisaRunner @@ -36,7 +39,7 @@ class AzurePublishedConsumer: "Fedora-Cloud-Rawhide-Arm64", "Fedora-Cloud-42-x64", "Fedora-Cloud-42-Arm64", - ] + ] def __init__(self): try: @@ -51,7 +54,7 @@ class AzurePublishedConsumer: self.azure_published_callback(message) def _get_image_definition_name(self, message): - """ Get image definition name from the message body. + """Get image definition name from the message body. Args: message (AzurePublishedV1): The message containing image details. @@ -63,9 +66,7 @@ class AzurePublishedConsumer: 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 - ) + _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 @@ -75,9 +76,7 @@ class AzurePublishedConsumer: 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 - ) + _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): @@ -89,8 +88,7 @@ class AzurePublishedConsumer: # 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 '%s' not in supported Fedora" " versions, skipping.", image_definition_name, ) return None @@ -105,9 +103,7 @@ class AzurePublishedConsumer: # 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 - ) + _log.error("image_resource_id format is invalid: %s", image_resource_id) return None resource_id = parts[2] @@ -115,15 +111,11 @@ class AzurePublishedConsumer: f"{self.conf['region']}/{resource_id}/" f"{image_definition_name}/{image_version_name}" ) - _log.info( - "Constructed community gallery image: %s", community_gallery_image - ) + _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) - ) + _log.error("Failed to extract image details from the message: %s", str(e)) return None def azure_published_callback(self, message): @@ -134,15 +126,12 @@ class AzurePublishedConsumer: 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) - ) + _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.") + _log.error("Unsupported or No community gallery image found in the message.") return image_definition_name = self._get_image_definition_name(message) @@ -155,8 +144,7 @@ class AzurePublishedConsumer: # 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" + prefix=f"lisa_results_{image_definition_name}_", suffix="_logs" ) as log_path: _log.info("Temporary log path created: %s", log_path) @@ -176,7 +164,7 @@ class AzurePublishedConsumer: runner.trigger_lisa( region=self.conf["region"], community_gallery_image=community_gallery_image, - config=config_params + config=config_params, ) ) _log.info("LISA trigger completed with return code: %d", ret) @@ -215,8 +203,7 @@ class AzurePublishedConsumer: # Publish message using fedora-messaging API api.publish(result_message) - _log.info("Successfully published test results for %s", - body["image_id"]) + _log.info("Successfully published test results for %s", body["image_id"]) except ValidationError as e: _log.error("Message validation failed: %s", str(e)) @@ -247,11 +234,10 @@ class AzurePublishedConsumer: "compose_id": body["compose_id"], "image_id": body["image_definition_name"], # Use definition name as image ID "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": {}}) + "passed_tests": test_results.get("passed_tests", {"count": 0, "tests": {}}), } return result_body @@ -302,64 +288,64 @@ class AzurePublishedConsumer: dict: Dictionary with lists of test names categorized by status: {'passed': [...], 'failed': [...], 'skipped': [...]} """ - test_details = { - 'passed': [], - 'failed': [], - 'skipped': [] - } + test_details = {"passed": [], "failed": [], "skipped": []} 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') + suite_name = suite.attrib.get("name") - for testcase in suite.findall('testcase'): - test_name = testcase.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') + 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') + 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 = failure_elem.attrib.get("message", "Test case failed") failure_msg = self._remove_html_tags(failure_msg) - traceback_msg = failure_elem.text or '' + 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()}" - test_details['failed'].append((test_identifier, failure_msg)) + failure_msg = ( + f"Summary: {failure_msg}\n Traceback: \n{traceback_msg.strip()}" + ) + test_details["failed"].append((test_identifier, failure_msg)) elif error_elem is not None: - error_msg = error_elem.attrib.get('message', 'Test error') + error_msg = error_elem.attrib.get("message", "Test error") error_msg = self._remove_html_tags(error_msg) - traceback_msg = error_elem.text or '' + traceback_msg = error_elem.text or "" if traceback_msg.strip(): error_msg = f"Summary: {error_msg}\n Traceback: \n{traceback_msg.strip()}" - test_details['failed'].append((test_identifier, error_msg)) + test_details["failed"].append((test_identifier, error_msg)) elif skipped_elem is not None: - skip_msg = skipped_elem.attrib.get('message', 'Test skipped') + 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)) + 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)) + test_details["passed"].append((test_identifier, passed_msg)) - _log.info("Extracted test details - Passed: %d, Failed: %d, Skipped: %d", - len(test_details['passed']), - len(test_details['failed']), - len(test_details['skipped'])) + _log.info( + "Extracted test details - Passed: %d, Failed: %d, Skipped: %d", + len(test_details["passed"]), + len(test_details["failed"]), + len(test_details["skipped"]), + ) return test_details @@ -378,16 +364,13 @@ class AzurePublishedConsumer: """ results = {} if test_details: - for each_category in ['passed', 'failed', 'skipped']: + for each_category in ["passed", "failed", "skipped"]: test_list = test_details.get(each_category, []) tests_dict = {} for test_name, message in test_list: tests_dict[test_name] = message - results[f"{each_category}_tests"] = { - 'count': len(test_list), - 'tests': tests_dict - } + results[f"{each_category}_tests"] = {"count": len(test_list), "tests": tests_dict} return results @@ -444,7 +427,10 @@ class AzurePublishedConsumer: # 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) + _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 diff --git a/fedora-image-upload-tester/fedora_image_upload_tester/trigger_lisa.py b/fedora-image-upload-tester/fedora_image_upload_tester/trigger_lisa.py index e1a0334..d40a861 100644 --- a/fedora-image-upload-tester/fedora_image_upload_tester/trigger_lisa.py +++ b/fedora-image-upload-tester/fedora_image_upload_tester/trigger_lisa.py @@ -14,8 +14,7 @@ class LisaRunner: def __init__(self): pass - async def trigger_lisa( - self, region, community_gallery_image, config): + 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. @@ -38,9 +37,7 @@ class LisaRunner: 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" - ) + _log.error("Invalid community_gallery_image parameter: must be a non-empty string") return False if not isinstance(config, dict): @@ -64,9 +61,12 @@ class LisaRunner: ] command = [ "lisa", - "-r", "microsoft/runbook/azure_fedora.yml", - "-v", "tier:1", - "-v", "test_case_name:verify_dhcp_file_configuration", + "-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]) @@ -88,9 +88,7 @@ class LisaRunner: _log.info("Starting LISA test with command: %s", " ".join(command)) process = await asyncio.create_subprocess_exec( - *command, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT + *command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT ) async for line in process.stdout: line_content = line.decode().strip() diff --git a/fedora-image-upload-tester/pyproject.toml b/fedora-image-upload-tester/pyproject.toml index 02f3d0e..e25199f 100644 --- a/fedora-image-upload-tester/pyproject.toml +++ b/fedora-image-upload-tester/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "mslisa[azure] @ git+https://github.com/microsoft/lisa.git", ] +[project.optional-dependencies] test = [ "coverage", "pytest", diff --git a/fedora-image-upload-tester/tests/__init__.py b/fedora-image-upload-tester/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/fedora-image-upload-tester/tests/__init__.py diff --git a/fedora-image-upload-tester/tests/test_azure.py b/fedora-image-upload-tester/tests/test_azure.py index ca6cc0f..e9085a9 100644 --- a/fedora-image-upload-tester/tests/test_azure.py +++ b/fedora-image-upload-tester/tests/test_azure.py @@ -3,13 +3,14 @@ import os import subprocess from tempfile import TemporaryDirectory -from unittest.mock import patch, MagicMock, Mock +from unittest.mock import MagicMock, Mock, patch import pytest from fedora_image_uploader_messages.publish import AzurePublishedV1 from fedora_messaging import config as fm_config -from fedora_cloud_tests.azure import AzurePublishedConsumer +from fedora_image_upload_tester.azure import AzurePublishedConsumer + @pytest.fixture(scope="module") def azure_conf(): @@ -25,6 +26,7 @@ def azure_conf(): ): yield + @pytest.fixture def consumer(azure_conf): # pylint: disable=unused-argument """Create an AzurePublishedConsumer instance for testing.""" @@ -39,7 +41,10 @@ def valid_message(): message.body = { "image_definition_name": "Fedora-Cloud-Rawhide-x64", "image_version_name": "20250101.0", - "image_resource_id": "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/test-gallery" + "image_resource_id": ( + "/subscriptions/test-sub/resourceGroups/test-rg/providers" + "/Microsoft.Compute/galleries/test-gallery" + ), } return message @@ -51,7 +56,7 @@ class TestAzurePublishedConsumer: 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 hasattr(AzurePublishedConsumer, "SUPPORTED_FEDORA_VERSIONS") assert isinstance(AzurePublishedConsumer.SUPPORTED_FEDORA_VERSIONS, list) assert len(AzurePublishedConsumer.SUPPORTED_FEDORA_VERSIONS) > 0 @@ -81,15 +86,15 @@ class TestAzurePublishedConsumer: del message.body assert consumer._get_image_definition_name(message) is None - @patch('fedora_cloud_tests.azure.subprocess.run') - @patch('os.chmod') + @patch("fedora_cloud_tests.azure.subprocess.run") + @patch("os.chmod") def test_generate_ssh_key_pair_success(self, mock_chmod, mock_subprocess, consumer): """Test successful SSH key pair generation.""" # Mock subprocess.run to simulate successful ssh-keygen mock_subprocess.return_value = MagicMock(stdout="Key generated successfully") with TemporaryDirectory() as temp_dir: - with patch('os.path.exists', return_value=True): + with patch("os.path.exists", return_value=True): result = consumer._generate_ssh_key_pair(temp_dir) # Verify the method returns the expected private key path @@ -106,12 +111,12 @@ class TestAzurePublishedConsumer: # Verify file permissions were set mock_chmod.assert_called_once_with(expected_path, 0o600) - @patch('fedora_cloud_tests.azure.subprocess.run') + @patch("fedora_cloud_tests.azure.subprocess.run") def test_generate_ssh_key_pair_failures(self, mock_subprocess, consumer): """Test SSH key pair generation failure cases.""" with TemporaryDirectory() as temp_dir: # Test subprocess failure - mock_subprocess.side_effect = subprocess.CalledProcessError(1, 'ssh-keygen') + mock_subprocess.side_effect = subprocess.CalledProcessError(1, "ssh-keygen") result = consumer._generate_ssh_key_pair(temp_dir) assert result is None @@ -120,7 +125,7 @@ class TestAzurePublishedConsumer: mock_subprocess.return_value = MagicMock(stdout="Key generated") # Test file not created scenario - with patch('os.path.exists', return_value=False): + with patch("os.path.exists", return_value=False): result = consumer._generate_ssh_key_pair(temp_dir) assert result is None @@ -137,7 +142,10 @@ class TestAzurePublishedConsumer: message.body = { "image_definition_name": "Fedora-Cloud-Unsupported-x64", "image_version_name": "20250101.0", - "image_resource_id": "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/galleries/test-gallery" + "image_resource_id": ( + "/subscriptions/test-sub/resourceGroups/test-rg/providers" + "/Microsoft.Compute/galleries/test-gallery" + ), } assert consumer.get_community_gallery_image(message) is None @@ -153,7 +161,7 @@ class TestAzurePublishedConsumer: message.body = { "image_definition_name": "Fedora-Cloud-Rawhide-x64", "image_version_name": "20250101.0", - "image_resource_id": "invalid/format" + "image_resource_id": "invalid/format", } assert consumer.get_community_gallery_image(message) is None @@ -161,16 +169,18 @@ class TestAzurePublishedConsumer: message.body = { "image_definition_name": "Fedora-Cloud-Rawhide-x64", "image_version_name": "20250101.0", - "image_resource_id": "//" # Results in empty parts[2] but still valid per current logic + "image_resource_id": "//", # Results in empty parts[2] but still valid } result = consumer.get_community_gallery_image(message) # The current code allows this and creates: "westus3//Fedora-Cloud-Rawhide-x64/20250101.0" assert result == "westus3//Fedora-Cloud-Rawhide-x64/20250101.0" - @patch('fedora_cloud_tests.azure.asyncio.run') - @patch('fedora_cloud_tests.azure.LisaRunner') - @patch.object(AzurePublishedConsumer, '_generate_ssh_key_pair') - def test_azure_published_callback_success(self, mock_ssh_keygen, mock_lisa_runner, mock_asyncio_run, consumer, valid_message): # pylint: disable=R0913,R0917 + @patch("fedora_cloud_tests.azure.asyncio.run") + @patch("fedora_cloud_tests.azure.LisaRunner") + @patch.object(AzurePublishedConsumer, "_generate_ssh_key_pair") + def test_azure_published_callback_success( + 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() mock_lisa_runner.return_value = mock_runner_instance @@ -180,9 +190,11 @@ class TestAzurePublishedConsumer: mock_lisa_runner.assert_called_once_with() mock_asyncio_run.assert_called_once() - @patch('fedora_cloud_tests.azure.asyncio.run') - @patch('fedora_cloud_tests.azure.LisaRunner') - def test_azure_published_callback_unsupported_image(self, mock_lisa_runner, mock_asyncio_run, consumer): + @patch("fedora_cloud_tests.azure.asyncio.run") + @patch("fedora_cloud_tests.azure.LisaRunner") + def test_azure_published_callback_unsupported_image( + self, mock_lisa_runner, mock_asyncio_run, consumer + ): """Test handling when community gallery image cannot be processed.""" message = Mock() message.topic = "test.topic" @@ -192,10 +204,12 @@ 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.LisaRunner') - @patch.object(AzurePublishedConsumer, '_generate_ssh_key_pair') - def test_azure_published_callback_lisa_exception(self, mock_ssh_keygen, mock_lisa_runner, mock_asyncio_run, consumer, valid_message): # pylint: disable=R0913,R0917 + @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") + def test_azure_published_callback_lisa_exception( + 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() mock_lisa_runner.return_value = mock_runner_instance @@ -218,6 +232,6 @@ class TestAzurePublishedConsumer: def test_call_method_delegates_to_callback(self, consumer, valid_message): """Test that __call__ method properly delegates to azure_published_callback.""" - with patch.object(consumer, 'azure_published_callback') as mock_callback: + with patch.object(consumer, "azure_published_callback") as mock_callback: consumer(valid_message) mock_callback.assert_called_once_with(valid_message) diff --git a/fedora-image-upload-tester/tests/test_trigger_lisa.py b/fedora-image-upload-tester/tests/test_trigger_lisa.py index 50eae83..fbc5f1e 100644 --- a/fedora-image-upload-tester/tests/test_trigger_lisa.py +++ b/fedora-image-upload-tester/tests/test_trigger_lisa.py @@ -1,9 +1,12 @@ """Unit tests for the LisaRunner class in trigger_lisa.py.""" import subprocess -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch + import pytest -from fedora_cloud_tests import trigger_lisa + +from fedora_image_upload_tester import trigger_lisa + # pylint: disable=protected-access @pytest.fixture @@ -16,10 +19,10 @@ def runner(): 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 + "runner": runner, + "region": region, + "community_gallery_image": community_gallery_image, + "config_params": config_params, } @@ -72,8 +75,10 @@ class TestLisaRunner: 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'] + result = await test_setup["runner"].trigger_lisa( + test_setup["region"], + test_setup["community_gallery_image"], + test_setup["config_params"], ) assert result is True @@ -87,8 +92,10 @@ class TestLisaRunner: 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'] + result = await test_setup["runner"].trigger_lisa( + test_setup["region"], + test_setup["community_gallery_image"], + test_setup["config_params"], ) assert result is True @@ -96,7 +103,9 @@ class TestLisaRunner: 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): + 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() @@ -113,23 +122,19 @@ class TestLisaRunner: 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 - ) + 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): + 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("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 - ) + result = await runner.trigger_lisa(region, community_gallery_image, config_params) assert result is False mock_logger_error.assert_called_with( @@ -137,12 +142,12 @@ class TestLisaRunner: ) @pytest.mark.asyncio - async def test_trigger_lisa_missing_region(self, runner, community_gallery_image, config_params): + 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 - ) + result = await runner.trigger_lisa("", community_gallery_image, config_params) assert result is False mock_logger_error.assert_called_with( @@ -150,7 +155,9 @@ class TestLisaRunner: ) @pytest.mark.asyncio - async def test_trigger_lisa_missing_community_gallery_image(self, runner, region, config_params): + 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) @@ -161,7 +168,9 @@ class TestLisaRunner: ) @pytest.mark.asyncio - async def test_trigger_lisa_missing_subscription(self, runner, region, community_gallery_image, config_params): + 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"] @@ -172,12 +181,12 @@ class TestLisaRunner: ) assert result is False - mock_logger_error.assert_called_with( - "Missing required parameter: subscription" - ) + 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): + 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"] @@ -188,9 +197,7 @@ class TestLisaRunner: ) assert result is False - mock_logger_error.assert_called_with( - "Missing required parameter: private_key" - ) + 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): @@ -198,8 +205,10 @@ class TestLisaRunner: 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'] + 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 @@ -220,9 +229,9 @@ class TestLisaRunner: "-v", f"admin_private_key_file:{test_setup['config_params']['private_key']}", "-l", - test_setup['config_params']["log_path"], + test_setup["config_params"]["log_path"], "-i", - test_setup['config_params']["run_name"], + test_setup["config_params"]["run_name"], ] mock_subproc_exec.assert_called_once_with( @@ -232,8 +241,10 @@ class TestLisaRunner: ) @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 + 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", @@ -244,9 +255,7 @@ class TestLisaRunner: 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 - ) + result = await runner.trigger_lisa(region, community_gallery_image, minimal_config) # Now the implementation should handle missing optional parameters gracefully assert result is True @@ -262,7 +271,9 @@ class TestLisaRunner: 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): + 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", @@ -296,6 +307,4 @@ class TestLisaRunner: ) assert result is False - mock_logger_error.assert_called_with( - "Invalid config parameter: must be a dictionary" - ) + mock_logger_error.assert_called_with("Invalid config parameter: must be a dictionary") diff --git a/fedora-image-upload-tester/tox.ini b/fedora-image-upload-tester/tox.ini new file mode 100644 index 0000000..e82e69a --- /dev/null +++ b/fedora-image-upload-tester/tox.ini @@ -0,0 +1,34 @@ +[tox] +envlist = format,flake8,py313 +isolated_build = True + +[testenv] +deps = + ../fedora-image-uploader-messages/ + .[test] +sitepackages = False +commands_pre = + pip install --upgrade pip +commands = + coverage erase + coverage run -m pytest -vv tests/ {posargs} + coverage report -m + coverage xml + coverage html + +[testenv:format] +deps = + black + isort +commands = + black --check . + isort --check . + +[testenv:flake8] +deps = + flake8 +commands = + flake8 . + +[flake8] +max-line-length = 100 From a72884d54fb195a5e3e6810c668d8f8ae496b0aa Mon Sep 17 00:00:00 2001 From: Jeremy Cline Date: Oct 31 2025 19:39:56 +0000 Subject: [PATCH 4/6] Add a Containerfile for the testing consumer Signed-off-by: Jeremy Cline --- diff --git a/Containerfile.tester b/Containerfile.tester new file mode 100644 index 0000000..c0b3e05 --- /dev/null +++ b/Containerfile.tester @@ -0,0 +1,51 @@ +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 " + +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"] From 862a3652512d1a18273edae88ef727f2bb4d762e Mon Sep 17 00:00:00 2001 From: Jeremy Cline Date: Nov 07 2025 19:40:02 +0000 Subject: [PATCH 5/6] Rename to fedora-image-tester Signed-off-by: Jeremy Cline --- diff --git a/fedora-image-tester/LICENSE b/fedora-image-tester/LICENSE new file mode 100644 index 0000000..479cdd0 --- /dev/null +++ b/fedora-image-tester/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Bala Konda Reddy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fedora-image-tester/README.md b/fedora-image-tester/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/fedora-image-tester/README.md diff --git a/fedora-image-tester/fedora_image_tester/__init__.py b/fedora-image-tester/fedora_image_tester/__init__.py new file mode 100644 index 0000000..5af73fd --- /dev/null +++ b/fedora-image-tester/fedora_image_tester/__init__.py @@ -0,0 +1,9 @@ +""" +Fedora Image Upload Tester. + +This defines a set of AMQP consumers that asynchronously test images uploaded +by fedora-image-uploader, emits test results via AMQP, and tags the images with +the results. +""" + +__version__ = "0.1.0" diff --git a/fedora-image-tester/fedora_image_tester/azure.py b/fedora-image-tester/fedora_image_tester/azure.py new file mode 100644 index 0000000..34ca955 --- /dev/null +++ b/fedora-image-tester/fedora_image_tester/azure.py @@ -0,0 +1,441 @@ +""" +AMQP consumer that processes messages from fedora-image-uploader when it uploads +a new Cloud image to Azure. + +This consumer is responsible for testing the new image via LISA and annotating the +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 + +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 .trigger_lisa import LisaRunner + +_log = logging.getLogger(__name__) + + +class AzurePublishedConsumer: + """Consumer class for AzurePublishedV1 messages to trigger LISA tests.""" + + # 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", + ] + + def __init__(self): + try: + self.conf = config.conf["consumer_config"]["azure"] + except KeyError: + _log.error("The Azure consumer requires an 'azure' config section") + raise + + def __call__(self, message): + """Callback method to handle incoming messages.""" + _log.info("Received message: %s", message) + self.azure_published_callback(message) + + 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") + _log.info("Run name generated: %s", run_name) + + 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: + _log.info("Temporary log path created: %s", log_path) + + # 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, + ) + ) + _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") + else: + _log.error("LISA trigger failed with return code: %d", ret) + # TemporaryDirectory automatically cleans up when exiting the context + + 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. + + Args: + original_message: The original AzurePublishedV1 message + test_results: Parsed test results dictionary + + 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_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": {}}), + } + + return result_body + + def _parse_test_results(self, log_path, run_name): + """ + Parse the test results from the LISA runner output. + 1. Find the xml file in the log_path + 2. Read the xml file and extract number of tests run, tests passed, failed and skipped + + Returns: + dict: Dictionary containing test results with keys: + '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) + return None + + _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) + + return results + + except ET.ParseError as e: + _log.error("Failed to parse XML file %s: %s", xml_file, str(e)) + return None + + def _extract_test_details(self, root): + """ + Extract individual test case details from XML. + + Args: + root: XML root element (either 'testsuites' or 'testsuite') + + Returns: + dict: Dictionary with lists of test names categorized by status: + {'passed': [...], 'failed': [...], 'skipped': [...]} + """ + test_details = {"passed": [], "failed": [], "skipped": []} + + 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()}" + ) + test_details["failed"].append((test_identifier, failure_msg)) + + elif error_elem is not None: + error_msg = error_elem.attrib.get("message", "Test error") + error_msg = self._remove_html_tags(error_msg) + traceback_msg = error_elem.text or "" + if traceback_msg.strip(): + error_msg = f"Summary: {error_msg}\n Traceback: \n{traceback_msg.strip()}" + test_details["failed"].append((test_identifier, error_msg)) + + 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)) + + _log.info( + "Extracted test details - Passed: %d, Failed: %d, Skipped: %d", + len(test_details["passed"]), + len(test_details["failed"]), + len(test_details["skipped"]), + ) + + return test_details + + def _remove_html_tags(self, msg): + "Remove HTML tags from the message." + return msg.replace("<", "<").replace(">", ">").replace("&", "&") + + def _format_for_schema(self, test_details=None): + """ + Format the test details into the schema required for publishing. + Args: + test_details (dict): Dictionary with test name lists + + Returns: + dict: Formatted test results for schema compliance + """ + results = {} + if test_details: + for each_category in ["passed", "failed", "skipped"]: + test_list = test_details.get(each_category, []) + tests_dict = {} + for test_name, message in test_list: + tests_dict[test_name] = message + + results[f"{each_category}_tests"] = {"count": len(test_list), "tests": tests_dict} + + return results + + def _find_xml_file(self, log_path, run_name): + """ + Find the XML file in the directory with the run name. + + Args: + log_path (str): Base log directory path + run_name (str): Specific run name subdirectory + + Returns: + str: Path to the XML file if found, None otherwise + """ + xml_path = os.path.join(log_path, run_name) + + if not os.path.exists(xml_path): + _log.error("XML path does not exist: %s", xml_path) + return None + + try: + for root, _, files in os.walk(xml_path): + for filename in files: + if filename.endswith("lisa.junit.xml"): + xml_file_path = os.path.join(root, filename) + _log.info("Found XML file at: %s", xml_file_path) + return xml_file_path + except OSError as e: + _log.error("Error while searching for XML file in %s: %s ", xml_path, str(e)) + + _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 diff --git a/fedora-image-tester/fedora_image_tester/trigger_lisa.py b/fedora-image-tester/fedora_image_tester/trigger_lisa.py new file mode 100644 index 0000000..d40a861 --- /dev/null +++ b/fedora-image-tester/fedora_image_tester/trigger_lisa.py @@ -0,0 +1,108 @@ +"""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 diff --git a/fedora-image-tester/pyproject.toml b/fedora-image-tester/pyproject.toml new file mode 100644 index 0000000..bd967ed --- /dev/null +++ b/fedora-image-tester/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "fedora-image-tester" +description = "An AMQP message consumer that automates the validation of Fedora nightly cloud images." +readme = "README.md" +license = "MIT" +license-files = { paths = ["LICENSE"] } +dynamic = ["version"] +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", +] + +[project.optional-dependencies] +test = [ + "coverage", + "pytest", + "pytest-asyncio" +] + +[tool.hatch.version] +path = "fedora_image_tester/__init__.py" + +[tool.hatch.metadata] +# Required for LISA since they don't publish releases to PyPI yet +allow-direct-references = true + +[tool.black] +line-length = 100 + +[tool.isort] +profile = "black" + +[tool.coverage.run] +source = [ + "fedora_image_tester/", +] diff --git a/fedora-image-tester/tests/__init__.py b/fedora-image-tester/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/fedora-image-tester/tests/__init__.py diff --git a/fedora-image-tester/tests/test_azure.py b/fedora-image-tester/tests/test_azure.py new file mode 100644 index 0000000..59fa702 --- /dev/null +++ b/fedora-image-tester/tests/test_azure.py @@ -0,0 +1,237 @@ +"""Unit tests for the AzurePublishedConsumer class in azure.py.""" + +import os +import subprocess +from tempfile import TemporaryDirectory +from unittest.mock import MagicMock, Mock, patch + +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 + + +@pytest.fixture(scope="module") +def azure_conf(): + """Provide a minimal config for Azure in the fedora-messaging configuration dictionary.""" + with patch.dict( + fm_config.conf["consumer_config"], + { + "azure": { + "region": "westus3", + "subscription_id": "00000000-0000-0000-0000-000000000000", + } + }, + ): + yield + + +@pytest.fixture +def consumer(azure_conf): # pylint: disable=unused-argument + """Create an AzurePublishedConsumer instance for testing.""" + return AzurePublishedConsumer() + + +@pytest.fixture +def valid_message(): + """Create a valid mock AzurePublishedV1 message.""" + message = Mock(spec=AzurePublishedV1) + message.topic = "org.fedoraproject.prod.fedora_image_uploader.published.v1.azure.test" + message.body = { + "image_definition_name": "Fedora-Cloud-Rawhide-x64", + "image_version_name": "20250101.0", + "image_resource_id": ( + "/subscriptions/test-sub/resourceGroups/test-rg/providers" + "/Microsoft.Compute/galleries/test-gallery" + ), + } + return message + + +class TestAzurePublishedConsumer: + # 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")) + + def test_get_image_definition_name_success(self, consumer, valid_message): + """Test successful extraction of image definition name.""" + result = consumer._get_image_definition_name(valid_message) + assert result == "Fedora-Cloud-Rawhide-x64" + + def test_get_image_definition_name_invalid_data(self, consumer): + """Test handling of invalid image definition name data.""" + # Test missing field + message = Mock() + message.body = {} + assert consumer._get_image_definition_name(message) is None + + # Test non-string value + message.body = {"image_definition_name": 123} + assert consumer._get_image_definition_name(message) is None + + # Test missing body attribute + del message.body + assert consumer._get_image_definition_name(message) is None + + @patch("fedora_cloud_tests.azure.subprocess.run") + @patch("os.chmod") + def test_generate_ssh_key_pair_success(self, mock_chmod, mock_subprocess, consumer): + """Test successful SSH key pair generation.""" + # Mock subprocess.run to simulate successful ssh-keygen + mock_subprocess.return_value = MagicMock(stdout="Key generated successfully") + + with TemporaryDirectory() as temp_dir: + with patch("os.path.exists", return_value=True): + result = consumer._generate_ssh_key_pair(temp_dir) + + # Verify the method returns the expected private key path + expected_path = os.path.join(temp_dir, "id_ed25519") + assert result == expected_path + + # Verify ssh-keygen was called with correct parameters + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args[0][0] + assert "ssh-keygen" in call_args + assert "-t" in call_args and "ed25519" in call_args + assert "-f" in call_args + + # Verify file permissions were set + mock_chmod.assert_called_once_with(expected_path, 0o600) + + @patch("fedora_cloud_tests.azure.subprocess.run") + def test_generate_ssh_key_pair_failures(self, mock_subprocess, consumer): + """Test SSH key pair generation failure cases.""" + with TemporaryDirectory() as temp_dir: + # Test subprocess failure + mock_subprocess.side_effect = subprocess.CalledProcessError(1, "ssh-keygen") + result = consumer._generate_ssh_key_pair(temp_dir) + assert result is None + + # Reset mock for next test + mock_subprocess.side_effect = None + mock_subprocess.return_value = MagicMock(stdout="Key generated") + + # Test file not created scenario + with patch("os.path.exists", return_value=False): + result = consumer._generate_ssh_key_pair(temp_dir) + assert result is None + + def test_get_community_gallery_image_success(self, consumer, valid_message): + """Test successful community gallery image construction.""" + result = consumer.get_community_gallery_image(valid_message) + expected = "westus3/test-sub/Fedora-Cloud-Rawhide-x64/20250101.0" + assert result == expected + + def test_get_community_gallery_image_invalid_cases(self, consumer): + """Test community gallery image extraction with invalid inputs.""" + # Test unsupported Fedora version + message = Mock() + message.body = { + "image_definition_name": "Fedora-Cloud-Unsupported-x64", + "image_version_name": "20250101.0", + "image_resource_id": ( + "/subscriptions/test-sub/resourceGroups/test-rg/providers" + "/Microsoft.Compute/galleries/test-gallery" + ), + } + assert consumer.get_community_gallery_image(message) is None + + # Test invalid message body type + message.body = "not_a_dict" + assert consumer.get_community_gallery_image(message) is None + + # Test missing required fields + message.body = {"image_definition_name": "Fedora-Cloud-Rawhide-x64"} + assert consumer.get_community_gallery_image(message) is None + + # Test invalid resource ID format + message.body = { + "image_definition_name": "Fedora-Cloud-Rawhide-x64", + "image_version_name": "20250101.0", + "image_resource_id": "invalid/format", + } + assert consumer.get_community_gallery_image(message) is None + + # Test resource_id with insufficient parts (code allows empty parts[2]) + message.body = { + "image_definition_name": "Fedora-Cloud-Rawhide-x64", + "image_version_name": "20250101.0", + "image_resource_id": "//", # Results in empty parts[2] but still valid + } + result = consumer.get_community_gallery_image(message) + # The current code allows this and creates: "westus3//Fedora-Cloud-Rawhide-x64/20250101.0" + assert result == "westus3//Fedora-Cloud-Rawhide-x64/20250101.0" + + @patch("fedora_cloud_tests.azure.asyncio.run") + @patch("fedora_cloud_tests.azure.LisaRunner") + @patch.object(AzurePublishedConsumer, "_generate_ssh_key_pair") + def test_azure_published_callback_success( + 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() + mock_lisa_runner.return_value = mock_runner_instance + mock_ssh_keygen.return_value = "/tmp/test_key" + + consumer.azure_published_callback(valid_message) + mock_lisa_runner.assert_called_once_with() + mock_asyncio_run.assert_called_once() + + @patch("fedora_cloud_tests.azure.asyncio.run") + @patch("fedora_cloud_tests.azure.LisaRunner") + def test_azure_published_callback_unsupported_image( + self, mock_lisa_runner, mock_asyncio_run, consumer + ): + """Test handling when community gallery image cannot be processed.""" + message = Mock() + message.topic = "test.topic" + message.body = {"image_definition_name": "Fedora-Cloud-Unsupported-x64"} + + consumer.azure_published_callback(message) + 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.LisaRunner") + @patch.object(AzurePublishedConsumer, "_generate_ssh_key_pair") + def test_azure_published_callback_lisa_exception( + 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() + mock_lisa_runner.return_value = mock_runner_instance + mock_ssh_keygen.return_value = "/tmp/test_key" + + # Should not raise exception, just log it + consumer.azure_published_callback(valid_message) + mock_asyncio_run.assert_called_once() + + def test_azure_published_callback_message_validation_exception(self, consumer): + """Test exception handling during message type validation.""" + # Create a message that will cause a TypeError during isinstance check + # by making it not a proper message type + invalid_message = Mock() + invalid_message.topic = "test.topic" + invalid_message.body = "not_a_dict" # This will cause isinstance issues + + # This should not crash but should log errors and return early + consumer.azure_published_callback(invalid_message) + + def test_call_method_delegates_to_callback(self, consumer, valid_message): + """Test that __call__ method properly delegates to azure_published_callback.""" + with patch.object(consumer, "azure_published_callback") as mock_callback: + consumer(valid_message) + mock_callback.assert_called_once_with(valid_message) diff --git a/fedora-image-tester/tests/test_trigger_lisa.py b/fedora-image-tester/tests/test_trigger_lisa.py new file mode 100644 index 0000000..075928f --- /dev/null +++ b/fedora-image-tester/tests/test_trigger_lisa.py @@ -0,0 +1,310 @@ +"""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") diff --git a/fedora-image-tester/tox.ini b/fedora-image-tester/tox.ini new file mode 100644 index 0000000..e82e69a --- /dev/null +++ b/fedora-image-tester/tox.ini @@ -0,0 +1,34 @@ +[tox] +envlist = format,flake8,py313 +isolated_build = True + +[testenv] +deps = + ../fedora-image-uploader-messages/ + .[test] +sitepackages = False +commands_pre = + pip install --upgrade pip +commands = + coverage erase + coverage run -m pytest -vv tests/ {posargs} + coverage report -m + coverage xml + coverage html + +[testenv:format] +deps = + black + isort +commands = + black --check . + isort --check . + +[testenv:flake8] +deps = + flake8 +commands = + flake8 . + +[flake8] +max-line-length = 100 diff --git a/fedora-image-upload-tester/LICENSE b/fedora-image-upload-tester/LICENSE deleted file mode 100644 index 479cdd0..0000000 --- a/fedora-image-upload-tester/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Bala Konda Reddy - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/fedora-image-upload-tester/README.md b/fedora-image-upload-tester/README.md deleted file mode 100644 index e69de29..0000000 --- a/fedora-image-upload-tester/README.md +++ /dev/null diff --git a/fedora-image-upload-tester/fedora_image_upload_tester/__init__.py b/fedora-image-upload-tester/fedora_image_upload_tester/__init__.py deleted file mode 100644 index 5af73fd..0000000 --- a/fedora-image-upload-tester/fedora_image_upload_tester/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Fedora Image Upload Tester. - -This defines a set of AMQP consumers that asynchronously test images uploaded -by fedora-image-uploader, emits test results via AMQP, and tags the images with -the results. -""" - -__version__ = "0.1.0" diff --git a/fedora-image-upload-tester/fedora_image_upload_tester/azure.py b/fedora-image-upload-tester/fedora_image_upload_tester/azure.py deleted file mode 100644 index 34ca955..0000000 --- a/fedora-image-upload-tester/fedora_image_upload_tester/azure.py +++ /dev/null @@ -1,441 +0,0 @@ -""" -AMQP consumer that processes messages from fedora-image-uploader when it uploads -a new Cloud image to Azure. - -This consumer is responsible for testing the new image via LISA and annotating the -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 - -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 .trigger_lisa import LisaRunner - -_log = logging.getLogger(__name__) - - -class AzurePublishedConsumer: - """Consumer class for AzurePublishedV1 messages to trigger LISA tests.""" - - # 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", - ] - - def __init__(self): - try: - self.conf = config.conf["consumer_config"]["azure"] - except KeyError: - _log.error("The Azure consumer requires an 'azure' config section") - raise - - def __call__(self, message): - """Callback method to handle incoming messages.""" - _log.info("Received message: %s", message) - self.azure_published_callback(message) - - 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") - _log.info("Run name generated: %s", run_name) - - 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: - _log.info("Temporary log path created: %s", log_path) - - # 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, - ) - ) - _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") - else: - _log.error("LISA trigger failed with return code: %d", ret) - # TemporaryDirectory automatically cleans up when exiting the context - - 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. - - Args: - original_message: The original AzurePublishedV1 message - test_results: Parsed test results dictionary - - 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_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": {}}), - } - - return result_body - - def _parse_test_results(self, log_path, run_name): - """ - Parse the test results from the LISA runner output. - 1. Find the xml file in the log_path - 2. Read the xml file and extract number of tests run, tests passed, failed and skipped - - Returns: - dict: Dictionary containing test results with keys: - '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) - return None - - _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) - - return results - - except ET.ParseError as e: - _log.error("Failed to parse XML file %s: %s", xml_file, str(e)) - return None - - def _extract_test_details(self, root): - """ - Extract individual test case details from XML. - - Args: - root: XML root element (either 'testsuites' or 'testsuite') - - Returns: - dict: Dictionary with lists of test names categorized by status: - {'passed': [...], 'failed': [...], 'skipped': [...]} - """ - test_details = {"passed": [], "failed": [], "skipped": []} - - 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()}" - ) - test_details["failed"].append((test_identifier, failure_msg)) - - elif error_elem is not None: - error_msg = error_elem.attrib.get("message", "Test error") - error_msg = self._remove_html_tags(error_msg) - traceback_msg = error_elem.text or "" - if traceback_msg.strip(): - error_msg = f"Summary: {error_msg}\n Traceback: \n{traceback_msg.strip()}" - test_details["failed"].append((test_identifier, error_msg)) - - 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)) - - _log.info( - "Extracted test details - Passed: %d, Failed: %d, Skipped: %d", - len(test_details["passed"]), - len(test_details["failed"]), - len(test_details["skipped"]), - ) - - return test_details - - def _remove_html_tags(self, msg): - "Remove HTML tags from the message." - return msg.replace("<", "<").replace(">", ">").replace("&", "&") - - def _format_for_schema(self, test_details=None): - """ - Format the test details into the schema required for publishing. - Args: - test_details (dict): Dictionary with test name lists - - Returns: - dict: Formatted test results for schema compliance - """ - results = {} - if test_details: - for each_category in ["passed", "failed", "skipped"]: - test_list = test_details.get(each_category, []) - tests_dict = {} - for test_name, message in test_list: - tests_dict[test_name] = message - - results[f"{each_category}_tests"] = {"count": len(test_list), "tests": tests_dict} - - return results - - def _find_xml_file(self, log_path, run_name): - """ - Find the XML file in the directory with the run name. - - Args: - log_path (str): Base log directory path - run_name (str): Specific run name subdirectory - - Returns: - str: Path to the XML file if found, None otherwise - """ - xml_path = os.path.join(log_path, run_name) - - if not os.path.exists(xml_path): - _log.error("XML path does not exist: %s", xml_path) - return None - - try: - for root, _, files in os.walk(xml_path): - for filename in files: - if filename.endswith("lisa.junit.xml"): - xml_file_path = os.path.join(root, filename) - _log.info("Found XML file at: %s", xml_file_path) - return xml_file_path - except OSError as e: - _log.error("Error while searching for XML file in %s: %s ", xml_path, str(e)) - - _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 diff --git a/fedora-image-upload-tester/fedora_image_upload_tester/trigger_lisa.py b/fedora-image-upload-tester/fedora_image_upload_tester/trigger_lisa.py deleted file mode 100644 index d40a861..0000000 --- a/fedora-image-upload-tester/fedora_image_upload_tester/trigger_lisa.py +++ /dev/null @@ -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 diff --git a/fedora-image-upload-tester/pyproject.toml b/fedora-image-upload-tester/pyproject.toml deleted file mode 100644 index e25199f..0000000 --- a/fedora-image-upload-tester/pyproject.toml +++ /dev/null @@ -1,44 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "fedora-image-upload-tester" -description = "An AMQP message consumer that automates the validation of Fedora nightly cloud images." -readme = "README.md" -license = "MIT" -license-files = { paths = ["LICENSE"] } -dynamic = ["version"] -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", -] - -[project.optional-dependencies] -test = [ - "coverage", - "pytest", - "pytest-asyncio" -] - -[tool.hatch.version] -path = "fedora_image_upload_tester/__init__.py" - -[tool.hatch.metadata] -# Required for LISA since they don't publish releases to PyPI yet -allow-direct-references = true - -[tool.black] -line-length = 100 - -[tool.isort] -profile = "black" - -[tool.coverage.run] -source = [ - "fedora_image_upload_tester/", -] diff --git a/fedora-image-upload-tester/tests/__init__.py b/fedora-image-upload-tester/tests/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/fedora-image-upload-tester/tests/__init__.py +++ /dev/null diff --git a/fedora-image-upload-tester/tests/test_azure.py b/fedora-image-upload-tester/tests/test_azure.py deleted file mode 100644 index e9085a9..0000000 --- a/fedora-image-upload-tester/tests/test_azure.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Unit tests for the AzurePublishedConsumer class in azure.py.""" - -import os -import subprocess -from tempfile import TemporaryDirectory -from unittest.mock import MagicMock, Mock, patch - -import pytest -from fedora_image_uploader_messages.publish import AzurePublishedV1 -from fedora_messaging import config as fm_config - -from fedora_image_upload_tester.azure import AzurePublishedConsumer - - -@pytest.fixture(scope="module") -def azure_conf(): - """Provide a minimal config for Azure in the fedora-messaging configuration dictionary.""" - with patch.dict( - fm_config.conf["consumer_config"], - { - "azure": { - "region": "westus3", - "subscription_id": "00000000-0000-0000-0000-000000000000", - } - }, - ): - yield - - -@pytest.fixture -def consumer(azure_conf): # pylint: disable=unused-argument - """Create an AzurePublishedConsumer instance for testing.""" - return AzurePublishedConsumer() - - -@pytest.fixture -def valid_message(): - """Create a valid mock AzurePublishedV1 message.""" - message = Mock(spec=AzurePublishedV1) - message.topic = "org.fedoraproject.prod.fedora_image_uploader.published.v1.azure.test" - message.body = { - "image_definition_name": "Fedora-Cloud-Rawhide-x64", - "image_version_name": "20250101.0", - "image_resource_id": ( - "/subscriptions/test-sub/resourceGroups/test-rg/providers" - "/Microsoft.Compute/galleries/test-gallery" - ), - } - return message - - -class TestAzurePublishedConsumer: - # 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")) - - def test_get_image_definition_name_success(self, consumer, valid_message): - """Test successful extraction of image definition name.""" - result = consumer._get_image_definition_name(valid_message) - assert result == "Fedora-Cloud-Rawhide-x64" - - def test_get_image_definition_name_invalid_data(self, consumer): - """Test handling of invalid image definition name data.""" - # Test missing field - message = Mock() - message.body = {} - assert consumer._get_image_definition_name(message) is None - - # Test non-string value - message.body = {"image_definition_name": 123} - assert consumer._get_image_definition_name(message) is None - - # Test missing body attribute - del message.body - assert consumer._get_image_definition_name(message) is None - - @patch("fedora_cloud_tests.azure.subprocess.run") - @patch("os.chmod") - def test_generate_ssh_key_pair_success(self, mock_chmod, mock_subprocess, consumer): - """Test successful SSH key pair generation.""" - # Mock subprocess.run to simulate successful ssh-keygen - mock_subprocess.return_value = MagicMock(stdout="Key generated successfully") - - with TemporaryDirectory() as temp_dir: - with patch("os.path.exists", return_value=True): - result = consumer._generate_ssh_key_pair(temp_dir) - - # Verify the method returns the expected private key path - expected_path = os.path.join(temp_dir, "id_ed25519") - assert result == expected_path - - # Verify ssh-keygen was called with correct parameters - mock_subprocess.assert_called_once() - call_args = mock_subprocess.call_args[0][0] - assert "ssh-keygen" in call_args - assert "-t" in call_args and "ed25519" in call_args - assert "-f" in call_args - - # Verify file permissions were set - mock_chmod.assert_called_once_with(expected_path, 0o600) - - @patch("fedora_cloud_tests.azure.subprocess.run") - def test_generate_ssh_key_pair_failures(self, mock_subprocess, consumer): - """Test SSH key pair generation failure cases.""" - with TemporaryDirectory() as temp_dir: - # Test subprocess failure - mock_subprocess.side_effect = subprocess.CalledProcessError(1, "ssh-keygen") - result = consumer._generate_ssh_key_pair(temp_dir) - assert result is None - - # Reset mock for next test - mock_subprocess.side_effect = None - mock_subprocess.return_value = MagicMock(stdout="Key generated") - - # Test file not created scenario - with patch("os.path.exists", return_value=False): - result = consumer._generate_ssh_key_pair(temp_dir) - assert result is None - - def test_get_community_gallery_image_success(self, consumer, valid_message): - """Test successful community gallery image construction.""" - result = consumer.get_community_gallery_image(valid_message) - expected = "westus3/test-sub/Fedora-Cloud-Rawhide-x64/20250101.0" - assert result == expected - - def test_get_community_gallery_image_invalid_cases(self, consumer): - """Test community gallery image extraction with invalid inputs.""" - # Test unsupported Fedora version - message = Mock() - message.body = { - "image_definition_name": "Fedora-Cloud-Unsupported-x64", - "image_version_name": "20250101.0", - "image_resource_id": ( - "/subscriptions/test-sub/resourceGroups/test-rg/providers" - "/Microsoft.Compute/galleries/test-gallery" - ), - } - assert consumer.get_community_gallery_image(message) is None - - # Test invalid message body type - message.body = "not_a_dict" - assert consumer.get_community_gallery_image(message) is None - - # Test missing required fields - message.body = {"image_definition_name": "Fedora-Cloud-Rawhide-x64"} - assert consumer.get_community_gallery_image(message) is None - - # Test invalid resource ID format - message.body = { - "image_definition_name": "Fedora-Cloud-Rawhide-x64", - "image_version_name": "20250101.0", - "image_resource_id": "invalid/format", - } - assert consumer.get_community_gallery_image(message) is None - - # Test resource_id with insufficient parts (code allows empty parts[2]) - message.body = { - "image_definition_name": "Fedora-Cloud-Rawhide-x64", - "image_version_name": "20250101.0", - "image_resource_id": "//", # Results in empty parts[2] but still valid - } - result = consumer.get_community_gallery_image(message) - # The current code allows this and creates: "westus3//Fedora-Cloud-Rawhide-x64/20250101.0" - assert result == "westus3//Fedora-Cloud-Rawhide-x64/20250101.0" - - @patch("fedora_cloud_tests.azure.asyncio.run") - @patch("fedora_cloud_tests.azure.LisaRunner") - @patch.object(AzurePublishedConsumer, "_generate_ssh_key_pair") - def test_azure_published_callback_success( - 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() - mock_lisa_runner.return_value = mock_runner_instance - mock_ssh_keygen.return_value = "/tmp/test_key" - - consumer.azure_published_callback(valid_message) - mock_lisa_runner.assert_called_once_with() - mock_asyncio_run.assert_called_once() - - @patch("fedora_cloud_tests.azure.asyncio.run") - @patch("fedora_cloud_tests.azure.LisaRunner") - def test_azure_published_callback_unsupported_image( - self, mock_lisa_runner, mock_asyncio_run, consumer - ): - """Test handling when community gallery image cannot be processed.""" - message = Mock() - message.topic = "test.topic" - message.body = {"image_definition_name": "Fedora-Cloud-Unsupported-x64"} - - consumer.azure_published_callback(message) - 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.LisaRunner") - @patch.object(AzurePublishedConsumer, "_generate_ssh_key_pair") - def test_azure_published_callback_lisa_exception( - 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() - mock_lisa_runner.return_value = mock_runner_instance - mock_ssh_keygen.return_value = "/tmp/test_key" - - # Should not raise exception, just log it - consumer.azure_published_callback(valid_message) - mock_asyncio_run.assert_called_once() - - def test_azure_published_callback_message_validation_exception(self, consumer): - """Test exception handling during message type validation.""" - # Create a message that will cause a TypeError during isinstance check - # by making it not a proper message type - invalid_message = Mock() - invalid_message.topic = "test.topic" - invalid_message.body = "not_a_dict" # This will cause isinstance issues - - # This should not crash but should log errors and return early - consumer.azure_published_callback(invalid_message) - - def test_call_method_delegates_to_callback(self, consumer, valid_message): - """Test that __call__ method properly delegates to azure_published_callback.""" - with patch.object(consumer, "azure_published_callback") as mock_callback: - consumer(valid_message) - mock_callback.assert_called_once_with(valid_message) diff --git a/fedora-image-upload-tester/tests/test_trigger_lisa.py b/fedora-image-upload-tester/tests/test_trigger_lisa.py deleted file mode 100644 index fbc5f1e..0000000 --- a/fedora-image-upload-tester/tests/test_trigger_lisa.py +++ /dev/null @@ -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_upload_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") diff --git a/fedora-image-upload-tester/tox.ini b/fedora-image-upload-tester/tox.ini deleted file mode 100644 index e82e69a..0000000 --- a/fedora-image-upload-tester/tox.ini +++ /dev/null @@ -1,34 +0,0 @@ -[tox] -envlist = format,flake8,py313 -isolated_build = True - -[testenv] -deps = - ../fedora-image-uploader-messages/ - .[test] -sitepackages = False -commands_pre = - pip install --upgrade pip -commands = - coverage erase - coverage run -m pytest -vv tests/ {posargs} - coverage report -m - coverage xml - coverage html - -[testenv:format] -deps = - black - isort -commands = - black --check . - isort --check . - -[testenv:flake8] -deps = - flake8 -commands = - flake8 . - -[flake8] -max-line-length = 100 From 15b67101ef70334edd2e0ee8d683731b05c4951e Mon Sep 17 00:00:00 2001 From: Jeremy Cline Date: Nov 10 2025 18:28:30 +0000 Subject: [PATCH 6/6] 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 --- diff --git a/Containerfile.fit b/Containerfile.fit new file mode 100644 index 0000000..175e6c9 --- /dev/null +++ b/Containerfile.fit @@ -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 " + +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"] diff --git a/Containerfile.tester b/Containerfile.tester deleted file mode 100644 index c0b3e05..0000000 --- a/Containerfile.tester +++ /dev/null @@ -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 " - -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"] diff --git a/fedora-image-tester/README.md b/fedora-image-tester/README.md index e69de29..b3c0f19 100644 --- a/fedora-image-tester/README.md +++ b/fedora-image-tester/README.md @@ -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/" +$ 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 +``` diff --git a/fedora-image-tester/fedora_image_tester/azure.py b/fedora-image-tester/fedora_image_tester/azure.py index 34ca955..65eb6e2 100644 --- a/fedora-image-tester/fedora_image_tester/azure.py +++ b/fedora-image-tester/fedora_image_tester/azure.py @@ -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): - """Callback method to handle incoming messages.""" - _log.info("Received message: %s", message) - self.azure_published_callback(message) - - 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 + 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, + ) - image_definition_name = self._get_image_definition_name(message) + def __call__(self, message: AzurePublishedV1): + """Callback method to handle incoming messages.""" + 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']}" + ) - # 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 diff --git a/fedora-image-tester/fedora_image_tester/trigger_lisa.py b/fedora-image-tester/fedora_image_tester/trigger_lisa.py deleted file mode 100644 index d40a861..0000000 --- a/fedora-image-tester/fedora_image_tester/trigger_lisa.py +++ /dev/null @@ -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 diff --git a/fedora-image-tester/fedora_image_tester/utils.py b/fedora-image-tester/fedora_image_tester/utils.py new file mode 100644 index 0000000..d90bfff --- /dev/null +++ b/fedora-image-tester/fedora_image_tester/utils.py @@ -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), + ) diff --git a/fedora-image-tester/pyproject.toml b/fedora-image-tester/pyproject.toml index bd967ed..485ed24 100644 --- a/fedora-image-tester/pyproject.toml +++ b/fedora-image-tester/pyproject.toml @@ -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] diff --git a/fedora-image-tester/tests/test_azure.py b/fedora-image-tester/tests/test_azure.py index 59fa702..b2c80b0 100644 --- a/fedora-image-tester/tests/test_azure.py +++ b/fedora-image-tester/tests/test_azure.py @@ -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() diff --git a/fedora-image-tester/tests/test_trigger_lisa.py b/fedora-image-tester/tests/test_trigger_lisa.py deleted file mode 100644 index 075928f..0000000 --- a/fedora-image-tester/tests/test_trigger_lisa.py +++ /dev/null @@ -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") diff --git a/fedora-messaging-fit.toml.example b/fedora-messaging-fit.toml.example new file mode 100644 index 0000000..fa352e8 --- /dev/null +++ b/fedora-messaging-fit.toml.example @@ -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"]