Skip to content

Commit faacf4b

Browse files
committed
PUC-785: crud unvni group range with neutron-segment-range
1 parent 634be61 commit faacf4b

8 files changed

Lines changed: 558 additions & 3 deletions

File tree

python/understack-workflows/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ undersync-device = "understack_workflows.main.undersync_device:main"
5151
enroll-server = "understack_workflows.main.enroll_server:main"
5252
bmc-password = "understack_workflows.main.print_bmc_password:main"
5353
bmc-kube-password = "understack_workflows.main.bmc_display_password:main"
54+
sync-network-segment-range = "understack_workflows.main.sync_ucvni_group_range:main"
5455

5556
[tool.pytest.ini_options]
5657
minversion = "6.0"
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from argparse import Namespace
2+
from unittest import mock
3+
from unittest.mock import Mock
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
from understack_workflows.main.sync_ucvni_group_range import _EXIT_API_ERROR
9+
from understack_workflows.main.sync_ucvni_group_range import _EXIT_EVENT_UNKNOWN
10+
from understack_workflows.main.sync_ucvni_group_range import _EXIT_SUCCESS
11+
from understack_workflows.main.sync_ucvni_group_range import SegmentRangeEvent
12+
from understack_workflows.main.sync_ucvni_group_range import handle_event
13+
from understack_workflows.main.sync_ucvni_group_range import main
14+
from understack_workflows.main.sync_ucvni_group_range import modify_range
15+
16+
17+
@pytest.mark.parametrize(
18+
"action,new_range,existing_range,segment_range_note,expected",
19+
[
20+
(SegmentRangeEvent.CREATE, "20-30", "", None, "20-30"),
21+
(SegmentRangeEvent.CREATE, "20-30", "10-15", None, "10-15,20-30"),
22+
(SegmentRangeEvent.DELETE, "", "10-15,20-30", "20-30", "10-15"),
23+
(SegmentRangeEvent.DELETE, "", "10-30", "15-25", "10-14,26-30"),
24+
(SegmentRangeEvent.DELETE, "", "10-15,20-30", "invalid", "10-15,20-30"),
25+
(SegmentRangeEvent.UPDATE, "40-50", "10-20,30-35", "30-35", "10-20,40-50"),
26+
(SegmentRangeEvent.UPDATE, "30-35", "10-20,40-50", "40-50", "10-20,30-35"),
27+
(SegmentRangeEvent.UPDATE, "15-18", "10-20", "12-16", "10-11,17-20,15-18"),
28+
],
29+
)
30+
def test_modify_range(action, new_range, existing_range, segment_range_note, expected):
31+
mock_notes_endpoint = Mock()
32+
mock_notes_endpoint.get.return_value = Mock(note=segment_range_note)
33+
34+
result = modify_range(
35+
action,
36+
new_range,
37+
existing_range,
38+
segment_range_id="segment123",
39+
notes_endpoint=mock_notes_endpoint,
40+
)
41+
42+
result_parts = sorted(result.split(",")) if result else []
43+
expected_parts = sorted(expected.split(",")) if expected else []
44+
45+
assert result_parts == expected_parts
46+
47+
48+
@mock.patch(
49+
"understack_workflows.main.sync_ucvni_group_range.credential",
50+
return_value="dummy-token",
51+
)
52+
@mock.patch("understack_workflows.main.sync_ucvni_group_range.Nautobot")
53+
def test_handle_event_ucvni_group_not_found(mock_nautobot_class, mock_credential):
54+
mock_nautobot = Mock()
55+
mock_ucvni_groups = Mock()
56+
mock_ucvni_groups.get.return_value = None # Simulate UCVNI group not found
57+
58+
mock_nautobot.session.plugins.undercloud_vni.ucvni_groups = mock_ucvni_groups
59+
mock_nautobot.session.extras.notes = Mock()
60+
61+
mock_nautobot_class.return_value = mock_nautobot
62+
63+
segment_args = Namespace(
64+
nautobot_token=None,
65+
nautobot_url="http://mocked-nautobot",
66+
segment_name="fake-segment",
67+
)
68+
result = handle_event(segment_args)
69+
assert result == _EXIT_API_ERROR
70+
mock_ucvni_groups.get.assert_called_once_with("fake-segment")
71+
72+
73+
@pytest.mark.parametrize(
74+
"event,handle_event_return,expected_exit_code",
75+
[
76+
(SegmentRangeEvent.CREATE, _EXIT_SUCCESS, _EXIT_SUCCESS),
77+
("UNKNOWN_EVENT", None, _EXIT_EVENT_UNKNOWN),
78+
],
79+
)
80+
@patch("understack_workflows.main.sync_ucvni_group_range.argument_parser")
81+
@patch("understack_workflows.main.sync_ucvni_group_range.logger")
82+
@patch("understack_workflows.main.sync_ucvni_group_range.handle_event")
83+
def test_main(
84+
mock_handle_event,
85+
mock_logger,
86+
mock_argument_parser,
87+
event,
88+
handle_event_return,
89+
expected_exit_code,
90+
):
91+
mock_args = Namespace(event=event)
92+
mock_argument_parser.return_value.parse_args.return_value = mock_args
93+
94+
if event != "UNKNOWN_EVENT":
95+
mock_handle_event.return_value = handle_event_return
96+
97+
result = main()
98+
99+
assert result == expected_exit_code
100+
101+
if event == "UNKNOWN_EVENT":
102+
mock_logger.error.assert_called_once_with(
103+
"Cannot handle event: %s", "UNKNOWN_EVENT"
104+
)
105+
mock_handle_event.assert_not_called()
106+
else:
107+
mock_handle_event.assert_called_once_with(segment_args=mock_args)
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import argparse
2+
import logging
3+
from argparse import Namespace
4+
from enum import StrEnum
5+
from typing import cast
6+
7+
import requests
8+
from pynautobot.core.endpoint import DetailEndpoint
9+
from pynautobot.core.endpoint import Endpoint
10+
from pynautobot.core.response import Record
11+
from requests import RequestException
12+
13+
from understack_workflows.helpers import credential
14+
from understack_workflows.helpers import parser_nautobot_args
15+
from understack_workflows.helpers import setup_logger
16+
from understack_workflows.nautobot import Nautobot
17+
18+
logger = setup_logger(__name__, level=logging.INFO)
19+
20+
_EXIT_SUCCESS = 0
21+
_EXIT_API_ERROR = 1
22+
_EXIT_EVENT_UNKNOWN = 2
23+
24+
25+
class SegmentRangeEvent(StrEnum):
26+
CREATE = "network_segment_range.create.end"
27+
UPDATE = "network_segment_range.update.end"
28+
DELETE = "network_segment_range.delete.end"
29+
30+
31+
def argument_parser() -> argparse.ArgumentParser:
32+
parser = argparse.ArgumentParser(description="Handle Network Segment Range Events")
33+
parser.add_argument(
34+
"event", type=SegmentRangeEvent, choices=list(SegmentRangeEvent)
35+
)
36+
parser.add_argument("segment_name", type=str, help="Segment name")
37+
parser.add_argument("network_type", type=str, help="Network type")
38+
parser.add_argument("segment_range_id", type=str, help="Segment range ID")
39+
parser.add_argument("segment_min_range", type=int, help="Segment minimum range")
40+
parser.add_argument("segment_max_range", type=int, help="Segment maximum range")
41+
return parser_nautobot_args(parser)
42+
43+
44+
def _remove_subrange(
45+
ranges: list[str], segment_range_id: str, notes_endpoint
46+
) -> list[str]:
47+
range_record: Record = cast(Record, notes_endpoint.get(segment_range_id))
48+
range_to_remove: str | None = range_record.note
49+
50+
"""Remove sub-range and adjust existing ranges."""
51+
if not range_to_remove:
52+
return ranges
53+
54+
updated_ranges = []
55+
try:
56+
remove_start, remove_end = map(int, range_to_remove.split("-"))
57+
except ValueError:
58+
return ranges # Return unchanged if range_to_remove is invalid
59+
60+
for r in ranges:
61+
start, end = map(int, r.split("-"))
62+
63+
# If it's an exact match, remove it
64+
if start == remove_start and end == remove_end:
65+
continue
66+
67+
# If there's no overlap, keep the range
68+
if end < remove_start or start > remove_end:
69+
updated_ranges.append(r)
70+
else:
71+
# Handle partial overlap by splitting
72+
if start < remove_start:
73+
updated_ranges.append(f"{start}-{remove_start - 1}")
74+
if end > remove_end:
75+
updated_ranges.append(f"{remove_end + 1}-{end}")
76+
77+
return updated_ranges
78+
79+
80+
def modify_range(
81+
action: SegmentRangeEvent,
82+
new_range: str,
83+
existing_range: str | None,
84+
segment_range_id: str,
85+
notes_endpoint: Endpoint,
86+
) -> str:
87+
ranges = existing_range.split(",") if existing_range else []
88+
89+
match action:
90+
case SegmentRangeEvent.CREATE:
91+
ranges.append(new_range)
92+
93+
case SegmentRangeEvent.DELETE:
94+
ranges = _remove_subrange(
95+
ranges=ranges,
96+
segment_range_id=segment_range_id,
97+
notes_endpoint=notes_endpoint,
98+
)
99+
100+
case SegmentRangeEvent.UPDATE:
101+
# Remove old range completely or adjust if overlapping
102+
ranges = _remove_subrange(
103+
ranges,
104+
segment_range_id=segment_range_id,
105+
notes_endpoint=notes_endpoint,
106+
)
107+
108+
# Add the new updated range
109+
ranges.append(new_range)
110+
111+
return ",".join(ranges)
112+
113+
114+
def update_ucvni_range(
115+
segment_args: Namespace,
116+
notes_endpoint: Endpoint,
117+
ucvni_group: Record,
118+
) -> bool:
119+
action: SegmentRangeEvent = segment_args.event
120+
requested_range = (
121+
f"{segment_args.segment_min_range}-{segment_args.segment_max_range}"
122+
)
123+
new_range = modify_range(
124+
action=action,
125+
existing_range=ucvni_group.range,
126+
new_range=requested_range,
127+
segment_range_id=segment_args.segment_range_id,
128+
notes_endpoint=notes_endpoint,
129+
)
130+
try:
131+
is_updated = ucvni_group.update(data={"range": new_range})
132+
if is_updated:
133+
capture_segment_range_in_notes(
134+
ucvni_group_notes_endpoint=ucvni_group.notes,
135+
notes_endpoint=notes_endpoint,
136+
segment_range_id=segment_args.segment_range_id,
137+
segment_range=requested_range,
138+
action=action,
139+
)
140+
return is_updated
141+
except requests.RequestException:
142+
logger.exception(
143+
"Failed to update range %s of segment-id %s",
144+
new_range,
145+
segment_args.segment_range_id,
146+
)
147+
return False
148+
149+
150+
def capture_segment_range_in_notes(
151+
ucvni_group_notes_endpoint: DetailEndpoint,
152+
notes_endpoint: Endpoint,
153+
segment_range_id: str,
154+
segment_range: str,
155+
action: str,
156+
):
157+
try:
158+
if action == SegmentRangeEvent.UPDATE:
159+
notes_endpoint.update(id=segment_range_id, data={"note": segment_range})
160+
elif action == SegmentRangeEvent.DELETE:
161+
notes_endpoint.delete([segment_range_id])
162+
else:
163+
ucvni_group_notes_endpoint.create(
164+
{"id": segment_range_id, "note": segment_range}
165+
)
166+
except RequestException as e:
167+
logger.exception(
168+
"error calling notes api action %s for segment-id: %s ",
169+
action,
170+
segment_range_id,
171+
)
172+
raise e
173+
174+
175+
def handle_event(segment_args: Namespace) -> int:
176+
nb_token = segment_args.nautobot_token or credential("nb-token", "token")
177+
try:
178+
nautobot: Nautobot = Nautobot(
179+
segment_args.nautobot_url, nb_token, logger=logger
180+
)
181+
ucvni_group_endpoint: Endpoint = (
182+
nautobot.session.plugins.undercloud_vni.ucvni_groups
183+
)
184+
notes_endpoint: Endpoint = nautobot.session.extras.notes
185+
except requests.exceptions.ConnectTimeout as e:
186+
logger.error("Network error while connecting to Nautobot: %s", str(e))
187+
return _EXIT_API_ERROR
188+
189+
ucvni_group: Record = cast(
190+
Record, ucvni_group_endpoint.get(segment_args.segment_name)
191+
)
192+
if ucvni_group is None:
193+
logger.error("No UCVNI group found for segment %s", {segment_args.segment_name})
194+
return _EXIT_API_ERROR
195+
196+
return (
197+
_EXIT_SUCCESS
198+
if update_ucvni_range(
199+
segment_args=segment_args,
200+
ucvni_group=ucvni_group,
201+
notes_endpoint=notes_endpoint,
202+
)
203+
else _EXIT_API_ERROR
204+
)
205+
206+
207+
def main() -> int:
208+
args = argument_parser().parse_args()
209+
210+
event: SegmentRangeEvent = args.event
211+
if event not in [
212+
SegmentRangeEvent.CREATE,
213+
SegmentRangeEvent.UPDATE,
214+
SegmentRangeEvent.DELETE,
215+
]:
216+
logger.error("Cannot handle event: %s", event)
217+
return _EXIT_EVENT_UNKNOWN
218+
219+
return handle_event(segment_args=args)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
apiVersion: argoproj.io/v1alpha1
2+
metadata:
3+
name: neutron-event-network-segment-range
4+
annotations:
5+
workflows.argoproj.io/title: CRUD UCVNI Group range with network-segment-range
6+
workflows.argoproj.io/description: |
7+
Updates Nautobot UCVNI Group range field from a network-segment-range min, max values.
8+
9+
To test this workflow you can run it with the following:
10+
11+
```
12+
argo -n argo-events submit --from workflowtemplate/neutron-event-network-segment-range \
13+
-p event_type network_segment_range.create.end -p segment_min_range=1800 segment_max_range=3799
14+
```
15+
16+
Defined in `workflows/argo-events/workflowtemplates/neutron-event-network-segment-range.yaml`
17+
kind: WorkflowTemplate
18+
spec:
19+
entrypoint: sync-network-segment-range
20+
serviceAccountName: workflow
21+
arguments:
22+
parameters:
23+
- name: event_type
24+
- name: segment_name
25+
- name: network_type
26+
- name: segment_range_id
27+
- name: segment_min_range
28+
- name: segment_max_range
29+
templates:
30+
- name: sync-network-segment-range
31+
inputs:
32+
parameters:
33+
- name: event_type
34+
- name: segment_name
35+
- name: network_type
36+
- name: segment_range_id
37+
- name: segment_min_range
38+
- name: segment_max_range
39+
container:
40+
image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest
41+
command:
42+
- sync-network-segment-range
43+
args:
44+
- "{{inputs.parameters.event_type}}"
45+
- "{{inputs.parameters.segment_name}}"
46+
- "{{inputs.parameters.network_type}}"
47+
- "{{inputs.parameters.segment_range_id}}"
48+
- "{{inputs.parameters.segment_min_range}}"
49+
- "{{inputs.parameters.segment_max_range}}"
50+
volumeMounts:
51+
- mountPath: /etc/nb-token/
52+
name: nb-token
53+
readOnly: true
54+
- mountPath: /etc/openstack
55+
name: openstack-svc-acct
56+
readOnly: true
57+
volumes:
58+
- name: nb-token
59+
secret:
60+
secretName: nautobot-token
61+
- name: openstack-svc-acct
62+
secret:
63+
secretName: openstack-svc-acct

0 commit comments

Comments
 (0)