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