diff --git a/.github/workflows/basic-ci.yml b/.github/workflows/basic-ci.yml index 0032d3b..11eea61 100644 --- a/.github/workflows/basic-ci.yml +++ b/.github/workflows/basic-ci.yml @@ -2,9 +2,9 @@ name: Basic CI on: push: - branches: [ mainline ] + branches: [ main ] pull_request: - branches: [ mainline ] + branches: [ main ] permissions: contents: read @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d1fb096..e35a710 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: "CodeQL" on: push: - branches: [ mainline ] + branches: [ main ] pull_request: - branches: [ mainline ] + branches: [ main ] jobs: analyze: diff --git a/.github/workflows/coverage-check.yml b/.github/workflows/coverage-check.yml index a84ea13..87847c5 100644 --- a/.github/workflows/coverage-check.yml +++ b/.github/workflows/coverage-check.yml @@ -29,7 +29,7 @@ jobs: - name: Get base branch coverage run: | - git checkout origin/mainline + git checkout origin/main pip install -e ".[dev]" python -m pytest tests/ -m "not integration" --cov=src --cov-report=json:coverage-base.json --tb=no -q || true BASE_COVERAGE=$(python -c "import json; print(json.load(open('coverage-base.json'))['totals']['percent_covered'])") diff --git a/.gitignore b/.gitignore index 7ea422a..6c3487e 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ analysis/data/ # Distribution archives share/ +test-nested.json diff --git a/examples/aws/alltests-config.json b/examples/aws/alltests-config.json index 374f9f0..055e70c 100644 --- a/examples/aws/alltests-config.json +++ b/examples/aws/alltests-config.json @@ -205,7 +205,7 @@ "vms": [ { "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "root_volume_size": 40, "max_runtime": 3600, "test": [ @@ -215,7 +215,7 @@ }, { "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "root_volume_size": 40, "max_runtime": 3600, "test": [ @@ -225,7 +225,7 @@ }, { "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "root_volume_size": 40, "max_runtime": 3600, "test": [ @@ -235,7 +235,7 @@ }, { "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "root_volume_size": 40, "max_runtime": 3600, "test": [ @@ -245,7 +245,7 @@ }, { "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "root_volume_size": 40, "max_runtime": 3600, "test": [ @@ -255,7 +255,7 @@ }, { "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "root_volume_size": 40, "max_runtime": 3600, "test": [ @@ -265,7 +265,7 @@ }, { "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "root_volume_size": 40, "max_runtime": 3600, "test": [ @@ -275,7 +275,7 @@ }, { "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "root_volume_size": 40, "max_runtime": 3600, "test": [ diff --git a/examples/aws/config.json b/examples/aws/config.json index 7348f26..3ea2344 100644 --- a/examples/aws/config.json +++ b/examples/aws/config.json @@ -219,7 +219,7 @@ "vms": [ { "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "root_volume_size": 40, "max_runtime": 3600, "test": [ @@ -229,7 +229,7 @@ }, { "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "root_volume_size": 40, "max_runtime": 3600, "test": [ diff --git a/setup.py b/setup.py index 8e39de9..520742b 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ version="1.0.0", description="Kernel-ci-cloud-labs", long_description=long_description, + long_description_content_type="text/markdown", package_dir={"": "src"}, packages=find_packages(where="src"), python_requires=">=3.11", @@ -45,6 +46,9 @@ "isort>=5.0", "pre-commit>=2.0", "pytest-cov>=2.0", + "pandas>=1.3.0", + "matplotlib>=3.4.0", + "seaborn>=0.11.0", ], "analysis": [ "pandas>=1.3.0", diff --git a/src/kernel_ci_cloud_labs/cli.py b/src/kernel_ci_cloud_labs/cli.py index 2187bce..76df4ac 100644 --- a/src/kernel_ci_cloud_labs/cli.py +++ b/src/kernel_ci_cloud_labs/cli.py @@ -292,8 +292,11 @@ def main(): val_parser.add_argument("--bucket", help="S3 bucket to verify (and create with --fix)") val_parser.add_argument("--role", help="IAM role name used by VM instance profiles") val_parser.add_argument("--region", default="us-west-2", help="AWS region (default: us-west-2)") - val_parser.add_argument("--api-url", help=f"KernelCI API base URI (overrides $KERNELCI_API_BASE_URI)") - val_parser.add_argument("--fix", action="store_true", help="Create missing resources (S3 bucket) instead of just reporting them") + val_parser.add_argument("--api-url", help="KernelCI API base URI (overrides $KERNELCI_API_BASE_URI)") + val_parser.add_argument( + "--fix", action="store_true", + help="Create missing resources (S3 bucket) instead of just reporting them", + ) val_parser.set_defaults(func=cmd_setup_validate) args = parser.parse_args() diff --git a/src/kernel_ci_cloud_labs/core/artifacts.py b/src/kernel_ci_cloud_labs/core/artifacts.py index ebe330d..4b4de9b 100644 --- a/src/kernel_ci_cloud_labs/core/artifacts.py +++ b/src/kernel_ci_cloud_labs/core/artifacts.py @@ -262,5 +262,3 @@ def collect_run_artifacts( manifest_path, ) return manifest - - diff --git a/src/kernel_ci_cloud_labs/launch_vm.py b/src/kernel_ci_cloud_labs/launch_vm.py index ac4add2..8cad9aa 100644 --- a/src/kernel_ci_cloud_labs/launch_vm.py +++ b/src/kernel_ci_cloud_labs/launch_vm.py @@ -175,6 +175,25 @@ def _resolve_ssm_parameter(self, parameter_name): log_error(f"Failed to resolve SSM parameter {parameter_name}: {e}") raise + def _supports_nested_virtualization(self): + """Check if the instance type supports nested virtualization. + + AWS supports nested virtualization on C8i, M8i, R8i, C7i, M7i, R7i + and related instance families. + Returns True if the current instance_type belongs to a supported family. + + See: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/amazon-ec2-nested-virtualization.html + """ + nested_families = ( + "c8i", "m8i", "r8i", "c8id", "m8id", "r8id", + "c8i-flex", "m8i-flex", "r8i-flex", "x8i", + "c7i", "m7i", "r7i", "c7id", "m7id", "r7id", + "c7i-flex", "m7i-flex", "i7i", + ) + # Extract family: e.g. "c8i.4xlarge" -> "c8i" + instance_family = self.instance_type.rsplit(".", 1)[0].lower() + return instance_family in nested_families + def prepare_test_artifacts(self): """Verify test payload zip exists in S3.""" log_info(f"\n=== Verifying test artifacts for {self.test} ===") @@ -254,6 +273,12 @@ def spawn_vm(self): params["IamInstanceProfile"] = {"Name": self.role_name} + # Enable nested virtualization for instance families that support it. + # Required for KVM/Hyper-V workloads inside the EC2 instance. + if self._supports_nested_virtualization(): + params["CpuOptions"] = {"NestedVirtualization": "enabled"} + log_info(f"Nested virtualization enabled for {self.instance_type}") + log_not("Calling run_instances...") response = self.ec2.run_instances(**params) diff --git a/src/kernel_ci_cloud_labs/pull_labs_poller.py b/src/kernel_ci_cloud_labs/pull_labs_poller.py index 124e596..5057fe6 100644 --- a/src/kernel_ci_cloud_labs/pull_labs_poller.py +++ b/src/kernel_ci_cloud_labs/pull_labs_poller.py @@ -45,7 +45,6 @@ from kernel_ci_cloud_labs.kcidb_submit import ( build_test_row, - submit_tests, to_kcidb_status, ) from kernel_ci_cloud_labs.pull_labs_translate import translate_job @@ -314,7 +313,7 @@ def _validate_default_executor_deps() -> None: problems: List[str] = [] try: - import boto3 # noqa: F401,PLC0415 + import boto3 # noqa: F401,PLC0415 # pylint: disable=unused-import except ImportError as e: problems.append( f"boto3 import failed ({e}) — run: python3.11 -m pip install -e ." diff --git a/src/kernel_ci_cloud_labs/pull_labs_translate.py b/src/kernel_ci_cloud_labs/pull_labs_translate.py index 2695d21..fe78a70 100644 --- a/src/kernel_ci_cloud_labs/pull_labs_translate.py +++ b/src/kernel_ci_cloud_labs/pull_labs_translate.py @@ -24,7 +24,7 @@ # argument of translate_job() to customise per-deployment. DEFAULT_PLATFORM_MAP: Dict[str, Dict[str, str]] = { "x86_64": { - "instance_type": "c5a.4xlarge", + "instance_type": "c8i.4xlarge", "ami_id": "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", }, "arm64": { diff --git a/src/kernel_ci_cloud_labs/setup_validate.py b/src/kernel_ci_cloud_labs/setup_validate.py index bb51f5b..da0419b 100644 --- a/src/kernel_ci_cloud_labs/setup_validate.py +++ b/src/kernel_ci_cloud_labs/setup_validate.py @@ -200,7 +200,7 @@ def _check_bucket_policy_statement(s3, bucket_name: str, fix: bool) -> bool: print(f"✗ Could not read bucket policy ({code}): {e}") return False existing = None - except (ValueError, json.JSONDecodeError) as e: + except ValueError as e: print(f"✗ Bucket policy is not valid JSON: {e}") return False @@ -365,7 +365,7 @@ def check_kcidb_jwt() -> bool: # JWT payload is base64url; pad to a multiple of 4 before decoding. payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4) payload = json.loads(base64.urlsafe_b64decode(payload_b64)) - except (ValueError, json.JSONDecodeError) as e: + except ValueError as e: print(f"✗ JWT payload not decodable: {e}") return False diff --git a/tests/test_kcidb_submit.py b/tests/test_kcidb_submit.py index 5c2a8d2..fd1dfeb 100644 --- a/tests/test_kcidb_submit.py +++ b/tests/test_kcidb_submit.py @@ -187,8 +187,10 @@ def test_submit_revision_posts_with_bearer_auth(self): class FakeResp: def __enter__(self): return self + def __exit__(self, *a): return False + def read(self): return b'{"status":"ok","id":"sub-1"}' @@ -237,7 +239,7 @@ def __enter__(self): return self def __exit__(self, *a): return False def read(self): return b'{}' - def fake_urlopen(req, timeout=None): + def fake_urlopen(req, timeout=None): # pylint: disable=unused-argument captured["body"] = json.loads(req.data.decode("utf-8")) return FakeResp() @@ -264,7 +266,7 @@ def __enter__(self): return self def __exit__(self, *a): return False def read(self): return b'{}' - def fake_urlopen(req, timeout=None): + def fake_urlopen(req, timeout=None): # pylint: disable=unused-argument captured["body"] = json.loads(req.data.decode("utf-8")) return FakeResp() diff --git a/tests/test_nested_virtualization.py b/tests/test_nested_virtualization.py new file mode 100644 index 0000000..c664b27 --- /dev/null +++ b/tests/test_nested_virtualization.py @@ -0,0 +1,83 @@ +"""Unit tests for nested virtualization support in launch_vm.""" +# pylint: disable=protected-access + +__authors__ = ["Norbert Manthey "] +__copyright__ = "Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved." +# SPDX-License-Identifier: Apache-2.0 + +from unittest.mock import patch, MagicMock + +import pytest + +from kernel_ci_cloud_labs.launch_vm import VMLauncher + + +@pytest.fixture +def _mock_boto3(): + """Patch boto3 so VMLauncher.__init__ doesn't need real AWS credentials.""" + with patch("kernel_ci_cloud_labs.launch_vm.boto3") as mock_boto: + mock_boto.client.return_value = MagicMock() + yield mock_boto + + +class TestNestedVirtualization: + """Tests for _supports_nested_virtualization and CpuOptions.""" + + def _make_launcher(self, instance_type, _mock_boto3): + """Create a VMLauncher with a given instance_type, mocking AWS.""" + vm_config = { + "instance_type": instance_type, + "ami_id": "ami-test123", + "role_name": "test-role", + "s3_bucket": "test-bucket", + "run_prefix": "run_test", + } + return VMLauncher(vm_config) + + def test_c8i_supports_nested(self, _mock_boto3): + launcher = self._make_launcher("c8i.4xlarge", _mock_boto3) + assert launcher._supports_nested_virtualization() is True + + def test_c8i_flex_supports_nested(self, _mock_boto3): + launcher = self._make_launcher("c8i-flex.2xlarge", _mock_boto3) + assert launcher._supports_nested_virtualization() is True + + def test_m8i_supports_nested(self, _mock_boto3): + launcher = self._make_launcher("m8i.large", _mock_boto3) + assert launcher._supports_nested_virtualization() is True + + def test_r8i_supports_nested(self, _mock_boto3): + launcher = self._make_launcher("r8i.xlarge", _mock_boto3) + assert launcher._supports_nested_virtualization() is True + + def test_c7i_supports_nested(self, _mock_boto3): + launcher = self._make_launcher("c7i.2xlarge", _mock_boto3) + assert launcher._supports_nested_virtualization() is True + + def test_m7i_supports_nested(self, _mock_boto3): + launcher = self._make_launcher("m7i.xlarge", _mock_boto3) + assert launcher._supports_nested_virtualization() is True + + def test_x8i_supports_nested(self, _mock_boto3): + launcher = self._make_launcher("x8i.large", _mock_boto3) + assert launcher._supports_nested_virtualization() is True + + def test_i7i_supports_nested(self, _mock_boto3): + launcher = self._make_launcher("i7i.xlarge", _mock_boto3) + assert launcher._supports_nested_virtualization() is True + + def test_c5a_does_not_support_nested(self, _mock_boto3): + launcher = self._make_launcher("c5a.4xlarge", _mock_boto3) + assert launcher._supports_nested_virtualization() is False + + def test_c6g_does_not_support_nested(self, _mock_boto3): + launcher = self._make_launcher("c6g.4xlarge", _mock_boto3) + assert launcher._supports_nested_virtualization() is False + + def test_t3_does_not_support_nested(self, _mock_boto3): + launcher = self._make_launcher("t3.micro", _mock_boto3) + assert launcher._supports_nested_virtualization() is False + + def test_m5_does_not_support_nested(self, _mock_boto3): + launcher = self._make_launcher("m5.large", _mock_boto3) + assert launcher._supports_nested_virtualization() is False diff --git a/tests/test_pull_labs_poller.py b/tests/test_pull_labs_poller.py index 787c341..8b5e980 100644 --- a/tests/test_pull_labs_poller.py +++ b/tests/test_pull_labs_poller.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # # Copyright (C) 2026 Collabora Limited +# pylint: disable=protected-access # Author: Denys Fedoryshchenko """Unit tests for pull_labs_poller (no network, no AWS).""" @@ -8,7 +9,6 @@ import json import logging import os -import tempfile import urllib.error from unittest.mock import patch @@ -626,14 +626,16 @@ class TestProcessEventNodeResult: def _run(self, poller, event, translate=None): translate = translate or {"return_value": {}} captured = {} - with patch.object(poller, "_claim_node", return_value=True), \ - patch.object( - poller, "_finish_node", - side_effect=lambda nid, outcome: captured.update(outcome=outcome), - ), \ - patch(_GET, return_value={"artifacts": {}}), \ - patch("kernel_ci_cloud_labs.pull_labs_poller.translate_job", **translate), \ - patch("kernel_ci_cloud_labs.pull_labs_poller.submit_tests", return_value={}): + with ( + patch.object(poller, "_claim_node", return_value=True), + patch.object( + poller, "_finish_node", + side_effect=lambda nid, outcome: captured.update(outcome=outcome), + ), + patch(_GET, return_value={"artifacts": {}}), + patch("kernel_ci_cloud_labs.pull_labs_poller.translate_job", + **translate), + ): poller.process_event(event) return captured["outcome"] @@ -682,9 +684,10 @@ def test_translate_failure_finishes_invalid_job_params(self): assert "missing artifacts.kernel" in outcome.error_msg def test_per_instance_rows_carry_log_url_and_stable_test_id(self): - """When executor returns per-instance rows with log_url, the submitted - KCIDB rows must each carry that URL and a test_id derived from the - instance_id (not the positional index).""" + """When executor returns per-instance rows with log_url, the node + outcome must carry those URLs in artifacts.test_log (first URL) and + test_log_N (subsequent URLs), and the node result must reflect the + aggregated per-instance statuses.""" per_test = [ {"name": "boot", "status": "PASS", "instance_id": "i-aaaa1111", "log_url": "https://b.s3.eu-west-1.amazonaws.com/a.log"}, @@ -695,31 +698,15 @@ def test_per_instance_rows_carry_log_url_and_stable_test_id(self): _minimal_kc(), job_executor=lambda cfg: (per_test, None), ) - seen = {} - with patch.object(p, "_claim_node", return_value=True), \ - patch.object(p, "_finish_node"), \ - patch(_GET, return_value={"artifacts": {}}), \ - patch("kernel_ci_cloud_labs.pull_labs_poller.translate_job", - return_value={}), \ - patch( - "kernel_ci_cloud_labs.pull_labs_poller.submit_tests", - side_effect=lambda url, jwt, origin, build_id, rows: seen.update(rows=rows), - ): - p.process_event(_job_event(node_id="ndX")) - - rows = seen["rows"] - assert len(rows) == 2 - by_id = {r["id"]: r for r in rows} - # test_id derived from instance_id => stable across retries. - assert set(by_id) == {"pullab_cloud_aws:ndX.i-aaaa1111", "pullab_cloud_aws:ndX.i-bbbb2222"} - # Per-row log_url survives the build_test_row pass-through. - assert by_id["pullab_cloud_aws:ndX.i-aaaa1111"]["log_url"] == \ + outcome = self._run(p, _job_event(node_id="ndX")) + + # One FAIL among results -> overall node result is "fail". + assert outcome.result == "fail" + # Log URLs are attached to the outcome artifacts for send_kcidb. + assert outcome.artifacts["test_log"] == \ "https://b.s3.eu-west-1.amazonaws.com/a.log" - assert by_id["pullab_cloud_aws:ndX.i-bbbb2222"]["log_url"] == \ + assert outcome.artifacts["test_log_1"] == \ "https://b.s3.eu-west-1.amazonaws.com/b.log" - # instance_id surfaces in misc for traceability. - assert by_id["pullab_cloud_aws:ndX.i-aaaa1111"]["misc"]["instance_id"] == "i-aaaa1111" - # Aggregated node outcome from per-instance statuses. # (one fail among two -> fail; verified indirectly via existing tests). diff --git a/tests/test_pull_labs_translate.py b/tests/test_pull_labs_translate.py index 68a33a2..b1a6298 100644 --- a/tests/test_pull_labs_translate.py +++ b/tests/test_pull_labs_translate.py @@ -9,7 +9,6 @@ from kernel_ci_cloud_labs.pull_labs_translate import ( DEFAULT_PLATFORM_MAP, - DEFAULT_TEST_TYPE_MAP, translate_job, ) diff --git a/tests/test_role_manager.py b/tests/test_role_manager.py index c3afbfb..dede843 100644 --- a/tests/test_role_manager.py +++ b/tests/test_role_manager.py @@ -1,4 +1,5 @@ """Unit tests for AWS Role Manager""" +# pylint: disable=protected-access __authors__ = ["Max Hubmann ", "Norbert Manthey "] __copyright__ = "Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved."