diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 6d1499b27..cdb9cf59e 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -33,7 +33,11 @@ jobs: pre-run-script: scripts/setup-lxd.sh provider: lxd test-tox-env: integration-juju3.2 - modules: '["test_charm_metrics_failure", "test_charm_metrics_success", "test_charm_fork_repo", "test_charm_runner", "test_reactive"]' + # TODO: debug only remove + # modules: '["test_charm_metrics_failure", "test_charm_metrics_success", "test_charm_fork_repo", "test_charm_runner", "test_reactive", "test_openstack_cloud"]' + modules: '["test_openstack_cloud"]' extra-arguments: "-m openstack" self-hosted-runner: true self-hosted-runner-label: stg-private-endpoint + tmate-debug: true + tmate-timeout: 300 diff --git a/requirements.txt b/requirements.txt index 1046b854a..927fa70c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +# TODO 2024-07-12: PyGithub-based inteface will be replacing the ghapi in the future +PyGithub ghapi jinja2 fabric >=3,<4 diff --git a/src-docs/managed_requests.md b/src-docs/managed_requests.md new file mode 100644 index 000000000..23939bcc2 --- /dev/null +++ b/src-docs/managed_requests.md @@ -0,0 +1,32 @@ + + + + +# module `managed_requests` +Get configured requests session instance + + +--- + + + +## function `get_requests_session` + +```python +get_requests_session(proxy: ProxyConfig) → Session +``` + +Get managed requests session instance. + + + +**Args:** + + - `proxy`: HTTP proxy configurations. + + + +**Returns:** + Requests session with proxy and retry setup. + + diff --git a/src-docs/openstack_cloud.openstack_cloud.md b/src-docs/openstack_cloud.openstack_cloud.md new file mode 100644 index 000000000..8956f7c8c --- /dev/null +++ b/src-docs/openstack_cloud.openstack_cloud.md @@ -0,0 +1,141 @@ + + + + +# module `openstack_cloud.openstack_cloud` + + + + + + +--- + + + +## class `OpenstackInstance` +OpenstackInstance(server: openstack.compute.v2.server.Server) + + + +### method `__init__` + +```python +__init__(server: Server) +``` + + + + + + + + + +--- + + + +## class `OpenstackCloud` + + + + + + +### method `__init__` + +```python +__init__(clouds_config: dict[str, dict], cloud: str, prefix: str) +``` + +Create a OpenstackCloud instance. + + + +**Args:** + + - `clouds_config`: The openstack clouds.yaml in dict format. + - `cloud`: The name of cloud to use in the clouds.yaml. + - `prefix`: Prefix attached to names of resource managed by this instance. Used for identifying which resource belongs to this instance. + + + + +--- + + + +### method `delete_instance` + +```python +delete_instance(name: str) +``` + + + + + +--- + + + +### method `get_instance_name` + +```python +get_instance_name(name: str) → str +``` + + + + + +--- + + + +### method `get_instances` + +```python +get_instances() → list[OpenstackInstance] +``` + + + + + +--- + + + +### method `get_ssh_connection` + +```python +get_ssh_connection(instance: OpenstackInstance) → Connection +``` + + + + + +--- + + + +### method `launch_instance` + +```python +launch_instance( + name: str, + image: str, + flavor: str, + network: str, + userdata: str +) → OpenstackInstance +``` + + + + + + diff --git a/src-docs/openstack_cloud.openstack_manager.md b/src-docs/openstack_cloud.openstack_manager.md index 697d3d96a..93cff6908 100644 --- a/src-docs/openstack_cloud.openstack_manager.md +++ b/src-docs/openstack_cloud.openstack_manager.md @@ -18,7 +18,7 @@ Module for handling interactions with OpenStack. --- - + ## function `create_instance_config` @@ -93,7 +93,7 @@ __init__( --- - + ## class `GithubRunnerRemoveError` Represents an error removing registered runner from Github. @@ -104,7 +104,7 @@ Represents an error removing registered runner from Github. --- - + ## class `OpenstackRunnerManager` Runner manager for OpenStack-based instances. @@ -117,7 +117,7 @@ Runner manager for OpenStack-based instances. - `unit_num`: The juju unit number. - `instance_name`: Prefix of the name for the set of runners. - + ### method `__init__` @@ -146,7 +146,7 @@ Construct OpenstackRunnerManager object. --- - + ### method `flush` @@ -163,7 +163,7 @@ Flush Openstack servers. --- - + ### method `get_github_runner_info` @@ -180,7 +180,7 @@ Get information on GitHub for the runners. --- - + ### method `reconcile` diff --git a/src/openstack_cloud/openstack_cloud.py b/src/openstack_cloud/openstack_cloud.py new file mode 100644 index 000000000..9070d6dac --- /dev/null +++ b/src/openstack_cloud/openstack_cloud.py @@ -0,0 +1,433 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +import datetime +import logging +import shutil +from contextlib import contextmanager +from dataclasses import dataclass +from functools import reduce +from pathlib import Path +from typing import Iterable, Iterator, cast + +import openstack +import openstack.exceptions +import paramiko +import yaml +from fabric import Connection as SshConnection +from openstack.compute.v2.keypair import Keypair as OpenstackKeypair +from openstack.compute.v2.server import Server as OpenstackServer +from openstack.connection import Connection as OpenstackConnection +from openstack.network.v2.security_group import SecurityGroup as OpenstackSecurityGroup +from paramiko.ssh_exception import NoValidConnectionsError + +from errors import OpenStackError + +logger = logging.getLogger(__name__) + +_CLOUDS_YAML_PATH = Path(Path.home() / ".config/openstack/clouds.yaml") + +# Update the version when the security group rules are not backward compatible. +_SECURITY_GROUP_NAME = "github-runner-v1" + +_CREATE_SERVER_TIMEOUT = 5 * 60 +_SSH_TIMEOUT = 30 +_SSH_KEY_PATH = Path("/home/ubuntu/.ssh") +_TEST_STRING = "test_string" + + +class _SshError(Exception): + """Represents an error while interacting with SSH.""" + + +@dataclass +class OpenstackInstance: + id: str + name: str + addresses: list[str] + + def __init__(self, server: OpenstackServer): + self.id = server.id + self.name = server.name + self.addresses = [ + address["addr"] + for network_addresses in server.addresses.values() + for address in network_addresses + ] + + +@contextmanager +def _get_openstack_connection( + clouds_config: dict[str, dict], cloud: str +) -> Iterator[OpenstackConnection]: + """Create a connection context managed object, to be used within with statements. + + The file of _CLOUDS_YAML_PATH should only be modified by this function. + + Args: + cloud_config: The configuration in clouds.yaml format to apply. + cloud: The name of cloud to use in the clouds.yaml. + + Raises: + OpenStackError: if the credentials provided is not authorized. + + Yields: + An openstack.connection.Connection object. + """ + if not _CLOUDS_YAML_PATH.exists(): + _CLOUDS_YAML_PATH.parent.mkdir(parents=True, exist_ok=True) + _CLOUDS_YAML_PATH.write_text(data=yaml.dump(clouds_config), encoding="utf-8") + + # api documents that keystoneauth1.exceptions.MissingRequiredOptions can be raised but + # I could not reproduce it. Therefore, no catch here for such exception. + try: + with openstack.connect(cloud=cloud) as conn: + conn.authorize() + yield conn + # pylint thinks this isn't an exception, but does inherit from Exception class. + except openstack.exceptions.HttpException as exc: # pylint: disable=bad-exception-cause + logger.exception("OpenStack API call failure") + raise OpenStackError("Failed OpenStack API call") from exc + + +class OpenstackCloud: + + def __init__(self, clouds_config: dict[str, dict], cloud: str, prefix: str): + """Create a OpenstackCloud instance. + + Args: + clouds_config: The openstack clouds.yaml in dict format. + cloud: The name of cloud to use in the clouds.yaml. + prefix: Prefix attached to names of resource managed by this instance. Used for + identifying which resource belongs to this instance. + """ + self._clouds_config = clouds_config + self._cloud = cloud + self.prefix = prefix + + def launch_instance( + self, name: str, image: str, flavor: str, network: str, userdata: str + ) -> OpenstackInstance: + full_name = self.get_instance_name(name) + logger.info("Creating openstack server with %s", full_name) + + with _get_openstack_connection( + clouds_config=self._clouds_config, cloud=self._cloud + ) as conn: + security_group = OpenstackCloud._ensure_security_group(conn) + keypair = OpenstackCloud._setup_key_pair(conn, full_name) + + try: + server = conn.create_server( + name=full_name, + image=image, + key_name=keypair.name, + flavor=flavor, + network=network, + security_groups=[security_group.id], + userdata=userdata, + auto_ip=False, + timeout=_CREATE_SERVER_TIMEOUT, + wait=True, + ) + except openstack.exceptions.ResourceTimeout as err: + logger.exception("Timeout creating openstack server %s", full_name) + logger.info("Attempting clean up of openstack server %s that timeout during creation", full_name) + try: + conn.delete_server(name_or_id=full_name, wait=True) + except (openstack.exceptions.SDKException, openstack.exceptions.ResourceTimeout) as err: + logger.exception("Failed to cleanup openstack server %s that timeout during creation", full_name) + self._delete_key_pair(conn, name) + raise OpenStackError(f"Timeout creating openstack server {full_name}") from err + except openstack.exceptions.SDKException as err: + logger.exception("Failed to create openstack server %s", full_name) + self._delete_key_pair(conn, name) + raise OpenStackError(f"Failed to create openstack server {full_name}") from err + + return OpenstackInstance(server) + + def delete_instance(self, name: str): + full_name = self.get_instance_name(name) + logger.info("Deleting openstack server with %s", full_name) + + with _get_openstack_connection( + clouds_config=self._clouds_config, cloud=self._cloud + ) as conn: + server = OpenstackCloud._get_and_ensure_unique_server(conn, full_name) + conn.delete_server(name_or_id=server.id) + OpenstackCloud._delete_key_pair(conn, full_name) + + def get_ssh_connection(self, instance: OpenstackInstance) -> SshConnection: + key_path = OpenstackCloud._get_key_path(instance.name) + + if not key_path.exists(): + raise _SshError(f"Missing keyfile for server: {instance.name}, key path: {key_path}") + if not instance.addresses: + raise _SshError(f"No addresses found for OpenStack server {instance.name}") + + for ip in instance.addresses: + try: + connection = SshConnection( + host=ip, + user="ubuntu", + connect_kwargs={"key_filename": str(key_path)}, + connect_timeout=_SSH_TIMEOUT, + ) + result = connection.run("echo {_TEST_STRING}", warn=True, timeout=_SSH_TIMEOUT) + if not result.ok: + logger.warning( + "SSH test connection failed, server: %s, address: %s", instance.name, ip + ) + continue + if _TEST_STRING in result.stdout: + return connection + except (NoValidConnectionsError, TimeoutError, paramiko.ssh_exception.SSHException): + logger.warning( + "Unable to SSH into %s with address %s", + instance.name, + connection.host, + exc_info=True, + ) + continue + raise _SshError( + f"No connectable SSH addresses found, server: {instance.name}, " + f"addresses: {instance.addresses}" + ) + + def get_instances(self) -> list[OpenstackInstance]: + logger.info("Getting all openstack servers managed by the charm") + + with _get_openstack_connection( + clouds_config=self._clouds_config, cloud=self._cloud + ) as conn: + servers = self._get_openstack_instances(conn) + server_names = set(server.name for server in servers) + return [ + OpenstackInstance(OpenstackCloud._get_and_ensure_unique_server(conn, name)) + for name in server_names + ] + + def _cleanup_key_files( + self, conn: OpenstackConnection, exclude_instances: Iterable[str] + ) -> None: + """Delete all SSH key files except the specified instances. + + Args: + conn: The Openstack connection instance. + exclude_instances: The keys of these instance will not be deleted. + """ + logger.info("Cleaning up SSH key files") + exclude_filename = set( + OpenstackCloud._get_key_path(instance) for instance in exclude_instances + ) + + total = 0 + deleted = 0 + for path in _SSH_KEY_PATH.iterdir(): + # Find key file from this application. + if ( + path.is_file() + and path.name.startswith(self.instance_name) + and path.name.endswith(".key") + ): + total += 1 + if path.name in exclude_filename: + continue + + keypair_name = path.name.split(".")[0] + try: + conn.delete_keypair(keypair_name) + except openstack.exceptions.SDKException: + logger.warning( + "Unable to delete OpenStack keypair associated with deleted key file %s ", + path.name, + ) + + path.unlink() + deleted += 1 + logger.info("Found %s key files, clean up %s key files", total, deleted) + + def _clean_up_openstack_keypairs( + self, conn: OpenstackConnection, exclude_instances: Iterable[str] + ) -> None: + """Delete all OpenStack keypairs except the specified instances. + + Args: + conn: The Openstack connection instance. + exclude_instances: The keys of these instance will not be deleted. + """ + logger.info("Cleaning up openstack keypairs") + keypairs = conn.list_keypairs() + for key in keypairs: + # The `name` attribute is of resource.Body type. + if key.name and str(key.name).startswith(self.instance_name): + if str(key.name) in exclude_instances: + continue + + try: + conn.delete_keypair(key.name) + except openstack.exceptions.SDKException: + logger.warning( + "Unable to delete OpenStack keypair associated with deleted key file %s ", + key.name, + ) + + def get_instance_name(self, name: str) -> str: + return f"{self.prefix}-{name}" + + def _get_openstack_instances(self, conn: OpenstackConnection) -> list[OpenstackServer]: + """Get the OpenStack servers managed by this unit. + + Args: + conn: The connection object to access OpenStack cloud. + + Returns: + List of OpenStack instances. + """ + return [ + server + for server in cast(list[OpenstackServer], conn.list_servers()) + if server.name.startswith(f"{self.prefix}-") + ] + + @staticmethod + def _get_and_ensure_unique_server( + conn: OpenstackConnection, name: str + ) -> OpenstackServer | None: + """Get the latest server of the name and ensure it is unique. + + If multiple servers with the same name is found, the latest server in creation time is + returned. Other servers is deleted. + """ + servers: list[OpenstackServer] = conn.search_servers(name) + + latest_server = reduce( + lambda a, b: ( + a if datetime.strptime(a.created_at) < datetime.strptime(b.create_at) else b + ), + servers, + ) + outdated_servers = filter(lambda x: x != latest_server, servers) + for server in outdated_servers: + server.delete() + + return latest_server + + @staticmethod + def _get_key_path(name: str) -> Path: + """Get the filepath for storing private SSH of a runner. + + Args: + name: The name of the runner. + + Returns: + Path to reserved for the key file of the runner. + """ + return _SSH_KEY_PATH / f"{name}.key" + + @staticmethod + def _setup_key_pair(conn: OpenstackConnection, name: str) -> OpenstackKeypair: + key_path = OpenstackCloud._get_key_path(name) + + if key_path.exists: + logger.warning("Existing private key file for %s found, removing it.", name) + key_path.unlink(missing_ok=True) + + keypair = conn.create_keypair(name=name) + key_path.write_text(keypair.private_key) + shutil.chown(key_path, user="ubuntu", group="ubuntu") + key_path.chmod(0o400) + return keypair + + @staticmethod + def _delete_key_pair(conn: OpenstackConnection, name: str) -> None: + try: + # Keypair have unique names, access by ID is not needed. + if not conn.delete_keypair(name): + logger.warning("Unable to delete keypair for %s", name) + except (openstack.exceptions.SDKException, openstack.exceptions.ResourceTimeout) as err: + logger.warning("Unable to delete keypair for %s", name, stack_info=True) + + key_path = OpenstackCloud._get_key_path(name) + key_path.unlink(missing_ok=True) + + @staticmethod + def _ensure_security_group(conn: OpenstackConnection) -> OpenstackSecurityGroup: + """Ensure runner security group exists. + + Args: + conn: The connection object to access OpenStack cloud. + + Returns: + The security group with the rules for runners. + """ + rule_exists_icmp = False + rule_exists_ssh = False + rule_exists_tmate_ssh = False + + security_group_list = conn.list_security_groups(filters={"name": _SECURITY_GROUP_NAME}) + # Pick the first security_group returned. + security_group = next(iter(security_group_list), None) + if security_group is None: + logger.info("Security group %s not found, creating it", _SECURITY_GROUP_NAME) + security_group = conn.create_security_group( + name=_SECURITY_GROUP_NAME, + description="For servers managed by the github-runner charm.", + ) + else: + existing_rules = security_group.security_group_rules + for rule in existing_rules: + if rule["protocol"] == "icmp": + logger.debug( + "Found ICMP rule in existing security group %s of ID %s", + _SECURITY_GROUP_NAME, + security_group.id, + ) + rule_exists_icmp = True + if ( + rule["protocol"] == "tcp" + and rule["port_range_min"] == rule["port_range_max"] == 22 + ): + logger.debug( + "Found SSH rule in existing security group %s of ID %s", + _SECURITY_GROUP_NAME, + security_group.id, + ) + rule_exists_ssh = True + if ( + rule["protocol"] == "tcp" + and rule["port_range_min"] == rule["port_range_max"] == 10022 + ): + logger.debug( + "Found tmate SSH rule in existing security group %s of ID %s", + _SECURITY_GROUP_NAME, + security_group.id, + ) + rule_exists_tmate_ssh = True + + if not rule_exists_icmp: + conn.create_security_group_rule( + secgroup_name_or_id=_SECURITY_GROUP_NAME, + protocol="icmp", + direction="ingress", + ethertype="IPv4", + ) + if not rule_exists_ssh: + conn.create_security_group_rule( + secgroup_name_or_id=_SECURITY_GROUP_NAME, + port_range_min="22", + port_range_max="22", + protocol="tcp", + direction="ingress", + ethertype="IPv4", + ) + if not rule_exists_tmate_ssh: + conn.create_security_group_rule( + secgroup_name_or_id=_SECURITY_GROUP_NAME, + port_range_min="10022", + port_range_max="10022", + protocol="tcp", + direction="egress", + ethertype="IPv4", + ) + return security_group diff --git a/src/openstack_cloud/openstack_manager.py b/src/openstack_cloud/openstack_manager.py index 7fcaa2f6f..f5fb1f0f1 100644 --- a/src/openstack_cloud/openstack_manager.py +++ b/src/openstack_cloud/openstack_manager.py @@ -149,40 +149,6 @@ class _CloudInitUserData: proxies: Optional[ProxyConfig] = None -@contextmanager -def _create_connection(cloud_config: dict[str, dict]) -> Iterator[openstack.connection.Connection]: - """Create a connection context managed object, to be used within with statements. - - This method should be called with a valid cloud_config. See _validate_cloud_config. - Also, this method assumes that the clouds.yaml exists on ~/.config/openstack/clouds.yaml. - See charm_state.py _write_openstack_config_to_disk. - - Args: - cloud_config: The configuration in clouds.yaml format to apply. - - Raises: - OpenStackError: if the credentials provided is not authorized. - - Yields: - An openstack.connection.Connection object. - """ - clouds = list(cloud_config["clouds"].keys()) - if len(clouds) > 1: - logger.warning("Multiple clouds defined in clouds.yaml. Using the first one to connect.") - cloud_name = clouds[0] - - # api documents that keystoneauth1.exceptions.MissingRequiredOptions can be raised but - # I could not reproduce it. Therefore, no catch here for such exception. - try: - with openstack.connect(cloud=cloud_name) as conn: - conn.authorize() - yield conn - # pylint thinks this isn't an exception, but does inherit from Exception class. - except openstack.exceptions.HttpException as exc: # pylint: disable=bad-exception-cause - logger.exception("OpenStack API call failure") - raise OpenStackError("Failed OpenStack API call") from exc - - # Disable too many arguments, as they are needed to create the dataclass. def create_instance_config( # pylint: disable=too-many-arguments app_name: str, @@ -1526,3 +1492,37 @@ def flush(self) -> int: remove_token=remove_token, ) return len(runners_to_delete) + + +@contextmanager +def _create_connection(cloud_config: dict[str, dict]) -> Iterator[OpenstackConnection]: + """Create a connection context managed object, to be used within with statements. + + This method should be called with a valid cloud_config. See _validate_cloud_config. + Also, this method assumes that the clouds.yaml exists on ~/.config/openstack/clouds.yaml. + See charm_state.py _write_openstack_config_to_disk. + + Args: + cloud_config: The configuration in clouds.yaml format to apply. + + Raises: + OpenStackError: if the credentials provided is not authorized. + + Yields: + An openstack.connection.Connection object. + """ + clouds = list(cloud_config["clouds"].keys()) + if len(clouds) > 1: + logger.warning("Multiple clouds defined in clouds.yaml. Using the first one to connect.") + cloud_name = clouds[0] + + # api documents that keystoneauth1.exceptions.MissingRequiredOptions can be raised but + # I could not reproduce it. Therefore, no catch here for such exception. + try: + with openstack.connect(cloud=cloud_name) as conn: + conn.authorize() + yield conn + # pylint thinks this isn't an exception, but does inherit from Exception class. + except openstack.exceptions.HttpException as exc: # pylint: disable=bad-exception-cause + logger.exception("OpenStack API call failure") + raise OpenStackError("Failed OpenStack API call") from exc diff --git a/tests/conftest.py b/tests/conftest.py index 7bb35c4f3..7ae97d4a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -139,3 +139,16 @@ def pytest_addoption(parser: Parser): help="The Openstack region to authenticate to.", default=None, ) + # OpenStack integration tests + parser.addoption( + "--openstack-test-image", + action="store", + help="The image for testing openstack interfaces. Any ubuntu image should work.", + default=None, + ) + parser.addoption( + "--openstack-test-flavor", + action="store", + help="The flavor for testing openstack interfaces. The resource should be enough to boot the test image.", + default=None, + ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 4d54c8f89..25f4f1ee3 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -278,6 +278,22 @@ def flavor_name_fixture(pytestconfig: pytest.Config) -> str: return flavor_name +@pytest.fixture(scope="module", name="openstack_test_image") +def openstack_test_image_fixture(pytestconfig: pytest.Config) -> str: + """Image for testing openstack interfaces.""" + test_image = pytestconfig.getoption("--openstack-test-image") + assert test_image, "Please specify the --openstack-test-image command line option" + return test_image + + +@pytest.fixture(scope="module", name="openstack_test_flavor") +def openstack_test_flavor_fixture(pytestconfig: pytest.Config) -> str: + """Flavor for testing openstack interfaces.""" + test_flavor = pytestconfig.getoption("--openstack-test-flavor") + assert test_flavor, "Please specify the --openstack-test-flavor command line option" + return test_flavor + + @pytest.fixture(scope="module", name="openstack_connection") def openstack_connection_fixture( clouds_yaml_contents: str, app_name: str diff --git a/tests/integration/test_e2e.py b/tests/integration/test_e2e.py index b3fb311ed..bed193216 100644 --- a/tests/integration/test_e2e.py +++ b/tests/integration/test_e2e.py @@ -44,7 +44,7 @@ async def test_e2e_workflow( """ arrange: An app connected to an OpenStack cloud with no runners. act: Run e2e test workflow. - assert: + assert: No exception thrown. """ virt_type: str if instance_type == InstanceType.OPENSTACK: diff --git a/tests/integration/test_openstack_cloud.py b/tests/integration/test_openstack_cloud.py new file mode 100644 index 000000000..321fbe1fa --- /dev/null +++ b/tests/integration/test_openstack_cloud.py @@ -0,0 +1,130 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Test for OpenstackCloud class integration with OpenStack.""" + +from secrets import token_hex +from typing import AsyncIterator + +import pytest +import pytest_asyncio +import yaml +from openstack.connection import Connection as OpenstackConnection + +from openstack_cloud.openstack_cloud import OpenstackCloud + + +@pytest_asyncio.fixture(scope="function", name="base_openstack_cloud") +async def base_openstack_cloud_fixture(private_endpoint_clouds_yaml: str) -> OpenstackCloud: + """Setup a OpenstackCloud object with connection to openstack.""" + clouds_yaml = yaml.safe_load(private_endpoint_clouds_yaml) + return OpenstackCloud(clouds_yaml, "testcloud", f"test-{token_hex(4)}") + + +@pytest_asyncio.fixture(scope="function", name="openstack_cloud") +async def openstack_cloud_fixture(base_openstack_cloud: OpenstackCloud) -> OpenstackCloud: + """Ensures the OpenstackCloud object has no openstack servers.""" + instances = base_openstack_cloud.get_instances() + for instance in instances: + base_openstack_cloud.delete_instance(name=instance.name) + return base_openstack_cloud + + +@pytest.mark.openstack +@pytest.mark.asyncio +@pytest.mark.abort_on_fail +async def test_get_no_instances(base_openstack_cloud: OpenstackCloud) -> None: + """ + arrange: No instance on OpenStack. + act: Get instances on OpenStack. + assert: An empty list returned. + + Uses base_openstack_cloud as openstack_cloud_fixture relies on this test. + """ + instances = base_openstack_cloud.get_instances() + assert not instances + + +@pytest.mark.openstack +@pytest.mark.asyncio +@pytest.mark.abort_on_fail +async def test_launch_instance_and_delete( + base_openstack_cloud: OpenstackCloud, + openstack_connection: OpenstackConnection, + openstack_test_image: str, + openstack_test_flavor: str, + network_name: str, +) -> None: + """ + arrange: No instance on OpenStack. + act: + 1. Create an openstack instance. + 2. Delete openstack instance. + assert: + 1. Instance returned. + 2. No instance exists. + + Uses base_openstack_cloud as openstack_cloud_fixture relies on this test. + """ + instances = base_openstack_cloud.get_instances() + assert not instances, "Test arrange failure: found existing openstack instance." + + instance_name = f"{token_hex(2)}" + + # 1. + instance = base_openstack_cloud.launch_instance( + name=instance_name, + image=openstack_test_image, + flavor=openstack_test_flavor, + network=network_name, + userdata="", + ) + + assert instance is not None + assert instance.name is not None + assert instance.id is not None + + servers = openstack_connection.list_servers() + for server in servers: + if instance_name in server.name: + break + else: + assert False, f"OpenStack server with {instance_name} in the name not found" + + # 2. + base_openstack_cloud.delete_instance(name=instance_name) + instances = base_openstack_cloud.get_instances() + assert not instances, "Test failure: openstack instance should be deleted." + + +@pytest.mark.openstack +@pytest.mark.asyncio +@pytest.mark.abort_on_fail +async def test_instance_ssh_connection( + openstack_cloud: OpenstackCloud, + openstack_test_image: str, + openstack_test_flavor: str, + network_name: str, +) -> None: + """ + arrange: One instance on OpenStack. + act: Get SSH connection of instance and execute command. + assert: Test SSH command executed successfully. + + This tests whether the network rules (security group) are in place. + """ + rand_chars = f"{token_hex(10)}" + instance_name = f"{token_hex(2)}" + instance = openstack_cloud.launch_instance( + name=instance_name, + image=openstack_test_image, + flavor=openstack_test_flavor, + network=network_name, + userdata="", + ) + + ssh_conn = openstack_cloud.get_ssh_connection(instance) + result = ssh_conn.run(f"echo {rand_chars}") + + assert result.ok + assert rand_chars in result.stdout