Skip to content

Commit 1efa2c5

Browse files
authored
Merge pull request #48 from keeganwhite/main
feat: Add port management endpoints and UI
2 parents cd4ccac + f030961 commit 1efa2c5

18 files changed

Lines changed: 855 additions & 50 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
- name: Check port details (speed and status)
3+
become: true
4+
hosts: localserver
5+
roles:
6+
- ../roles/check-port-details
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
- name: Get port speed from sysfs
3+
shell: |
4+
if [ -f /sys/class/net/{{ port_name }}/speed ]; then
5+
speed=$(cat /sys/class/net/{{ port_name }}/speed 2>/dev/null || echo "")
6+
if [ -n "$speed" ] && [ "$speed" != "-1" ]; then
7+
echo "$speed"
8+
else
9+
echo ""
10+
fi
11+
else
12+
echo ""
13+
fi
14+
register: port_speed_output
15+
changed_when: false
16+
17+
- name: Get port status using ip link show
18+
shell: ip link show {{ port_name }} 2>/dev/null || echo "NOT_FOUND"
19+
register: port_status_output
20+
changed_when: false
21+
failed_when: false
22+
23+
- name: Debug port speed
24+
debug:
25+
var: port_speed_output.stdout
26+
27+
- name: Debug port status
28+
debug:
29+
var: port_status_output.stdout_lines
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 5.0.1 on 2025-01-XX XX:XX
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('general', '0031_port_link_speed'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='port',
15+
name='is_up',
16+
field=models.BooleanField(blank=True, help_text='Port status: True if up, False if down, None if unknown', null=True),
17+
),
18+
]
19+

backend/control_center/general/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class Port(models.Model):
8181
name = models.CharField(max_length=100)
8282
ovs_port_number = models.IntegerField(null=True, blank=True)
8383
link_speed = models.IntegerField(null=True, blank=True, help_text="Link speed in Mb/s")
84+
is_up = models.BooleanField(null=True, blank=True, help_text="Port status: True if up, False if down, None if unknown")
8485

8586
class Meta:
8687
unique_together = ('device', 'name')

backend/control_center/general/serializers.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,38 @@ def get_lan_ip_address(self, obj):
3838
class PortSerializer(serializers.ModelSerializer):
3939
class Meta:
4040
model = Port
41-
fields = ['id', 'name', 'ovs_port_number', 'link_speed']
41+
fields = ['id', 'name', 'ovs_port_number', 'link_speed', 'is_up']
42+
43+
44+
class PortUpdateSerializer(serializers.ModelSerializer):
45+
"""
46+
Serializer for updating port link_speed only.
47+
All other fields are read-only.
48+
"""
49+
50+
class Meta:
51+
model = Port
52+
fields = ['id', 'name', 'ovs_port_number', 'link_speed', 'is_up']
53+
read_only_fields = ['id', 'name', 'ovs_port_number', 'is_up']
54+
55+
def save(self, **kwargs):
56+
# Only save link_speed, filter validated_data to just link_speed
57+
if 'link_speed' in self.validated_data:
58+
# Filter validated_data to only include link_speed
59+
filtered_data = {'link_speed': self.validated_data['link_speed']}
60+
# Temporarily store original validated_data
61+
original_validated_data = self.validated_data
62+
# Replace with filtered data
63+
self._validated_data = filtered_data
64+
try:
65+
instance = super().save(**kwargs)
66+
# Ensure only link_speed was updated in the database
67+
instance.save(update_fields=['link_speed'])
68+
return instance
69+
finally:
70+
# Restore original validated_data
71+
self._validated_data = original_validated_data
72+
return super().save(**kwargs)
4273

4374

4475
class DeviceSerializer(serializers.ModelSerializer):

backend/control_center/general/urls.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from django.urls import path, include
22
from rest_framework.routers import DefaultRouter
3-
from .views import ControllerViewSet, SwitchViewSet
3+
from .views import ControllerViewSet, SwitchViewSet, PortViewSet
44

55
app_name = 'general'
66

7-
# Create a router and register the ControllerViewSet
7+
# Create a router and register the ViewSets
88
router = DefaultRouter()
99
router.register(r'controllers', ControllerViewSet, basename='controller')
1010
router.register(r'switches', SwitchViewSet, basename='switch')
11+
router.register(r'ports', PortViewSet, basename='port')
1112

1213
urlpatterns = [
1314
path('', include(router.urls)),

backend/control_center/general/views.py

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from django.core.validators import validate_ipv4_address
3535
from django.core.exceptions import ValidationError
3636
import logging
37-
from .serializers import BridgeSerializer, ControllerSerializer
37+
from .serializers import BridgeSerializer, ControllerSerializer, PortSerializer, PortUpdateSerializer
3838
from .models import Controller, Plugins
3939
from .serializers import DeviceSerializer
4040

@@ -44,6 +44,7 @@
4444
from rest_framework.permissions import IsAuthenticated
4545
from knox.auth import TokenAuthentication
4646
from utils.ansible_utils import run_playbook_with_extravars, create_temp_inv, create_inv_data
47+
from utils.ansible_formtter import get_single_port_speed_from_results, get_port_status_from_results
4748
# Import the model manager
4849
from classifier.model_manager import model_manager
4950
from odl.models import Category
@@ -615,4 +616,132 @@ def bridges(self, request, pk=None):
615616
return Response({"bridges": data}, status=200)
616617
except Exception as e:
617618
logger.exception('Error getting switch bridges...')
618-
return Response({"error": str(e)}, status=500)
619+
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
620+
621+
@action(detail=True, methods=['get'], url_path='bridge-ports')
622+
def bridge_ports(self, request, pk=None):
623+
"""
624+
Custom action to fetch all ports on a switch that are assigned to bridges.
625+
Returns ports where bridge is not null.
626+
"""
627+
try:
628+
switch = self.get_object()
629+
# Get all ports on this switch that have a bridge assigned
630+
ports = Port.objects.filter(device=switch, bridge__isnull=False)
631+
serializer = PortSerializer(ports, many=True)
632+
return Response({"ports": serializer.data}, status=status.HTTP_200_OK)
633+
except Exception as e:
634+
logger.exception('Error getting switch bridge ports...')
635+
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
636+
637+
638+
# ----- PORT VIEWS ------
639+
class PortViewSet(ModelViewSet):
640+
"""
641+
A viewset that provides actions for Port model.
642+
"""
643+
queryset = Port.objects.all()
644+
serializer_class = PortSerializer
645+
authentication_classes = (TokenAuthentication,)
646+
permission_classes = (IsAuthenticated,)
647+
648+
def get_serializer_class(self):
649+
"""
650+
Use PortUpdateSerializer for partial updates (PATCH).
651+
"""
652+
if self.action == 'partial_update':
653+
return PortUpdateSerializer
654+
return PortSerializer
655+
656+
def partial_update(self, request, pk=None):
657+
"""
658+
Update port link_speed only.
659+
All other fields are read-only.
660+
"""
661+
try:
662+
port = self.get_object()
663+
serializer = PortUpdateSerializer(port, data=request.data, partial=True)
664+
665+
if serializer.is_valid():
666+
# Only allow link_speed to be updated
667+
if 'link_speed' in serializer.validated_data:
668+
updated_port = serializer.save()
669+
return Response(PortSerializer(updated_port).data, status=status.HTTP_200_OK)
670+
else:
671+
return Response(
672+
{"error": "Only link_speed can be updated"},
673+
status=status.HTTP_400_BAD_REQUEST
674+
)
675+
else:
676+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
677+
except Exception as e:
678+
logger.exception(f'Error updating port: {str(e)}')
679+
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
680+
681+
@action(detail=True, methods=['post'], url_path='sync')
682+
def sync(self, request, pk=None):
683+
"""
684+
Sync port details (speed and status) from the switch.
685+
Connects to the device and checks the current port speed and status.
686+
"""
687+
try:
688+
port = self.get_object()
689+
device = port.device
690+
691+
if device.device_type != 'switch':
692+
return Response(
693+
{"error": "Port sync is only available for switch devices"},
694+
status=status.HTTP_400_BAD_REQUEST
695+
)
696+
697+
# Create inventory for the device
698+
inv_content = create_inv_data(device.lan_ip_address, device.username, device.password)
699+
inv_path = create_temp_inv(inv_content)
700+
701+
# Run the check-port-details playbook with port name as extravar
702+
playbook_name = "check-port-details"
703+
extra_vars = {
704+
'port_name': port.name,
705+
'ip_address': device.lan_ip_address,
706+
}
707+
708+
result = run_playbook_with_extravars(
709+
playbook_name,
710+
playbook_dir_path,
711+
inv_path,
712+
extra_vars,
713+
quiet=True
714+
)
715+
716+
if result.get('status') != 'success':
717+
error_msg = result.get('error', 'Unknown error occurred')
718+
logger.error(f"Failed to sync port {port.name}: {error_msg}")
719+
return Response(
720+
{"error": f"Failed to sync port details: {error_msg}"},
721+
status=status.HTTP_500_INTERNAL_SERVER_ERROR
722+
)
723+
724+
# Parse results
725+
port_speed = get_single_port_speed_from_results(result, port.name)
726+
port_status = get_port_status_from_results(result, port.name)
727+
728+
# Update port in database
729+
update_fields = []
730+
if port_speed is not None:
731+
port.link_speed = port_speed
732+
update_fields.append('link_speed')
733+
if port_status is not None:
734+
port.is_up = port_status
735+
update_fields.append('is_up')
736+
737+
if update_fields:
738+
port.save(update_fields=update_fields)
739+
logger.debug(f"Updated port {port.name}: speed={port_speed}, status={port_status}")
740+
741+
# Return updated port data
742+
serializer = PortSerializer(port)
743+
return Response(serializer.data, status=status.HTTP_200_OK)
744+
745+
except Exception as e:
746+
logger.exception(f'Error syncing port: {str(e)}')
747+
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

backend/control_center/utils/ansible_formtter.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,100 @@ def extract_ovs_port_map(playbook_result):
181181
logger.exception(f"Error accessing top-level results for ovs_port_map: {e}")
182182

183183
logger.warning("Could not find 'ovs_port_map' in any expected location within the playbook results.")
184-
return ovs_map # Return empty dict if not found
184+
return ovs_map # Return empty dict if not found
185+
186+
187+
def get_single_port_speed_from_results(results, port_name):
188+
"""
189+
Extract the speed for a specific port from Ansible playbook results.
190+
191+
Parameters:
192+
results (dict): Ansible playbook result dictionary containing a 'results' mapping
193+
with the key "Get port speed from sysfs" whose value provides 'stdout'.
194+
port_name (str): Name of the port to get speed for.
195+
196+
Returns:
197+
int or None: Port speed in megabits per second, or None if not found or invalid.
198+
"""
199+
results_data = results.get('results', results) if isinstance(results, dict) else results
200+
201+
command_key = "Get port speed from sysfs"
202+
203+
if command_key in results_data:
204+
speed_output = results_data[command_key].get('stdout', '').strip()
205+
if speed_output and speed_output != '':
206+
try:
207+
speed = int(speed_output)
208+
if speed > 0: # Valid speed (exclude -1 or 0)
209+
logger.debug(f"Extracted port speed for {port_name}: {speed} Mb/s")
210+
return speed
211+
except ValueError:
212+
logger.warning(f"Invalid speed value for {port_name}: {speed_output}")
213+
214+
logger.debug(f"No valid speed found for port {port_name}")
215+
return None
216+
217+
218+
def get_port_status_from_results(results, port_name):
219+
"""
220+
Extract the port status (up/down) from Ansible playbook results.
221+
222+
Parameters:
223+
results (dict): Ansible playbook result dictionary containing a 'results' mapping
224+
with the key "Get port status using ip link show" whose value provides 'stdout_lines'.
225+
port_name (str): Name of the port to get status for.
226+
227+
Returns:
228+
bool or None: True if port is up, False if down, None if status cannot be determined.
229+
"""
230+
results_data = results.get('results', results) if isinstance(results, dict) else results
231+
232+
command_key = "Get port status using ip link show"
233+
234+
if command_key in results_data:
235+
output_lines = results_data[command_key].get('stdout_lines', [])
236+
output_str = ' '.join(output_lines) if output_lines else ''
237+
238+
# Check if port was not found
239+
if 'NOT_FOUND' in output_str or not output_lines:
240+
logger.warning(f"Port {port_name} not found on device")
241+
return None
242+
243+
# Parse ip link show output to find state
244+
# Example formats:
245+
# "2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000"
246+
# "2: eth0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc mq state DOWN group default qlen 1000"
247+
for line in output_lines:
248+
line_lower = line.lower()
249+
# Check for explicit state UP or DOWN
250+
if 'state up' in line_lower:
251+
logger.debug(f"Port {port_name} is UP (from state)")
252+
return True
253+
elif 'state down' in line_lower:
254+
logger.debug(f"Port {port_name} is DOWN (from state)")
255+
return False
256+
257+
# If no explicit state found, check flags in angle brackets
258+
# UP flag without NO-CARRIER usually means the interface is up
259+
# But we need to be careful - LOWER_UP alone doesn't mean the interface is up
260+
for line in output_lines:
261+
line_lower = line.lower()
262+
if '<' in line and '>' in line:
263+
flags_section = line[line.find('<'):line.find('>')+1]
264+
flags_lower = flags_section.lower()
265+
# If we see UP in flags and no NO-CARRIER, it's likely up
266+
# But state takes precedence, so only use flags if state wasn't found
267+
# Split flags and check for exact 'up' flag (not lower_up, upper_up, etc.)
268+
flags_list = [f.strip() for f in flags_lower.replace('<', '').replace('>', '').split(',')]
269+
if 'up' in flags_list and 'no-carrier' not in flags_list:
270+
# Check if there's a state mentioned elsewhere in the line
271+
if 'state' not in line_lower:
272+
logger.debug(f"Port {port_name} appears to be UP (from flags)")
273+
return True
274+
275+
# If we have output but couldn't determine state, default to None
276+
logger.warning(f"Could not determine status for port {port_name} from output: {output_str}")
277+
return None
278+
279+
logger.warning(f"Could not find port status task results for {port_name}")
280+
return None

0 commit comments

Comments
 (0)