Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions actions/hv.shutdown.servers.yaml
Original file line number Diff line number Diff line change
@@ -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
54 changes: 53 additions & 1 deletion lib/apis/openstack_api/openstack_server.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
65 changes: 65 additions & 0 deletions lib/workflows/hv_shutdown_servers.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions tests/lib/apis/openstack_api/test_openstack_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
snapshot_server,
wait_for_image_status,
wait_for_migration_status,
shutoff_server,
)
from openstack.exceptions import ResourceFailure, ResourceTimeout

Expand Down Expand Up @@ -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
58 changes: 58 additions & 0 deletions tests/lib/workflows/test_hv_shutdown_servers.py
Original file line number Diff line number Diff line change
@@ -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