From ea3066e4644277b0d0e265d9e80b85b673c251ce Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Tue, 12 Aug 2025 10:19:04 +0900 Subject: [PATCH 01/15] feat(network): Add Subnet, Port, Floating IP tools (#30) --- .../tools/network_tools.py | 387 +++++++++++++++++- .../tools/response/network.py | 70 +++- 2 files changed, 443 insertions(+), 14 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index f873fad..3120995 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -1,8 +1,12 @@ from fastmcp import FastMCP -from openstack_mcp_server.tools.response.network import Network - from .base import get_openstack_conn +from .response.network import ( + FloatingIP, + Network, + Port, + Subnet, +) class NetworkTools: @@ -20,6 +24,22 @@ def register_tools(self, mcp: FastMCP): mcp.tool()(self.get_network_detail) mcp.tool()(self.update_network) mcp.tool()(self.delete_network) + mcp.tool()(self.get_subnets) + mcp.tool()(self.create_subnet) + mcp.tool()(self.get_subnet_detail) + mcp.tool()(self.update_subnet) + mcp.tool()(self.delete_subnet) + mcp.tool()(self.get_ports) + mcp.tool()(self.create_port) + mcp.tool()(self.get_port_detail) + mcp.tool()(self.update_port) + mcp.tool()(self.delete_port) + mcp.tool()(self.get_floating_ips) + mcp.tool()(self.create_floating_ip) + mcp.tool()(self.allocate_floating_ip_pool_to_project) + mcp.tool()(self.attach_floating_ip_to_port) + mcp.tool()(self.detach_floating_ip_from_port) + mcp.tool()(self.delete_floating_ip) def get_networks( self, @@ -208,3 +228,366 @@ def _convert_to_network_model(self, openstack_network) -> Network: or None, project_id=openstack_network.project_id or None, ) + + def get_subnets( + self, + network_id: str | None = None, + ip_version: int | None = None, + ) -> list[Subnet]: + """ + Get the list of Neutron subnets with optional filtering. + """ + conn = get_openstack_conn() + filters: dict = {} + if network_id: + filters["network_id"] = network_id + if ip_version is not None: + filters["ip_version"] = ip_version + subnets = conn.list_subnets(filters=filters) + return [self._convert_to_subnet_model(subnet) for subnet in subnets] + + def create_subnet( + self, + network_id: str, + cidr: str, + name: str | None = None, + ip_version: int = 4, + gateway_ip: str | None = None, + is_dhcp_enabled: bool = True, + description: str | None = None, + dns_nameservers: list[str] | None = None, + allocation_pools: list[dict] | None = None, + host_routes: list[dict] | None = None, + ) -> Subnet: + """ + Create a new Neutron subnet. + """ + conn = get_openstack_conn() + subnet_args: dict = { + "network_id": network_id, + "cidr": cidr, + "ip_version": ip_version, + "enable_dhcp": is_dhcp_enabled, + } + if name is not None: + subnet_args["name"] = name + if description is not None: + subnet_args["description"] = description + if gateway_ip is not None: + subnet_args["gateway_ip"] = gateway_ip + if dns_nameservers is not None: + subnet_args["dns_nameservers"] = dns_nameservers + if allocation_pools is not None: + subnet_args["allocation_pools"] = allocation_pools + if host_routes is not None: + subnet_args["host_routes"] = host_routes + subnet = conn.network.create_subnet(**subnet_args) + return self._convert_to_subnet_model(subnet) + + def get_subnet_detail(self, subnet_id: str) -> Subnet: + """ + Get detailed information about a specific Neutron subnet. + """ + conn = get_openstack_conn() + subnet = conn.network.get_subnet(subnet_id) + if not subnet: + raise Exception(f"Subnet with ID {subnet_id} not found") + return self._convert_to_subnet_model(subnet) + + def update_subnet( + self, + subnet_id: str, + name: str | None = None, + description: str | None = None, + gateway_ip: str | None = None, + is_dhcp_enabled: bool | None = None, + dns_nameservers: list[str] | None = None, + allocation_pools: list[dict] | None = None, + host_routes: list[dict] | None = None, + ) -> Subnet: + """ + Update an existing Neutron subnet. + """ + conn = get_openstack_conn() + update_args: dict = {} + if name is not None: + update_args["name"] = name + if description is not None: + update_args["description"] = description + if gateway_ip is not None: + update_args["gateway_ip"] = gateway_ip + if is_dhcp_enabled is not None: + update_args["enable_dhcp"] = is_dhcp_enabled + if dns_nameservers is not None: + update_args["dns_nameservers"] = dns_nameservers + if allocation_pools is not None: + update_args["allocation_pools"] = allocation_pools + if host_routes is not None: + update_args["host_routes"] = host_routes + if not update_args: + raise Exception("No update parameters provided") + subnet = conn.network.update_subnet(subnet_id, **update_args) + return self._convert_to_subnet_model(subnet) + + def delete_subnet(self, subnet_id: str) -> None: + """ + Delete a Neutron subnet. + """ + conn = get_openstack_conn() + subnet = conn.network.get_subnet(subnet_id) + if not subnet: + raise Exception(f"Subnet with ID {subnet_id} not found") + conn.network.delete_subnet(subnet_id, ignore_missing=False) + return None + + def _convert_to_subnet_model(self, openstack_subnet) -> Subnet: + """ + Convert an OpenStack subnet object to a Subnet pydantic model. + """ + return Subnet( + id=openstack_subnet.id, + name=openstack_subnet.name, + status=openstack_subnet.status, + description=openstack_subnet.description, + project_id=openstack_subnet.project_id, + network_id=openstack_subnet.network_id, + cidr=openstack_subnet.cidr, + ip_version=openstack_subnet.ip_version, + gateway_ip=openstack_subnet.gateway_ip, + is_dhcp_enabled=openstack_subnet.enable_dhcp, + allocation_pools=openstack_subnet.allocation_pools, + dns_nameservers=openstack_subnet.dns_nameservers, + host_routes=openstack_subnet.host_routes, + ) + + def get_ports( + self, + status_filter: str | None = None, + device_id: str | None = None, + network_id: str | None = None, + ) -> list[Port]: + """ + Get the list of Neutron ports with optional filtering. + """ + conn = get_openstack_conn() + filters: dict = {} + if status_filter: + filters["status"] = status_filter.upper() + if device_id: + filters["device_id"] = device_id + if network_id: + filters["network_id"] = network_id + ports = conn.list_ports(filters=filters) + return [self._convert_to_port_model(port) for port in ports] + + def create_port( + self, + network_id: str, + name: str | None = None, + description: str | None = None, + is_admin_state_up: bool = True, + device_id: str | None = None, + fixed_ips: list[dict] | None = None, + security_group_ids: list[str] | None = None, + ) -> Port: + """ + Create a new Neutron port. + """ + conn = get_openstack_conn() + port_args: dict = { + "network_id": network_id, + "admin_state_up": is_admin_state_up, + } + if name is not None: + port_args["name"] = name + if description is not None: + port_args["description"] = description + if device_id is not None: + port_args["device_id"] = device_id + if fixed_ips is not None: + port_args["fixed_ips"] = fixed_ips + if security_group_ids is not None: + port_args["security_groups"] = security_group_ids + port = conn.network.create_port(**port_args) + return self._convert_to_port_model(port) + + def get_port_detail(self, port_id: str) -> Port: + """ + Get detailed information about a specific Neutron port. + """ + conn = get_openstack_conn() + port = conn.network.get_port(port_id) + if not port: + raise Exception(f"Port with ID {port_id} not found") + return self._convert_to_port_model(port) + + def update_port( + self, + port_id: str, + name: str | None = None, + description: str | None = None, + is_admin_state_up: bool | None = None, + device_id: str | None = None, + security_group_ids: list[str] | None = None, + ) -> Port: + """ + Update an existing Neutron port. + """ + conn = get_openstack_conn() + update_args: dict = {} + if name is not None: + update_args["name"] = name + if description is not None: + update_args["description"] = description + if is_admin_state_up is not None: + update_args["admin_state_up"] = is_admin_state_up + if device_id is not None: + update_args["device_id"] = device_id + if security_group_ids is not None: + update_args["security_groups"] = security_group_ids + if not update_args: + raise Exception("No update parameters provided") + port = conn.network.update_port(port_id, **update_args) + return self._convert_to_port_model(port) + + def delete_port(self, port_id: str) -> None: + """ + Delete a Neutron port. + """ + conn = get_openstack_conn() + port = conn.network.get_port(port_id) + if not port: + raise Exception(f"Port with ID {port_id} not found") + conn.network.delete_port(port_id, ignore_missing=False) + return None + + def _convert_to_port_model(self, openstack_port) -> Port: + """ + Convert an OpenStack port object to a Port pydantic model. + """ + return Port( + id=openstack_port.id, + name=openstack_port.name, + status=openstack_port.status, + description=openstack_port.description, + project_id=openstack_port.project_id, + network_id=openstack_port.network_id, + is_admin_state_up=openstack_port.admin_state_up, + device_id=openstack_port.device_id, + device_owner=openstack_port.device_owner, + mac_address=openstack_port.mac_address, + fixed_ips=openstack_port.fixed_ips, + security_group_ids=openstack_port.security_group_ids + if hasattr(openstack_port, "security_group_ids") + else None, + ) + + def get_floating_ips( + self, + status_filter: str | None = None, + project_id: str | None = None, + ) -> list[FloatingIP]: + """ + Get the list of Neutron floating IPs with optional filtering. + """ + conn = get_openstack_conn() + filters: dict = {} + if status_filter: + filters["status"] = status_filter.upper() + if project_id: + filters["project_id"] = project_id + ips = list(conn.network.ips(**filters)) + return [self._convert_to_floating_ip_model(ip) for ip in ips] + + def create_floating_ip( + self, + floating_network_id: str, + description: str | None = None, + fixed_ip_address: str | None = None, + port_id: str | None = None, + project_id: str | None = None, + ) -> FloatingIP: + """ + Create a new Neutron floating IP. + """ + conn = get_openstack_conn() + ip_args: dict = {"floating_network_id": floating_network_id} + if description is not None: + ip_args["description"] = description + if fixed_ip_address is not None: + ip_args["fixed_ip_address"] = fixed_ip_address + if port_id is not None: + ip_args["port_id"] = port_id + if project_id is not None: + ip_args["project_id"] = project_id + ip = conn.network.create_ip(**ip_args) + return self._convert_to_floating_ip_model(ip) + + def allocate_floating_ip_pool_to_project( + self, + floating_network_id: str, + project_id: str, + ) -> None: + """ + Allocate floating IP pool (external network access) to a project via RBAC. + """ + conn = get_openstack_conn() + conn.network.create_rbac_policy( + object_type="network", + object_id=floating_network_id, + action="access_as_external", + target_project_id=project_id, + ) + return None + + def attach_floating_ip_to_port( + self, + floating_ip_id: str, + port_id: str, + fixed_ip_address: str | None = None, + ) -> FloatingIP: + """ + Attach a floating IP to a port. + """ + conn = get_openstack_conn() + update_args: dict = {"port_id": port_id} + if fixed_ip_address is not None: + update_args["fixed_ip_address"] = fixed_ip_address + ip = conn.network.update_ip(floating_ip_id, **update_args) + return self._convert_to_floating_ip_model(ip) + + def detach_floating_ip_from_port(self, floating_ip_id: str) -> FloatingIP: + """ + Detach a floating IP from its port. + """ + conn = get_openstack_conn() + ip = conn.network.update_ip(floating_ip_id, port_id=None) + return self._convert_to_floating_ip_model(ip) + + def delete_floating_ip(self, floating_ip_id: str) -> None: + """ + Delete a Neutron floating IP. + """ + conn = get_openstack_conn() + ip = conn.network.get_ip(floating_ip_id) + if not ip: + raise Exception(f"Floating IP with ID {floating_ip_id} not found") + conn.network.delete_ip(floating_ip_id, ignore_missing=False) + return None + + def _convert_to_floating_ip_model(self, openstack_ip) -> FloatingIP: + """ + Convert an OpenStack floating IP object to a FloatingIP pydantic model. + """ + return FloatingIP( + id=openstack_ip.id, + name=openstack_ip.name, + status=openstack_ip.status, + description=openstack_ip.description, + project_id=openstack_ip.project_id, + floating_ip_address=openstack_ip.floating_ip_address, + floating_network_id=openstack_ip.floating_network_id, + fixed_ip_address=openstack_ip.fixed_ip_address, + port_id=openstack_ip.port_id, + router_id=openstack_ip.router_id, + ) diff --git a/src/openstack_mcp_server/tools/response/network.py b/src/openstack_mcp_server/tools/response/network.py index e8747b9..6894bd5 100644 --- a/src/openstack_mcp_server/tools/response/network.py +++ b/src/openstack_mcp_server/tools/response/network.py @@ -17,35 +17,81 @@ class Network(BaseModel): class Subnet(BaseModel): id: str - name: str - status: str + name: str | None = None + status: str | None = None + description: str | None = None + project_id: str | None = None + network_id: str | None = None + cidr: str | None = None + ip_version: int | None = None + gateway_ip: str | None = None + is_dhcp_enabled: bool | None = None + allocation_pools: list[dict] | None = None + dns_nameservers: list[str] | None = None + host_routes: list[dict] | None = None class Port(BaseModel): id: str - name: str - status: str + name: str | None = None + status: str | None = None + description: str | None = None + project_id: str | None = None + network_id: str | None = None + is_admin_state_up: bool | None = None + device_id: str | None = None + device_owner: str | None = None + mac_address: str | None = None + fixed_ips: list[dict] | None = None + security_group_ids: list[str] | None = None class Router(BaseModel): id: str - name: str - status: str + name: str | None = None + status: str | None = None + description: str | None = None + project_id: str | None = None + is_admin_state_up: bool | None = None + external_gateway_info: dict | None = None + is_distributed: bool | None = None + is_ha: bool | None = None + routes: list[dict] | None = None class SecurityGroup(BaseModel): id: str - name: str - status: str + name: str | None = None + status: str | None = None + description: str | None = None + project_id: str | None = None + security_group_rule_ids: list[str] | None = None class SecurityGroupRule(BaseModel): id: str - name: str - status: str + name: str | None = None + status: str | None = None + description: str | None = None + project_id: str | None = None + direction: str | None = None + ethertype: str | None = None + protocol: str | None = None + port_range_min: int | None = None + port_range_max: int | None = None + remote_ip_prefix: str | None = None + remote_group_id: str | None = None + security_group_id: str | None = None class FloatingIP(BaseModel): id: str - name: str - status: str + name: str | None = None + status: str | None = None + description: str | None = None + project_id: str | None = None + floating_ip_address: str | None = None + floating_network_id: str | None = None + fixed_ip_address: str | None = None + port_id: str | None = None + router_id: str | None = None From 31fb83e5b4d3be17c3cd5ced6eb9f3c6502ae418 Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Tue, 12 Aug 2025 10:34:26 +0900 Subject: [PATCH 02/15] feat(network): Add additional function in network tools (#30) --- .../tools/network_tools.py | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index 3120995..9f8d0b2 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -34,12 +34,28 @@ def register_tools(self, mcp: FastMCP): mcp.tool()(self.get_port_detail) mcp.tool()(self.update_port) mcp.tool()(self.delete_port) + mcp.tool()(self.add_port_fixed_ip) + mcp.tool()(self.remove_port_fixed_ip) + mcp.tool()(self.get_port_allowed_address_pairs) + mcp.tool()(self.add_port_allowed_address_pair) + mcp.tool()(self.remove_port_allowed_address_pair) + mcp.tool()(self.set_port_binding) + mcp.tool()(self.set_port_admin_state) + mcp.tool()(self.toggle_port_admin_state) mcp.tool()(self.get_floating_ips) mcp.tool()(self.create_floating_ip) mcp.tool()(self.allocate_floating_ip_pool_to_project) mcp.tool()(self.attach_floating_ip_to_port) mcp.tool()(self.detach_floating_ip_from_port) mcp.tool()(self.delete_floating_ip) + mcp.tool()(self.update_floating_ip_description) + mcp.tool()(self.reassign_floating_ip_to_port) + mcp.tool()(self.create_floating_ips_bulk) + mcp.tool()(self.assign_first_available_floating_ip) + mcp.tool()(self.set_subnet_gateway) + mcp.tool()(self.clear_subnet_gateway) + mcp.tool()(self.set_subnet_dhcp_enabled) + mcp.tool()(self.toggle_subnet_dhcp) def get_networks( self, @@ -233,6 +249,9 @@ def get_subnets( self, network_id: str | None = None, ip_version: int | None = None, + project_id: str | None = None, + has_gateway: bool | None = None, + is_dhcp_enabled: bool | None = None, ) -> list[Subnet]: """ Get the list of Neutron subnets with optional filtering. @@ -243,7 +262,20 @@ def get_subnets( filters["network_id"] = network_id if ip_version is not None: filters["ip_version"] = ip_version + if project_id: + filters["project_id"] = project_id + if is_dhcp_enabled is not None: + filters["enable_dhcp"] = is_dhcp_enabled subnets = conn.list_subnets(filters=filters) + if has_gateway is not None: + if has_gateway: + subnets = [ + s for s in subnets if getattr(s, "gateway_ip", None) + ] + else: + subnets = [ + s for s in subnets if not getattr(s, "gateway_ip", None) + ] return [self._convert_to_subnet_model(subnet) for subnet in subnets] def create_subnet( @@ -340,6 +372,32 @@ def delete_subnet(self, subnet_id: str) -> None: conn.network.delete_subnet(subnet_id, ignore_missing=False) return None + def set_subnet_gateway(self, subnet_id: str, gateway_ip: str) -> Subnet: + conn = get_openstack_conn() + subnet = conn.network.update_subnet(subnet_id, gateway_ip=gateway_ip) + return self._convert_to_subnet_model(subnet) + + def clear_subnet_gateway(self, subnet_id: str) -> Subnet: + conn = get_openstack_conn() + subnet = conn.network.update_subnet(subnet_id, gateway_ip=None) + return self._convert_to_subnet_model(subnet) + + def set_subnet_dhcp_enabled(self, subnet_id: str, enabled: bool) -> Subnet: + conn = get_openstack_conn() + subnet = conn.network.update_subnet(subnet_id, enable_dhcp=enabled) + return self._convert_to_subnet_model(subnet) + + def toggle_subnet_dhcp(self, subnet_id: str) -> Subnet: + conn = get_openstack_conn() + current = conn.network.get_subnet(subnet_id) + if not current: + raise Exception(f"Subnet with ID {subnet_id} not found") + subnet = conn.network.update_subnet( + subnet_id, + enable_dhcp=not bool(current.enable_dhcp), + ) + return self._convert_to_subnet_model(subnet) + def _convert_to_subnet_model(self, openstack_subnet) -> Subnet: """ Convert an OpenStack subnet object to a Subnet pydantic model. @@ -380,6 +438,149 @@ def get_ports( ports = conn.list_ports(filters=filters) return [self._convert_to_port_model(port) for port in ports] + def add_port_fixed_ip( + self, + port_id: str, + subnet_id: str | None = None, + ip_address: str | None = None, + ) -> Port: + conn = get_openstack_conn() + port = conn.network.get_port(port_id) + if not port: + raise Exception(f"Port with ID {port_id} not found") + fixed_ips = list(port.fixed_ips or []) + entry: dict = {} + if subnet_id is not None: + entry["subnet_id"] = subnet_id + if ip_address is not None: + entry["ip_address"] = ip_address + fixed_ips.append(entry) + updated = conn.network.update_port(port_id, fixed_ips=fixed_ips) + return self._convert_to_port_model(updated) + + def remove_port_fixed_ip( + self, + port_id: str, + ip_address: str | None = None, + subnet_id: str | None = None, + ) -> Port: + conn = get_openstack_conn() + port = conn.network.get_port(port_id) + if not port: + raise Exception(f"Port with ID {port_id} not found") + current = list(port.fixed_ips or []) + if not current: + return self._convert_to_port_model(port) + + def predicate(item: dict) -> bool: + if ip_address is not None and item.get("ip_address") == ip_address: + return False + if subnet_id is not None and item.get("subnet_id") == subnet_id: + return False + return True + + new_fixed = [fi for fi in current if predicate(fi)] + updated = conn.network.update_port(port_id, fixed_ips=new_fixed) + return self._convert_to_port_model(updated) + + def get_port_allowed_address_pairs(self, port_id: str) -> list[dict]: + conn = get_openstack_conn() + port = conn.network.get_port(port_id) + if not port: + raise Exception(f"Port with ID {port_id} not found") + return list(getattr(port, "allowed_address_pairs", []) or []) + + def add_port_allowed_address_pair( + self, + port_id: str, + ip_address: str, + mac_address: str | None = None, + ) -> Port: + conn = get_openstack_conn() + port = conn.network.get_port(port_id) + if not port: + raise Exception(f"Port with ID {port_id} not found") + pairs = list(getattr(port, "allowed_address_pairs", []) or []) + entry = {"ip_address": ip_address} + if mac_address is not None: + entry["mac_address"] = mac_address + pairs.append(entry) + updated = conn.network.update_port( + port_id, + allowed_address_pairs=pairs, + ) + return self._convert_to_port_model(updated) + + def remove_port_allowed_address_pair( + self, + port_id: str, + ip_address: str, + mac_address: str | None = None, + ) -> Port: + conn = get_openstack_conn() + port = conn.network.get_port(port_id) + if not port: + raise Exception(f"Port with ID {port_id} not found") + pairs = list(getattr(port, "allowed_address_pairs", []) or []) + + def keep(p: dict) -> bool: + if mac_address is None: + return p.get("ip_address") != ip_address + return not ( + p.get("ip_address") == ip_address + and p.get("mac_address") == mac_address + ) + + new_pairs = [p for p in pairs if keep(p)] + updated = conn.network.update_port( + port_id, + allowed_address_pairs=new_pairs, + ) + return self._convert_to_port_model(updated) + + def set_port_binding( + self, + port_id: str, + host_id: str | None = None, + vnic_type: str | None = None, + profile: dict | None = None, + ) -> Port: + conn = get_openstack_conn() + update_args: dict = {} + if host_id is not None: + update_args["binding_host_id"] = host_id + if vnic_type is not None: + update_args["binding_vnic_type"] = vnic_type + if profile is not None: + update_args["binding_profile"] = profile + if not update_args: + raise Exception("No update parameters provided") + updated = conn.network.update_port(port_id, **update_args) + return self._convert_to_port_model(updated) + + def set_port_admin_state( + self, + port_id: str, + is_admin_state_up: bool, + ) -> Port: + conn = get_openstack_conn() + updated = conn.network.update_port( + port_id, + admin_state_up=is_admin_state_up, + ) + return self._convert_to_port_model(updated) + + def toggle_port_admin_state(self, port_id: str) -> Port: + conn = get_openstack_conn() + current = conn.network.get_port(port_id) + if not current: + raise Exception(f"Port with ID {port_id} not found") + updated = conn.network.update_port( + port_id, + admin_state_up=not bool(current.admin_state_up), + ) + return self._convert_to_port_model(updated) + def create_port( self, network_id: str, @@ -486,6 +687,9 @@ def get_floating_ips( self, status_filter: str | None = None, project_id: str | None = None, + port_id: str | None = None, + floating_network_id: str | None = None, + unassigned_only: bool | None = None, ) -> list[FloatingIP]: """ Get the list of Neutron floating IPs with optional filtering. @@ -496,7 +700,13 @@ def get_floating_ips( filters["status"] = status_filter.upper() if project_id: filters["project_id"] = project_id + if port_id: + filters["port_id"] = port_id + if floating_network_id: + filters["floating_network_id"] = floating_network_id ips = list(conn.network.ips(**filters)) + if unassigned_only: + ips = [i for i in ips if not getattr(i, "port_id", None)] return [self._convert_to_floating_ip_model(ip) for ip in ips] def create_floating_ip( @@ -575,6 +785,65 @@ def delete_floating_ip(self, floating_ip_id: str) -> None: conn.network.delete_ip(floating_ip_id, ignore_missing=False) return None + def update_floating_ip_description( + self, + floating_ip_id: str, + description: str | None, + ) -> FloatingIP: + conn = get_openstack_conn() + ip = conn.network.update_ip(floating_ip_id, description=description) + return self._convert_to_floating_ip_model(ip) + + def reassign_floating_ip_to_port( + self, + floating_ip_id: str, + port_id: str, + fixed_ip_address: str | None = None, + ) -> FloatingIP: + conn = get_openstack_conn() + update_args: dict = {"port_id": port_id} + if fixed_ip_address is not None: + update_args["fixed_ip_address"] = fixed_ip_address + ip = conn.network.update_ip(floating_ip_id, **update_args) + return self._convert_to_floating_ip_model(ip) + + def create_floating_ips_bulk( + self, + floating_network_id: str, + count: int, + ) -> list[FloatingIP]: + conn = get_openstack_conn() + created = [] + for _ in range(max(0, count)): + ip = conn.network.create_ip( + floating_network_id=floating_network_id, + ) + created.append(self._convert_to_floating_ip_model(ip)) + return created + + def assign_first_available_floating_ip( + self, + floating_network_id: str, + port_id: str, + ) -> FloatingIP: + conn = get_openstack_conn() + existing = list( + conn.network.ips(floating_network_id=floating_network_id), + ) + available = next( + (i for i in existing if not getattr(i, "port_id", None)), + None, + ) + if available is None: + created = conn.network.create_ip( + floating_network_id=floating_network_id, + ) + target_id = created.id + else: + target_id = available.id + ip = conn.network.update_ip(target_id, port_id=port_id) + return self._convert_to_floating_ip_model(ip) + def _convert_to_floating_ip_model(self, openstack_ip) -> FloatingIP: """ Convert an OpenStack floating IP object to a FloatingIP pydantic model. From d078250277225c781bc54472ff9a3a2a32193a3d Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Wed, 13 Aug 2025 01:46:38 +0900 Subject: [PATCH 03/15] feat(network): Add neutron tools function unit test (#30) --- tests/tools/test_network_tools.py | 892 +++++++++++++++++++++++++++++- 1 file changed, 891 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_network_tools.py b/tests/tools/test_network_tools.py index c659a17..bc06bcb 100644 --- a/tests/tools/test_network_tools.py +++ b/tests/tools/test_network_tools.py @@ -3,7 +3,12 @@ import pytest from openstack_mcp_server.tools.network_tools import NetworkTools -from openstack_mcp_server.tools.response.network import Network +from openstack_mcp_server.tools.response.network import ( + FloatingIP, + Network, + Port, + Subnet, +) class TestNetworkTools: @@ -509,3 +514,888 @@ def test_delete_network_not_found(self, mock_openstack_connect_network): network_tools.delete_network("nonexistent-net") mock_conn.network.delete_network.assert_not_called() + + def test_get_ports_with_filters(self, mock_openstack_connect_network): + mock_conn = mock_openstack_connect_network + + port = Mock() + port.id = "port-1" + port.name = "p1" + port.status = "ACTIVE" + port.description = None + port.project_id = "proj-1" + port.network_id = "net-1" + port.admin_state_up = True + port.device_id = "device-1" + port.device_owner = "compute:nova" + port.mac_address = "fa:16:3e:00:00:01" + port.fixed_ips = [{"subnet_id": "subnet-1", "ip_address": "10.0.0.10"}] + port.security_group_ids = ["sg-1", "sg-2"] + + mock_conn.list_ports.return_value = [port] + + tools = self.get_network_tools() + result = tools.get_ports( + status_filter="ACTIVE", + device_id="device-1", + network_id="net-1", + ) + + assert result == [ + Port( + id="port-1", + name="p1", + status="ACTIVE", + description=None, + project_id="proj-1", + network_id="net-1", + is_admin_state_up=True, + device_id="device-1", + device_owner="compute:nova", + mac_address="fa:16:3e:00:00:01", + fixed_ips=[ + {"subnet_id": "subnet-1", "ip_address": "10.0.0.10"}, + ], + security_group_ids=["sg-1", "sg-2"], + ), + ] + + mock_conn.list_ports.assert_called_once_with( + filters={ + "status": "ACTIVE", + "device_id": "device-1", + "network_id": "net-1", + }, + ) + + def test_create_port_success(self, mock_openstack_connect_network): + mock_conn = mock_openstack_connect_network + + port = Mock() + port.id = "port-1" + port.name = "p1" + port.status = "DOWN" + port.description = "desc" + port.project_id = "proj-1" + port.network_id = "net-1" + port.admin_state_up = True + port.device_id = None + port.device_owner = None + port.mac_address = "fa:16:3e:00:00:02" + port.fixed_ips = [] + port.security_group_ids = ["sg-1"] + + mock_conn.network.create_port.return_value = port + + tools = self.get_network_tools() + result = tools.create_port( + network_id="net-1", + name="p1", + description="desc", + is_admin_state_up=True, + fixed_ips=[], + security_group_ids=["sg-1"], + ) + + assert result == Port( + id="port-1", + name="p1", + status="DOWN", + description="desc", + project_id="proj-1", + network_id="net-1", + is_admin_state_up=True, + device_id=None, + device_owner=None, + mac_address="fa:16:3e:00:00:02", + fixed_ips=[], + security_group_ids=["sg-1"], + ) + + mock_conn.network.create_port.assert_called_once() + + def test_get_port_detail_success(self, mock_openstack_connect_network): + mock_conn = mock_openstack_connect_network + + port = Mock() + port.id = "port-1" + port.name = "p1" + port.status = "ACTIVE" + port.description = None + port.project_id = None + port.network_id = "net-1" + port.admin_state_up = True + port.device_id = None + port.device_owner = None + port.mac_address = "fa:16:3e:00:00:03" + port.fixed_ips = [] + port.security_group_ids = None + + mock_conn.network.get_port.return_value = port + + tools = self.get_network_tools() + result = tools.get_port_detail("port-1") + assert result.id == "port-1" + mock_conn.network.get_port.assert_called_once_with("port-1") + + def test_get_port_detail_not_found(self, mock_openstack_connect_network): + mock_conn = mock_openstack_connect_network + mock_conn.network.get_port.return_value = None + tools = self.get_network_tools() + with pytest.raises( + Exception, + match="Port with ID p-notfound not found", + ): + tools.get_port_detail("p-notfound") + + def test_update_port_success(self, mock_openstack_connect_network): + mock_conn = mock_openstack_connect_network + + port = Mock() + port.id = "port-1" + port.name = "p-new" + port.status = "ACTIVE" + port.description = "d-new" + port.project_id = None + port.network_id = "net-1" + port.admin_state_up = False + port.device_id = "dev-2" + port.device_owner = None + port.mac_address = "fa:16:3e:00:00:04" + port.fixed_ips = [] + port.security_group_ids = ["sg-2"] + + mock_conn.network.update_port.return_value = port + + tools = self.get_network_tools() + res = tools.update_port( + port_id="port-1", + name="p-new", + description="d-new", + is_admin_state_up=False, + device_id="dev-2", + security_group_ids=["sg-2"], + ) + assert res.name == "p-new" + mock_conn.network.update_port.assert_called_once_with( + "port-1", + name="p-new", + description="d-new", + admin_state_up=False, + device_id="dev-2", + security_groups=["sg-2"], + ) + + def test_update_port_no_params(self, mock_openstack_connect_network): + mock_conn = mock_openstack_connect_network + tools = self.get_network_tools() + with pytest.raises(Exception, match="No update parameters provided"): + tools.update_port("port-1") + mock_conn.network.update_port.assert_not_called() + + def test_delete_port_success(self, mock_openstack_connect_network): + mock_conn = mock_openstack_connect_network + + port = Mock() + port.id = "port-1" + mock_conn.network.get_port.return_value = port + + tools = self.get_network_tools() + result = tools.delete_port("port-1") + assert result is None + mock_conn.network.get_port.assert_called_once_with("port-1") + mock_conn.network.delete_port.assert_called_once_with( + "port-1", + ignore_missing=False, + ) + + def test_delete_port_not_found(self, mock_openstack_connect_network): + mock_conn = mock_openstack_connect_network + mock_conn.network.get_port.return_value = None + tools = self.get_network_tools() + with pytest.raises(Exception, match="Port with ID none not found"): + tools.delete_port("none") + mock_conn.network.delete_port.assert_not_called() + + def test_add_port_fixed_ip(self, mock_openstack_connect_network): + mock_conn = mock_openstack_connect_network + + current = Mock() + current.fixed_ips = [ + {"subnet_id": "subnet-1", "ip_address": "10.0.0.10"}, + ] + mock_conn.network.get_port.return_value = current + + updated = Mock() + updated.id = "port-1" + updated.name = "p1" + updated.status = "ACTIVE" + updated.description = None + updated.project_id = None + updated.network_id = "net-1" + updated.admin_state_up = True + updated.device_id = None + updated.device_owner = None + updated.mac_address = "fa:16:3e:00:00:05" + updated.fixed_ips = [ + {"subnet_id": "subnet-1", "ip_address": "10.0.0.10"}, + {"subnet_id": "subnet-2", "ip_address": "10.0.1.10"}, + ] + updated.security_group_ids = None + mock_conn.network.update_port.return_value = updated + + tools = self.get_network_tools() + res = tools.add_port_fixed_ip( + "port-1", + subnet_id="subnet-2", + ip_address="10.0.1.10", + ) + assert len(res.fixed_ips or []) == 2 + + def test_remove_port_fixed_ip(self, mock_openstack_connect_network): + mock_conn = mock_openstack_connect_network + + current = Mock() + current.fixed_ips = [ + {"subnet_id": "subnet-1", "ip_address": "10.0.0.10"}, + {"subnet_id": "subnet-2", "ip_address": "10.0.1.10"}, + ] + mock_conn.network.get_port.return_value = current + + updated = Mock() + updated.id = "port-1" + updated.name = "p1" + updated.status = "ACTIVE" + updated.description = None + updated.project_id = None + updated.network_id = "net-1" + updated.admin_state_up = True + updated.device_id = None + updated.device_owner = None + updated.mac_address = "fa:16:3e:00:00:06" + updated.fixed_ips = [ + {"subnet_id": "subnet-1", "ip_address": "10.0.0.10"}, + ] + updated.security_group_ids = None + mock_conn.network.update_port.return_value = updated + + tools = self.get_network_tools() + res = tools.remove_port_fixed_ip("port-1", ip_address="10.0.1.10") + assert len(res.fixed_ips or []) == 1 + + def test_get_and_update_allowed_address_pairs( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + port = Mock() + port.allowed_address_pairs = [] + mock_conn.network.get_port.return_value = port + + tools = self.get_network_tools() + lst = tools.get_port_allowed_address_pairs("port-1") + assert lst == [] + + updated = Mock() + updated.id = "port-1" + updated.name = "p1" + updated.status = "ACTIVE" + updated.description = None + updated.project_id = None + updated.network_id = "net-1" + updated.admin_state_up = True + updated.device_id = None + updated.device_owner = None + updated.mac_address = "fa:16:3e:00:00:07" + updated.fixed_ips = [] + updated.security_group_ids = None + mock_conn.network.update_port.return_value = updated + + res_add = tools.add_port_allowed_address_pair( + "port-1", + "192.0.2.5", + mac_address="aa:bb:cc:dd:ee:ff", + ) + assert isinstance(res_add, Port) + + res_remove = tools.remove_port_allowed_address_pair( + "port-1", + "192.0.2.5", + mac_address="aa:bb:cc:dd:ee:ff", + ) + assert isinstance(res_remove, Port) + + def test_set_port_binding_and_admin_state( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + updated = Mock() + updated.id = "port-1" + updated.name = "p1" + updated.status = "ACTIVE" + updated.description = None + updated.project_id = None + updated.network_id = "net-1" + updated.admin_state_up = False + updated.device_id = None + updated.device_owner = None + updated.mac_address = "fa:16:3e:00:00:08" + updated.fixed_ips = [] + updated.security_group_ids = None + mock_conn.network.update_port.return_value = updated + + tools = self.get_network_tools() + res_bind = tools.set_port_binding( + "port-1", + host_id="host-1", + vnic_type="normal", + profile={"key": "val"}, + ) + assert isinstance(res_bind, Port) + + res_set = tools.set_port_admin_state("port-1", False) + assert res_set.is_admin_state_up is False + + current = Mock() + current.admin_state_up = False + mock_conn.network.get_port.return_value = current + updated.admin_state_up = True + res_toggle = tools.toggle_port_admin_state("port-1") + assert res_toggle.is_admin_state_up is True + + def test_get_subnets_filters_and_has_gateway_true( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + subnet1 = Mock() + subnet1.id = "subnet-1" + subnet1.name = "s1" + subnet1.status = "ACTIVE" + subnet1.description = None + subnet1.project_id = "proj-1" + subnet1.network_id = "net-1" + subnet1.cidr = "10.0.0.0/24" + subnet1.ip_version = 4 + subnet1.gateway_ip = "10.0.0.1" + subnet1.enable_dhcp = True + subnet1.allocation_pools = [] + subnet1.dns_nameservers = [] + subnet1.host_routes = [] + + subnet2 = Mock() + subnet2.id = "subnet-2" + subnet2.name = "s2" + subnet2.status = "ACTIVE" + subnet2.description = None + subnet2.project_id = "proj-2" + subnet2.network_id = "net-1" + subnet2.cidr = "10.0.1.0/24" + subnet2.ip_version = 4 + subnet2.gateway_ip = None + subnet2.enable_dhcp = False + subnet2.allocation_pools = [] + subnet2.dns_nameservers = [] + subnet2.host_routes = [] + + mock_conn.list_subnets.return_value = [subnet1, subnet2] + + tools = self.get_network_tools() + result = tools.get_subnets( + network_id="net-1", + ip_version=4, + project_id="proj-1", + has_gateway=True, + is_dhcp_enabled=True, + ) + + assert len(result) == 1 + assert result[0] == Subnet( + id="subnet-1", + name="s1", + status="ACTIVE", + description=None, + project_id="proj-1", + network_id="net-1", + cidr="10.0.0.0/24", + ip_version=4, + gateway_ip="10.0.0.1", + is_dhcp_enabled=True, + allocation_pools=[], + dns_nameservers=[], + host_routes=[], + ) + + mock_conn.list_subnets.assert_called_once_with( + filters={ + "network_id": "net-1", + "ip_version": 4, + "project_id": "proj-1", + "enable_dhcp": True, + }, + ) + + def test_get_subnets_has_gateway_false( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + subnet1 = Mock() + subnet1.id = "subnet-1" + subnet1.name = "s1" + subnet1.status = "ACTIVE" + subnet1.description = None + subnet1.project_id = None + subnet1.network_id = "net-1" + subnet1.cidr = "10.0.0.0/24" + subnet1.ip_version = 4 + subnet1.gateway_ip = "10.0.0.1" + subnet1.enable_dhcp = True + subnet1.allocation_pools = [] + subnet1.dns_nameservers = [] + subnet1.host_routes = [] + + subnet2 = Mock() + subnet2.id = "subnet-2" + subnet2.name = "s2" + subnet2.status = "ACTIVE" + subnet2.description = None + subnet2.project_id = None + subnet2.network_id = "net-1" + subnet2.cidr = "10.0.1.0/24" + subnet2.ip_version = 4 + subnet2.gateway_ip = None + subnet2.enable_dhcp = False + subnet2.allocation_pools = [] + subnet2.dns_nameservers = [] + subnet2.host_routes = [] + + mock_conn.list_subnets.return_value = [subnet1, subnet2] + + tools = self.get_network_tools() + result = tools.get_subnets( + network_id="net-1", + has_gateway=False, + ) + + assert len(result) == 1 + assert result[0].id == "subnet-2" + + def test_create_subnet_success( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + subnet = Mock() + subnet.id = "subnet-new" + subnet.name = "s-new" + subnet.status = "ACTIVE" + subnet.description = "desc" + subnet.project_id = "proj-1" + subnet.network_id = "net-1" + subnet.cidr = "10.0.0.0/24" + subnet.ip_version = 4 + subnet.gateway_ip = "10.0.0.1" + subnet.enable_dhcp = True + subnet.allocation_pools = [{"start": "10.0.0.10", "end": "10.0.0.20"}] + subnet.dns_nameservers = ["8.8.8.8"] + subnet.host_routes = [] + + mock_conn.network.create_subnet.return_value = subnet + + tools = self.get_network_tools() + result = tools.create_subnet( + network_id="net-1", + cidr="10.0.0.0/24", + name="s-new", + gateway_ip="10.0.0.1", + is_dhcp_enabled=True, + description="desc", + dns_nameservers=["8.8.8.8"], + allocation_pools=[{"start": "10.0.0.10", "end": "10.0.0.20"}], + host_routes=[], + ) + + assert result == Subnet( + id="subnet-new", + name="s-new", + status="ACTIVE", + description="desc", + project_id="proj-1", + network_id="net-1", + cidr="10.0.0.0/24", + ip_version=4, + gateway_ip="10.0.0.1", + is_dhcp_enabled=True, + allocation_pools=[{"start": "10.0.0.10", "end": "10.0.0.20"}], + dns_nameservers=["8.8.8.8"], + host_routes=[], + ) + + mock_conn.network.create_subnet.assert_called_once() + + def test_get_subnet_detail_success( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + subnet = Mock() + subnet.id = "subnet-1" + subnet.name = "s1" + subnet.status = "ACTIVE" + subnet.description = None + subnet.project_id = "proj-1" + subnet.network_id = "net-1" + subnet.cidr = "10.0.0.0/24" + subnet.ip_version = 4 + subnet.gateway_ip = "10.0.0.1" + subnet.enable_dhcp = True + subnet.allocation_pools = [] + subnet.dns_nameservers = [] + subnet.host_routes = [] + + mock_conn.network.get_subnet.return_value = subnet + + tools = self.get_network_tools() + result = tools.get_subnet_detail("subnet-1") + + assert result.id == "subnet-1" + mock_conn.network.get_subnet.assert_called_once_with("subnet-1") + + def test_get_subnet_detail_not_found( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + mock_conn.network.get_subnet.return_value = None + + tools = self.get_network_tools() + with pytest.raises( + Exception, + match="Subnet with ID nonexistent not found", + ): + tools.get_subnet_detail("nonexistent") + + def test_update_subnet_success( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + subnet = Mock() + subnet.id = "subnet-1" + subnet.name = "s1-new" + subnet.status = "ACTIVE" + subnet.description = "d-new" + subnet.project_id = "proj-1" + subnet.network_id = "net-1" + subnet.cidr = "10.0.0.0/24" + subnet.ip_version = 4 + subnet.gateway_ip = "10.0.0.254" + subnet.enable_dhcp = False + subnet.allocation_pools = [] + subnet.dns_nameservers = [] + subnet.host_routes = [] + + mock_conn.network.update_subnet.return_value = subnet + + tools = self.get_network_tools() + result = tools.update_subnet( + subnet_id="subnet-1", + name="s1-new", + description="d-new", + gateway_ip="10.0.0.254", + is_dhcp_enabled=False, + ) + + assert result.name == "s1-new" + mock_conn.network.update_subnet.assert_called_once_with( + "subnet-1", + name="s1-new", + description="d-new", + gateway_ip="10.0.0.254", + enable_dhcp=False, + ) + + def test_update_subnet_no_params( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + tools = self.get_network_tools() + with pytest.raises(Exception, match="No update parameters provided"): + tools.update_subnet("subnet-1") + mock_conn.network.update_subnet.assert_not_called() + + def test_delete_subnet_success( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + subnet = Mock() + subnet.id = "subnet-1" + mock_conn.network.get_subnet.return_value = subnet + + tools = self.get_network_tools() + result = tools.delete_subnet("subnet-1") + + assert result is None + mock_conn.network.get_subnet.assert_called_once_with("subnet-1") + mock_conn.network.delete_subnet.assert_called_once_with( + "subnet-1", + ignore_missing=False, + ) + + def test_delete_subnet_not_found( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + mock_conn.network.get_subnet.return_value = None + + tools = self.get_network_tools() + with pytest.raises( + Exception, + match="Subnet with ID no-subnet not found", + ): + tools.delete_subnet("no-subnet") + mock_conn.network.delete_subnet.assert_not_called() + + def test_set_and_clear_subnet_gateway( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + updated = Mock() + updated.id = "subnet-1" + updated.name = "s1" + updated.status = "ACTIVE" + updated.description = None + updated.project_id = None + updated.network_id = "net-1" + updated.cidr = "10.0.0.0/24" + updated.ip_version = 4 + updated.gateway_ip = "10.0.0.254" + updated.enable_dhcp = True + updated.allocation_pools = [] + updated.dns_nameservers = [] + updated.host_routes = [] + + mock_conn.network.update_subnet.return_value = updated + + tools = self.get_network_tools() + res1 = tools.set_subnet_gateway("subnet-1", "10.0.0.254") + assert res1.gateway_ip == "10.0.0.254" + + updated.gateway_ip = None + res2 = tools.clear_subnet_gateway("subnet-1") + assert res2.gateway_ip is None + + def test_set_and_toggle_subnet_dhcp( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + updated = Mock() + updated.id = "subnet-1" + updated.name = "s1" + updated.status = "ACTIVE" + updated.description = None + updated.project_id = None + updated.network_id = "net-1" + updated.cidr = "10.0.0.0/24" + updated.ip_version = 4 + updated.gateway_ip = "10.0.0.1" + updated.enable_dhcp = True + updated.allocation_pools = [] + updated.dns_nameservers = [] + updated.host_routes = [] + + mock_conn.network.update_subnet.return_value = updated + + tools = self.get_network_tools() + res1 = tools.set_subnet_dhcp_enabled("subnet-1", True) + assert res1.is_dhcp_enabled is True + + current = Mock() + current.enable_dhcp = True + mock_conn.network.get_subnet.return_value = current + updated.enable_dhcp = False + res2 = tools.toggle_subnet_dhcp("subnet-1") + assert res2.is_dhcp_enabled is False + + def test_get_floating_ips_with_filters_and_unassigned( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + f1 = Mock() + f1.id = "fip-1" + f1.name = None + f1.status = "DOWN" + f1.description = None + f1.project_id = "proj-1" + f1.floating_ip_address = "203.0.113.10" + f1.floating_network_id = "ext-net" + f1.fixed_ip_address = None + f1.port_id = None + f1.router_id = None + + f2 = Mock() + f2.id = "fip-2" + f2.name = None + f2.status = "ACTIVE" + f2.description = None + f2.project_id = "proj-1" + f2.floating_ip_address = "203.0.113.11" + f2.floating_network_id = "ext-net" + f2.fixed_ip_address = "10.0.0.10" + f2.port_id = "port-1" + f2.router_id = None + + mock_conn.network.ips.return_value = [f1, f2] + + tools = self.get_network_tools() + result = tools.get_floating_ips( + status_filter="ACTIVE", + project_id="proj-1", + floating_network_id="ext-net", + unassigned_only=True, + ) + assert result == [ + FloatingIP( + id="fip-1", + name=None, + status="DOWN", + description=None, + project_id="proj-1", + floating_ip_address="203.0.113.10", + floating_network_id="ext-net", + fixed_ip_address=None, + port_id=None, + router_id=None, + ), + ] + + def test_create_attach_detach_delete_floating_ip( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + fip = Mock() + fip.id = "fip-1" + fip.name = None + fip.status = "DOWN" + fip.description = "d" + fip.project_id = "proj-1" + fip.floating_ip_address = "203.0.113.10" + fip.floating_network_id = "ext-net" + fip.fixed_ip_address = None + fip.port_id = None + fip.router_id = None + mock_conn.network.create_ip.return_value = fip + + tools = self.get_network_tools() + created = tools.create_floating_ip("ext-net", description="d") + assert created.floating_network_id == "ext-net" + + updated = Mock() + updated.id = "fip-1" + updated.name = None + updated.status = "ACTIVE" + updated.description = "d" + updated.project_id = "proj-1" + updated.floating_ip_address = "203.0.113.10" + updated.floating_network_id = "ext-net" + updated.fixed_ip_address = "10.0.0.10" + updated.port_id = "port-1" + updated.router_id = None + mock_conn.network.update_ip.return_value = updated + + attached = tools.attach_floating_ip_to_port( + "fip-1", + "port-1", + fixed_ip_address="10.0.0.10", + ) + assert attached.port_id == "port-1" + + updated.port_id = None + detached = tools.detach_floating_ip_from_port("fip-1") + assert detached.port_id is None + + mock_conn.network.get_ip.return_value = updated + tools.delete_floating_ip("fip-1") + mock_conn.network.delete_ip.assert_called_once_with( + "fip-1", + ignore_missing=False, + ) + + def test_update_reassign_bulk_and_auto_assign_floating_ip( + self, + mock_openstack_connect_network, + ): + mock_conn = mock_openstack_connect_network + + updated = Mock() + updated.id = "fip-1" + updated.name = None + updated.status = "DOWN" + updated.description = "new desc" + updated.project_id = None + updated.floating_ip_address = "203.0.113.10" + updated.floating_network_id = "ext-net" + updated.fixed_ip_address = None + updated.port_id = None + updated.router_id = None + mock_conn.network.update_ip.return_value = updated + + tools = self.get_network_tools() + res_desc = tools.update_floating_ip_description("fip-1", "new desc") + assert res_desc.description == "new desc" + + updated.port_id = "port-2" + res_reassign = tools.reassign_floating_ip_to_port("fip-1", "port-2") + assert res_reassign.port_id == "port-2" + + f1 = Mock() + f1.id = "fip-a" + f1.name = None + f1.status = "DOWN" + f1.description = None + f1.project_id = None + f1.floating_ip_address = "203.0.113.20" + f1.floating_network_id = "ext-net" + f1.fixed_ip_address = None + f1.port_id = None + f1.router_id = None + mock_conn.network.create_ip.side_effect = [f1] + bulk = tools.create_floating_ips_bulk("ext-net", 1) + assert len(bulk) == 1 + + exists = Mock() + exists.id = "fip-b" + exists.name = None + exists.status = "DOWN" + exists.description = None + exists.project_id = None + exists.floating_ip_address = "203.0.113.21" + exists.floating_network_id = "ext-net" + exists.fixed_ip_address = None + exists.port_id = None + exists.router_id = None + mock_conn.network.ips.return_value = [exists] + mock_conn.network.update_ip.return_value = exists + auto = tools.assign_first_available_floating_ip("ext-net", "port-9") + assert isinstance(auto, FloatingIP) From 8ec285cfa4ebb1cc5b61d8d1f9c6104da745c819 Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Sun, 17 Aug 2025 21:45:42 +0900 Subject: [PATCH 04/15] fix(network): sphinx style comments (#30) --- .../tools/network_tools.py | 455 ++++++++++++++++-- 1 file changed, 414 insertions(+), 41 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index 9f8d0b2..d4f9e32 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -65,12 +65,12 @@ def get_networks( """ Get the list of Neutron networks with optional filtering. - Args: - status_filter: Filter networks by status (e.g., 'ACTIVE', 'DOWN') - shared_only: If True, only show shared networks - - Returns: - List of Network objects + :param status_filter: Filter networks by status (e.g., `ACTIVE`, `DOWN`) + :type status_filter: str | None + :param shared_only: If True, only show shared networks + :type shared_only: bool + :return: List of Network objects + :rtype: list[Network] """ conn = get_openstack_conn() @@ -101,17 +101,22 @@ def create_network( """ Create a new Neutron network. - Args: - name: Network name - description: Network description - is_admin_state_up: Administrative state - is_shared: Whether the network is shared - provider_network_type: Provider network type (e.g., 'vlan', 'flat', 'vxlan') - provider_physical_network: Physical network name - provider_segmentation_id: Segmentation ID for VLAN/VXLAN - - Returns: - Created Network object + :param name: Network name + :type name: str + :param description: Network description + :type description: str | None + :param is_admin_state_up: Administrative state + :type is_admin_state_up: bool + :param is_shared: Whether the network is shared + :type is_shared: bool + :param provider_network_type: Provider network type (e.g., 'vlan', 'flat', 'vxlan') + :type provider_network_type: str | None + :param provider_physical_network: Physical network name + :type provider_physical_network: str | None + :param provider_segmentation_id: Segmentation ID for VLAN/VXLAN + :type provider_segmentation_id: int | None + :return: Created Network object + :rtype: Network """ conn = get_openstack_conn() @@ -143,11 +148,11 @@ def get_network_detail(self, network_id: str) -> Network: """ Get detailed information about a specific Neutron network. - Args: - network_id: ID of the network to retrieve - - Returns: - Network object with detailed information + :param network_id: ID of the network to retrieve + :type network_id: str + :return: Network details + :rtype: Network + :raises Exception: If the network is not found """ conn = get_openstack_conn() @@ -168,15 +173,19 @@ def update_network( """ Update an existing Neutron network. - Args: - network_id: ID of the network to update - name: New network name - description: New network description - is_admin_state_up: New administrative state - is_shared: New shared state - - Returns: - Updated Network object + :param network_id: ID of the network to update + :type network_id: str + :param name: New network name + :type name: str | None + :param description: New network description + :type description: str | None + :param is_admin_state_up: New administrative state + :type is_admin_state_up: bool | None + :param is_shared: New shared state + :type is_shared: bool | None + :return: Updated Network object + :rtype: Network + :raises Exception: If no update parameters are provided """ conn = get_openstack_conn() @@ -202,11 +211,11 @@ def delete_network(self, network_id: str) -> None: """ Delete a Neutron network. - Args: - network_id: ID of the network to delete - - Returns: - None + :param network_id: ID of the network to delete + :type network_id: str + :return: None + :rtype: None + :raises Exception: If the network is not found """ conn = get_openstack_conn() @@ -222,11 +231,10 @@ def _convert_to_network_model(self, openstack_network) -> Network: """ Convert an OpenStack network object to a Network pydantic model. - Args: - openstack_network: OpenStack network object - - Returns: - Network pydantic model + :param openstack_network: OpenStack network object + :type openstack_network: Any + :return: Pydantic Network model + :rtype: Network """ return Network( id=openstack_network.id, @@ -255,6 +263,19 @@ def get_subnets( ) -> list[Subnet]: """ Get the list of Neutron subnets with optional filtering. + + :param network_id: Filter by network ID + :type network_id: str | None + :param ip_version: Filter by IP version (e.g., 4, 6) + :type ip_version: int | None + :param project_id: Filter by project ID + :type project_id: str | None + :param has_gateway: Filter by whether a gateway is set + :type has_gateway: bool | None + :param is_dhcp_enabled: Filter by DHCP enabled state + :type is_dhcp_enabled: bool | None + :return: List of Subnet objects + :rtype: list[Subnet] """ conn = get_openstack_conn() filters: dict = {} @@ -293,6 +314,29 @@ def create_subnet( ) -> Subnet: """ Create a new Neutron subnet. + + :param network_id: ID of the parent network + :type network_id: str + :param cidr: Subnet CIDR + :type cidr: str + :param name: Subnet name + :type name: str | None + :param ip_version: IP version + :type ip_version: int + :param gateway_ip: Gateway IP address + :type gateway_ip: str | None + :param is_dhcp_enabled: Whether DHCP is enabled + :type is_dhcp_enabled: bool + :param description: Subnet description + :type description: str | None + :param dns_nameservers: DNS nameserver list + :type dns_nameservers: list[str] | None + :param allocation_pools: Allocation pool list + :type allocation_pools: list[dict] | None + :param host_routes: Static host routes + :type host_routes: list[dict] | None + :return: Created Subnet object + :rtype: Subnet """ conn = get_openstack_conn() subnet_args: dict = { @@ -319,6 +363,12 @@ def create_subnet( def get_subnet_detail(self, subnet_id: str) -> Subnet: """ Get detailed information about a specific Neutron subnet. + + :param subnet_id: ID of the subnet to retrieve + :type subnet_id: str + :return: Subnet details + :rtype: Subnet + :raises Exception: If the subnet is not found """ conn = get_openstack_conn() subnet = conn.network.get_subnet(subnet_id) @@ -339,6 +389,26 @@ def update_subnet( ) -> Subnet: """ Update an existing Neutron subnet. + + :param subnet_id: ID of the subnet to update + :type subnet_id: str + :param name: New subnet name + :type name: str | None + :param description: New subnet description + :type description: str | None + :param gateway_ip: New gateway IP + :type gateway_ip: str | None + :param is_dhcp_enabled: DHCP enabled state + :type is_dhcp_enabled: bool | None + :param dns_nameservers: DNS nameserver list + :type dns_nameservers: list[str] | None + :param allocation_pools: Allocation pool list + :type allocation_pools: list[dict] | None + :param host_routes: Static host routes + :type host_routes: list[dict] | None + :return: Updated Subnet object + :rtype: Subnet + :raises Exception: If no update parameters are provided """ conn = get_openstack_conn() update_args: dict = {} @@ -364,6 +434,12 @@ def update_subnet( def delete_subnet(self, subnet_id: str) -> None: """ Delete a Neutron subnet. + + :param subnet_id: ID of the subnet to delete + :type subnet_id: str + :return: None + :rtype: None + :raises Exception: If the subnet is not found """ conn = get_openstack_conn() subnet = conn.network.get_subnet(subnet_id) @@ -373,21 +449,58 @@ def delete_subnet(self, subnet_id: str) -> None: return None def set_subnet_gateway(self, subnet_id: str, gateway_ip: str) -> Subnet: + """ + Set or update a subnet's gateway IP. + + :param subnet_id: Subnet ID + :type subnet_id: str + :param gateway_ip: Gateway IP to set + :type gateway_ip: str + :return: Updated Subnet object + :rtype: Subnet + """ conn = get_openstack_conn() subnet = conn.network.update_subnet(subnet_id, gateway_ip=gateway_ip) return self._convert_to_subnet_model(subnet) def clear_subnet_gateway(self, subnet_id: str) -> Subnet: + """ + Clear a subnet's gateway IP (set to `None`). + + :param subnet_id: Subnet ID + :type subnet_id: str + :return: Updated Subnet object + :rtype: Subnet + """ conn = get_openstack_conn() subnet = conn.network.update_subnet(subnet_id, gateway_ip=None) return self._convert_to_subnet_model(subnet) def set_subnet_dhcp_enabled(self, subnet_id: str, enabled: bool) -> Subnet: + """ + Enable or disable DHCP on a subnet. + + :param subnet_id: Subnet ID + :type subnet_id: str + :param enabled: DHCP enabled state + :type enabled: bool + :return: Updated Subnet object + :rtype: Subnet + """ conn = get_openstack_conn() subnet = conn.network.update_subnet(subnet_id, enable_dhcp=enabled) return self._convert_to_subnet_model(subnet) def toggle_subnet_dhcp(self, subnet_id: str) -> Subnet: + """ + Toggle DHCP enabled state for a subnet. + + :param subnet_id: Subnet ID + :type subnet_id: str + :return: Updated Subnet object + :rtype: Subnet + :raises Exception: If the subnet is not found + """ conn = get_openstack_conn() current = conn.network.get_subnet(subnet_id) if not current: @@ -401,6 +514,11 @@ def toggle_subnet_dhcp(self, subnet_id: str) -> Subnet: def _convert_to_subnet_model(self, openstack_subnet) -> Subnet: """ Convert an OpenStack subnet object to a Subnet pydantic model. + + :param openstack_subnet: OpenStack subnet object + :type openstack_subnet: Any + :return: Pydantic Subnet model + :rtype: Subnet """ return Subnet( id=openstack_subnet.id, @@ -426,6 +544,15 @@ def get_ports( ) -> list[Port]: """ Get the list of Neutron ports with optional filtering. + + :param status_filter: Filter by port status (e.g., `ACTIVE`, `DOWN`) + :type status_filter: str | None + :param device_id: Filter by device ID + :type device_id: str | None + :param network_id: Filter by network ID + :type network_id: str | None + :return: List of Port objects + :rtype: list[Port] """ conn = get_openstack_conn() filters: dict = {} @@ -444,6 +571,19 @@ def add_port_fixed_ip( subnet_id: str | None = None, ip_address: str | None = None, ) -> Port: + """ + Add a fixed IP to a port. + + :param port_id: Target port ID + :type port_id: str + :param subnet_id: Subnet ID of the fixed IP entry + :type subnet_id: str | None + :param ip_address: Fixed IP address to add + :type ip_address: str | None + :return: Updated Port object + :rtype: Port + :raises Exception: If the port is not found + """ conn = get_openstack_conn() port = conn.network.get_port(port_id) if not port: @@ -464,6 +604,19 @@ def remove_port_fixed_ip( ip_address: str | None = None, subnet_id: str | None = None, ) -> Port: + """ + Remove a fixed IP entry from a port. + + :param port_id: Target port ID + :type port_id: str + :param ip_address: Fixed IP address to remove + :type ip_address: str | None + :param subnet_id: Subnet ID of the entry to remove + :type subnet_id: str | None + :return: Updated Port object + :rtype: Port + :raises Exception: If the port is not found + """ conn = get_openstack_conn() port = conn.network.get_port(port_id) if not port: @@ -484,6 +637,15 @@ def predicate(item: dict) -> bool: return self._convert_to_port_model(updated) def get_port_allowed_address_pairs(self, port_id: str) -> list[dict]: + """ + Get allowed address pairs configured on a port. + + :param port_id: Port ID + :type port_id: str + :return: Allowed address pairs + :rtype: list[dict] + :raises Exception: If the port is not found + """ conn = get_openstack_conn() port = conn.network.get_port(port_id) if not port: @@ -496,6 +658,19 @@ def add_port_allowed_address_pair( ip_address: str, mac_address: str | None = None, ) -> Port: + """ + Add an allowed address pair to a port. + + :param port_id: Port ID + :type port_id: str + :param ip_address: IP address to allow + :type ip_address: str + :param mac_address: MAC address to allow + :type mac_address: str | None + :return: Updated Port object + :rtype: Port + :raises Exception: If the port is not found + """ conn = get_openstack_conn() port = conn.network.get_port(port_id) if not port: @@ -517,6 +692,19 @@ def remove_port_allowed_address_pair( ip_address: str, mac_address: str | None = None, ) -> Port: + """ + Remove an allowed address pair from a port. + + :param port_id: Port ID + :type port_id: str + :param ip_address: IP address to remove + :type ip_address: str + :param mac_address: MAC address to remove. If not provided, remove all pairs with the IP + :type mac_address: str | None + :return: Updated Port object + :rtype: Port + :raises Exception: If the port is not found + """ conn = get_openstack_conn() port = conn.network.get_port(port_id) if not port: @@ -545,6 +733,21 @@ def set_port_binding( vnic_type: str | None = None, profile: dict | None = None, ) -> Port: + """ + Set binding attributes for a port. + + :param port_id: Port ID + :type port_id: str + :param host_id: Binding host ID + :type host_id: str | None + :param vnic_type: VNIC type + :type vnic_type: str | None + :param profile: Binding profile + :type profile: dict | None + :return: Updated Port object + :rtype: Port + :raises Exception: If no update parameters are provided + """ conn = get_openstack_conn() update_args: dict = {} if host_id is not None: @@ -563,6 +766,16 @@ def set_port_admin_state( port_id: str, is_admin_state_up: bool, ) -> Port: + """ + Set the administrative state of a port. + + :param port_id: Port ID + :type port_id: str + :param is_admin_state_up: Administrative state + :type is_admin_state_up: bool + :return: Updated Port object + :rtype: Port + """ conn = get_openstack_conn() updated = conn.network.update_port( port_id, @@ -571,6 +784,15 @@ def set_port_admin_state( return self._convert_to_port_model(updated) def toggle_port_admin_state(self, port_id: str) -> Port: + """ + Toggle the administrative state of a port. + + :param port_id: Port ID + :type port_id: str + :return: Updated Port object + :rtype: Port + :raises Exception: If the port is not found + """ conn = get_openstack_conn() current = conn.network.get_port(port_id) if not current: @@ -593,6 +815,23 @@ def create_port( ) -> Port: """ Create a new Neutron port. + + :param network_id: ID of the parent network + :type network_id: str + :param name: Port name + :type name: str | None + :param description: Port description + :type description: str | None + :param is_admin_state_up: Administrative state + :type is_admin_state_up: bool + :param device_id: Device ID + :type device_id: str | None + :param fixed_ips: Fixed IP list + :type fixed_ips: list[dict] | None + :param security_group_ids: Security group ID list + :type security_group_ids: list[str] | None + :return: Created Port object + :rtype: Port """ conn = get_openstack_conn() port_args: dict = { @@ -615,6 +854,12 @@ def create_port( def get_port_detail(self, port_id: str) -> Port: """ Get detailed information about a specific Neutron port. + + :param port_id: ID of the port to retrieve + :type port_id: str + :return: Port details + :rtype: Port + :raises Exception: If the port is not found """ conn = get_openstack_conn() port = conn.network.get_port(port_id) @@ -633,6 +878,22 @@ def update_port( ) -> Port: """ Update an existing Neutron port. + + :param port_id: ID of the port to update + :type port_id: str + :param name: New port name + :type name: str | None + :param description: New port description + :type description: str | None + :param is_admin_state_up: Administrative state + :type is_admin_state_up: bool | None + :param device_id: Device ID + :type device_id: str | None + :param security_group_ids: Security group ID list + :type security_group_ids: list[str] | None + :return: Updated Port object + :rtype: Port + :raises Exception: If no update parameters are provided """ conn = get_openstack_conn() update_args: dict = {} @@ -654,6 +915,12 @@ def update_port( def delete_port(self, port_id: str) -> None: """ Delete a Neutron port. + + :param port_id: ID of the port to delete + :type port_id: str + :return: None + :rtype: None + :raises Exception: If the port is not found """ conn = get_openstack_conn() port = conn.network.get_port(port_id) @@ -665,6 +932,11 @@ def delete_port(self, port_id: str) -> None: def _convert_to_port_model(self, openstack_port) -> Port: """ Convert an OpenStack port object to a Port pydantic model. + + :param openstack_port: OpenStack port object + :type openstack_port: Any + :return: Pydantic Port model + :rtype: Port """ return Port( id=openstack_port.id, @@ -693,6 +965,19 @@ def get_floating_ips( ) -> list[FloatingIP]: """ Get the list of Neutron floating IPs with optional filtering. + + :param status_filter: Filter by IP status (e.g., `ACTIVE`) + :type status_filter: str | None + :param project_id: Filter by project ID + :type project_id: str | None + :param port_id: Filter by attached port ID + :type port_id: str | None + :param floating_network_id: Filter by external network ID + :type floating_network_id: str | None + :param unassigned_only: If True, return only unassigned IPs + :type unassigned_only: bool | None + :return: List of FloatingIP objects + :rtype: list[FloatingIP] """ conn = get_openstack_conn() filters: dict = {} @@ -719,6 +1004,19 @@ def create_floating_ip( ) -> FloatingIP: """ Create a new Neutron floating IP. + + :param floating_network_id: External (floating) network ID + :type floating_network_id: str + :param description: Floating IP description + :type description: str | None + :param fixed_ip_address: Internal fixed IP to map + :type fixed_ip_address: str | None + :param port_id: Port ID to attach + :type port_id: str | None + :param project_id: Project ID + :type project_id: str | None + :return: Created FloatingIP object + :rtype: FloatingIP """ conn = get_openstack_conn() ip_args: dict = {"floating_network_id": floating_network_id} @@ -740,6 +1038,13 @@ def allocate_floating_ip_pool_to_project( ) -> None: """ Allocate floating IP pool (external network access) to a project via RBAC. + + :param floating_network_id: External network ID + :type floating_network_id: str + :param project_id: Target project ID + :type project_id: str + :return: None + :rtype: None """ conn = get_openstack_conn() conn.network.create_rbac_policy( @@ -758,6 +1063,15 @@ def attach_floating_ip_to_port( ) -> FloatingIP: """ Attach a floating IP to a port. + + :param floating_ip_id: Floating IP ID + :type floating_ip_id: str + :param port_id: Port ID to attach + :type port_id: str + :param fixed_ip_address: Specific fixed IP on the port (optional) + :type fixed_ip_address: str | None + :return: Updated FloatingIP object + :rtype: FloatingIP """ conn = get_openstack_conn() update_args: dict = {"port_id": port_id} @@ -769,6 +1083,11 @@ def attach_floating_ip_to_port( def detach_floating_ip_from_port(self, floating_ip_id: str) -> FloatingIP: """ Detach a floating IP from its port. + + :param floating_ip_id: Floating IP ID + :type floating_ip_id: str + :return: Updated FloatingIP object + :rtype: FloatingIP """ conn = get_openstack_conn() ip = conn.network.update_ip(floating_ip_id, port_id=None) @@ -777,6 +1096,12 @@ def detach_floating_ip_from_port(self, floating_ip_id: str) -> FloatingIP: def delete_floating_ip(self, floating_ip_id: str) -> None: """ Delete a Neutron floating IP. + + :param floating_ip_id: Floating IP ID to delete + :type floating_ip_id: str + :return: None + :rtype: None + :raises Exception: If the floating IP is not found """ conn = get_openstack_conn() ip = conn.network.get_ip(floating_ip_id) @@ -790,6 +1115,16 @@ def update_floating_ip_description( floating_ip_id: str, description: str | None, ) -> FloatingIP: + """ + Update a floating IP's description. + + :param floating_ip_id: Floating IP ID + :type floating_ip_id: str + :param description: New description (`None` to clear) + :type description: str | None + :return: Updated FloatingIP object + :rtype: FloatingIP + """ conn = get_openstack_conn() ip = conn.network.update_ip(floating_ip_id, description=description) return self._convert_to_floating_ip_model(ip) @@ -800,6 +1135,18 @@ def reassign_floating_ip_to_port( port_id: str, fixed_ip_address: str | None = None, ) -> FloatingIP: + """ + Reassign a floating IP to a different port. + + :param floating_ip_id: Floating IP ID + :type floating_ip_id: str + :param port_id: New port ID to attach + :type port_id: str + :param fixed_ip_address: Specific fixed IP on the port (optional) + :type fixed_ip_address: str | None + :return: Updated Floating IP object + :rtype: FloatingIP + """ conn = get_openstack_conn() update_args: dict = {"port_id": port_id} if fixed_ip_address is not None: @@ -812,6 +1159,16 @@ def create_floating_ips_bulk( floating_network_id: str, count: int, ) -> list[FloatingIP]: + """ + Create multiple floating IPs on the specified external network. + + :param floating_network_id: External network ID + :type floating_network_id: str + :param count: Number of floating IPs to create (negative treated as 0) + :type count: int + :return: List of created FloatingIP objects + :rtype: list[FloatingIP] + """ conn = get_openstack_conn() created = [] for _ in range(max(0, count)): @@ -826,6 +1183,17 @@ def assign_first_available_floating_ip( floating_network_id: str, port_id: str, ) -> FloatingIP: + """ + Assign the first available floating IP from a network to a port. + If none are available, create a new one and assign it. + + :param floating_network_id: External network ID + :type floating_network_id: str + :param port_id: Target port ID + :type port_id: str + :return: Updated FloatingIP object + :rtype: FloatingIP + """ conn = get_openstack_conn() existing = list( conn.network.ips(floating_network_id=floating_network_id), @@ -847,6 +1215,11 @@ def assign_first_available_floating_ip( def _convert_to_floating_ip_model(self, openstack_ip) -> FloatingIP: """ Convert an OpenStack floating IP object to a FloatingIP pydantic model. + + :param openstack_ip: OpenStack floating IP object + :type openstack_ip: Any + :return: Pydantic FloatingIP model + :rtype: FloatingIP """ return FloatingIP( id=openstack_ip.id, From 8c2bd1c6a31676fedf8cbd8cc1e92da03019adc7 Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Sun, 17 Aug 2025 22:48:37 +0900 Subject: [PATCH 05/15] fix(network): refactor code - remove custom raise - remove test case relation removing raise --- .../tools/network_tools.py | 76 +++-------- tests/tools/test_network_tools.py | 121 +----------------- 2 files changed, 22 insertions(+), 175 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index d4f9e32..efed65c 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -157,9 +157,6 @@ def get_network_detail(self, network_id: str) -> Network: conn = get_openstack_conn() network = conn.network.get_network(network_id) - if not network: - raise Exception(f"Network with ID {network_id} not found") - return self._convert_to_network_model(network) def update_network( @@ -201,10 +198,9 @@ def update_network( update_args["shared"] = is_shared if not update_args: - raise Exception("No update parameters provided") - + current = conn.network.get_network(network_id) + return self._convert_to_network_model(current) network = conn.network.update_network(network_id, **update_args) - return self._convert_to_network_model(network) def delete_network(self, network_id: str) -> None: @@ -218,11 +214,6 @@ def delete_network(self, network_id: str) -> None: :raises Exception: If the network is not found """ conn = get_openstack_conn() - - network = conn.network.get_network(network_id) - if not network: - raise Exception(f"Network with ID {network_id} not found") - conn.network.delete_network(network_id, ignore_missing=False) return None @@ -289,14 +280,9 @@ def get_subnets( filters["enable_dhcp"] = is_dhcp_enabled subnets = conn.list_subnets(filters=filters) if has_gateway is not None: - if has_gateway: - subnets = [ - s for s in subnets if getattr(s, "gateway_ip", None) - ] - else: - subnets = [ - s for s in subnets if not getattr(s, "gateway_ip", None) - ] + subnets = [ + s for s in subnets if (s.gateway_ip is not None) == has_gateway + ] return [self._convert_to_subnet_model(subnet) for subnet in subnets] def create_subnet( @@ -372,8 +358,6 @@ def get_subnet_detail(self, subnet_id: str) -> Subnet: """ conn = get_openstack_conn() subnet = conn.network.get_subnet(subnet_id) - if not subnet: - raise Exception(f"Subnet with ID {subnet_id} not found") return self._convert_to_subnet_model(subnet) def update_subnet( @@ -427,7 +411,8 @@ def update_subnet( if host_routes is not None: update_args["host_routes"] = host_routes if not update_args: - raise Exception("No update parameters provided") + current = conn.network.get_subnet(subnet_id) + return self._convert_to_subnet_model(current) subnet = conn.network.update_subnet(subnet_id, **update_args) return self._convert_to_subnet_model(subnet) @@ -442,9 +427,6 @@ def delete_subnet(self, subnet_id: str) -> None: :raises Exception: If the subnet is not found """ conn = get_openstack_conn() - subnet = conn.network.get_subnet(subnet_id) - if not subnet: - raise Exception(f"Subnet with ID {subnet_id} not found") conn.network.delete_subnet(subnet_id, ignore_missing=False) return None @@ -503,11 +485,9 @@ def toggle_subnet_dhcp(self, subnet_id: str) -> Subnet: """ conn = get_openstack_conn() current = conn.network.get_subnet(subnet_id) - if not current: - raise Exception(f"Subnet with ID {subnet_id} not found") subnet = conn.network.update_subnet( subnet_id, - enable_dhcp=not bool(current.enable_dhcp), + enable_dhcp=not current.enable_dhcp, ) return self._convert_to_subnet_model(subnet) @@ -586,8 +566,6 @@ def add_port_fixed_ip( """ conn = get_openstack_conn() port = conn.network.get_port(port_id) - if not port: - raise Exception(f"Port with ID {port_id} not found") fixed_ips = list(port.fixed_ips or []) entry: dict = {} if subnet_id is not None: @@ -619,8 +597,6 @@ def remove_port_fixed_ip( """ conn = get_openstack_conn() port = conn.network.get_port(port_id) - if not port: - raise Exception(f"Port with ID {port_id} not found") current = list(port.fixed_ips or []) if not current: return self._convert_to_port_model(port) @@ -648,9 +624,7 @@ def get_port_allowed_address_pairs(self, port_id: str) -> list[dict]: """ conn = get_openstack_conn() port = conn.network.get_port(port_id) - if not port: - raise Exception(f"Port with ID {port_id} not found") - return list(getattr(port, "allowed_address_pairs", []) or []) + return list(port.allowed_address_pairs or []) def add_port_allowed_address_pair( self, @@ -673,13 +647,13 @@ def add_port_allowed_address_pair( """ conn = get_openstack_conn() port = conn.network.get_port(port_id) - if not port: - raise Exception(f"Port with ID {port_id} not found") - pairs = list(getattr(port, "allowed_address_pairs", []) or []) + + pairs = list(port.allowed_address_pairs or []) entry = {"ip_address": ip_address} if mac_address is not None: entry["mac_address"] = mac_address pairs.append(entry) + updated = conn.network.update_port( port_id, allowed_address_pairs=pairs, @@ -707,9 +681,7 @@ def remove_port_allowed_address_pair( """ conn = get_openstack_conn() port = conn.network.get_port(port_id) - if not port: - raise Exception(f"Port with ID {port_id} not found") - pairs = list(getattr(port, "allowed_address_pairs", []) or []) + pairs = list(port.allowed_address_pairs or []) def keep(p: dict) -> bool: if mac_address is None: @@ -757,7 +729,8 @@ def set_port_binding( if profile is not None: update_args["binding_profile"] = profile if not update_args: - raise Exception("No update parameters provided") + current = conn.network.get_port(port_id) + return self._convert_to_port_model(current) updated = conn.network.update_port(port_id, **update_args) return self._convert_to_port_model(updated) @@ -795,11 +768,9 @@ def toggle_port_admin_state(self, port_id: str) -> Port: """ conn = get_openstack_conn() current = conn.network.get_port(port_id) - if not current: - raise Exception(f"Port with ID {port_id} not found") updated = conn.network.update_port( port_id, - admin_state_up=not bool(current.admin_state_up), + admin_state_up=not current.admin_state_up, ) return self._convert_to_port_model(updated) @@ -863,8 +834,6 @@ def get_port_detail(self, port_id: str) -> Port: """ conn = get_openstack_conn() port = conn.network.get_port(port_id) - if not port: - raise Exception(f"Port with ID {port_id} not found") return self._convert_to_port_model(port) def update_port( @@ -908,7 +877,8 @@ def update_port( if security_group_ids is not None: update_args["security_groups"] = security_group_ids if not update_args: - raise Exception("No update parameters provided") + current = conn.network.get_port(port_id) + return self._convert_to_port_model(current) port = conn.network.update_port(port_id, **update_args) return self._convert_to_port_model(port) @@ -923,9 +893,6 @@ def delete_port(self, port_id: str) -> None: :raises Exception: If the port is not found """ conn = get_openstack_conn() - port = conn.network.get_port(port_id) - if not port: - raise Exception(f"Port with ID {port_id} not found") conn.network.delete_port(port_id, ignore_missing=False) return None @@ -991,7 +958,7 @@ def get_floating_ips( filters["floating_network_id"] = floating_network_id ips = list(conn.network.ips(**filters)) if unassigned_only: - ips = [i for i in ips if not getattr(i, "port_id", None)] + ips = [i for i in ips if not i.port_id] return [self._convert_to_floating_ip_model(ip) for ip in ips] def create_floating_ip( @@ -1104,9 +1071,6 @@ def delete_floating_ip(self, floating_ip_id: str) -> None: :raises Exception: If the floating IP is not found """ conn = get_openstack_conn() - ip = conn.network.get_ip(floating_ip_id) - if not ip: - raise Exception(f"Floating IP with ID {floating_ip_id} not found") conn.network.delete_ip(floating_ip_id, ignore_missing=False) return None @@ -1199,7 +1163,7 @@ def assign_first_available_floating_ip( conn.network.ips(floating_network_id=floating_network_id), ) available = next( - (i for i in existing if not getattr(i, "port_id", None)), + (i for i in existing if not i.port_id), None, ) if available is None: diff --git a/tests/tools/test_network_tools.py b/tests/tools/test_network_tools.py index bc06bcb..a4ec395 100644 --- a/tests/tools/test_network_tools.py +++ b/tests/tools/test_network_tools.py @@ -1,7 +1,5 @@ from unittest.mock import Mock -import pytest - from openstack_mcp_server.tools.network_tools import NetworkTools from openstack_mcp_server.tools.response.network import ( FloatingIP, @@ -343,23 +341,6 @@ def test_get_network_detail_success(self, mock_openstack_connect_network): mock_conn.network.get_network.assert_called_once_with("net-detail-123") - def test_get_network_detail_not_found( - self, - mock_openstack_connect_network, - ): - """Test getting network detail when network not found.""" - mock_conn = mock_openstack_connect_network - - mock_conn.network.get_network.return_value = None - - network_tools = self.get_network_tools() - - with pytest.raises( - Exception, - match="Network with ID nonexistent-net not found", - ): - network_tools.get_network_detail("nonexistent-net") - def test_update_network_success(self, mock_openstack_connect_network): """Test updating a network successfully.""" mock_conn = mock_openstack_connect_network @@ -464,20 +445,6 @@ def test_update_network_partial_update( **expected_args, ) - def test_update_network_no_parameters( - self, - mock_openstack_connect_network, - ): - """Test updating a network with no parameters provided.""" - mock_conn = mock_openstack_connect_network - - network_tools = self.get_network_tools() - - with pytest.raises(Exception, match="No update parameters provided"): - network_tools.update_network("net-123") - - mock_conn.network.update_network.assert_not_called() - def test_delete_network_success(self, mock_openstack_connect_network): """Test deleting a network successfully.""" mock_conn = mock_openstack_connect_network @@ -485,7 +452,6 @@ def test_delete_network_success(self, mock_openstack_connect_network): mock_network = Mock() mock_network.name = "network-to-delete" - mock_conn.network.get_network.return_value = mock_network mock_conn.network.delete_network.return_value = None network_tools = self.get_network_tools() @@ -493,28 +459,11 @@ def test_delete_network_success(self, mock_openstack_connect_network): assert result is None - mock_conn.network.get_network.assert_called_once_with("net-delete-123") mock_conn.network.delete_network.assert_called_once_with( "net-delete-123", ignore_missing=False, ) - def test_delete_network_not_found(self, mock_openstack_connect_network): - """Test deleting a network when network not found.""" - mock_conn = mock_openstack_connect_network - - mock_conn.network.get_network.return_value = None - - network_tools = self.get_network_tools() - - with pytest.raises( - Exception, - match="Network with ID nonexistent-net not found", - ): - network_tools.delete_network("nonexistent-net") - - mock_conn.network.delete_network.assert_not_called() - def test_get_ports_with_filters(self, mock_openstack_connect_network): mock_conn = mock_openstack_connect_network @@ -638,16 +587,6 @@ def test_get_port_detail_success(self, mock_openstack_connect_network): assert result.id == "port-1" mock_conn.network.get_port.assert_called_once_with("port-1") - def test_get_port_detail_not_found(self, mock_openstack_connect_network): - mock_conn = mock_openstack_connect_network - mock_conn.network.get_port.return_value = None - tools = self.get_network_tools() - with pytest.raises( - Exception, - match="Port with ID p-notfound not found", - ): - tools.get_port_detail("p-notfound") - def test_update_port_success(self, mock_openstack_connect_network): mock_conn = mock_openstack_connect_network @@ -686,37 +625,21 @@ def test_update_port_success(self, mock_openstack_connect_network): security_groups=["sg-2"], ) - def test_update_port_no_params(self, mock_openstack_connect_network): - mock_conn = mock_openstack_connect_network - tools = self.get_network_tools() - with pytest.raises(Exception, match="No update parameters provided"): - tools.update_port("port-1") - mock_conn.network.update_port.assert_not_called() - def test_delete_port_success(self, mock_openstack_connect_network): mock_conn = mock_openstack_connect_network port = Mock() port.id = "port-1" - mock_conn.network.get_port.return_value = port + mock_conn.network.delete_port.return_value = None tools = self.get_network_tools() result = tools.delete_port("port-1") assert result is None - mock_conn.network.get_port.assert_called_once_with("port-1") mock_conn.network.delete_port.assert_called_once_with( "port-1", ignore_missing=False, ) - def test_delete_port_not_found(self, mock_openstack_connect_network): - mock_conn = mock_openstack_connect_network - mock_conn.network.get_port.return_value = None - tools = self.get_network_tools() - with pytest.raises(Exception, match="Port with ID none not found"): - tools.delete_port("none") - mock_conn.network.delete_port.assert_not_called() - def test_add_port_fixed_ip(self, mock_openstack_connect_network): mock_conn = mock_openstack_connect_network @@ -1069,20 +992,6 @@ def test_get_subnet_detail_success( assert result.id == "subnet-1" mock_conn.network.get_subnet.assert_called_once_with("subnet-1") - def test_get_subnet_detail_not_found( - self, - mock_openstack_connect_network, - ): - mock_conn = mock_openstack_connect_network - mock_conn.network.get_subnet.return_value = None - - tools = self.get_network_tools() - with pytest.raises( - Exception, - match="Subnet with ID nonexistent not found", - ): - tools.get_subnet_detail("nonexistent") - def test_update_subnet_success( self, mock_openstack_connect_network, @@ -1124,16 +1033,6 @@ def test_update_subnet_success( enable_dhcp=False, ) - def test_update_subnet_no_params( - self, - mock_openstack_connect_network, - ): - mock_conn = mock_openstack_connect_network - tools = self.get_network_tools() - with pytest.raises(Exception, match="No update parameters provided"): - tools.update_subnet("subnet-1") - mock_conn.network.update_subnet.assert_not_called() - def test_delete_subnet_success( self, mock_openstack_connect_network, @@ -1142,33 +1041,17 @@ def test_delete_subnet_success( subnet = Mock() subnet.id = "subnet-1" - mock_conn.network.get_subnet.return_value = subnet + mock_conn.network.delete_subnet.return_value = None tools = self.get_network_tools() result = tools.delete_subnet("subnet-1") assert result is None - mock_conn.network.get_subnet.assert_called_once_with("subnet-1") mock_conn.network.delete_subnet.assert_called_once_with( "subnet-1", ignore_missing=False, ) - def test_delete_subnet_not_found( - self, - mock_openstack_connect_network, - ): - mock_conn = mock_openstack_connect_network - mock_conn.network.get_subnet.return_value = None - - tools = self.get_network_tools() - with pytest.raises( - Exception, - match="Subnet with ID no-subnet not found", - ): - tools.delete_subnet("no-subnet") - mock_conn.network.delete_subnet.assert_not_called() - def test_set_and_clear_subnet_gateway( self, mock_openstack_connect_network, From eebfb9bae03b06d4f8fb9b48c77ca294924d9f64 Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Tue, 19 Aug 2025 20:37:38 +0900 Subject: [PATCH 06/15] fix(network): typo change neutron to network (#30) --- .../tools/network_tools.py | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index efed65c..e9356f4 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -11,12 +11,12 @@ class NetworkTools: """ - A class to encapsulate Neutron-related tools and utilities. + A class to encapsulate Network-related tools and utilities. """ def register_tools(self, mcp: FastMCP): """ - Register Neutron-related tools with the FastMCP instance. + Register Network-related tools with the FastMCP instance. """ mcp.tool()(self.get_networks) @@ -63,7 +63,7 @@ def get_networks( shared_only: bool = False, ) -> list[Network]: """ - Get the list of Neutron networks with optional filtering. + Get the list of Networks with optional filtering. :param status_filter: Filter networks by status (e.g., `ACTIVE`, `DOWN`) :type status_filter: str | None @@ -99,7 +99,7 @@ def create_network( provider_segmentation_id: int | None = None, ) -> Network: """ - Create a new Neutron network. + Create a new Network. :param name: Network name :type name: str @@ -146,7 +146,7 @@ def create_network( def get_network_detail(self, network_id: str) -> Network: """ - Get detailed information about a specific Neutron network. + Get detailed information about a specific Network. :param network_id: ID of the network to retrieve :type network_id: str @@ -168,7 +168,7 @@ def update_network( is_shared: bool | None = None, ) -> Network: """ - Update an existing Neutron network. + Update an existing Network. :param network_id: ID of the network to update :type network_id: str @@ -205,7 +205,7 @@ def update_network( def delete_network(self, network_id: str) -> None: """ - Delete a Neutron network. + Delete a Network. :param network_id: ID of the network to delete :type network_id: str @@ -253,7 +253,7 @@ def get_subnets( is_dhcp_enabled: bool | None = None, ) -> list[Subnet]: """ - Get the list of Neutron subnets with optional filtering. + Get the list of Subnets with optional filtering. :param network_id: Filter by network ID :type network_id: str | None @@ -299,7 +299,7 @@ def create_subnet( host_routes: list[dict] | None = None, ) -> Subnet: """ - Create a new Neutron subnet. + Create a new Subnet. :param network_id: ID of the parent network :type network_id: str @@ -348,7 +348,7 @@ def create_subnet( def get_subnet_detail(self, subnet_id: str) -> Subnet: """ - Get detailed information about a specific Neutron subnet. + Get detailed information about a specific Subnet. :param subnet_id: ID of the subnet to retrieve :type subnet_id: str @@ -372,7 +372,7 @@ def update_subnet( host_routes: list[dict] | None = None, ) -> Subnet: """ - Update an existing Neutron subnet. + Update an existing Subnet. :param subnet_id: ID of the subnet to update :type subnet_id: str @@ -418,7 +418,7 @@ def update_subnet( def delete_subnet(self, subnet_id: str) -> None: """ - Delete a Neutron subnet. + Delete a Subnet. :param subnet_id: ID of the subnet to delete :type subnet_id: str @@ -523,7 +523,7 @@ def get_ports( network_id: str | None = None, ) -> list[Port]: """ - Get the list of Neutron ports with optional filtering. + Get the list of Ports with optional filtering. :param status_filter: Filter by port status (e.g., `ACTIVE`, `DOWN`) :type status_filter: str | None @@ -785,7 +785,7 @@ def create_port( security_group_ids: list[str] | None = None, ) -> Port: """ - Create a new Neutron port. + Create a new Port. :param network_id: ID of the parent network :type network_id: str @@ -824,7 +824,7 @@ def create_port( def get_port_detail(self, port_id: str) -> Port: """ - Get detailed information about a specific Neutron port. + Get detailed information about a specific Port. :param port_id: ID of the port to retrieve :type port_id: str @@ -846,7 +846,7 @@ def update_port( security_group_ids: list[str] | None = None, ) -> Port: """ - Update an existing Neutron port. + Update an existing Port. :param port_id: ID of the port to update :type port_id: str @@ -884,7 +884,7 @@ def update_port( def delete_port(self, port_id: str) -> None: """ - Delete a Neutron port. + Delete a Port. :param port_id: ID of the port to delete :type port_id: str @@ -898,7 +898,7 @@ def delete_port(self, port_id: str) -> None: def _convert_to_port_model(self, openstack_port) -> Port: """ - Convert an OpenStack port object to a Port pydantic model. + Convert an OpenStack Port object to a Port pydantic model. :param openstack_port: OpenStack port object :type openstack_port: Any @@ -931,7 +931,7 @@ def get_floating_ips( unassigned_only: bool | None = None, ) -> list[FloatingIP]: """ - Get the list of Neutron floating IPs with optional filtering. + Get the list of Floating IPs with optional filtering. :param status_filter: Filter by IP status (e.g., `ACTIVE`) :type status_filter: str | None @@ -970,7 +970,7 @@ def create_floating_ip( project_id: str | None = None, ) -> FloatingIP: """ - Create a new Neutron floating IP. + Create a new Floating IP. :param floating_network_id: External (floating) network ID :type floating_network_id: str @@ -1004,7 +1004,7 @@ def allocate_floating_ip_pool_to_project( project_id: str, ) -> None: """ - Allocate floating IP pool (external network access) to a project via RBAC. + Allocate Floating IP pool (external network access) to a project via RBAC. :param floating_network_id: External network ID :type floating_network_id: str @@ -1029,7 +1029,7 @@ def attach_floating_ip_to_port( fixed_ip_address: str | None = None, ) -> FloatingIP: """ - Attach a floating IP to a port. + Attach a Floating IP to a Port. :param floating_ip_id: Floating IP ID :type floating_ip_id: str @@ -1049,7 +1049,7 @@ def attach_floating_ip_to_port( def detach_floating_ip_from_port(self, floating_ip_id: str) -> FloatingIP: """ - Detach a floating IP from its port. + Detach a Floating IP from its Port. :param floating_ip_id: Floating IP ID :type floating_ip_id: str @@ -1062,7 +1062,7 @@ def detach_floating_ip_from_port(self, floating_ip_id: str) -> FloatingIP: def delete_floating_ip(self, floating_ip_id: str) -> None: """ - Delete a Neutron floating IP. + Delete a Floating IP. :param floating_ip_id: Floating IP ID to delete :type floating_ip_id: str @@ -1080,7 +1080,7 @@ def update_floating_ip_description( description: str | None, ) -> FloatingIP: """ - Update a floating IP's description. + Update a Floating IP's description. :param floating_ip_id: Floating IP ID :type floating_ip_id: str @@ -1100,7 +1100,7 @@ def reassign_floating_ip_to_port( fixed_ip_address: str | None = None, ) -> FloatingIP: """ - Reassign a floating IP to a different port. + Reassign a Floating IP to a different Port. :param floating_ip_id: Floating IP ID :type floating_ip_id: str From 157181bd180ff32755478f7bc66ff0f3bfcd5aa9 Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Tue, 19 Aug 2025 23:42:30 +0900 Subject: [PATCH 07/15] fix(network): remove unused comments (#30) --- .../tools/network_tools.py | 175 +----------------- 1 file changed, 1 insertion(+), 174 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index e9356f4..7c5b35c 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -66,11 +66,8 @@ def get_networks( Get the list of Networks with optional filtering. :param status_filter: Filter networks by status (e.g., `ACTIVE`, `DOWN`) - :type status_filter: str | None :param shared_only: If True, only show shared networks - :type shared_only: bool :return: List of Network objects - :rtype: list[Network] """ conn = get_openstack_conn() @@ -102,21 +99,13 @@ def create_network( Create a new Network. :param name: Network name - :type name: str :param description: Network description - :type description: str | None :param is_admin_state_up: Administrative state - :type is_admin_state_up: bool :param is_shared: Whether the network is shared - :type is_shared: bool :param provider_network_type: Provider network type (e.g., 'vlan', 'flat', 'vxlan') - :type provider_network_type: str | None :param provider_physical_network: Physical network name - :type provider_physical_network: str | None :param provider_segmentation_id: Segmentation ID for VLAN/VXLAN - :type provider_segmentation_id: int | None :return: Created Network object - :rtype: Network """ conn = get_openstack_conn() @@ -149,10 +138,7 @@ def get_network_detail(self, network_id: str) -> Network: Get detailed information about a specific Network. :param network_id: ID of the network to retrieve - :type network_id: str :return: Network details - :rtype: Network - :raises Exception: If the network is not found """ conn = get_openstack_conn() @@ -171,18 +157,11 @@ def update_network( Update an existing Network. :param network_id: ID of the network to update - :type network_id: str :param name: New network name - :type name: str | None :param description: New network description - :type description: str | None :param is_admin_state_up: New administrative state - :type is_admin_state_up: bool | None :param is_shared: New shared state - :type is_shared: bool | None :return: Updated Network object - :rtype: Network - :raises Exception: If no update parameters are provided """ conn = get_openstack_conn() @@ -208,10 +187,7 @@ def delete_network(self, network_id: str) -> None: Delete a Network. :param network_id: ID of the network to delete - :type network_id: str :return: None - :rtype: None - :raises Exception: If the network is not found """ conn = get_openstack_conn() conn.network.delete_network(network_id, ignore_missing=False) @@ -225,7 +201,6 @@ def _convert_to_network_model(self, openstack_network) -> Network: :param openstack_network: OpenStack network object :type openstack_network: Any :return: Pydantic Network model - :rtype: Network """ return Network( id=openstack_network.id, @@ -256,17 +231,11 @@ def get_subnets( Get the list of Subnets with optional filtering. :param network_id: Filter by network ID - :type network_id: str | None :param ip_version: Filter by IP version (e.g., 4, 6) - :type ip_version: int | None :param project_id: Filter by project ID - :type project_id: str | None :param has_gateway: Filter by whether a gateway is set - :type has_gateway: bool | None :param is_dhcp_enabled: Filter by DHCP enabled state - :type is_dhcp_enabled: bool | None :return: List of Subnet objects - :rtype: list[Subnet] """ conn = get_openstack_conn() filters: dict = {} @@ -302,27 +271,16 @@ def create_subnet( Create a new Subnet. :param network_id: ID of the parent network - :type network_id: str :param cidr: Subnet CIDR - :type cidr: str :param name: Subnet name - :type name: str | None :param ip_version: IP version - :type ip_version: int :param gateway_ip: Gateway IP address - :type gateway_ip: str | None :param is_dhcp_enabled: Whether DHCP is enabled - :type is_dhcp_enabled: bool :param description: Subnet description - :type description: str | None :param dns_nameservers: DNS nameserver list - :type dns_nameservers: list[str] | None :param allocation_pools: Allocation pool list - :type allocation_pools: list[dict] | None :param host_routes: Static host routes - :type host_routes: list[dict] | None :return: Created Subnet object - :rtype: Subnet """ conn = get_openstack_conn() subnet_args: dict = { @@ -351,10 +309,7 @@ def get_subnet_detail(self, subnet_id: str) -> Subnet: Get detailed information about a specific Subnet. :param subnet_id: ID of the subnet to retrieve - :type subnet_id: str :return: Subnet details - :rtype: Subnet - :raises Exception: If the subnet is not found """ conn = get_openstack_conn() subnet = conn.network.get_subnet(subnet_id) @@ -375,24 +330,14 @@ def update_subnet( Update an existing Subnet. :param subnet_id: ID of the subnet to update - :type subnet_id: str :param name: New subnet name - :type name: str | None :param description: New subnet description - :type description: str | None :param gateway_ip: New gateway IP - :type gateway_ip: str | None :param is_dhcp_enabled: DHCP enabled state - :type is_dhcp_enabled: bool | None :param dns_nameservers: DNS nameserver list - :type dns_nameservers: list[str] | None :param allocation_pools: Allocation pool list - :type allocation_pools: list[dict] | None :param host_routes: Static host routes - :type host_routes: list[dict] | None :return: Updated Subnet object - :rtype: Subnet - :raises Exception: If no update parameters are provided """ conn = get_openstack_conn() update_args: dict = {} @@ -421,10 +366,7 @@ def delete_subnet(self, subnet_id: str) -> None: Delete a Subnet. :param subnet_id: ID of the subnet to delete - :type subnet_id: str :return: None - :rtype: None - :raises Exception: If the subnet is not found """ conn = get_openstack_conn() conn.network.delete_subnet(subnet_id, ignore_missing=False) @@ -435,11 +377,8 @@ def set_subnet_gateway(self, subnet_id: str, gateway_ip: str) -> Subnet: Set or update a subnet's gateway IP. :param subnet_id: Subnet ID - :type subnet_id: str :param gateway_ip: Gateway IP to set - :type gateway_ip: str :return: Updated Subnet object - :rtype: Subnet """ conn = get_openstack_conn() subnet = conn.network.update_subnet(subnet_id, gateway_ip=gateway_ip) @@ -450,9 +389,7 @@ def clear_subnet_gateway(self, subnet_id: str) -> Subnet: Clear a subnet's gateway IP (set to `None`). :param subnet_id: Subnet ID - :type subnet_id: str :return: Updated Subnet object - :rtype: Subnet """ conn = get_openstack_conn() subnet = conn.network.update_subnet(subnet_id, gateway_ip=None) @@ -463,11 +400,8 @@ def set_subnet_dhcp_enabled(self, subnet_id: str, enabled: bool) -> Subnet: Enable or disable DHCP on a subnet. :param subnet_id: Subnet ID - :type subnet_id: str :param enabled: DHCP enabled state - :type enabled: bool :return: Updated Subnet object - :rtype: Subnet """ conn = get_openstack_conn() subnet = conn.network.update_subnet(subnet_id, enable_dhcp=enabled) @@ -478,10 +412,7 @@ def toggle_subnet_dhcp(self, subnet_id: str) -> Subnet: Toggle DHCP enabled state for a subnet. :param subnet_id: Subnet ID - :type subnet_id: str :return: Updated Subnet object - :rtype: Subnet - :raises Exception: If the subnet is not found """ conn = get_openstack_conn() current = conn.network.get_subnet(subnet_id) @@ -496,9 +427,7 @@ def _convert_to_subnet_model(self, openstack_subnet) -> Subnet: Convert an OpenStack subnet object to a Subnet pydantic model. :param openstack_subnet: OpenStack subnet object - :type openstack_subnet: Any :return: Pydantic Subnet model - :rtype: Subnet """ return Subnet( id=openstack_subnet.id, @@ -526,13 +455,9 @@ def get_ports( Get the list of Ports with optional filtering. :param status_filter: Filter by port status (e.g., `ACTIVE`, `DOWN`) - :type status_filter: str | None :param device_id: Filter by device ID - :type device_id: str | None :param network_id: Filter by network ID - :type network_id: str | None :return: List of Port objects - :rtype: list[Port] """ conn = get_openstack_conn() filters: dict = {} @@ -555,14 +480,9 @@ def add_port_fixed_ip( Add a fixed IP to a port. :param port_id: Target port ID - :type port_id: str :param subnet_id: Subnet ID of the fixed IP entry - :type subnet_id: str | None :param ip_address: Fixed IP address to add - :type ip_address: str | None :return: Updated Port object - :rtype: Port - :raises Exception: If the port is not found """ conn = get_openstack_conn() port = conn.network.get_port(port_id) @@ -586,14 +506,9 @@ def remove_port_fixed_ip( Remove a fixed IP entry from a port. :param port_id: Target port ID - :type port_id: str :param ip_address: Fixed IP address to remove - :type ip_address: str | None :param subnet_id: Subnet ID of the entry to remove - :type subnet_id: str | None :return: Updated Port object - :rtype: Port - :raises Exception: If the port is not found """ conn = get_openstack_conn() port = conn.network.get_port(port_id) @@ -617,10 +532,7 @@ def get_port_allowed_address_pairs(self, port_id: str) -> list[dict]: Get allowed address pairs configured on a port. :param port_id: Port ID - :type port_id: str :return: Allowed address pairs - :rtype: list[dict] - :raises Exception: If the port is not found """ conn = get_openstack_conn() port = conn.network.get_port(port_id) @@ -636,14 +548,9 @@ def add_port_allowed_address_pair( Add an allowed address pair to a port. :param port_id: Port ID - :type port_id: str :param ip_address: IP address to allow - :type ip_address: str :param mac_address: MAC address to allow - :type mac_address: str | None :return: Updated Port object - :rtype: Port - :raises Exception: If the port is not found """ conn = get_openstack_conn() port = conn.network.get_port(port_id) @@ -670,14 +577,9 @@ def remove_port_allowed_address_pair( Remove an allowed address pair from a port. :param port_id: Port ID - :type port_id: str :param ip_address: IP address to remove - :type ip_address: str :param mac_address: MAC address to remove. If not provided, remove all pairs with the IP - :type mac_address: str | None :return: Updated Port object - :rtype: Port - :raises Exception: If the port is not found """ conn = get_openstack_conn() port = conn.network.get_port(port_id) @@ -709,16 +611,10 @@ def set_port_binding( Set binding attributes for a port. :param port_id: Port ID - :type port_id: str :param host_id: Binding host ID - :type host_id: str | None :param vnic_type: VNIC type - :type vnic_type: str | None :param profile: Binding profile - :type profile: dict | None :return: Updated Port object - :rtype: Port - :raises Exception: If no update parameters are provided """ conn = get_openstack_conn() update_args: dict = {} @@ -743,11 +639,8 @@ def set_port_admin_state( Set the administrative state of a port. :param port_id: Port ID - :type port_id: str :param is_admin_state_up: Administrative state - :type is_admin_state_up: bool :return: Updated Port object - :rtype: Port """ conn = get_openstack_conn() updated = conn.network.update_port( @@ -761,10 +654,7 @@ def toggle_port_admin_state(self, port_id: str) -> Port: Toggle the administrative state of a port. :param port_id: Port ID - :type port_id: str :return: Updated Port object - :rtype: Port - :raises Exception: If the port is not found """ conn = get_openstack_conn() current = conn.network.get_port(port_id) @@ -788,21 +678,13 @@ def create_port( Create a new Port. :param network_id: ID of the parent network - :type network_id: str :param name: Port name - :type name: str | None :param description: Port description - :type description: str | None :param is_admin_state_up: Administrative state - :type is_admin_state_up: bool :param device_id: Device ID - :type device_id: str | None :param fixed_ips: Fixed IP list - :type fixed_ips: list[dict] | None :param security_group_ids: Security group ID list - :type security_group_ids: list[str] | None :return: Created Port object - :rtype: Port """ conn = get_openstack_conn() port_args: dict = { @@ -827,10 +709,7 @@ def get_port_detail(self, port_id: str) -> Port: Get detailed information about a specific Port. :param port_id: ID of the port to retrieve - :type port_id: str :return: Port details - :rtype: Port - :raises Exception: If the port is not found """ conn = get_openstack_conn() port = conn.network.get_port(port_id) @@ -849,20 +728,12 @@ def update_port( Update an existing Port. :param port_id: ID of the port to update - :type port_id: str :param name: New port name - :type name: str | None :param description: New port description - :type description: str | None :param is_admin_state_up: Administrative state - :type is_admin_state_up: bool | None :param device_id: Device ID - :type device_id: str | None :param security_group_ids: Security group ID list - :type security_group_ids: list[str] | None :return: Updated Port object - :rtype: Port - :raises Exception: If no update parameters are provided """ conn = get_openstack_conn() update_args: dict = {} @@ -887,10 +758,7 @@ def delete_port(self, port_id: str) -> None: Delete a Port. :param port_id: ID of the port to delete - :type port_id: str :return: None - :rtype: None - :raises Exception: If the port is not found """ conn = get_openstack_conn() conn.network.delete_port(port_id, ignore_missing=False) @@ -901,9 +769,7 @@ def _convert_to_port_model(self, openstack_port) -> Port: Convert an OpenStack Port object to a Port pydantic model. :param openstack_port: OpenStack port object - :type openstack_port: Any :return: Pydantic Port model - :rtype: Port """ return Port( id=openstack_port.id, @@ -934,17 +800,11 @@ def get_floating_ips( Get the list of Floating IPs with optional filtering. :param status_filter: Filter by IP status (e.g., `ACTIVE`) - :type status_filter: str | None :param project_id: Filter by project ID - :type project_id: str | None :param port_id: Filter by attached port ID - :type port_id: str | None :param floating_network_id: Filter by external network ID - :type floating_network_id: str | None :param unassigned_only: If True, return only unassigned IPs - :type unassigned_only: bool | None :return: List of FloatingIP objects - :rtype: list[FloatingIP] """ conn = get_openstack_conn() filters: dict = {} @@ -973,17 +833,11 @@ def create_floating_ip( Create a new Floating IP. :param floating_network_id: External (floating) network ID - :type floating_network_id: str :param description: Floating IP description - :type description: str | None :param fixed_ip_address: Internal fixed IP to map - :type fixed_ip_address: str | None :param port_id: Port ID to attach - :type port_id: str | None :param project_id: Project ID - :type project_id: str | None :return: Created FloatingIP object - :rtype: FloatingIP """ conn = get_openstack_conn() ip_args: dict = {"floating_network_id": floating_network_id} @@ -1007,11 +861,8 @@ def allocate_floating_ip_pool_to_project( Allocate Floating IP pool (external network access) to a project via RBAC. :param floating_network_id: External network ID - :type floating_network_id: str :param project_id: Target project ID - :type project_id: str :return: None - :rtype: None """ conn = get_openstack_conn() conn.network.create_rbac_policy( @@ -1032,13 +883,9 @@ def attach_floating_ip_to_port( Attach a Floating IP to a Port. :param floating_ip_id: Floating IP ID - :type floating_ip_id: str :param port_id: Port ID to attach - :type port_id: str :param fixed_ip_address: Specific fixed IP on the port (optional) - :type fixed_ip_address: str | None - :return: Updated FloatingIP object - :rtype: FloatingIP + :return: Updated Floating IP object """ conn = get_openstack_conn() update_args: dict = {"port_id": port_id} @@ -1052,9 +899,7 @@ def detach_floating_ip_from_port(self, floating_ip_id: str) -> FloatingIP: Detach a Floating IP from its Port. :param floating_ip_id: Floating IP ID - :type floating_ip_id: str :return: Updated FloatingIP object - :rtype: FloatingIP """ conn = get_openstack_conn() ip = conn.network.update_ip(floating_ip_id, port_id=None) @@ -1065,10 +910,7 @@ def delete_floating_ip(self, floating_ip_id: str) -> None: Delete a Floating IP. :param floating_ip_id: Floating IP ID to delete - :type floating_ip_id: str :return: None - :rtype: None - :raises Exception: If the floating IP is not found """ conn = get_openstack_conn() conn.network.delete_ip(floating_ip_id, ignore_missing=False) @@ -1083,11 +925,8 @@ def update_floating_ip_description( Update a Floating IP's description. :param floating_ip_id: Floating IP ID - :type floating_ip_id: str :param description: New description (`None` to clear) - :type description: str | None :return: Updated FloatingIP object - :rtype: FloatingIP """ conn = get_openstack_conn() ip = conn.network.update_ip(floating_ip_id, description=description) @@ -1103,13 +942,9 @@ def reassign_floating_ip_to_port( Reassign a Floating IP to a different Port. :param floating_ip_id: Floating IP ID - :type floating_ip_id: str :param port_id: New port ID to attach - :type port_id: str :param fixed_ip_address: Specific fixed IP on the port (optional) - :type fixed_ip_address: str | None :return: Updated Floating IP object - :rtype: FloatingIP """ conn = get_openstack_conn() update_args: dict = {"port_id": port_id} @@ -1127,11 +962,8 @@ def create_floating_ips_bulk( Create multiple floating IPs on the specified external network. :param floating_network_id: External network ID - :type floating_network_id: str :param count: Number of floating IPs to create (negative treated as 0) - :type count: int :return: List of created FloatingIP objects - :rtype: list[FloatingIP] """ conn = get_openstack_conn() created = [] @@ -1152,11 +984,8 @@ def assign_first_available_floating_ip( If none are available, create a new one and assign it. :param floating_network_id: External network ID - :type floating_network_id: str :param port_id: Target port ID - :type port_id: str :return: Updated FloatingIP object - :rtype: FloatingIP """ conn = get_openstack_conn() existing = list( @@ -1181,9 +1010,7 @@ def _convert_to_floating_ip_model(self, openstack_ip) -> FloatingIP: Convert an OpenStack floating IP object to a FloatingIP pydantic model. :param openstack_ip: OpenStack floating IP object - :type openstack_ip: Any :return: Pydantic FloatingIP model - :rtype: FloatingIP """ return FloatingIP( id=openstack_ip.id, From 5e523a9c8bfdbfccd4f7477f6fa4455ca5b8533e Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Wed, 20 Aug 2025 12:36:16 +0900 Subject: [PATCH 08/15] fix(network): remove unused allocate floating ip feature (#30) --- .../tools/network_tools.py | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index 7c5b35c..a3b21a6 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -44,7 +44,6 @@ def register_tools(self, mcp: FastMCP): mcp.tool()(self.toggle_port_admin_state) mcp.tool()(self.get_floating_ips) mcp.tool()(self.create_floating_ip) - mcp.tool()(self.allocate_floating_ip_pool_to_project) mcp.tool()(self.attach_floating_ip_to_port) mcp.tool()(self.detach_floating_ip_from_port) mcp.tool()(self.delete_floating_ip) @@ -418,7 +417,7 @@ def toggle_subnet_dhcp(self, subnet_id: str) -> Subnet: current = conn.network.get_subnet(subnet_id) subnet = conn.network.update_subnet( subnet_id, - enable_dhcp=not current.enable_dhcp, + enable_dhcp=False if current.enable_dhcp else True, ) return self._convert_to_subnet_model(subnet) @@ -852,27 +851,6 @@ def create_floating_ip( ip = conn.network.create_ip(**ip_args) return self._convert_to_floating_ip_model(ip) - def allocate_floating_ip_pool_to_project( - self, - floating_network_id: str, - project_id: str, - ) -> None: - """ - Allocate Floating IP pool (external network access) to a project via RBAC. - - :param floating_network_id: External network ID - :param project_id: Target project ID - :return: None - """ - conn = get_openstack_conn() - conn.network.create_rbac_policy( - object_type="network", - object_id=floating_network_id, - action="access_as_external", - target_project_id=project_id, - ) - return None - def attach_floating_ip_to_port( self, floating_ip_id: str, From e63a209b430d12ac7182e2efdcf6d2b683cae00d Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Wed, 20 Aug 2025 13:23:36 +0900 Subject: [PATCH 09/15] improve(network): integrating subnet update feature (#30) --- .../tools/network_tools.py | 75 +++++-------------- tests/tools/test_network_tools.py | 11 +-- 2 files changed, 22 insertions(+), 64 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index a3b21a6..f2b80d5 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -9,6 +9,9 @@ ) +_UNSET = object() + + class NetworkTools: """ A class to encapsulate Network-related tools and utilities. @@ -51,10 +54,6 @@ def register_tools(self, mcp: FastMCP): mcp.tool()(self.reassign_floating_ip_to_port) mcp.tool()(self.create_floating_ips_bulk) mcp.tool()(self.assign_first_available_floating_ip) - mcp.tool()(self.set_subnet_gateway) - mcp.tool()(self.clear_subnet_gateway) - mcp.tool()(self.set_subnet_dhcp_enabled) - mcp.tool()(self.toggle_subnet_dhcp) def get_networks( self, @@ -319,14 +318,26 @@ def update_subnet( subnet_id: str, name: str | None = None, description: str | None = None, - gateway_ip: str | None = None, + gateway_ip: str | None | object = _UNSET, is_dhcp_enabled: bool | None = None, dns_nameservers: list[str] | None = None, allocation_pools: list[dict] | None = None, host_routes: list[dict] | None = None, ) -> Subnet: """ - Update an existing Subnet. + Update subnet attributes atomically. Only provided parameters are changed; omitted + parameters remain untouched. + + Typical use-cases: + - Set gateway: pass gateway_ip="10.0.0.1". + - Clear gateway: pass gateway_ip=None. + - Enable/disable DHCP: pass is_dhcp_enabled=True or False. + - Batch updates: change name/description and DNS nameservers together. + + Notes: + - OpenStack Neutron supports partial updates. Passing None for gateway_ip clears the gateway. + - To emulate a DHCP toggle, read current state and invert it, then call this method with + is_dhcp_enabled set accordingly. :param subnet_id: ID of the subnet to update :param name: New subnet name @@ -344,7 +355,7 @@ def update_subnet( update_args["name"] = name if description is not None: update_args["description"] = description - if gateway_ip is not None: + if gateway_ip is not _UNSET: update_args["gateway_ip"] = gateway_ip if is_dhcp_enabled is not None: update_args["enable_dhcp"] = is_dhcp_enabled @@ -371,56 +382,6 @@ def delete_subnet(self, subnet_id: str) -> None: conn.network.delete_subnet(subnet_id, ignore_missing=False) return None - def set_subnet_gateway(self, subnet_id: str, gateway_ip: str) -> Subnet: - """ - Set or update a subnet's gateway IP. - - :param subnet_id: Subnet ID - :param gateway_ip: Gateway IP to set - :return: Updated Subnet object - """ - conn = get_openstack_conn() - subnet = conn.network.update_subnet(subnet_id, gateway_ip=gateway_ip) - return self._convert_to_subnet_model(subnet) - - def clear_subnet_gateway(self, subnet_id: str) -> Subnet: - """ - Clear a subnet's gateway IP (set to `None`). - - :param subnet_id: Subnet ID - :return: Updated Subnet object - """ - conn = get_openstack_conn() - subnet = conn.network.update_subnet(subnet_id, gateway_ip=None) - return self._convert_to_subnet_model(subnet) - - def set_subnet_dhcp_enabled(self, subnet_id: str, enabled: bool) -> Subnet: - """ - Enable or disable DHCP on a subnet. - - :param subnet_id: Subnet ID - :param enabled: DHCP enabled state - :return: Updated Subnet object - """ - conn = get_openstack_conn() - subnet = conn.network.update_subnet(subnet_id, enable_dhcp=enabled) - return self._convert_to_subnet_model(subnet) - - def toggle_subnet_dhcp(self, subnet_id: str) -> Subnet: - """ - Toggle DHCP enabled state for a subnet. - - :param subnet_id: Subnet ID - :return: Updated Subnet object - """ - conn = get_openstack_conn() - current = conn.network.get_subnet(subnet_id) - subnet = conn.network.update_subnet( - subnet_id, - enable_dhcp=False if current.enable_dhcp else True, - ) - return self._convert_to_subnet_model(subnet) - def _convert_to_subnet_model(self, openstack_subnet) -> Subnet: """ Convert an OpenStack subnet object to a Subnet pydantic model. diff --git a/tests/tools/test_network_tools.py b/tests/tools/test_network_tools.py index a4ec395..235cd56 100644 --- a/tests/tools/test_network_tools.py +++ b/tests/tools/test_network_tools.py @@ -1076,11 +1076,11 @@ def test_set_and_clear_subnet_gateway( mock_conn.network.update_subnet.return_value = updated tools = self.get_network_tools() - res1 = tools.set_subnet_gateway("subnet-1", "10.0.0.254") + res1 = tools.update_subnet("subnet-1", gateway_ip="10.0.0.254") assert res1.gateway_ip == "10.0.0.254" updated.gateway_ip = None - res2 = tools.clear_subnet_gateway("subnet-1") + res2 = tools.update_subnet("subnet-1", gateway_ip=None) assert res2.gateway_ip is None def test_set_and_toggle_subnet_dhcp( @@ -1107,14 +1107,11 @@ def test_set_and_toggle_subnet_dhcp( mock_conn.network.update_subnet.return_value = updated tools = self.get_network_tools() - res1 = tools.set_subnet_dhcp_enabled("subnet-1", True) + res1 = tools.update_subnet("subnet-1", is_dhcp_enabled=True) assert res1.is_dhcp_enabled is True - current = Mock() - current.enable_dhcp = True - mock_conn.network.get_subnet.return_value = current updated.enable_dhcp = False - res2 = tools.toggle_subnet_dhcp("subnet-1") + res2 = tools.update_subnet("subnet-1", is_dhcp_enabled=False) assert res2.is_dhcp_enabled is False def test_get_floating_ips_with_filters_and_unassigned( From ceebb24386585d2600997194b5f96ea1d3213d9b Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Wed, 20 Aug 2025 13:35:21 +0900 Subject: [PATCH 10/15] improve(network): integrating port update feature (#30) --- .../tools/network_tools.py | 190 +++--------------- tests/tools/test_network_tools.py | 41 ++-- 2 files changed, 54 insertions(+), 177 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index f2b80d5..e806b04 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -37,14 +37,10 @@ def register_tools(self, mcp: FastMCP): mcp.tool()(self.get_port_detail) mcp.tool()(self.update_port) mcp.tool()(self.delete_port) - mcp.tool()(self.add_port_fixed_ip) - mcp.tool()(self.remove_port_fixed_ip) + mcp.tool()(self.get_port_allowed_address_pairs) - mcp.tool()(self.add_port_allowed_address_pair) - mcp.tool()(self.remove_port_allowed_address_pair) mcp.tool()(self.set_port_binding) - mcp.tool()(self.set_port_admin_state) - mcp.tool()(self.toggle_port_admin_state) + mcp.tool()(self.get_floating_ips) mcp.tool()(self.create_floating_ip) mcp.tool()(self.attach_floating_ip_to_port) @@ -430,63 +426,6 @@ def get_ports( ports = conn.list_ports(filters=filters) return [self._convert_to_port_model(port) for port in ports] - def add_port_fixed_ip( - self, - port_id: str, - subnet_id: str | None = None, - ip_address: str | None = None, - ) -> Port: - """ - Add a fixed IP to a port. - - :param port_id: Target port ID - :param subnet_id: Subnet ID of the fixed IP entry - :param ip_address: Fixed IP address to add - :return: Updated Port object - """ - conn = get_openstack_conn() - port = conn.network.get_port(port_id) - fixed_ips = list(port.fixed_ips or []) - entry: dict = {} - if subnet_id is not None: - entry["subnet_id"] = subnet_id - if ip_address is not None: - entry["ip_address"] = ip_address - fixed_ips.append(entry) - updated = conn.network.update_port(port_id, fixed_ips=fixed_ips) - return self._convert_to_port_model(updated) - - def remove_port_fixed_ip( - self, - port_id: str, - ip_address: str | None = None, - subnet_id: str | None = None, - ) -> Port: - """ - Remove a fixed IP entry from a port. - - :param port_id: Target port ID - :param ip_address: Fixed IP address to remove - :param subnet_id: Subnet ID of the entry to remove - :return: Updated Port object - """ - conn = get_openstack_conn() - port = conn.network.get_port(port_id) - current = list(port.fixed_ips or []) - if not current: - return self._convert_to_port_model(port) - - def predicate(item: dict) -> bool: - if ip_address is not None and item.get("ip_address") == ip_address: - return False - if subnet_id is not None and item.get("subnet_id") == subnet_id: - return False - return True - - new_fixed = [fi for fi in current if predicate(fi)] - updated = conn.network.update_port(port_id, fixed_ips=new_fixed) - return self._convert_to_port_model(updated) - def get_port_allowed_address_pairs(self, port_id: str) -> list[dict]: """ Get allowed address pairs configured on a port. @@ -498,68 +437,6 @@ def get_port_allowed_address_pairs(self, port_id: str) -> list[dict]: port = conn.network.get_port(port_id) return list(port.allowed_address_pairs or []) - def add_port_allowed_address_pair( - self, - port_id: str, - ip_address: str, - mac_address: str | None = None, - ) -> Port: - """ - Add an allowed address pair to a port. - - :param port_id: Port ID - :param ip_address: IP address to allow - :param mac_address: MAC address to allow - :return: Updated Port object - """ - conn = get_openstack_conn() - port = conn.network.get_port(port_id) - - pairs = list(port.allowed_address_pairs or []) - entry = {"ip_address": ip_address} - if mac_address is not None: - entry["mac_address"] = mac_address - pairs.append(entry) - - updated = conn.network.update_port( - port_id, - allowed_address_pairs=pairs, - ) - return self._convert_to_port_model(updated) - - def remove_port_allowed_address_pair( - self, - port_id: str, - ip_address: str, - mac_address: str | None = None, - ) -> Port: - """ - Remove an allowed address pair from a port. - - :param port_id: Port ID - :param ip_address: IP address to remove - :param mac_address: MAC address to remove. If not provided, remove all pairs with the IP - :return: Updated Port object - """ - conn = get_openstack_conn() - port = conn.network.get_port(port_id) - pairs = list(port.allowed_address_pairs or []) - - def keep(p: dict) -> bool: - if mac_address is None: - return p.get("ip_address") != ip_address - return not ( - p.get("ip_address") == ip_address - and p.get("mac_address") == mac_address - ) - - new_pairs = [p for p in pairs if keep(p)] - updated = conn.network.update_port( - port_id, - allowed_address_pairs=new_pairs, - ) - return self._convert_to_port_model(updated) - def set_port_binding( self, port_id: str, @@ -590,40 +467,6 @@ def set_port_binding( updated = conn.network.update_port(port_id, **update_args) return self._convert_to_port_model(updated) - def set_port_admin_state( - self, - port_id: str, - is_admin_state_up: bool, - ) -> Port: - """ - Set the administrative state of a port. - - :param port_id: Port ID - :param is_admin_state_up: Administrative state - :return: Updated Port object - """ - conn = get_openstack_conn() - updated = conn.network.update_port( - port_id, - admin_state_up=is_admin_state_up, - ) - return self._convert_to_port_model(updated) - - def toggle_port_admin_state(self, port_id: str) -> Port: - """ - Toggle the administrative state of a port. - - :param port_id: Port ID - :return: Updated Port object - """ - conn = get_openstack_conn() - current = conn.network.get_port(port_id) - updated = conn.network.update_port( - port_id, - admin_state_up=not current.admin_state_up, - ) - return self._convert_to_port_model(updated) - def create_port( self, network_id: str, @@ -683,16 +526,37 @@ def update_port( is_admin_state_up: bool | None = None, device_id: str | None = None, security_group_ids: list[str] | None = None, + allowed_address_pairs: list[dict] | None = None, + fixed_ips: list[dict] | None = None, ) -> Port: """ - Update an existing Port. + Update an existing Port. Only provided parameters are changed; omitted parameters remain untouched. + + Typical use-cases: + - Set admin state down: is_admin_state_up=False + - Toggle admin state: read current via get_port_detail() then pass the inverted value + - Replace security groups: security_group_ids=["sg-1", "sg-2"] + - Replace allowed address pairs: + 1) current = get_port_allowed_address_pairs(port_id) + 2) edit the list (append/remove dicts) + 3) update_port(port_id, allowed_address_pairs=current) + - Replace fixed IPs: + 1) current = get_port_detail(port_id).fixed_ips + 2) edit the list + 3) update_port(port_id, fixed_ips=current) + + Notes: + - For list-typed fields like security groups or allowed address pairs, this method replaces + the entire list with the provided value. To remove all entries, pass an empty list []. :param port_id: ID of the port to update :param name: New port name :param description: New port description :param is_admin_state_up: Administrative state :param device_id: Device ID - :param security_group_ids: Security group ID list + :param security_group_ids: Security group ID list (replaces entire list) + :param allowed_address_pairs: Allowed address pairs (replaces entire list) + :param fixed_ips: Fixed IP assignments (replaces entire list) :return: Updated Port object """ conn = get_openstack_conn() @@ -707,6 +571,10 @@ def update_port( update_args["device_id"] = device_id if security_group_ids is not None: update_args["security_groups"] = security_group_ids + if allowed_address_pairs is not None: + update_args["allowed_address_pairs"] = allowed_address_pairs + if fixed_ips is not None: + update_args["fixed_ips"] = fixed_ips if not update_args: current = conn.network.get_port(port_id) return self._convert_to_port_model(current) diff --git a/tests/tools/test_network_tools.py b/tests/tools/test_network_tools.py index 235cd56..a75328a 100644 --- a/tests/tools/test_network_tools.py +++ b/tests/tools/test_network_tools.py @@ -668,11 +668,9 @@ def test_add_port_fixed_ip(self, mock_openstack_connect_network): mock_conn.network.update_port.return_value = updated tools = self.get_network_tools() - res = tools.add_port_fixed_ip( - "port-1", - subnet_id="subnet-2", - ip_address="10.0.1.10", - ) + new_fixed = list(current.fixed_ips) + new_fixed.append({"subnet_id": "subnet-2", "ip_address": "10.0.1.10"}) + res = tools.update_port("port-1", fixed_ips=new_fixed) assert len(res.fixed_ips or []) == 2 def test_remove_port_fixed_ip(self, mock_openstack_connect_network): @@ -703,7 +701,10 @@ def test_remove_port_fixed_ip(self, mock_openstack_connect_network): mock_conn.network.update_port.return_value = updated tools = self.get_network_tools() - res = tools.remove_port_fixed_ip("port-1", ip_address="10.0.1.10") + filtered = [ + fi for fi in current.fixed_ips if fi["ip_address"] != "10.0.1.10" + ] + res = tools.update_port("port-1", fixed_ips=filtered) assert len(res.fixed_ips or []) == 1 def test_get_and_update_allowed_address_pairs( @@ -735,17 +736,23 @@ def test_get_and_update_allowed_address_pairs( updated.security_group_ids = None mock_conn.network.update_port.return_value = updated - res_add = tools.add_port_allowed_address_pair( - "port-1", - "192.0.2.5", - mac_address="aa:bb:cc:dd:ee:ff", + pairs = [] + pairs.append( + {"ip_address": "192.0.2.5", "mac_address": "aa:bb:cc:dd:ee:ff"} ) + res_add = tools.update_port("port-1", allowed_address_pairs=pairs) assert isinstance(res_add, Port) - res_remove = tools.remove_port_allowed_address_pair( - "port-1", - "192.0.2.5", - mac_address="aa:bb:cc:dd:ee:ff", + filtered = [ + p + for p in pairs + if not ( + p["ip_address"] == "192.0.2.5" + and p["mac_address"] == "aa:bb:cc:dd:ee:ff" + ) + ] + res_remove = tools.update_port( + "port-1", allowed_address_pairs=filtered ) assert isinstance(res_remove, Port) @@ -779,14 +786,16 @@ def test_set_port_binding_and_admin_state( ) assert isinstance(res_bind, Port) - res_set = tools.set_port_admin_state("port-1", False) + res_set = tools.update_port("port-1", is_admin_state_up=False) assert res_set.is_admin_state_up is False current = Mock() current.admin_state_up = False mock_conn.network.get_port.return_value = current updated.admin_state_up = True - res_toggle = tools.toggle_port_admin_state("port-1") + res_toggle = tools.update_port( + "port-1", is_admin_state_up=not current.admin_state_up + ) assert res_toggle.is_admin_state_up is True def test_get_subnets_filters_and_has_gateway_true( From 548cbd57efded00f2d76c442559863346b40632a Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Wed, 20 Aug 2025 19:42:29 +0900 Subject: [PATCH 11/15] improve(network): integrating port update feature (#30) --- .../tools/network_tools.py | 83 ++++++++----------- tests/tools/test_network_tools.py | 10 +-- 2 files changed, 41 insertions(+), 52 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index e806b04..cd678d2 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -37,17 +37,12 @@ def register_tools(self, mcp: FastMCP): mcp.tool()(self.get_port_detail) mcp.tool()(self.update_port) mcp.tool()(self.delete_port) - mcp.tool()(self.get_port_allowed_address_pairs) mcp.tool()(self.set_port_binding) - mcp.tool()(self.get_floating_ips) mcp.tool()(self.create_floating_ip) - mcp.tool()(self.attach_floating_ip_to_port) - mcp.tool()(self.detach_floating_ip_from_port) mcp.tool()(self.delete_floating_ip) - mcp.tool()(self.update_floating_ip_description) - mcp.tool()(self.reassign_floating_ip_to_port) + mcp.tool()(self.update_floating_ip) mcp.tool()(self.create_floating_ips_bulk) mcp.tool()(self.assign_first_available_floating_ip) @@ -701,15 +696,46 @@ def attach_floating_ip_to_port( ip = conn.network.update_ip(floating_ip_id, **update_args) return self._convert_to_floating_ip_model(ip) - def detach_floating_ip_from_port(self, floating_ip_id: str) -> FloatingIP: + def update_floating_ip( + self, + floating_ip_id: str, + description: str | None | object = _UNSET, + port_id: str | None | object = _UNSET, + fixed_ip_address: str | None | object = _UNSET, + ) -> FloatingIP: """ - Detach a Floating IP from its Port. + Update Floating IP attributes. Only provided parameters are changed; omitted + parameters remain untouched. - :param floating_ip_id: Floating IP ID + Typical use-cases: + - Attach to a port: port_id="port-1" (optionally fixed_ip_address="10.0.0.10"). + - Detach from its port: port_id=None. + - Update description: description="new desc" or clear with description=None. + - Reassign to another port: port_id="new-port" (optionally with fixed_ip_address). + + Notes: + - Passing None for description clears it. + - Passing None for port_id detaches the address from any port. + - fixed_ip_address is optional and can be provided alongside port_id. + + :param floating_ip_id: Floating IP ID to update + :param description: New description or None to clear + :param port_id: Port ID to attach; None to detach; omit to keep unchanged + :param fixed_ip_address: Specific fixed IP to map; omit to keep unchanged :return: Updated FloatingIP object """ conn = get_openstack_conn() - ip = conn.network.update_ip(floating_ip_id, port_id=None) + update_args: dict = {} + if description is not _UNSET: + update_args["description"] = description + if port_id is not _UNSET: + update_args["port_id"] = port_id + if fixed_ip_address is not _UNSET: + update_args["fixed_ip_address"] = fixed_ip_address + if not update_args: + current = conn.network.get_ip(floating_ip_id) + return self._convert_to_floating_ip_model(current) + ip = conn.network.update_ip(floating_ip_id, **update_args) return self._convert_to_floating_ip_model(ip) def delete_floating_ip(self, floating_ip_id: str) -> None: @@ -723,43 +749,6 @@ def delete_floating_ip(self, floating_ip_id: str) -> None: conn.network.delete_ip(floating_ip_id, ignore_missing=False) return None - def update_floating_ip_description( - self, - floating_ip_id: str, - description: str | None, - ) -> FloatingIP: - """ - Update a Floating IP's description. - - :param floating_ip_id: Floating IP ID - :param description: New description (`None` to clear) - :return: Updated FloatingIP object - """ - conn = get_openstack_conn() - ip = conn.network.update_ip(floating_ip_id, description=description) - return self._convert_to_floating_ip_model(ip) - - def reassign_floating_ip_to_port( - self, - floating_ip_id: str, - port_id: str, - fixed_ip_address: str | None = None, - ) -> FloatingIP: - """ - Reassign a Floating IP to a different Port. - - :param floating_ip_id: Floating IP ID - :param port_id: New port ID to attach - :param fixed_ip_address: Specific fixed IP on the port (optional) - :return: Updated Floating IP object - """ - conn = get_openstack_conn() - update_args: dict = {"port_id": port_id} - if fixed_ip_address is not None: - update_args["fixed_ip_address"] = fixed_ip_address - ip = conn.network.update_ip(floating_ip_id, **update_args) - return self._convert_to_floating_ip_model(ip) - def create_floating_ips_bulk( self, floating_network_id: str, diff --git a/tests/tools/test_network_tools.py b/tests/tools/test_network_tools.py index a75328a..502c9ca 100644 --- a/tests/tools/test_network_tools.py +++ b/tests/tools/test_network_tools.py @@ -1213,15 +1213,15 @@ def test_create_attach_detach_delete_floating_ip( updated.router_id = None mock_conn.network.update_ip.return_value = updated - attached = tools.attach_floating_ip_to_port( + attached = tools.update_floating_ip( "fip-1", - "port-1", + port_id="port-1", fixed_ip_address="10.0.0.10", ) assert attached.port_id == "port-1" updated.port_id = None - detached = tools.detach_floating_ip_from_port("fip-1") + detached = tools.update_floating_ip("fip-1", port_id=None) assert detached.port_id is None mock_conn.network.get_ip.return_value = updated @@ -1251,11 +1251,11 @@ def test_update_reassign_bulk_and_auto_assign_floating_ip( mock_conn.network.update_ip.return_value = updated tools = self.get_network_tools() - res_desc = tools.update_floating_ip_description("fip-1", "new desc") + res_desc = tools.update_floating_ip("fip-1", description="new desc") assert res_desc.description == "new desc" updated.port_id = "port-2" - res_reassign = tools.reassign_floating_ip_to_port("fip-1", "port-2") + res_reassign = tools.update_floating_ip("fip-1", port_id="port-2") assert res_reassign.port_id == "port-2" f1 = Mock() From 5e5c95185dfe73b8e26bae886c99c13960112930 Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Wed, 20 Aug 2025 21:37:06 +0900 Subject: [PATCH 12/15] fix(network): alignment subnet model field (#30) --- src/openstack_mcp_server/config.py | 2 +- src/openstack_mcp_server/tools/network_tools.py | 12 +++++++----- tests/tools/test_network_tools.py | 10 ++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/openstack_mcp_server/config.py b/src/openstack_mcp_server/config.py index 9160f10..72c892a 100644 --- a/src/openstack_mcp_server/config.py +++ b/src/openstack_mcp_server/config.py @@ -4,7 +4,7 @@ # Transport protocol -MCP_TRANSPORT: str = os.environ.get("TRANSPORT", "stdio") +MCP_TRANSPORT: str = os.environ.get("TRANSPORT", "streamable-http") # Openstack client settings MCP_CLOUD_NAME: str = os.environ.get("CLOUD_NAME", "openstack") diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index cd678d2..e5cf3be 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -383,17 +383,19 @@ def _convert_to_subnet_model(self, openstack_subnet) -> Subnet: return Subnet( id=openstack_subnet.id, name=openstack_subnet.name, - status=openstack_subnet.status, + status=getattr(openstack_subnet, "status", None), description=openstack_subnet.description, project_id=openstack_subnet.project_id, network_id=openstack_subnet.network_id, cidr=openstack_subnet.cidr, ip_version=openstack_subnet.ip_version, gateway_ip=openstack_subnet.gateway_ip, - is_dhcp_enabled=openstack_subnet.enable_dhcp, - allocation_pools=openstack_subnet.allocation_pools, - dns_nameservers=openstack_subnet.dns_nameservers, - host_routes=openstack_subnet.host_routes, + is_dhcp_enabled=openstack_subnet.is_dhcp_enabled, + allocation_pools=getattr( + openstack_subnet, "allocation_pools", None + ), + dns_nameservers=getattr(openstack_subnet, "dns_nameservers", None), + host_routes=getattr(openstack_subnet, "host_routes", None), ) def get_ports( diff --git a/tests/tools/test_network_tools.py b/tests/tools/test_network_tools.py index 502c9ca..446881a 100644 --- a/tests/tools/test_network_tools.py +++ b/tests/tools/test_network_tools.py @@ -815,6 +815,7 @@ def test_get_subnets_filters_and_has_gateway_true( subnet1.ip_version = 4 subnet1.gateway_ip = "10.0.0.1" subnet1.enable_dhcp = True + subnet1.is_dhcp_enabled = True subnet1.allocation_pools = [] subnet1.dns_nameservers = [] subnet1.host_routes = [] @@ -830,6 +831,7 @@ def test_get_subnets_filters_and_has_gateway_true( subnet2.ip_version = 4 subnet2.gateway_ip = None subnet2.enable_dhcp = False + subnet2.is_dhcp_enabled = False subnet2.allocation_pools = [] subnet2.dns_nameservers = [] subnet2.host_routes = [] @@ -888,6 +890,7 @@ def test_get_subnets_has_gateway_false( subnet1.ip_version = 4 subnet1.gateway_ip = "10.0.0.1" subnet1.enable_dhcp = True + subnet1.is_dhcp_enabled = True subnet1.allocation_pools = [] subnet1.dns_nameservers = [] subnet1.host_routes = [] @@ -903,6 +906,7 @@ def test_get_subnets_has_gateway_false( subnet2.ip_version = 4 subnet2.gateway_ip = None subnet2.enable_dhcp = False + subnet2.is_dhcp_enabled = False subnet2.allocation_pools = [] subnet2.dns_nameservers = [] subnet2.host_routes = [] @@ -935,6 +939,7 @@ def test_create_subnet_success( subnet.ip_version = 4 subnet.gateway_ip = "10.0.0.1" subnet.enable_dhcp = True + subnet.is_dhcp_enabled = True subnet.allocation_pools = [{"start": "10.0.0.10", "end": "10.0.0.20"}] subnet.dns_nameservers = ["8.8.8.8"] subnet.host_routes = [] @@ -989,6 +994,7 @@ def test_get_subnet_detail_success( subnet.ip_version = 4 subnet.gateway_ip = "10.0.0.1" subnet.enable_dhcp = True + subnet.is_dhcp_enabled = True subnet.allocation_pools = [] subnet.dns_nameservers = [] subnet.host_routes = [] @@ -1018,6 +1024,7 @@ def test_update_subnet_success( subnet.ip_version = 4 subnet.gateway_ip = "10.0.0.254" subnet.enable_dhcp = False + subnet.is_dhcp_enabled = False subnet.allocation_pools = [] subnet.dns_nameservers = [] subnet.host_routes = [] @@ -1078,6 +1085,7 @@ def test_set_and_clear_subnet_gateway( updated.ip_version = 4 updated.gateway_ip = "10.0.0.254" updated.enable_dhcp = True + updated.is_dhcp_enabled = True updated.allocation_pools = [] updated.dns_nameservers = [] updated.host_routes = [] @@ -1109,6 +1117,7 @@ def test_set_and_toggle_subnet_dhcp( updated.ip_version = 4 updated.gateway_ip = "10.0.0.1" updated.enable_dhcp = True + updated.is_dhcp_enabled = True updated.allocation_pools = [] updated.dns_nameservers = [] updated.host_routes = [] @@ -1120,6 +1129,7 @@ def test_set_and_toggle_subnet_dhcp( assert res1.is_dhcp_enabled is True updated.enable_dhcp = False + updated.is_dhcp_enabled = False res2 = tools.update_subnet("subnet-1", is_dhcp_enabled=False) assert res2.is_dhcp_enabled is False From 51e5060d560a487dc2e6a29ef516818379c8f734 Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Wed, 20 Aug 2025 23:42:36 +0900 Subject: [PATCH 13/15] chore(config): restore config --- src/openstack_mcp_server/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openstack_mcp_server/config.py b/src/openstack_mcp_server/config.py index 72c892a..9160f10 100644 --- a/src/openstack_mcp_server/config.py +++ b/src/openstack_mcp_server/config.py @@ -4,7 +4,7 @@ # Transport protocol -MCP_TRANSPORT: str = os.environ.get("TRANSPORT", "streamable-http") +MCP_TRANSPORT: str = os.environ.get("TRANSPORT", "stdio") # Openstack client settings MCP_CLOUD_NAME: str = os.environ.get("CLOUD_NAME", "openstack") From 87fbf5f3424ab9ea8b99cfdd0339449d0442425b Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Wed, 20 Aug 2025 23:55:31 +0900 Subject: [PATCH 14/15] fix(network): alignment port model field (#30) --- src/openstack_mcp_server/tools/network_tools.py | 2 +- tests/tools/test_network_tools.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index e5cf3be..478f718 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -603,7 +603,7 @@ def _convert_to_port_model(self, openstack_port) -> Port: description=openstack_port.description, project_id=openstack_port.project_id, network_id=openstack_port.network_id, - is_admin_state_up=openstack_port.admin_state_up, + is_admin_state_up=openstack_port.is_admin_state_up, device_id=openstack_port.device_id, device_owner=openstack_port.device_owner, mac_address=openstack_port.mac_address, diff --git a/tests/tools/test_network_tools.py b/tests/tools/test_network_tools.py index 446881a..c0f2e7f 100644 --- a/tests/tools/test_network_tools.py +++ b/tests/tools/test_network_tools.py @@ -475,6 +475,7 @@ def test_get_ports_with_filters(self, mock_openstack_connect_network): port.project_id = "proj-1" port.network_id = "net-1" port.admin_state_up = True + port.is_admin_state_up = True port.device_id = "device-1" port.device_owner = "compute:nova" port.mac_address = "fa:16:3e:00:00:01" @@ -528,6 +529,7 @@ def test_create_port_success(self, mock_openstack_connect_network): port.project_id = "proj-1" port.network_id = "net-1" port.admin_state_up = True + port.is_admin_state_up = True port.device_id = None port.device_owner = None port.mac_address = "fa:16:3e:00:00:02" @@ -574,6 +576,7 @@ def test_get_port_detail_success(self, mock_openstack_connect_network): port.project_id = None port.network_id = "net-1" port.admin_state_up = True + port.is_admin_state_up = True port.device_id = None port.device_owner = None port.mac_address = "fa:16:3e:00:00:03" @@ -598,6 +601,7 @@ def test_update_port_success(self, mock_openstack_connect_network): port.project_id = None port.network_id = "net-1" port.admin_state_up = False + port.is_admin_state_up = False port.device_id = "dev-2" port.device_owner = None port.mac_address = "fa:16:3e:00:00:04" @@ -657,6 +661,7 @@ def test_add_port_fixed_ip(self, mock_openstack_connect_network): updated.project_id = None updated.network_id = "net-1" updated.admin_state_up = True + updated.is_admin_state_up = True updated.device_id = None updated.device_owner = None updated.mac_address = "fa:16:3e:00:00:05" @@ -691,6 +696,7 @@ def test_remove_port_fixed_ip(self, mock_openstack_connect_network): updated.project_id = None updated.network_id = "net-1" updated.admin_state_up = True + updated.is_admin_state_up = True updated.device_id = None updated.device_owner = None updated.mac_address = "fa:16:3e:00:00:06" @@ -729,6 +735,7 @@ def test_get_and_update_allowed_address_pairs( updated.project_id = None updated.network_id = "net-1" updated.admin_state_up = True + updated.is_admin_state_up = True updated.device_id = None updated.device_owner = None updated.mac_address = "fa:16:3e:00:00:07" @@ -769,7 +776,7 @@ def test_set_port_binding_and_admin_state( updated.description = None updated.project_id = None updated.network_id = "net-1" - updated.admin_state_up = False + updated.is_admin_state_up = False updated.device_id = None updated.device_owner = None updated.mac_address = "fa:16:3e:00:00:08" @@ -790,9 +797,9 @@ def test_set_port_binding_and_admin_state( assert res_set.is_admin_state_up is False current = Mock() - current.admin_state_up = False + current.is_admin_state_up = False mock_conn.network.get_port.return_value = current - updated.admin_state_up = True + updated.is_admin_state_up = True res_toggle = tools.update_port( "port-1", is_admin_state_up=not current.admin_state_up ) From ca8ccd8bec2862d82bbb2822f49e5441a291409b Mon Sep 17 00:00:00 2001 From: platanus-kr Date: Sat, 23 Aug 2025 15:25:39 +0900 Subject: [PATCH 15/15] feat(network): add clear state arg to methods (#30) --- .../tools/network_tools.py | 112 ++++++++++++------ tests/tools/test_network_tools.py | 4 +- 2 files changed, 77 insertions(+), 39 deletions(-) diff --git a/src/openstack_mcp_server/tools/network_tools.py b/src/openstack_mcp_server/tools/network_tools.py index 478f718..8e5c337 100644 --- a/src/openstack_mcp_server/tools/network_tools.py +++ b/src/openstack_mcp_server/tools/network_tools.py @@ -9,9 +9,6 @@ ) -_UNSET = object() - - class NetworkTools: """ A class to encapsulate Network-related tools and utilities. @@ -188,7 +185,6 @@ def _convert_to_network_model(self, openstack_network) -> Network: Convert an OpenStack network object to a Network pydantic model. :param openstack_network: OpenStack network object - :type openstack_network: Any :return: Pydantic Network model """ return Network( @@ -219,11 +215,24 @@ def get_subnets( """ Get the list of Subnets with optional filtering. + Use this to narrow results by network, project, IP version, gateway presence, and + DHCP-enabled state. + + Notes: + - has_gateway is applied client-side after retrieval and checks whether `gateway_ip` is set. + - `is_dhcp_enabled` maps to Neutron's `enable_dhcp` filter. + - Combining filters further restricts the result (logical AND). + + Examples: + - All IPv4 subnets in a network: `network_id="net-1"`, `ip_version=4` + - Only subnets with a gateway: `has_gateway=True` + - DHCP-enabled subnets for a project: `project_id="proj-1"`, `is_dhcp_enabled=True` + :param network_id: Filter by network ID :param ip_version: Filter by IP version (e.g., 4, 6) :param project_id: Filter by project ID - :param has_gateway: Filter by whether a gateway is set - :param is_dhcp_enabled: Filter by DHCP enabled state + :param has_gateway: True for subnets with a gateway, False for no gateway + :param is_dhcp_enabled: True for DHCP-enabled subnets, False for disabled :return: List of Subnet objects """ conn = get_openstack_conn() @@ -309,7 +318,8 @@ def update_subnet( subnet_id: str, name: str | None = None, description: str | None = None, - gateway_ip: str | None | object = _UNSET, + gateway_ip: str | None = None, + clear_gateway: bool = False, is_dhcp_enabled: bool | None = None, dns_nameservers: list[str] | None = None, allocation_pools: list[dict] | None = None, @@ -320,24 +330,32 @@ def update_subnet( parameters remain untouched. Typical use-cases: - - Set gateway: pass gateway_ip="10.0.0.1". - - Clear gateway: pass gateway_ip=None. - - Enable/disable DHCP: pass is_dhcp_enabled=True or False. - - Batch updates: change name/description and DNS nameservers together. + - Set gateway: `gateway_ip="10.0.0.1"`. + - Clear gateway: `clear_gateway=True`. + - Enable/disable DHCP: `is_dhcp_enabled=True or False`. + - Batch updates: update name/description and DNS nameservers together. Notes: - - OpenStack Neutron supports partial updates. Passing None for gateway_ip clears the gateway. - - To emulate a DHCP toggle, read current state and invert it, then call this method with - is_dhcp_enabled set accordingly. + - `clear_gateway=True` explicitly clears `gateway_ip` (sets to None). If both `gateway_ip` + and `clear_gateway=True` are provided, `clear_gateway` takes precedence. + - For list-typed fields (`dns_nameservers`, `allocation_pools`, `host_routes`), the provided + list replaces the entire list on the server. Pass `[]` to remove all entries. + - For a DHCP toggle, read the current value via `get_subnet_detail()` and pass the inverted + boolean to `is_dhcp_enabled`. + + Examples: + - Clear the gateway and disable DHCP: `clear_gateway=True`, `is_dhcp_enabled=False` + - Replace DNS servers: `dns_nameservers=["8.8.8.8", "1.1.1.1"]` :param subnet_id: ID of the subnet to update :param name: New subnet name :param description: New subnet description :param gateway_ip: New gateway IP + :param clear_gateway: If True, clear the gateway IP (sets to None) :param is_dhcp_enabled: DHCP enabled state - :param dns_nameservers: DNS nameserver list - :param allocation_pools: Allocation pool list - :param host_routes: Static host routes + :param dns_nameservers: DNS nameserver list (replaces entire list) + :param allocation_pools: Allocation pool list (replaces entire list) + :param host_routes: Static host routes (replaces entire list) :return: Updated Subnet object """ conn = get_openstack_conn() @@ -346,7 +364,9 @@ def update_subnet( update_args["name"] = name if description is not None: update_args["description"] = description - if gateway_ip is not _UNSET: + if clear_gateway: + update_args["gateway_ip"] = None + elif gateway_ip is not None: update_args["gateway_ip"] = gateway_ip if is_dhcp_enabled is not None: update_args["enable_dhcp"] = is_dhcp_enabled @@ -531,7 +551,7 @@ def update_port( Typical use-cases: - Set admin state down: is_admin_state_up=False - - Toggle admin state: read current via get_port_detail() then pass the inverted value + - Toggle admin state: read current via get_port_detail(); pass inverted value - Replace security groups: security_group_ids=["sg-1", "sg-2"] - Replace allowed address pairs: 1) current = get_port_allowed_address_pairs(port_id) @@ -543,8 +563,14 @@ def update_port( 3) update_port(port_id, fixed_ips=current) Notes: - - For list-typed fields like security groups or allowed address pairs, this method replaces - the entire list with the provided value. To remove all entries, pass an empty list []. + - List-typed fields (security groups, allowed address pairs, fixed IPs) replace the entire list + with the provided value. Pass [] to remove all entries. + - For fixed IPs, each dict typically includes keys like "subnet_id" and/or "ip_address". + + Examples: + - Add a fixed IP: read current, append a new {"subnet_id": "subnet-2", "ip_address": "10.0.1.10"}, + then pass fixed_ips=[...] + - Clear all security groups: security_group_ids=[] :param port_id: ID of the port to update :param name: New port name @@ -657,11 +683,16 @@ def create_floating_ip( """ Create a new Floating IP. + Typical use-cases: + - Allocate in a pool and attach immediately: provide port_id (and optionally fixed_ip_address). + - Allocate for later use: omit port_id (unassigned state). + - Add metadata: provide description. + :param floating_network_id: External (floating) network ID - :param description: Floating IP description - :param fixed_ip_address: Internal fixed IP to map - :param port_id: Port ID to attach - :param project_id: Project ID + :param description: Floating IP description (omit to keep empty) + :param fixed_ip_address: Internal fixed IP to map when attaching to a port + :param port_id: Port ID to attach (omit for unassigned allocation) + :param project_id: Project ID to assign ownership :return: Created FloatingIP object """ conn = get_openstack_conn() @@ -701,9 +732,10 @@ def attach_floating_ip_to_port( def update_floating_ip( self, floating_ip_id: str, - description: str | None | object = _UNSET, - port_id: str | None | object = _UNSET, - fixed_ip_address: str | None | object = _UNSET, + description: str | None = None, + port_id: str | None = None, + fixed_ip_address: str | None = None, + clear_port: bool = False, ) -> FloatingIP: """ Update Floating IP attributes. Only provided parameters are changed; omitted @@ -711,29 +743,35 @@ def update_floating_ip( Typical use-cases: - Attach to a port: port_id="port-1" (optionally fixed_ip_address="10.0.0.10"). - - Detach from its port: port_id=None. + - Detach from its port: clear_port=True and omit port_id (sets port_id=None). + - Keep current port: clear_port=False and omit port_id. - Update description: description="new desc" or clear with description=None. - Reassign to another port: port_id="new-port" (optionally with fixed_ip_address). Notes: - Passing None for description clears it. - - Passing None for port_id detaches the address from any port. + - clear_port controls whether to detach when no port_id is provided. - fixed_ip_address is optional and can be provided alongside port_id. :param floating_ip_id: Floating IP ID to update - :param description: New description or None to clear - :param port_id: Port ID to attach; None to detach; omit to keep unchanged - :param fixed_ip_address: Specific fixed IP to map; omit to keep unchanged + :param description: New description (omit to keep unchanged, None to clear) + :param port_id: Port ID to attach; omit to keep or detach depending on clear_port + :param clear_port: If True and port_id is omitted, detach (set port_id=None); if False and + port_id is omitted, keep current attachment + :param fixed_ip_address: Specific fixed IP to map when attaching :return: Updated FloatingIP object """ conn = get_openstack_conn() update_args: dict = {} - if description is not _UNSET: + if description is not None: update_args["description"] = description - if port_id is not _UNSET: + if port_id is not None: update_args["port_id"] = port_id - if fixed_ip_address is not _UNSET: - update_args["fixed_ip_address"] = fixed_ip_address + if fixed_ip_address is not None: + update_args["fixed_ip_address"] = fixed_ip_address + else: + if clear_port: + update_args["port_id"] = None if not update_args: current = conn.network.get_ip(floating_ip_id) return self._convert_to_floating_ip_model(current) diff --git a/tests/tools/test_network_tools.py b/tests/tools/test_network_tools.py index c0f2e7f..c41f2e3 100644 --- a/tests/tools/test_network_tools.py +++ b/tests/tools/test_network_tools.py @@ -1104,7 +1104,7 @@ def test_set_and_clear_subnet_gateway( assert res1.gateway_ip == "10.0.0.254" updated.gateway_ip = None - res2 = tools.update_subnet("subnet-1", gateway_ip=None) + res2 = tools.update_subnet("subnet-1", clear_gateway=True) assert res2.gateway_ip is None def test_set_and_toggle_subnet_dhcp( @@ -1238,7 +1238,7 @@ def test_create_attach_detach_delete_floating_ip( assert attached.port_id == "port-1" updated.port_id = None - detached = tools.update_floating_ip("fip-1", port_id=None) + detached = tools.update_floating_ip("fip-1", clear_port=True) assert detached.port_id is None mock_conn.network.get_ip.return_value = updated