Skip to content

Commit aea9490

Browse files
committed
feat(network): Add security group tools (#85)
1 parent 3b04619 commit aea9490

File tree

2 files changed

+254
-1
lines changed

2 files changed

+254
-1
lines changed

src/openstack_mcp_server/tools/network_tools.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
Port,
1212
Router,
1313
RouterInterface,
14+
SecurityGroup,
1415
Subnet,
1516
)
1617

@@ -56,6 +57,11 @@ def register_tools(self, mcp: FastMCP):
5657
mcp.tool()(self.add_router_interface)
5758
mcp.tool()(self.get_router_interfaces)
5859
mcp.tool()(self.remove_router_interface)
60+
mcp.tool()(self.get_security_groups)
61+
mcp.tool()(self.create_security_group)
62+
mcp.tool()(self.get_security_group_detail)
63+
mcp.tool()(self.update_security_group)
64+
mcp.tool()(self.delete_security_group)
5965

6066
def get_networks(
6167
self,
@@ -1161,6 +1167,135 @@ def _sanitize_server_filters(self, filters: dict) -> dict:
11611167
if not filters:
11621168
return {}
11631169
attrs = dict(filters)
1164-
# Remove client-only or unsupported filters
11651170
attrs.pop("status", None)
11661171
return attrs
1172+
1173+
def get_security_groups(
1174+
self,
1175+
project_id: str | None = None,
1176+
name: str | None = None,
1177+
) -> list[SecurityGroup]:
1178+
"""
1179+
Get the list of Security Groups with optional filtering.
1180+
1181+
:param project_id: Filter by project ID
1182+
:param name: Filter by security group name
1183+
:return: List of SecurityGroup objects
1184+
"""
1185+
conn = get_openstack_conn()
1186+
filters: dict = {}
1187+
if project_id:
1188+
filters["project_id"] = project_id
1189+
if name:
1190+
filters["name"] = name
1191+
security_groups = conn.network.security_groups(**filters)
1192+
return [
1193+
self._convert_to_security_group_model(sg) for sg in security_groups
1194+
]
1195+
1196+
def create_security_group(
1197+
self,
1198+
name: str,
1199+
description: str | None = None,
1200+
project_id: str | None = None,
1201+
) -> SecurityGroup:
1202+
"""
1203+
Create a new Security Group.
1204+
1205+
:param name: Security group name
1206+
:param description: Security group description
1207+
:param project_id: Project ID to assign ownership
1208+
:return: Created SecurityGroup object
1209+
"""
1210+
conn = get_openstack_conn()
1211+
args: dict = {"name": name}
1212+
if description is not None:
1213+
args["description"] = description
1214+
if project_id is not None:
1215+
args["project_id"] = project_id
1216+
sg = conn.network.create_security_group(**args)
1217+
return self._convert_to_security_group_model(sg)
1218+
1219+
def get_security_group_detail(
1220+
self, security_group_id: str
1221+
) -> SecurityGroup:
1222+
"""
1223+
Get detailed information about a specific Security Group.
1224+
1225+
:param security_group_id: ID of the security group to retrieve
1226+
:return: SecurityGroup details
1227+
"""
1228+
conn = get_openstack_conn()
1229+
sg = conn.network.get_security_group(security_group_id)
1230+
return self._convert_to_security_group_model(sg)
1231+
1232+
def update_security_group(
1233+
self,
1234+
security_group_id: str,
1235+
name: str | None = None,
1236+
description: str | None = None,
1237+
) -> SecurityGroup:
1238+
"""
1239+
Update an existing Security Group.
1240+
1241+
:param security_group_id: ID of the security group to update
1242+
:param name: New security group name
1243+
:param description: New security group description
1244+
:return: Updated SecurityGroup object
1245+
"""
1246+
conn = get_openstack_conn()
1247+
update_args: dict = {}
1248+
if name is not None:
1249+
update_args["name"] = name
1250+
if description is not None:
1251+
update_args["description"] = description
1252+
if not update_args:
1253+
current = conn.network.get_security_group(security_group_id)
1254+
return self._convert_to_security_group_model(current)
1255+
sg = conn.network.update_security_group(
1256+
security_group_id, **update_args
1257+
)
1258+
return self._convert_to_security_group_model(sg)
1259+
1260+
def delete_security_group(self, security_group_id: str) -> None:
1261+
"""
1262+
Delete a Security Group.
1263+
1264+
:param security_group_id: ID of the security group to delete
1265+
:return: None
1266+
"""
1267+
conn = get_openstack_conn()
1268+
conn.network.delete_security_group(
1269+
security_group_id, ignore_missing=False
1270+
)
1271+
return None
1272+
1273+
def _convert_to_security_group_model(self, openstack_sg) -> SecurityGroup:
1274+
"""
1275+
Convert an OpenStack Security Group object to a SecurityGroup pydantic model.
1276+
1277+
:param openstack_sg: OpenStack security group object
1278+
:return: Pydantic SecurityGroup model
1279+
"""
1280+
rule_ids: list[str] | None = None
1281+
rules = getattr(openstack_sg, "security_group_rules", None)
1282+
if rules is not None:
1283+
extracted: list[str] = []
1284+
for r in rules:
1285+
rid = None
1286+
if isinstance(r, dict):
1287+
rid = r.get("id")
1288+
else:
1289+
rid = getattr(r, "id", None)
1290+
if rid:
1291+
extracted.append(str(rid))
1292+
rule_ids = extracted
1293+
1294+
return SecurityGroup(
1295+
id=openstack_sg.id,
1296+
name=getattr(openstack_sg, "name", None),
1297+
status=getattr(openstack_sg, "status", None),
1298+
description=getattr(openstack_sg, "description", None),
1299+
project_id=getattr(openstack_sg, "project_id", None),
1300+
security_group_rule_ids=rule_ids,
1301+
)

tests/tools/test_network_tools.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
Port,
1212
Router,
1313
RouterInterface,
14+
SecurityGroup,
1415
Subnet,
1516
)
1617

@@ -1368,6 +1369,123 @@ def test_update_reassign_bulk_and_auto_assign_floating_ip(
13681369
auto = tools.assign_first_available_floating_ip("ext-net", "port-9")
13691370
assert isinstance(auto, FloatingIP)
13701371

1372+
def test_get_security_groups_filters(self, mock_openstack_connect_network):
1373+
mock_conn = mock_openstack_connect_network
1374+
1375+
sg = Mock()
1376+
sg.id = "sg-1"
1377+
sg.name = "default"
1378+
sg.status = None
1379+
sg.description = "desc"
1380+
sg.project_id = "proj-1"
1381+
sg.security_group_rules = [
1382+
{"id": "r-1"},
1383+
{"id": "r-2"},
1384+
]
1385+
mock_conn.network.security_groups.return_value = [sg]
1386+
1387+
tools = self.get_network_tools()
1388+
res = tools.get_security_groups(project_id="proj-1", name="default")
1389+
assert res == [
1390+
SecurityGroup(
1391+
id="sg-1",
1392+
name="default",
1393+
status=None,
1394+
description="desc",
1395+
project_id="proj-1",
1396+
security_group_rule_ids=["r-1", "r-2"],
1397+
)
1398+
]
1399+
mock_conn.network.security_groups.assert_called_once_with(
1400+
project_id="proj-1", name="default"
1401+
)
1402+
1403+
def test_create_security_group(self, mock_openstack_connect_network):
1404+
mock_conn = mock_openstack_connect_network
1405+
sg = Mock()
1406+
sg.id = "sg-2"
1407+
sg.name = "web"
1408+
sg.status = None
1409+
sg.description = "for web"
1410+
sg.project_id = "proj-1"
1411+
sg.security_group_rules = []
1412+
mock_conn.network.create_security_group.return_value = sg
1413+
1414+
tools = self.get_network_tools()
1415+
res = tools.create_security_group(
1416+
name="web", description="for web", project_id="proj-1"
1417+
)
1418+
assert res == SecurityGroup(
1419+
id="sg-2",
1420+
name="web",
1421+
status=None,
1422+
description="for web",
1423+
project_id="proj-1",
1424+
security_group_rule_ids=[],
1425+
)
1426+
mock_conn.network.create_security_group.assert_called_once_with(
1427+
name="web", description="for web", project_id="proj-1"
1428+
)
1429+
1430+
def test_get_security_group_detail(self, mock_openstack_connect_network):
1431+
mock_conn = mock_openstack_connect_network
1432+
sg = Mock()
1433+
sg.id = "sg-3"
1434+
sg.name = "db"
1435+
sg.status = None
1436+
sg.description = None
1437+
sg.project_id = None
1438+
sg.security_group_rules = None
1439+
mock_conn.network.get_security_group.return_value = sg
1440+
1441+
tools = self.get_network_tools()
1442+
res = tools.get_security_group_detail("sg-3")
1443+
assert res.id == "sg-3"
1444+
mock_conn.network.get_security_group.assert_called_once_with("sg-3")
1445+
1446+
def test_update_security_group(self, mock_openstack_connect_network):
1447+
mock_conn = mock_openstack_connect_network
1448+
sg = Mock()
1449+
sg.id = "sg-4"
1450+
sg.name = "new-name"
1451+
sg.status = None
1452+
sg.description = "new-desc"
1453+
sg.project_id = None
1454+
sg.security_group_rules = []
1455+
mock_conn.network.update_security_group.return_value = sg
1456+
1457+
tools = self.get_network_tools()
1458+
res = tools.update_security_group(
1459+
security_group_id="sg-4", name="new-name", description="new-desc"
1460+
)
1461+
assert res.name == "new-name"
1462+
mock_conn.network.update_security_group.assert_called_once_with(
1463+
"sg-4", name="new-name", description="new-desc"
1464+
)
1465+
1466+
# No fields -> returns current
1467+
current = Mock()
1468+
current.id = "sg-5"
1469+
current.name = "cur"
1470+
current.status = None
1471+
current.description = None
1472+
current.project_id = None
1473+
current.security_group_rules = None
1474+
mock_conn.network.get_security_group.return_value = current
1475+
res2 = tools.update_security_group("sg-5")
1476+
assert res2.id == "sg-5"
1477+
1478+
def test_delete_security_group(self, mock_openstack_connect_network):
1479+
mock_conn = mock_openstack_connect_network
1480+
mock_conn.network.delete_security_group.return_value = None
1481+
1482+
tools = self.get_network_tools()
1483+
res = tools.delete_security_group("sg-6")
1484+
assert res is None
1485+
mock_conn.network.delete_security_group.assert_called_once_with(
1486+
"sg-6", ignore_missing=False
1487+
)
1488+
13711489
def test_get_routers_with_filters(self, mock_openstack_connect_network):
13721490
mock_conn = mock_openstack_connect_network
13731491

0 commit comments

Comments
 (0)