diff --git a/README.md b/README.md index 14c7ba7a..426119e2 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ The Quick Start runs **tier0** tests (smoke tests). You can run other test categ | `copyoffload` | Fast migrations via shared storage | Testing storage arrays | | `copyoffload_sanity` | Copy-offload sanity subset (see below) | Quick copy-offload validation | | `warm` | Warm migrations (VMs stay running) | Specific scenario testing | +| `upgrade` | Migration across MTV operator upgrades | Validating upgrade compatibility | ### Copy-Offload Sanity Tests @@ -629,6 +630,49 @@ Configure pod affinity/anti-affinity and node affinity rules. --- +## Upgrade Tests + +Upgrade tests validate that migration resources created on a pre-upgrade MTV version survive the +operator upgrade and that migrations execute successfully afterward. The test flow is: + +1. Create migration resources (StorageMap, NetworkMap, Plan) on the current MTV version +2. Upgrade the MTV operator to the target version using an external upgrade script +3. Verify the upgraded operator version and that existing Plan CRs remain in Ready state +4. Execute the migration on the upgraded operator +5. Validate the migrated VMs + +### Required `--tc` Parameters + +Upgrade tests require additional `--tc` parameters beyond the standard cluster and provider options: + +| Parameter | Required | Description | +| --------- | -------- | ----------- | +| `upgrade_repo_url` | Yes | URL of the Git repository containing the upgrade script | +| `upgrade_repo_ref` | Yes | Git ref (branch or tag) to checkout | +| `upgrade_script_path` | Yes | Relative path to the upgrade script within the repository | +| `mtv_upgrade_to_version` | Yes | Target MTV version to upgrade to (e.g., `2.7.0`) | +| `mtv_upgrade_to_source` | Yes | MTV source identifier (e.g., `brew`, `released`) | +| `mtv_upgrade_image_index` | No | Image index override (pass empty string `""` to skip) | + +### Example + +```bash +uv run pytest -m upgrade -v \ + --tc=cluster_host:https://api.your-cluster.com:6443 \ + --tc=cluster_username:kubeadmin \ + --tc=cluster_password:${CLUSTER_PASSWORD} \ + --tc=source_provider:vsphere-8.0.1 \ + --tc=storage_class:YOUR-STORAGE-CLASS \ + --tc=upgrade_repo_url:https://github.com/org/mtv-autodeploy.git \ + --tc=upgrade_repo_ref:main \ + --tc=upgrade_script_path:scripts/upgrade.sh \ + --tc=mtv_upgrade_to_version:2.7.0 \ + --tc=mtv_upgrade_to_source:released \ + --tc=mtv_upgrade_image_index:"" +``` + +--- + ## Useful Test Options ### Debug and Troubleshooting Flags diff --git a/exceptions/exceptions.py b/exceptions/exceptions.py index 64b0f533..763ae32d 100644 --- a/exceptions/exceptions.py +++ b/exceptions/exceptions.py @@ -125,3 +125,7 @@ class InvalidVMNameError(Exception): class GuestCommandError(Exception): """Raised when a command executed via VMware Guest Operations exits with non-zero code.""" + + +class MtvUpgradeError(Exception): + """Raised when MTV operator upgrade fails.""" diff --git a/pyproject.toml b/pyproject.toml index aeaebe0c..f63895ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,4 +64,5 @@ dependencies = [ "filelock>=3.16.0", "python-dotenv>=1.2.1", "requests>=2.32.5", + "gitpython>=3.1.50", ] diff --git a/pytest.ini b/pytest.ini index 123969eb..b4f7c814 100644 --- a/pytest.ini +++ b/pytest.ini @@ -28,5 +28,6 @@ markers = rhv: RHV provider-specific tests openstack: OpenStack provider-specific tests openshift: OpenShift provider-specific tests + upgrade: MTV operator upgrade tests junit_logging = all diff --git a/tests/tests_config/config.py b/tests/tests_config/config.py index cb19cd5d..2692429e 100644 --- a/tests/tests_config/config.py +++ b/tests/tests_config/config.py @@ -13,7 +13,6 @@ snapshots_interval: int = 2 mins_before_cutover: int = 5 plan_wait_timeout: int = 3600 - tests_params: dict = { "test_sanity_warm_mtv_migration": { "virtual_machines": [ @@ -606,6 +605,32 @@ "post_hook": {"expected_result": "fail"}, "expected_migration_result": "fail", }, + "test_shared_disk_rhel_migration": { + "virtual_machines": [ + { + "name": "mtv-feature-shared-rhel1", + "source_vm_power": "off", + "guest_agent": True, + "migrate_shared_disks": True, + }, + { + "name": "mtv-feature-shared-rhel2", + "source_vm_power": "off", + "guest_agent": True, + "migrate_shared_disks": False, + }, + ], + "warm_migration": False, + "migrate_shared_disks": True, + "shared_disk_device": "/dev/vdc", + "target_power_state": "on", + }, + "test_upgrade_cold_migration": { + "virtual_machines": [ + {"name": "mtv-tests-rhel8", "guest_agent": True}, + ], + "warm_migration": False, + }, } for _dir in dir(): diff --git a/tests/upgrade/__init__.py b/tests/upgrade/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/upgrade/conftest.py b/tests/upgrade/conftest.py new file mode 100644 index 00000000..41f4a6af --- /dev/null +++ b/tests/upgrade/conftest.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import stat +from typing import TYPE_CHECKING, Any + +import pytest +from git import Repo +from ocp_resources.network_map import NetworkMap +from ocp_resources.plan import Plan +from ocp_resources.storage_map import StorageMap +from pytest_testconfig import config as py_config +from simple_logger.logger import get_logger + +from utilities.mtv_migration import ( + create_plan_resource, + get_network_migration_map, + get_storage_migration_map, +) +from utilities.utils import populate_vm_ids + +if TYPE_CHECKING: + from kubernetes.dynamic import DynamicClient + + from libs.base_provider import BaseProvider + from libs.forklift_inventory import ForkliftInventory + from libs.providers.openshift import OCPProvider + +LOGGER = get_logger(name=__name__) + + +@pytest.fixture(scope="session") +def upgrade_script_path(tmp_path_factory: pytest.TempPathFactory) -> str: + """Clone the mtv-autodeploy repo and return the absolute path to the upgrade script. + + Args: + tmp_path_factory (pytest.TempPathFactory): Pytest factory for session-scoped temporary directories. + + Returns: + str: Absolute path to the executable upgrade script. + + Raises: + ValueError: If required config values are missing or the script is not found after cloning. + git.exc.GitCommandError: If the git clone fails. + """ + repo_url: str = py_config.get("upgrade_repo_url", "") + repo_ref: str = py_config.get("upgrade_repo_ref", "") + script_relative_path: str = py_config.get("upgrade_script_path", "") + + if not repo_url: + raise ValueError("upgrade_repo_url must be provided via --tc=upgrade_repo_url:") + if not repo_ref: + raise ValueError("upgrade_repo_ref must be provided via --tc=upgrade_repo_ref:") + if not script_relative_path: + raise ValueError("upgrade_script_path must be provided via --tc=upgrade_script_path:") + + clone_dir = tmp_path_factory.mktemp("mtv-autodeploy") + + LOGGER.info(f"Cloning {repo_url} (ref={repo_ref}) into {clone_dir}") + Repo.clone_from(url=repo_url, to_path=str(clone_dir), branch=repo_ref, depth=1) + + script_path = clone_dir / script_relative_path + if not script_path.is_file(): + raise ValueError(f"Upgrade script not found at '{script_path}' after cloning {repo_url}") + + current_mode = script_path.stat().st_mode + script_path.chmod(current_mode | stat.S_IXUSR) + + return str(script_path) + + +@pytest.fixture(scope="class") +def pre_upgrade_storage_map( + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: DynamicClient, + source_provider: BaseProvider, + destination_provider: BaseProvider, + source_provider_inventory: ForkliftInventory, + target_namespace: str, +) -> StorageMap: + """Create StorageMap resource for upgrade migration tests. + + Args: + prepared_plan (dict[str, Any]): Test plan configuration with VM details. + fixture_store (dict[str, Any]): Resource tracking dictionary. + ocp_admin_client (DynamicClient): OpenShift admin client. + source_provider (BaseProvider): Source provider connection. + destination_provider (BaseProvider): Destination provider. + source_provider_inventory (ForkliftInventory): Provider inventory. + target_namespace (str): Target namespace. + + Returns: + StorageMap: The created StorageMap resource. + """ + vms: list[str] = [vm["name"] for vm in prepared_plan["virtual_machines"]] + return get_storage_migration_map( + fixture_store=fixture_store, + source_provider=source_provider, + destination_provider=destination_provider, + source_provider_inventory=source_provider_inventory, + ocp_admin_client=ocp_admin_client, + target_namespace=target_namespace, + vms=vms, + ) + + +@pytest.fixture(scope="class") +def pre_upgrade_network_map( + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: DynamicClient, + source_provider: BaseProvider, + destination_provider: BaseProvider, + source_provider_inventory: ForkliftInventory, + target_namespace: str, + multus_network_name: dict[str, str], +) -> NetworkMap: + """Create NetworkMap resource for upgrade migration tests. + + Args: + prepared_plan (dict[str, Any]): Test plan configuration with VM details. + fixture_store (dict[str, Any]): Resource tracking dictionary. + ocp_admin_client (DynamicClient): OpenShift admin client. + source_provider (BaseProvider): Source provider connection. + destination_provider (BaseProvider): Destination provider. + source_provider_inventory (ForkliftInventory): Provider inventory. + target_namespace (str): Target namespace. + multus_network_name (dict[str, str]): Multus network name mapping. + + Returns: + NetworkMap: The created NetworkMap resource. + """ + vms: list[str] = [vm["name"] for vm in prepared_plan["virtual_machines"]] + return get_network_migration_map( + fixture_store=fixture_store, + source_provider=source_provider, + destination_provider=destination_provider, + source_provider_inventory=source_provider_inventory, + ocp_admin_client=ocp_admin_client, + target_namespace=target_namespace, + multus_network_name=multus_network_name, + vms=vms, + ) + + +@pytest.fixture(scope="class") +def pre_upgrade_plan_resource( + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: DynamicClient, + source_provider: BaseProvider, + destination_provider: OCPProvider, + source_provider_inventory: ForkliftInventory, + target_namespace: str, + pre_upgrade_storage_map: StorageMap, + pre_upgrade_network_map: NetworkMap, +) -> Plan: + """Create MTV Plan CR resource for upgrade migration tests. + + Args: + prepared_plan (dict[str, Any]): Test plan configuration with VM details. + fixture_store (dict[str, Any]): Resource tracking dictionary. + ocp_admin_client (DynamicClient): OpenShift admin client. + source_provider (BaseProvider): Source provider connection. + destination_provider (OCPProvider): Destination provider. + source_provider_inventory (ForkliftInventory): Provider inventory. + target_namespace (str): Target namespace. + pre_upgrade_storage_map (StorageMap): Storage map for the migration. + pre_upgrade_network_map (NetworkMap): Network map for the migration. + + Returns: + Plan: The created Plan CR resource. + """ + populate_vm_ids(prepared_plan, source_provider_inventory) + return create_plan_resource( + ocp_admin_client=ocp_admin_client, + fixture_store=fixture_store, + source_provider=source_provider, + destination_provider=destination_provider, + storage_map=pre_upgrade_storage_map, + network_map=pre_upgrade_network_map, + virtual_machines_list=prepared_plan["virtual_machines"], + target_namespace=target_namespace, + warm_migration=prepared_plan.get("warm_migration", False), + ) diff --git a/tests/upgrade/test_upgrade_migration.py b/tests/upgrade/test_upgrade_migration.py new file mode 100644 index 00000000..4448694a --- /dev/null +++ b/tests/upgrade/test_upgrade_migration.py @@ -0,0 +1,129 @@ +""" +Upgrade migration tests for MTV. + +This module implements tests that validate migration behavior across MTV +operator upgrades. The test flow creates migration resources (StorageMap, +NetworkMap, Plan) on the pre-upgrade version, upgrades the MTV operator, +verifies post-upgrade state, and then executes the migration. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from ocp_resources.network_map import NetworkMap +from ocp_resources.plan import Plan +from ocp_resources.storage_map import StorageMap +from packaging.version import InvalidVersion, Version +from pytest_testconfig import config as py_config +from simple_logger.logger import get_logger + +from utilities.mtv_migration import execute_migration +from utilities.post_migration import check_vms +from utilities.upgrade import run_mtv_upgrade +from utilities.utils import get_mtv_version + +if TYPE_CHECKING: + from kubernetes.dynamic import DynamicClient + + from libs.base_provider import BaseProvider + from libs.forklift_inventory import ForkliftInventory + from utilities.ssh_utils import SSHConnectionManager + +LOGGER = get_logger(name=__name__) + + +@pytest.mark.upgrade +@pytest.mark.vsphere +@pytest.mark.incremental +@pytest.mark.parametrize( + "class_plan_config", + [ + pytest.param( + py_config["tests_params"]["test_upgrade_cold_migration"], + ) + ], + indirect=True, + ids=["upgrade-cold"], +) +@pytest.mark.usefixtures("cleanup_migrated_vms") +class TestUpgradeColdMigration: + """Cold migration with MTV operator upgrade between plan creation and execution.""" + + def test_upgrade_mtv(self, upgrade_script_path: str, pre_upgrade_plan_resource: Plan) -> None: + """Upgrade the MTV operator to the target version.""" + run_mtv_upgrade( + script_path=upgrade_script_path, + mtv_version=py_config["mtv_upgrade_to_version"], + mtv_source=py_config["mtv_upgrade_to_source"], + image_index=py_config["mtv_upgrade_image_index"], + ) + + def test_verify_post_upgrade( + self, + ocp_admin_client: DynamicClient, + pre_upgrade_plan_resource: Plan, + ) -> None: + """Verify MTV version, pod readiness, and Plan CR state after upgrade.""" + version = get_mtv_version(client=ocp_admin_client) + LOGGER.info(f"Post-upgrade MTV version: {version}") + + expected_version: str = py_config["mtv_upgrade_to_version"] + try: + actual = Version(version) + expected = Version(expected_version) + except InvalidVersion as exc: + raise AssertionError( + f"Could not parse MTV version strings: actual='{version}', expected='{expected_version}'" + ) from exc + assert (actual.major, actual.minor) == (expected.major, expected.minor), ( + f"Expected MTV major.minor version '{expected.major}.{expected.minor}', got '{version}'" + ) + + pre_upgrade_plan_resource.wait_for_condition( + condition=Plan.Condition.READY, + status=Plan.Condition.Status.TRUE, + timeout=300, + ) + LOGGER.info(f"Plan '{pre_upgrade_plan_resource.name}' is in Ready state after upgrade") + + def test_migrate_vms( + self, + fixture_store: dict[str, Any], + ocp_admin_client: DynamicClient, + target_namespace: str, + pre_upgrade_plan_resource: Plan, + ) -> None: + """Execute migration on the upgraded MTV operator.""" + execute_migration( + ocp_admin_client=ocp_admin_client, + fixture_store=fixture_store, + plan=pre_upgrade_plan_resource, + target_namespace=target_namespace, + ) + + def test_check_vms( + self, + prepared_plan: dict[str, Any], + source_provider: BaseProvider, + destination_provider: BaseProvider, + source_provider_data: dict[str, Any], + source_vms_namespace: str, + source_provider_inventory: ForkliftInventory, + vm_ssh_connections: SSHConnectionManager | None, + pre_upgrade_network_map: NetworkMap, + pre_upgrade_storage_map: StorageMap, + ) -> None: + """Validate migrated VMs after upgrade.""" + check_vms( + plan=prepared_plan, + source_provider=source_provider, + destination_provider=destination_provider, + network_map_resource=pre_upgrade_network_map, + storage_map_resource=pre_upgrade_storage_map, + source_provider_data=source_provider_data, + source_vms_namespace=source_vms_namespace, + source_provider_inventory=source_provider_inventory, + vm_ssh_connections=vm_ssh_connections, + ) diff --git a/tests/warm/test_mtv_warm_migration.py b/tests/warm/test_mtv_warm_migration.py index b8d72fca..657f202f 100644 --- a/tests/warm/test_mtv_warm_migration.py +++ b/tests/warm/test_mtv_warm_migration.py @@ -19,6 +19,7 @@ @pytest.mark.rhv @pytest.mark.tier0 @pytest.mark.warm +@pytest.mark.upgrade @pytest.mark.incremental @pytest.mark.parametrize( "class_plan_config", diff --git a/utilities/upgrade.py b/utilities/upgrade.py new file mode 100644 index 00000000..092e0223 --- /dev/null +++ b/utilities/upgrade.py @@ -0,0 +1,75 @@ +""" +Upgrade utilities for MTV operator upgrade tests. + +This module provides functions to run the MTV upgrade process. +""" + +from __future__ import annotations + +import os +import subprocess + +from pytest_testconfig import config as py_config +from simple_logger.logger import get_logger + +from exceptions.exceptions import MtvUpgradeError + +LOGGER = get_logger(name=__name__) + + +def run_mtv_upgrade( + script_path: str, + mtv_version: str, + mtv_source: str, + image_index: str = "", +) -> None: + """Run the MTV operator upgrade using the specified upgrade script. + + Args: + script_path (str): Full path to the upgrade script. + mtv_version (str): Target MTV version to upgrade to. + mtv_source (str): MTV source identifier (e.g., "brew", "released"). + image_index (str): Optional image index override for the upgrade. + + Raises: + MtvUpgradeError: If the upgrade script exits with a non-zero status or times out. + """ + env = os.environ.copy() + env.update({ + "MTV_VERSION": mtv_version, + "MTV_SOURCE": mtv_source.upper(), + "IMAGE_INDEX": image_index, + "CLUSTER_USERNAME": py_config["cluster_username"], + "CLUSTER_PASSWORD": py_config["cluster_password"], + "CLUSTER_API_URL": py_config["cluster_host"], + }) + + LOGGER.info(f"Running MTV upgrade: {script_path} (version={mtv_version}, source={mtv_source}, index={image_index})") + + try: + result = subprocess.run( + [script_path], + env=env, + cwd=os.path.dirname(script_path), + check=True, + timeout=3600, + capture_output=True, + ) + except subprocess.TimeoutExpired as exc: + stdout = (exc.stdout.decode() if exc.stdout else "").replace(env["CLUSTER_PASSWORD"], "***") + stderr = (exc.stderr.decode() if exc.stderr else "").replace(env["CLUSTER_PASSWORD"], "***") + raise MtvUpgradeError( + f"MTV upgrade script timed out after {exc.timeout} seconds\nstdout: {stdout}\nstderr: {stderr}" + ) from exc + except subprocess.CalledProcessError as exc: + stdout = (exc.stdout.decode() if exc.stdout else "").replace(env["CLUSTER_PASSWORD"], "***") + stderr = (exc.stderr.decode() if exc.stderr else "").replace(env["CLUSTER_PASSWORD"], "***") + raise MtvUpgradeError( + f"MTV upgrade script failed with exit code {exc.returncode}\nstdout: {stdout}\nstderr: {stderr}" + ) from exc + + LOGGER.info(f"MTV upgrade stdout:\n{result.stdout.decode().replace(env['CLUSTER_PASSWORD'], '***')}") + if result.stderr: + LOGGER.warning(f"MTV upgrade stderr:\n{result.stderr.decode().replace(env['CLUSTER_PASSWORD'], '***')}") + + LOGGER.info(f"MTV upgrade to version {mtv_version} completed successfully") diff --git a/uv.lock b/uv.lock index 12b611a8..5abf60fb 100644 --- a/uv.lock +++ b/uv.lock @@ -635,6 +635,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + [[package]] name = "gprof2dot" version = "2025.4.14" @@ -1154,6 +1178,7 @@ source = { virtual = "." } dependencies = [ { name = "cloudpickle" }, { name = "filelock" }, + { name = "gitpython" }, { name = "humanfriendly" }, { name = "jc" }, { name = "openshift-python-utilities" }, @@ -1196,6 +1221,7 @@ dev = [ requires-dist = [ { name = "cloudpickle", specifier = ">=3.1.1" }, { name = "filelock", specifier = ">=3.16.0" }, + { name = "gitpython", specifier = ">=3.1.50" }, { name = "humanfriendly", specifier = ">=10.0" }, { name = "jc", specifier = ">=1.25.0" }, { name = "openshift-python-utilities", specifier = ">=6.0.7" }, @@ -2404,6 +2430,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "soupsieve" version = "2.8.4"