diff --git a/actions/hv.shutdown.servers.yaml b/actions/hv.shutdown.servers.yaml new file mode 100644 index 000000000..304460637 --- /dev/null +++ b/actions/hv.shutdown.servers.yaml @@ -0,0 +1,27 @@ +--- +description: Shutdown all servers in a given hypervisor +enabled: true +entry_point: src/openstack_actions.py +name: hv.shutdown.servers +parameters: + lib_entry_point: + default: workflows.hv_shutdown_servers.shutdown_all_servers_in_hypervisor + immutable: true + type: string + requires_openstack: + default: true + immutable: true + type: boolean + cloud_account: + description: "The clouds.yaml account to use whilst performing this action" + required: true + type: string + default: "dev" + enum: + - "dev" + - "prod" + hypervisor_name: + type: string + required: true + description: Name of the hypervisor hosting all servers to be shut off +runner_type: python-script diff --git a/lib/apis/openstack_api/openstack_server.py b/lib/apis/openstack_api/openstack_server.py index a965d8662..208e1a74b 100644 --- a/lib/apis/openstack_api/openstack_server.py +++ b/lib/apis/openstack_api/openstack_server.py @@ -1,7 +1,7 @@ from datetime import datetime import logging import time -from typing import Optional +from typing import Optional, List from openstack.connection import Connection from openstack.compute.v2.image import Image from openstack.compute.v2.server import Server @@ -203,3 +203,55 @@ def delete_server( conn.compute.wait_for_delete(server, interval=5, wait=3600) logger.info("Deleted server: %s", server.id) + + +def shutoff_server(conn: Connection, server_id: str) -> None: + """ + Shutoff a server + + :param conn: openstack connection object + :type conn: Connection + :param server_id: ID of server to delete + :type server_id: str + :return: None + :rtype: None + """ + server = conn.compute.find_server(server_id) + logger.info("Attempt to shutoff server %s", server.id) + if server.status.upper() == "ACTIVE": + logger.info("Shutting off server: %s", server.id) + conn.compute.stop_server(server) + logger.info("Waiting for server to shut off: %s", server.id) + try: + conn.compute.wait_for_status(server, status="SHUTOFF") + except ResourceFailure as ex: + logger.error("server %s is in ERROR status : %s", server.id, ex) + raise ex + logger.info("server is shut off: %s", server.id) + elif server.status.upper() in ["SHUTOFF", "STOPPED"]: + logger.info( + "Server %s is in status %s, nothing to do", + server.id, + server.status, + ) + else: + logger.info( + "Server %s is in status %s, cannot perform standard shutdown", + server.id, + server.status, + ) + + +def shutoff_server_list(conn: Connection, server_id_list: List[str]) -> None: + """ + Shutoff a list of servers + + :param conn: openstack connection object + :type conn: Connection + :param server_id_list: List of ID of servers to delete + :type server_id: List[str] + :return: None + :rtype: None + """ + for server_id in server_id_list: + shutoff_server(conn, server_id) diff --git a/lib/workflows/hv_shutdown_servers.py b/lib/workflows/hv_shutdown_servers.py new file mode 100644 index 000000000..24f2f43f5 --- /dev/null +++ b/lib/workflows/hv_shutdown_servers.py @@ -0,0 +1,65 @@ +import logging +import re + +from openstack.connection import Connection +from apis.openstack_query_api.server_queries import find_servers_on_hv +from apis.openstack_api.openstack_server import shutoff_server_list + +logger = logging.getLogger(__name__) + + +def shutdown_all_servers_in_hypervisor( + conn: Connection, + hypervisor_name: str, +) -> None: + """ + Shutdown all servers in a hypervisor + + :param conn: openstack connection object + :type conn: Connection + :param hypervisor_name: Hostname of the hypervisor + :type hypervisor_name: str + :return: None + :rtype: None + """ + logger.info("Attempting to shut down all servers is hypervisor %s", hypervisor_name) + # 1st we ensure the hypervisor name is correct + # remove potential leading/trailing whitespaces + hypervisor_name = hypervisor_name.strip() + if not hypervisor_name: + logger.error("Hypervisor hostname is empty") + raise ValueError("Hypervisor hostname is empty") + # check no special characters are included + pattern = re.compile(r"^[A-Za-z0-9._-]+$") + # Compile a regular expression that allows: + # - letters (a–z, A–Z) + # - digits (0–9) + # - dot (.) + # - underscore (_) + # - dash (-) + if not pattern.fullmatch(hypervisor_name): + logger.error("Hypervisor hostname cannot include special characters") + raise ValueError("Hypervisor hostname cannot include special characters") + # if everything is OK with the hostname we can proceed + + # we get the entire list of server in this hypervisor + servers_query = find_servers_on_hv( + cloud_account=conn.name, + hypervisor_name=hypervisor_name, + from_projects=None, + webhook=None, + ) + # servers_query is a ServerQuery object + # we extract the information we need from it + server_id_list = [server.id for server in servers_query.to_objects()] + if not server_id_list: + logger.info("No server found in hypervisor %s", hypervisor_name) + else: + logger.info("Found all servers for hypervisor %s", hypervisor_name) + # we shut them down + try: + shutoff_server_list(conn, server_id_list) + logger.info("All servers for hypervisor %s shut down", hypervisor_name) + except Exception as ex: + logger.error("Exception captured when trying to shut down servers: %s", ex) + raise ex diff --git a/tests/lib/apis/openstack_api/test_openstack_server.py b/tests/lib/apis/openstack_api/test_openstack_server.py index c4cbf4f3f..f7fd373de 100644 --- a/tests/lib/apis/openstack_api/test_openstack_server.py +++ b/tests/lib/apis/openstack_api/test_openstack_server.py @@ -10,6 +10,7 @@ snapshot_server, wait_for_image_status, wait_for_migration_status, + shutoff_server, ) from openstack.exceptions import ResourceFailure, ResourceTimeout @@ -530,3 +531,25 @@ def test_force_delete_server(): mock_conn.compute.wait_for_delete.assert_called_once_with( mock_server, interval=5, wait=3600 ) + + +def test_shutoff_server_propagates_resource_failure(): + """ + test that an Exception is raised when the servers goes + into ERROR status + """ + mock_server = MagicMock() + mock_server.id = "server-123" + mock_server.status = "ACTIVE" + + expected_exception = ResourceFailure("Any generic error message can go here") + + mock_conn = MagicMock() + mock_conn.compute.find_server.return_value = mock_server + + mock_conn.compute.wait_for_status.side_effect = expected_exception + + with pytest.raises(ResourceFailure) as exc_info: + shutoff_server(mock_conn, "server-123") + + assert exc_info.value is expected_exception diff --git a/tests/lib/workflows/test_hv_shutdown_servers.py b/tests/lib/workflows/test_hv_shutdown_servers.py new file mode 100644 index 000000000..e270dc412 --- /dev/null +++ b/tests/lib/workflows/test_hv_shutdown_servers.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock, patch +import pytest +from workflows.hv_shutdown_servers import shutdown_all_servers_in_hypervisor + + +def test_shutdown_all_servers_in_hypervisor_raises_on_empty_hostname(): + """ + Test an Exception is raised when the hostname value passed to function + shutdown_all_servers_in_hypervisor is empty + """ + mock_conn = MagicMock() + + with pytest.raises(ValueError): + shutdown_all_servers_in_hypervisor(mock_conn, "") + + +def test_shutdown_all_servers_in_hypervisor_raises_on_comma_in_hostname(): + """ + Test an Exception is raised when the hostname value passed to function + shutdown_all_servers_in_hypervisor includes a comma + """ + mock_conn = MagicMock() + + with pytest.raises(ValueError): + shutdown_all_servers_in_hypervisor(mock_conn, "hv1,example.com") + + +def test_shutdown_all_servers_in_hypervisor_raises_on_colon_in_hostname(): + """ + Test an Exception is raised when the hostname value passed to function + shutdown_all_servers_in_hypervisor includes a colon + """ + mock_conn = MagicMock() + + with pytest.raises(ValueError): + shutdown_all_servers_in_hypervisor(mock_conn, "hv1:example.com") + + +@patch("workflows.hv_shutdown_servers.find_servers_on_hv") +@patch("workflows.hv_shutdown_servers.shutoff_server_list") +def test_shutoff_server_list_exception_is_reraised( + mock_shutoff_list, mock_find_servers +): + mock_conn = MagicMock() + + # Mock the query chain: find_servers_on_hv().to_objects() returns a list with a mock server + mock_server = MagicMock() + mock_server.id = "server-123" + mock_find_servers.return_value.to_objects.return_value = [mock_server] + + # Set the expected exception on the shutdown function + expected_exception = Exception("test error") + mock_shutoff_list.side_effect = expected_exception + + with pytest.raises(Exception) as exc: + shutdown_all_servers_in_hypervisor(mock_conn, "hv1.example.com") + + assert exc.value is expected_exception