Skip to content

Commit 88d11c1

Browse files
authored
feat: Add get regions and create region tool (#18)
* feat(keystone): implement get-regions tool (#14) - Add get_regions tool - Add get_regions test code
1 parent 1110926 commit 88d11c1

File tree

4 files changed

+180
-0
lines changed

4 files changed

+180
-0
lines changed

src/openstack_mcp_server/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ def register_tool(mcp: FastMCP):
77
"""
88
from .glance_tools import GlanceTools
99
from .nova_tools import NovaTools
10+
from .keystone_tools import KeystoneTools
1011

1112
NovaTools().register_tools(mcp)
1213
GlanceTools().register_tools(mcp)
14+
KeystoneTools().register_tools(mcp)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from fastmcp import FastMCP
2+
from pydantic import BaseModel
3+
from .base import get_openstack_conn
4+
5+
# NOTE: In openstacksdk, all of the fields are optional.
6+
# In this case, we are only using description field as optional.
7+
class Region(BaseModel):
8+
id: str
9+
description: str | None = None
10+
11+
class KeystoneTools:
12+
"""
13+
A class to encapsulate Keystone-related tools and utilities.
14+
"""
15+
16+
def register_tools(self, mcp: FastMCP):
17+
"""
18+
Register Keystone-related tools with the FastMCP instance.
19+
"""
20+
21+
mcp.tool()(self.get_regions)
22+
mcp.tool()(self.create_region)
23+
24+
def get_regions(self) -> list[Region]:
25+
"""
26+
Get the list of Keystone regions.
27+
28+
:return: A list of Region objects representing the regions.
29+
"""
30+
conn = get_openstack_conn()
31+
32+
region_list = []
33+
for region in conn.identity.regions():
34+
region_list.append(Region(id=region.id, description=region.description))
35+
36+
return region_list
37+
38+
def create_region(self, id: str, description: str | None = None) -> Region:
39+
"""
40+
Create a new region.
41+
42+
:param id: The ID of the region.
43+
:param description: The description of the region. It can be None.
44+
45+
:return: The created Region object.
46+
"""
47+
conn = get_openstack_conn()
48+
49+
region = conn.identity.create_region(id=id, description=description)
50+
51+
return Region(id=region.id, description=region.description)
52+

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ def mock_get_openstack_conn_glance():
2525
) as mock_func:
2626
yield mock_conn
2727

28+
@pytest.fixture
29+
def mock_get_openstack_conn_keystone():
30+
"""Mock get_openstack_conn function for keystone_tools."""
31+
mock_conn = Mock()
32+
33+
with patch(
34+
"openstack_mcp_server.tools.keystone_tools.get_openstack_conn",
35+
return_value=mock_conn
36+
) as mock_func:
37+
yield mock_conn
2838

2939
@pytest.fixture
3040
def mock_openstack_base():

tests/tools/test_keystone_tools.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import pytest
2+
from unittest.mock import Mock
3+
from openstack import exceptions
4+
from openstack_mcp_server.tools.keystone_tools import KeystoneTools, Region
5+
6+
class TestKeystoneTools:
7+
"""Test cases for KeystoneTools class."""
8+
9+
def get_keystone_tools(self) -> KeystoneTools:
10+
"""Get an instance of KeystoneTools."""
11+
return KeystoneTools()
12+
13+
def test_get_regions_success(self, mock_get_openstack_conn_keystone):
14+
"""Test getting keystone regions successfully."""
15+
mock_conn = mock_get_openstack_conn_keystone
16+
17+
# Create mock region objects
18+
mock_region1 = Mock()
19+
mock_region1.id = "RegionOne"
20+
mock_region1.description = "Region One description"
21+
22+
mock_region2 = Mock()
23+
mock_region2.id = "RegionTwo"
24+
mock_region2.description = "Region Two description"
25+
26+
# Configure mock region.regions()
27+
mock_conn.identity.regions.return_value = [mock_region1, mock_region2]
28+
29+
# Test get_regions()
30+
keystone_tools = self.get_keystone_tools()
31+
result = keystone_tools.get_regions()
32+
33+
# Verify results
34+
assert result == [Region(id="RegionOne", description="Region One description"),
35+
Region(id="RegionTwo", description="Region Two description")]
36+
37+
# Verify mock calls
38+
mock_conn.identity.regions.assert_called_once()
39+
40+
def test_get_regions_empty_list(self, mock_get_openstack_conn_keystone):
41+
"""Test getting keystone regions when there are no regions."""
42+
mock_conn = mock_get_openstack_conn_keystone
43+
44+
# Empty region list
45+
mock_conn.identity.regions.return_value = []
46+
47+
# Test get_regions()
48+
keystone_tools = self.get_keystone_tools()
49+
result = keystone_tools.get_regions()
50+
51+
# Verify results
52+
assert result == []
53+
54+
# Verify mock calls
55+
mock_conn.identity.regions.assert_called_once()
56+
57+
def test_create_region_success(self, mock_get_openstack_conn_keystone):
58+
"""Test creating a keystone region successfully."""
59+
mock_conn = mock_get_openstack_conn_keystone
60+
61+
# Create mock region object
62+
mock_region = Mock()
63+
mock_region.id = "RegionOne"
64+
mock_region.description = "Region One description"
65+
66+
# Configure mock region.create_region()
67+
mock_conn.identity.create_region.return_value = mock_region
68+
69+
# Test create_region()
70+
keystone_tools = self.get_keystone_tools()
71+
result = keystone_tools.create_region(id="RegionOne", description="Region One description")
72+
73+
# Verify results
74+
assert result == Region(id="RegionOne", description="Region One description")
75+
76+
# Verify mock calls
77+
mock_conn.identity.create_region.assert_called_once_with(id="RegionOne", description="Region One description")
78+
79+
def test_create_region_without_description(self, mock_get_openstack_conn_keystone):
80+
"""Test creating a keystone region without a description."""
81+
mock_conn = mock_get_openstack_conn_keystone
82+
83+
# Create mock region object
84+
mock_region = Mock()
85+
mock_region.id = "RegionOne"
86+
mock_region.description = None
87+
88+
# Configure mock region.create_region()
89+
mock_conn.identity.create_region.return_value = mock_region
90+
91+
# Test create_region()
92+
keystone_tools = self.get_keystone_tools()
93+
result = keystone_tools.create_region(id="RegionOne")
94+
95+
# Verify results
96+
assert result == Region(id="RegionOne")
97+
98+
def test_create_region_invalid_id_format(self, mock_get_openstack_conn_keystone):
99+
"""Test creating a keystone region with an invalid ID format."""
100+
mock_conn = mock_get_openstack_conn_keystone
101+
102+
# Configure mock region.create_region() to raise an exception
103+
mock_conn.identity.create_region.side_effect = exceptions.BadRequestException(
104+
"Invalid input for field 'id': Expected string, got integer"
105+
)
106+
107+
# Test create_region()
108+
keystone_tools = self.get_keystone_tools()
109+
110+
# Verify results
111+
with pytest.raises(exceptions.BadRequestException, match="Invalid input for field 'id': Expected string, got integer"):
112+
keystone_tools.create_region(id=1, description="Region One description")
113+
114+
# Verify mock calls
115+
mock_conn.identity.create_region.assert_called_once_with(id=1, description="Region One description")
116+

0 commit comments

Comments
 (0)