Merge ~fnordahl/ubuntu/+source/python-openstackclient:master into ~ubuntu-openstack-dev/ubuntu/+source/python-openstackclient:master

Proposed by Frode Nordahl
Status: Merged
Merged at revision: d2a74e9a52baf9c6d96eca55541f0223bf47be80
Proposed branch: ~fnordahl/ubuntu/+source/python-openstackclient:master
Merge into: ~ubuntu-openstack-dev/ubuntu/+source/python-openstackclient:master
Diff against target: 1802 lines (+1764/-0)
6 files modified
debian/changelog (+7/-0)
debian/patches/lp-2002687-1-Parse-external-gateway-argument-in-separate-helper.patch (+109/-0)
debian/patches/lp-2002687-2-router-Use-plural-form-for-storage-of-fixed_ip-argum.patch (+130/-0)
debian/patches/lp-2002687-3-Add-support-for-managing-external-gateways.patch (+1294/-0)
debian/patches/lp-2002687-4-Add-router-default-route-BFD-ECMP-options.patch (+220/-0)
debian/patches/series (+4/-0)
Reviewer Review Type Date Requested Status
James Page Pending
Review via email: mp+463871@code.launchpad.net
To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/debian/changelog b/debian/changelog
2index 2ecccc2..b6da2bb 100644
3--- a/debian/changelog
4+++ b/debian/changelog
5@@ -1,3 +1,10 @@
6+python-openstackclient (6.6.0-0ubuntu2) UNRELEASED; urgency=medium
7+
8+ * d/p/lp-2002687-*: Add support for managing multiple gateways and
9+ BFD/ECMP options (LP: #2002687).
10+
11+ -- Frode Nordahl <fnordahl@ubuntu.com> Tue, 09 Apr 2024 06:29:16 +0000
12+
13 python-openstackclient (6.6.0-0ubuntu1) noble; urgency=medium
14
15 [ Corey Bryant ]
16diff --git a/debian/patches/lp-2002687-1-Parse-external-gateway-argument-in-separate-helper.patch b/debian/patches/lp-2002687-1-Parse-external-gateway-argument-in-separate-helper.patch
17new file mode 100644
18index 0000000..a2a4365
19--- /dev/null
20+++ b/debian/patches/lp-2002687-1-Parse-external-gateway-argument-in-separate-helper.patch
21@@ -0,0 +1,109 @@
22+From 3915baa0b7996fd2fe34f44239bccd9b60836126 Mon Sep 17 00:00:00 2001
23+From: Frode Nordahl <fnordahl@ubuntu.com>
24+Date: Tue, 5 Mar 2024 11:15:23 +0100
25+Subject: [PATCH 1/4] Parse external-gateway argument in separate helper
26+
27+From: Frode Nordahl <frode.nordahl@canonical.com>
28+
29+This is to prepare for subsequent patches that will add support
30+for managing multiple gateways.
31+
32+Related-Bug: #2002687
33+Change-Id: Ic088dca0b7cd83bd7568d775b4e70285ce72411d
34+Signed-off-by: Frode Nordahl <frode.nordahl@canonical.com>
35+(cherry picked from commit f696aee81d8b20ffdaa11c786c7e55cd7aa9cbb8)
36+
37+Origin: upstream, https://opendev.org/openstack/python-openstackclient/commit/f696aee81d8b20ffdaa11c786c7e55cd7aa9cbb8
38+Bug: https://launchpad.net/bugs/2002687
39+Description: The main feature went into OpenStack Neutron at Caracal, and due
40+ to review capacity the client patches did unfortunately not make it. Enable
41+ the feature for our users while we await the next python-openstackclient
42+ release.
43+
44+---
45+ openstackclient/network/v2/router.py | 62 ++++++++++++++++------------
46+ 1 file changed, 36 insertions(+), 26 deletions(-)
47+
48+diff --git a/openstackclient/network/v2/router.py b/openstackclient/network/v2/router.py
49+index fdb7102b..b4430153 100644
50+--- a/openstackclient/network/v2/router.py
51++++ b/openstackclient/network/v2/router.py
52+@@ -85,6 +85,39 @@ def _get_columns(item):
53+ )
54+
55+
56++def _get_external_gateway_attrs(client_manager, parsed_args):
57++ attrs = {}
58++
59++ if parsed_args.external_gateway:
60++ gateway_info = {}
61++ n_client = client_manager.network
62++ network = n_client.find_network(
63++ parsed_args.external_gateway, ignore_missing=False
64++ )
65++ gateway_info['network_id'] = network.id
66++ if parsed_args.disable_snat:
67++ gateway_info['enable_snat'] = False
68++ if parsed_args.enable_snat:
69++ gateway_info['enable_snat'] = True
70++ if parsed_args.fixed_ip:
71++ ips = []
72++ for ip_spec in parsed_args.fixed_ip:
73++ if ip_spec.get('subnet', False):
74++ subnet_name_id = ip_spec.pop('subnet')
75++ if subnet_name_id:
76++ subnet = n_client.find_subnet(
77++ subnet_name_id, ignore_missing=False
78++ )
79++ ip_spec['subnet_id'] = subnet.id
80++ if ip_spec.get('ip-address', False):
81++ ip_spec['ip_address'] = ip_spec.pop('ip-address')
82++ ips.append(ip_spec)
83++ gateway_info['external_fixed_ips'] = ips
84++ attrs['external_gateway_info'] = gateway_info
85++
86++ return attrs
87++
88++
89+ def _get_attrs(client_manager, parsed_args):
90+ attrs = {}
91+ if parsed_args.name is not None:
92+@@ -113,32 +146,9 @@ def _get_attrs(client_manager, parsed_args):
93+ parsed_args.project_domain,
94+ ).id
95+ attrs['project_id'] = project_id
96+- if parsed_args.external_gateway:
97+- gateway_info = {}
98+- n_client = client_manager.network
99+- network = n_client.find_network(
100+- parsed_args.external_gateway, ignore_missing=False
101+- )
102+- gateway_info['network_id'] = network.id
103+- if parsed_args.disable_snat:
104+- gateway_info['enable_snat'] = False
105+- if parsed_args.enable_snat:
106+- gateway_info['enable_snat'] = True
107+- if parsed_args.fixed_ip:
108+- ips = []
109+- for ip_spec in parsed_args.fixed_ip:
110+- if ip_spec.get('subnet', False):
111+- subnet_name_id = ip_spec.pop('subnet')
112+- if subnet_name_id:
113+- subnet = n_client.find_subnet(
114+- subnet_name_id, ignore_missing=False
115+- )
116+- ip_spec['subnet_id'] = subnet.id
117+- if ip_spec.get('ip-address', False):
118+- ip_spec['ip_address'] = ip_spec.pop('ip-address')
119+- ips.append(ip_spec)
120+- gateway_info['external_fixed_ips'] = ips
121+- attrs['external_gateway_info'] = gateway_info
122++
123++ attrs.update(_get_external_gateway_attrs(client_manager, parsed_args))
124++
125+ # "router set" command doesn't support setting flavor_id.
126+ if 'flavor_id' in parsed_args and parsed_args.flavor_id is not None:
127+ attrs['flavor_id'] = parsed_args.flavor_id
128+--
129+2.43.0
130+
131diff --git a/debian/patches/lp-2002687-2-router-Use-plural-form-for-storage-of-fixed_ip-argum.patch b/debian/patches/lp-2002687-2-router-Use-plural-form-for-storage-of-fixed_ip-argum.patch
132new file mode 100644
133index 0000000..cfef2e3
134--- /dev/null
135+++ b/debian/patches/lp-2002687-2-router-Use-plural-form-for-storage-of-fixed_ip-argum.patch
136@@ -0,0 +1,130 @@
137+From c4c86884c5c706dcf148a30f2b24ad07db4b44a5 Mon Sep 17 00:00:00 2001
138+From: Frode Nordahl <fnordahl@ubuntu.com>
139+Date: Tue, 5 Mar 2024 15:56:49 +0100
140+Subject: [PATCH 2/4] router: Use plural form for storage of ``--fixed_ip``
141+ argument
142+
143+From: Frode Nordahl <frode.nordahl@canonical.com>
144+
145+The variable already takes multiple values, let's make it obvious
146+just by reading the code.
147+
148+Related-Bug: #2002687
149+Change-Id: I294ee710d989d7a3a54331fca424e84708a2faab
150+Signed-off-by: Frode Nordahl <frode.nordahl@canonical.com>
151+(cherry picked from commit 58ad3cefa7870320d9d9835b202f7b9661b1d825)
152+
153+Origin: upstream, https://opendev.org/openstack/python-openstackclient/commit/58ad3cefa7870320d9d9835b202f7b9661b1d825
154+Bug: https://launchpad.net/bugs/2002687
155+Description: The main feature went into OpenStack Neutron at Caracal, and due
156+ to review capacity the client patches did unfortunately not make it. Enable
157+ the feature for our users while we await the next python-openstackclient
158+ release.
159+
160+---
161+ openstackclient/network/v2/router.py | 10 ++++++----
162+ openstackclient/tests/unit/network/v2/test_router.py | 10 +++++-----
163+ 2 files changed, 11 insertions(+), 9 deletions(-)
164+
165+diff --git a/openstackclient/network/v2/router.py b/openstackclient/network/v2/router.py
166+index b4430153..21170ffa 100644
167+--- a/openstackclient/network/v2/router.py
168++++ b/openstackclient/network/v2/router.py
169+@@ -99,9 +99,9 @@ def _get_external_gateway_attrs(client_manager, parsed_args):
170+ gateway_info['enable_snat'] = False
171+ if parsed_args.enable_snat:
172+ gateway_info['enable_snat'] = True
173+- if parsed_args.fixed_ip:
174++ if parsed_args.fixed_ips:
175+ ips = []
176+- for ip_spec in parsed_args.fixed_ip:
177++ for ip_spec in parsed_args.fixed_ips:
178+ if ip_spec.get('subnet', False):
179+ subnet_name_id = ip_spec.pop('subnet')
180+ if subnet_name_id:
181+@@ -379,6 +379,7 @@ class CreateRouter(command.ShowOne, common.NeutronCommandWithExtraArgs):
182+ metavar='subnet=<subnet>,ip-address=<ip-address>',
183+ action=parseractions.MultiKeyValueAction,
184+ optional_keys=['subnet', 'ip-address'],
185++ dest='fixed_ips',
186+ help=_(
187+ "Desired IP and/or subnet (name or ID) "
188+ "on external gateway: "
189+@@ -449,7 +450,7 @@ class CreateRouter(command.ShowOne, common.NeutronCommandWithExtraArgs):
190+ if (
191+ parsed_args.disable_snat
192+ or parsed_args.enable_snat
193+- or parsed_args.fixed_ip
194++ or parsed_args.fixed_ips
195+ ) and not parsed_args.external_gateway:
196+ msg = _(
197+ "You must specify '--external-gateway' in order "
198+@@ -797,6 +798,7 @@ class SetRouter(common.NeutronCommandWithExtraArgs):
199+ metavar='subnet=<subnet>,ip-address=<ip-address>',
200+ action=parseractions.MultiKeyValueAction,
201+ optional_keys=['subnet', 'ip-address'],
202++ dest='fixed_ips',
203+ help=_(
204+ "Desired IP and/or subnet (name or ID) "
205+ "on external gateway: "
206+@@ -870,7 +872,7 @@ class SetRouter(common.NeutronCommandWithExtraArgs):
207+ if (
208+ parsed_args.disable_snat
209+ or parsed_args.enable_snat
210+- or parsed_args.fixed_ip
211++ or parsed_args.fixed_ips
212+ ) and not parsed_args.external_gateway:
213+ msg = _(
214+ "You must specify '--external-gateway' in order "
215+diff --git a/openstackclient/tests/unit/network/v2/test_router.py b/openstackclient/tests/unit/network/v2/test_router.py
216+index 9935db4d..fb12e3f0 100644
217+--- a/openstackclient/tests/unit/network/v2/test_router.py
218++++ b/openstackclient/tests/unit/network/v2/test_router.py
219+@@ -230,7 +230,7 @@ class TestCreateRouter(TestRouter):
220+ ('ha', False),
221+ ('external_gateway', _network.name),
222+ ('enable_snat', True),
223+- ('fixed_ip', [{'ip-address': '2001:db8::1'}]),
224++ ('fixed_ips', [{'ip-address': '2001:db8::1'}]),
225+ ]
226+ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
227+
228+@@ -1297,7 +1297,7 @@ class TestSetRouter(TestRouter):
229+ self._router.id,
230+ ]
231+ verifylist = [
232+- ('fixed_ip', [{'subnet': "'abc'"}]),
233++ ('fixed_ips', [{'subnet': "'abc'"}]),
234+ ('router', self._router.id),
235+ ]
236+ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
237+@@ -1336,7 +1336,7 @@ class TestSetRouter(TestRouter):
238+ verifylist = [
239+ ('router', self._router.id),
240+ ('external_gateway', self._network.id),
241+- ('fixed_ip', [{'subnet': "'abc'"}]),
242++ ('fixed_ips', [{'subnet': "'abc'"}]),
243+ ('enable_snat', True),
244+ ]
245+ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
246+@@ -1370,7 +1370,7 @@ class TestSetRouter(TestRouter):
247+ verifylist = [
248+ ('router', self._router.id),
249+ ('external_gateway', self._network.id),
250+- ('fixed_ip', [{'ip-address': "10.0.1.1"}]),
251++ ('fixed_ips', [{'ip-address': "10.0.1.1"}]),
252+ ('enable_snat', True),
253+ ]
254+ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
255+@@ -1404,7 +1404,7 @@ class TestSetRouter(TestRouter):
256+ verifylist = [
257+ ('router', self._router.id),
258+ ('external_gateway', self._network.id),
259+- ('fixed_ip', [{'subnet': "'abc'", 'ip-address': "10.0.1.1"}]),
260++ ('fixed_ips', [{'subnet': "'abc'", 'ip-address': "10.0.1.1"}]),
261+ ('enable_snat', True),
262+ ]
263+ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
264+--
265+2.43.0
266+
267diff --git a/debian/patches/lp-2002687-3-Add-support-for-managing-external-gateways.patch b/debian/patches/lp-2002687-3-Add-support-for-managing-external-gateways.patch
268new file mode 100644
269index 0000000..e977a4c
270--- /dev/null
271+++ b/debian/patches/lp-2002687-3-Add-support-for-managing-external-gateways.patch
272@@ -0,0 +1,1294 @@
273+From f60a7cc006632b0c6478f4a0dfcb8ed217e960ae Mon Sep 17 00:00:00 2001
274+From: Frode Nordahl <fnordahl@ubuntu.com>
275+Date: Fri, 7 Jul 2023 19:08:33 +0300
276+Subject: [PATCH 3/4] Add support for managing external gateways
277+
278+From: Dmitrii Shcherbakov <dmitrii.shcherbakov@canonical.com>
279+
280+This change implements the logic to call the new API for managing
281+external gateways.
282+
283+Relevant Neutron core change:
284+https://review.opendev.org/c/openstack/neutron/+/873593
285+
286+Co-Authored-by: Frode Nordahl <frode.nordahl@canonical.com>
287+Related-Bug: #2002687
288+Change-Id: Ib45f30f552934a0a5c035c3b7fadfc0d522219ba
289+(cherry picked from commit 16c695045c5618a733574fd2a5558d672a2df553)
290+Conflicts:
291+ setup.cfg
292+
293+Origin: upstream, https://opendev.org/openstack/python-openstackclient/commit/16c695045c5618a733574fd2a5558d672a2df553
294+Bug: https://launchpad.net/bugs/2002687
295+Description: The main feature went into OpenStack Neutron at Caracal, and due
296+ to review capacity the client patches did unfortunately not make it. Enable
297+ the feature for our users while we await the next python-openstackclient
298+ release.
299+
300+---
301+ openstackclient/network/v2/router.py | 313 ++++++++-
302+ .../tests/unit/network/v2/test_router.py | 616 +++++++++++++++++-
303+ setup.cfg | 2 +
304+ 3 files changed, 885 insertions(+), 46 deletions(-)
305+
306+diff --git a/openstackclient/network/v2/router.py b/openstackclient/network/v2/router.py
307+index 21170ffa..52b7d25f 100644
308+--- a/openstackclient/network/v2/router.py
309++++ b/openstackclient/network/v2/router.py
310+@@ -13,6 +13,7 @@
311+
312+ """Router action implementations"""
313+
314++import collections
315+ import copy
316+ import json
317+ import logging
318+@@ -85,23 +86,67 @@ def _get_columns(item):
319+ )
320+
321+
322++def is_multiple_gateways_supported(n_client):
323++ return n_client.find_extension("external-gateway-multihoming") is not None
324++
325++
326++def _passed_multiple_gateways(extension_supported, external_gateways):
327++ passed_multiple_gws = len(external_gateways) > 1
328++ if passed_multiple_gws and not extension_supported:
329++ msg = _(
330++ 'Supplying --external-gateway option multiple times is not '
331++ 'supported due to the lack of external-gateway-multihoming '
332++ 'extension at the Neutron side.'
333++ )
334++ raise exceptions.CommandError(msg)
335++ return passed_multiple_gws
336++
337++
338+ def _get_external_gateway_attrs(client_manager, parsed_args):
339+ attrs = {}
340+
341+- if parsed_args.external_gateway:
342+- gateway_info = {}
343++ if parsed_args.external_gateways:
344++ external_gateways: collections.defaultdict[
345++ str, list[dict]
346++ ] = collections.defaultdict(list)
347+ n_client = client_manager.network
348+- network = n_client.find_network(
349+- parsed_args.external_gateway, ignore_missing=False
350+- )
351+- gateway_info['network_id'] = network.id
352+- if parsed_args.disable_snat:
353+- gateway_info['enable_snat'] = False
354+- if parsed_args.enable_snat:
355+- gateway_info['enable_snat'] = True
356++ first_network_id = None
357++
358++ for gw_net_name_or_id in parsed_args.external_gateways:
359++ gateway_info = {}
360++ gw_net = n_client.find_network(
361++ gw_net_name_or_id, ignore_missing=False
362++ )
363++ if first_network_id is None:
364++ first_network_id = gw_net.id
365++ gateway_info['network_id'] = gw_net.id
366++ if 'disable_snat' in parsed_args and parsed_args.disable_snat:
367++ gateway_info['enable_snat'] = False
368++ if 'enable_snat' in parsed_args and parsed_args.enable_snat:
369++ gateway_info['enable_snat'] = True
370++
371++ # This option was added before multiple gateways were supported, so
372++ # it does not have a per-gateway port granularity so just pass it
373++ # along in gw info in case it is specified.
374++ if 'qos_policy' in parsed_args and parsed_args.qos_policy:
375++ qos_id = n_client.find_qos_policy(
376++ parsed_args.qos_policy, ignore_missing=False
377++ ).id
378++ gateway_info['qos_policy_id'] = qos_id
379++ if 'no_qos_policy' in parsed_args and parsed_args.no_qos_policy:
380++ gateway_info['qos_policy_id'] = None
381++
382++ external_gateways[gw_net.id].append(gateway_info)
383++
384++ multiple_gws_supported = is_multiple_gateways_supported(n_client)
385++ # Parse the external fixed IP specs and match them to specific gateway
386++ # ports if needed.
387+ if parsed_args.fixed_ips:
388+- ips = []
389+ for ip_spec in parsed_args.fixed_ips:
390++ # If there is only one gateway, this value will represent the
391++ # network ID for it, otherwise it will be overridden.
392++ ip_net_id = first_network_id
393++
394+ if ip_spec.get('subnet', False):
395+ subnet_name_id = ip_spec.pop('subnet')
396+ if subnet_name_id:
397+@@ -109,12 +154,45 @@ def _get_external_gateway_attrs(client_manager, parsed_args):
398+ subnet_name_id, ignore_missing=False
399+ )
400+ ip_spec['subnet_id'] = subnet.id
401++ ip_net_id = subnet.network_id
402+ if ip_spec.get('ip-address', False):
403+ ip_spec['ip_address'] = ip_spec.pop('ip-address')
404+- ips.append(ip_spec)
405+- gateway_info['external_fixed_ips'] = ips
406+- attrs['external_gateway_info'] = gateway_info
407+-
408++ # Finally, add an ip_spec to the specific gateway identified
409++ # by a network from the spec.
410++ if (
411++ 'subnet_id' in ip_spec
412++ and ip_net_id not in external_gateways
413++ ):
414++ msg = _(
415++ 'Subnet %s does not belong to any of the networks '
416++ 'provided for --external-gateway.'
417++ ) % (ip_spec['subnet_id'])
418++ raise exceptions.CommandError(msg)
419++ for gw_info in external_gateways[ip_net_id]:
420++ if 'external_fixed_ips' not in gw_info:
421++ gw_info['external_fixed_ips'] = [ip_spec]
422++ break
423++ else:
424++ # The end user has requested more fixed IPs than there are
425++ # gateways, add multiple fixed IPs to single gateway to
426++ # retain current behavior.
427++ for gw_info in external_gateways[ip_net_id]:
428++ gw_info['external_fixed_ips'].append(ip_spec)
429++ break
430++
431++ # Use the newer API whenever it is supported regardless of whether one
432++ # or multiple gateways are passed as arguments.
433++ if multiple_gws_supported:
434++ gateway_list = []
435++ # Now merge the per-network-id lists of external gateway info
436++ # dicts into one list.
437++ for gw_info_list in external_gateways.values():
438++ gateway_list.extend(gw_info_list)
439++ attrs['external_gateways'] = gateway_list
440++ else:
441++ attrs['external_gateway_info'] = external_gateways[
442++ first_network_id
443++ ][0]
444+ return attrs
445+
446+
447+@@ -372,7 +450,13 @@ class CreateRouter(command.ShowOne, common.NeutronCommandWithExtraArgs):
448+ parser.add_argument(
449+ '--external-gateway',
450+ metavar="<network>",
451+- help=_("External Network used as router's gateway (name or ID)"),
452++ action='append',
453++ help=_(
454++ "External Network used as router's gateway (name or ID). "
455++ "(repeat option to set multiple gateways per router "
456++ "if the L3 service plugin in use supports it)."
457++ ),
458++ dest='external_gateways',
459+ )
460+ parser.add_argument(
461+ '--fixed-ip',
462+@@ -384,7 +468,7 @@ class CreateRouter(command.ShowOne, common.NeutronCommandWithExtraArgs):
463+ "Desired IP and/or subnet (name or ID) "
464+ "on external gateway: "
465+ "subnet=<subnet>,ip-address=<ip-address> "
466+- "(repeat option to set multiple fixed IP addresses)"
467++ "(repeat option to set multiple fixed IP addresses)."
468+ ),
469+ )
470+ snat_group = parser.add_mutually_exclusive_group()
471+@@ -433,7 +517,7 @@ class CreateRouter(command.ShowOne, common.NeutronCommandWithExtraArgs):
472+ self._parse_extra_properties(parsed_args.extra_properties)
473+ )
474+
475+- if parsed_args.enable_ndp_proxy and not parsed_args.external_gateway:
476++ if parsed_args.enable_ndp_proxy and not parsed_args.external_gateways:
477+ msg = _(
478+ "You must specify '--external-gateway' in order "
479+ "to enable router's NDP proxy"
480+@@ -443,15 +527,24 @@ class CreateRouter(command.ShowOne, common.NeutronCommandWithExtraArgs):
481+ if parsed_args.enable_ndp_proxy is not None:
482+ attrs['enable_ndp_proxy'] = parsed_args.enable_ndp_proxy
483+
484++ external_gateways = attrs.pop('external_gateways', None)
485+ obj = client.create_router(**attrs)
486+ # tags cannot be set when created, so tags need to be set later.
487+ _tag.update_tags_for_set(client, obj, parsed_args)
488+
489++ # If the multiple external gateways API is intended to be used,
490++ # do a separate API call to set the desired external gateways as the
491++ # router creation API supports adding only one.
492++ if external_gateways:
493++ client.update_external_gateways(
494++ obj, body={'router': {'external_gateways': external_gateways}}
495++ )
496++
497+ if (
498+ parsed_args.disable_snat
499+ or parsed_args.enable_snat
500+ or parsed_args.fixed_ips
501+- ) and not parsed_args.external_gateway:
502++ ) and not parsed_args.external_gateways:
503+ msg = _(
504+ "You must specify '--external-gateway' in order "
505+ "to specify SNAT or fixed-ip values"
506+@@ -791,7 +884,13 @@ class SetRouter(common.NeutronCommandWithExtraArgs):
507+ parser.add_argument(
508+ '--external-gateway',
509+ metavar="<network>",
510+- help=_("External Network used as router's gateway (name or ID)"),
511++ action='append',
512++ help=_(
513++ "External Network used as router's gateway (name or ID). "
514++ "(repeat option to set multiple gateways per router "
515++ "if the L3 service plugin in use supports it)."
516++ ),
517++ dest='external_gateways',
518+ )
519+ parser.add_argument(
520+ '--fixed-ip',
521+@@ -803,7 +902,7 @@ class SetRouter(common.NeutronCommandWithExtraArgs):
522+ "Desired IP and/or subnet (name or ID) "
523+ "on external gateway: "
524+ "subnet=<subnet>,ip-address=<ip-address> "
525+- "(repeat option to set multiple fixed IP addresses)"
526++ "(repeat option to set multiple fixed IP addresses)."
527+ ),
528+ )
529+ snat_group = parser.add_mutually_exclusive_group()
530+@@ -873,7 +972,7 @@ class SetRouter(common.NeutronCommandWithExtraArgs):
531+ parsed_args.disable_snat
532+ or parsed_args.enable_snat
533+ or parsed_args.fixed_ips
534+- ) and not parsed_args.external_gateway:
535++ ) and not parsed_args.external_gateways:
536+ msg = _(
537+ "You must specify '--external-gateway' in order "
538+ "to update the SNAT or fixed-ip values"
539+@@ -882,7 +981,7 @@ class SetRouter(common.NeutronCommandWithExtraArgs):
540+
541+ if (
542+ parsed_args.qos_policy or parsed_args.no_qos_policy
543+- ) and not parsed_args.external_gateway:
544++ ) and not parsed_args.external_gateways:
545+ try:
546+ original_net_id = obj.external_gateway_info['network_id']
547+ except (KeyError, TypeError):
548+@@ -893,17 +992,21 @@ class SetRouter(common.NeutronCommandWithExtraArgs):
549+ )
550+ raise exceptions.CommandError(msg)
551+ else:
552+- if not attrs.get('external_gateway_info'):
553++ if not attrs.get('external_gateway_info') and not attrs.get(
554++ 'external_gateways'
555++ ):
556+ attrs['external_gateway_info'] = {}
557+ attrs['external_gateway_info']['network_id'] = original_net_id
558+ if parsed_args.qos_policy:
559+ check_qos_id = client.find_qos_policy(
560+ parsed_args.qos_policy, ignore_missing=False
561+ ).id
562+- attrs['external_gateway_info']['qos_policy_id'] = check_qos_id
563++ if not attrs.get('external_gateways'):
564++ attrs['external_gateway_info']['qos_policy_id'] = check_qos_id
565+
566+ if 'no_qos_policy' in parsed_args and parsed_args.no_qos_policy:
567+- attrs['external_gateway_info']['qos_policy_id'] = None
568++ if not attrs.get('external_gateways'):
569++ attrs['external_gateway_info']['qos_policy_id'] = None
570+
571+ attrs.update(
572+ self._parse_extra_properties(parsed_args.extra_properties)
573+@@ -913,7 +1016,16 @@ class SetRouter(common.NeutronCommandWithExtraArgs):
574+ attrs['enable_ndp_proxy'] = parsed_args.enable_ndp_proxy
575+
576+ if attrs:
577++ external_gateways = attrs.pop('external_gateways', None)
578+ client.update_router(obj, **attrs)
579++ # If the multiple external gateways API is intended to be used,
580++ # do a separate API call to set external gateways.
581++ if external_gateways:
582++ client.update_external_gateways(
583++ obj,
584++ body={'router': {'external_gateways': external_gateways}},
585++ )
586++
587+ # tags is a subresource and it needs to be updated separately.
588+ _tag.update_tags_for_set(client, obj, parsed_args)
589+
590+@@ -973,11 +1085,15 @@ class UnsetRouter(common.NeutronUnsetCommandWithExtraArgs):
591+ "(repeat option to unset multiple routes)"
592+ ),
593+ )
594++ # NOTE(dmitriis): This was not extended to support selective removal
595++ # of external gateways due to a cpython bug in argparse:
596++ # https://github.com/python/cpython/issues/53584
597+ parser.add_argument(
598+ '--external-gateway',
599+ action='store_true',
600+ default=False,
601+ help=_("Remove external gateway information from the router"),
602++ dest='external_gateways',
603+ )
604+ parser.add_argument(
605+ '--qos-policy',
606+@@ -1024,7 +1140,7 @@ class UnsetRouter(common.NeutronUnsetCommandWithExtraArgs):
607+ 'qos_policy_id': None,
608+ }
609+
610+- if parsed_args.external_gateway:
611++ if parsed_args.external_gateways:
612+ attrs['external_gateway_info'] = {}
613+
614+ attrs.update(
615+@@ -1032,6 +1148,149 @@ class UnsetRouter(common.NeutronUnsetCommandWithExtraArgs):
616+ )
617+
618+ if attrs:
619++ # If removing multiple gateways per router are supported,
620++ # use the relevant API to remove them all.
621++ if is_multiple_gateways_supported(client):
622++ client.remove_external_gateways(
623++ obj,
624++ body={'router': {'external_gateways': {}}},
625++ )
626++
627+ client.update_router(obj, **attrs)
628+ # tags is a subresource and it needs to be updated separately.
629+ _tag.update_tags_for_unset(client, obj, parsed_args)
630++
631++
632++class AddGatewayToRouter(command.ShowOne):
633++ _description = _("Add router gateway")
634++
635++ def get_parser(self, prog_name):
636++ parser = super().get_parser(prog_name)
637++ parser.add_argument(
638++ 'router',
639++ metavar="<router>",
640++ help=_("Router to modify (name or ID)."),
641++ )
642++ parser.add_argument(
643++ metavar="<network>",
644++ help=_(
645++ "External Network to a attach a router gateway to (name or "
646++ "ID)."
647++ ),
648++ dest='external_gateways',
649++ # The argument is stored in a list in order to reuse the
650++ # common attribute parsing code.
651++ nargs=1,
652++ )
653++ parser.add_argument(
654++ '--fixed-ip',
655++ metavar='subnet=<subnet>,ip-address=<ip-address>',
656++ action=parseractions.MultiKeyValueAction,
657++ optional_keys=['subnet', 'ip-address'],
658++ dest='fixed_ips',
659++ help=_(
660++ "Desired IP and/or subnet (name or ID) "
661++ "on external gateway: "
662++ "subnet=<subnet>,ip-address=<ip-address> "
663++ "(repeat option to set multiple fixed IP addresses)."
664++ ),
665++ )
666++ return parser
667++
668++ def take_action(self, parsed_args):
669++ client = self.app.client_manager.network
670++ if not is_multiple_gateways_supported(client):
671++ msg = _(
672++ 'The external-gateway-multihoming extension is not enabled at '
673++ 'the Neutron side.'
674++ )
675++ raise exceptions.CommandError(msg)
676++
677++ router_obj = client.find_router(
678++ parsed_args.router, ignore_missing=False
679++ )
680++
681++ # Get the common attributes.
682++ attrs = _get_external_gateway_attrs(
683++ self.app.client_manager, parsed_args
684++ )
685++
686++ if attrs:
687++ external_gateways = attrs.pop('external_gateways')
688++ router_obj = client.add_external_gateways(
689++ router_obj,
690++ body={'router': {'external_gateways': external_gateways}},
691++ )
692++
693++ display_columns, columns = _get_columns(router_obj)
694++ data = utils.get_item_properties(
695++ router_obj, columns, formatters=_formatters
696++ )
697++ return (display_columns, data)
698++
699++
700++class RemoveGatewayFromRouter(command.ShowOne):
701++ _description = _("Remove router gateway")
702++
703++ def get_parser(self, prog_name):
704++ parser = super().get_parser(prog_name)
705++ parser.add_argument(
706++ 'router',
707++ metavar="<router>",
708++ help=_("Router to modify (name or ID)."),
709++ )
710++ parser.add_argument(
711++ metavar="<network>",
712++ help=_(
713++ "External Network to remove a router gateway from (name or "
714++ "ID)."
715++ ),
716++ dest='external_gateways',
717++ # The argument is stored in a list in order to reuse the
718++ # common attribute parsing code.
719++ nargs=1,
720++ )
721++ parser.add_argument(
722++ '--fixed-ip',
723++ metavar='subnet=<subnet>,ip-address=<ip-address>',
724++ action=parseractions.MultiKeyValueAction,
725++ optional_keys=['subnet', 'ip-address'],
726++ dest='fixed_ips',
727++ help=_(
728++ "IP and/or subnet (name or ID) on the external gateway "
729++ "which is used to identify a particular gateway if multiple "
730++ "are attached to the same network: subnet=<subnet>,"
731++ "ip-address=<ip-address>."
732++ ),
733++ )
734++ return parser
735++
736++ def take_action(self, parsed_args):
737++ client = self.app.client_manager.network
738++ if not is_multiple_gateways_supported(client):
739++ msg = _(
740++ 'The external-gateway-multihoming extension is not enabled at '
741++ 'the Neutron side.'
742++ )
743++ raise exceptions.CommandError(msg)
744++
745++ router_obj = client.find_router(
746++ parsed_args.router, ignore_missing=False
747++ )
748++
749++ # Get the common attributes.
750++ attrs = _get_external_gateway_attrs(
751++ self.app.client_manager, parsed_args
752++ )
753++ if attrs:
754++ external_gateways = attrs.pop('external_gateways')
755++ router_obj = client.remove_external_gateways(
756++ router_obj,
757++ body={'router': {'external_gateways': external_gateways}},
758++ )
759++
760++ display_columns, columns = _get_columns(router_obj)
761++ data = utils.get_item_properties(
762++ router_obj, columns, formatters=_formatters
763++ )
764++ return (display_columns, data)
765+diff --git a/openstackclient/tests/unit/network/v2/test_router.py b/openstackclient/tests/unit/network/v2/test_router.py
766+index fb12e3f0..33956b3e 100644
767+--- a/openstackclient/tests/unit/network/v2/test_router.py
768++++ b/openstackclient/tests/unit/network/v2/test_router.py
769+@@ -75,7 +75,7 @@ class TestAddPortToRouter(TestRouter):
770+ self._router,
771+ **{
772+ 'port_id': self._router.port,
773+- }
774++ },
775+ )
776+ self.assertIsNone(result)
777+
778+@@ -130,6 +130,7 @@ class TestAddSubnetToRouter(TestRouter):
779+ class TestCreateRouter(TestRouter):
780+ # The new router created.
781+ new_router = network_fakes.FakeRouter.create_one_router()
782++ _extensions = {'fake': network_fakes.create_one_extension()}
783+
784+ columns = (
785+ 'admin_state_up',
786+@@ -169,7 +170,9 @@ class TestCreateRouter(TestRouter):
787+ return_value=self.new_router
788+ )
789+ self.network_client.set_tags = mock.Mock(return_value=None)
790+-
791++ self.network_client.find_extension = mock.Mock(
792++ side_effect=lambda name: self._extensions.get(name)
793++ )
794+ # Get the command object to test
795+ self.cmd = router.CreateRouter(self.app, self.namespace)
796+
797+@@ -228,7 +231,7 @@ class TestCreateRouter(TestRouter):
798+ ('enable', True),
799+ ('distributed', False),
800+ ('ha', False),
801+- ('external_gateway', _network.name),
802++ ('external_gateways', [_network.name]),
803+ ('enable_snat', True),
804+ ('fixed_ips', [{'ip-address': '2001:db8::1'}]),
805+ ]
806+@@ -1100,10 +1103,13 @@ class TestSetRouter(TestRouter):
807+ # The router to set.
808+ _default_route = {'destination': '10.20.20.0/24', 'nexthop': '10.20.30.1'}
809+ _network = network_fakes.create_one_network()
810+- _subnet = network_fakes.FakeSubnet.create_one_subnet()
811++ _subnet = network_fakes.FakeSubnet.create_one_subnet(
812++ attrs={'network_id': _network.id}
813++ )
814+ _router = network_fakes.FakeRouter.create_one_router(
815+ attrs={'routes': [_default_route], 'tags': ['green', 'red']}
816+ )
817++ _extensions = {'fake': network_fakes.create_one_extension()}
818+
819+ def setUp(self):
820+ super(TestSetRouter, self).setUp()
821+@@ -1114,7 +1120,9 @@ class TestSetRouter(TestRouter):
822+ return_value=self._network
823+ )
824+ self.network_client.find_subnet = mock.Mock(return_value=self._subnet)
825+-
826++ self.network_client.find_extension = mock.Mock(
827++ side_effect=lambda name: self._extensions.get(name)
828++ )
829+ # Get the command object to test
830+ self.cmd = router.SetRouter(self.app, self.namespace)
831+
832+@@ -1312,7 +1320,7 @@ class TestSetRouter(TestRouter):
833+ self._router.id,
834+ ]
835+ verifylist = [
836+- ('external_gateway', self._network.id),
837++ ('external_gateways', [self._network.id]),
838+ ('router', self._router.id),
839+ ]
840+ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
841+@@ -1320,7 +1328,7 @@ class TestSetRouter(TestRouter):
842+ result = self.cmd.take_action(parsed_args)
843+ self.network_client.update_router.assert_called_with(
844+ self._router,
845+- **{'external_gateway_info': {'network_id': self._network.id}}
846++ **{'external_gateway_info': {'network_id': self._network.id}},
847+ )
848+ self.assertIsNone(result)
849+
850+@@ -1335,7 +1343,7 @@ class TestSetRouter(TestRouter):
851+ ]
852+ verifylist = [
853+ ('router', self._router.id),
854+- ('external_gateway', self._network.id),
855++ ('external_gateways', [self._network.id]),
856+ ('fixed_ips', [{'subnet': "'abc'"}]),
857+ ('enable_snat', True),
858+ ]
859+@@ -1354,7 +1362,7 @@ class TestSetRouter(TestRouter):
860+ ],
861+ 'enable_snat': True,
862+ }
863+- }
864++ },
865+ )
866+ self.assertIsNone(result)
867+
868+@@ -1369,7 +1377,7 @@ class TestSetRouter(TestRouter):
869+ ]
870+ verifylist = [
871+ ('router', self._router.id),
872+- ('external_gateway', self._network.id),
873++ ('external_gateways', [self._network.id]),
874+ ('fixed_ips', [{'ip-address': "10.0.1.1"}]),
875+ ('enable_snat', True),
876+ ]
877+@@ -1388,7 +1396,7 @@ class TestSetRouter(TestRouter):
878+ ],
879+ 'enable_snat': True,
880+ }
881+- }
882++ },
883+ )
884+ self.assertIsNone(result)
885+
886+@@ -1403,7 +1411,7 @@ class TestSetRouter(TestRouter):
887+ ]
888+ verifylist = [
889+ ('router', self._router.id),
890+- ('external_gateway', self._network.id),
891++ ('external_gateways', [self._network.id]),
892+ ('fixed_ips', [{'subnet': "'abc'", 'ip-address': "10.0.1.1"}]),
893+ ('enable_snat', True),
894+ ]
895+@@ -1423,7 +1431,7 @@ class TestSetRouter(TestRouter):
896+ ],
897+ 'enable_snat': True,
898+ }
899+- }
900++ },
901+ )
902+ self.assertIsNone(result)
903+
904+@@ -1468,7 +1476,7 @@ class TestSetRouter(TestRouter):
905+ ]
906+ verifylist = [
907+ ('router', self._router.id),
908+- ('external_gateway', self._network.id),
909++ ('external_gateways', [self._network.id]),
910+ ('qos_policy', qos_policy.id),
911+ ]
912+ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
913+@@ -1481,7 +1489,7 @@ class TestSetRouter(TestRouter):
914+ 'network_id': self._network.id,
915+ 'qos_policy_id': qos_policy.id,
916+ }
917+- }
918++ },
919+ )
920+ self.assertIsNone(result)
921+
922+@@ -1494,7 +1502,7 @@ class TestSetRouter(TestRouter):
923+ ]
924+ verifylist = [
925+ ('router', self._router.id),
926+- ('external_gateway', self._network.id),
927++ ('external_gateways', [self._network.id]),
928+ ('no_qos_policy', True),
929+ ]
930+ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
931+@@ -1507,7 +1515,7 @@ class TestSetRouter(TestRouter):
932+ 'network_id': self._network.id,
933+ 'qos_policy_id': None,
934+ }
935+- }
936++ },
937+ )
938+ self.assertIsNone(result)
939+
940+@@ -1526,7 +1534,7 @@ class TestSetRouter(TestRouter):
941+ ]
942+ verifylist = [
943+ ('router', self._router.id),
944+- ('external_gateway', self._network.id),
945++ ('external_gateways', [self._network.id]),
946+ ('qos_policy', qos_policy.id),
947+ ('no_qos_policy', True),
948+ ]
949+@@ -1747,6 +1755,13 @@ class TestUnsetRouter(TestRouter):
950+ )
951+ self.network_client.update_router = mock.Mock(return_value=None)
952+ self.network_client.set_tags = mock.Mock(return_value=None)
953++ self._extensions = {'fake': network_fakes.create_one_extension()}
954++ self.network_client.find_extension = mock.Mock(
955++ side_effect=lambda name: self._extensions.get(name)
956++ )
957++ self.network_client.remove_external_gateways = mock.Mock(
958++ return_value=None
959++ )
960+ # Get the command object to test
961+ self.cmd = router.UnsetRouter(self.app, self.namespace)
962+
963+@@ -1799,7 +1814,7 @@ class TestUnsetRouter(TestRouter):
964+ '--external-gateway',
965+ self._testrouter.name,
966+ ]
967+- verifylist = [('external_gateway', True)]
968++ verifylist = [('external_gateways', True)]
969+ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
970+ result = self.cmd.take_action(parsed_args)
971+ attrs = {'external_gateway_info': {}}
972+@@ -1808,6 +1823,33 @@ class TestUnsetRouter(TestRouter):
973+ )
974+ self.assertIsNone(result)
975+
976++ def test_unset_router_external_gateway_multiple_supported(self):
977++ # Add the relevant extension in order to test the alternate behavior.
978++ self._extensions = {
979++ 'external-gateway-multihoming': network_fakes.create_one_extension(
980++ attrs={'name': 'external-gateway-multihoming'}
981++ )
982++ }
983++ arglist = [
984++ '--external-gateway',
985++ self._testrouter.name,
986++ ]
987++ verifylist = [('external_gateways', True)]
988++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
989++ result = self.cmd.take_action(parsed_args)
990++ # The removal of all gateways should be requested using the multiple
991++ # gateways API.
992++ self.network_client.remove_external_gateways.assert_called_once_with(
993++ self._testrouter, body={'router': {'external_gateways': {}}}
994++ )
995++ # The compatibility API will also be called in order to potentially
996++ # unset other parameters along with external_gateway_info which
997++ # should already be empty at that point anyway.
998++ self.network_client.update_router.assert_called_once_with(
999++ self._testrouter, **{'external_gateway_info': {}}
1000++ )
1001++ self.assertIsNone(result)
1002++
1003+ def _test_unset_tags(self, with_tags=True):
1004+ if with_tags:
1005+ arglist = ['--tag', 'red', '--tag', 'blue']
1006+@@ -1895,3 +1937,539 @@ class TestUnsetRouter(TestRouter):
1007+ self.assertRaises(
1008+ exceptions.CommandError, self.cmd.take_action, parsed_args
1009+ )
1010++
1011++
1012++class TestGatewayOps(TestRouter):
1013++ def setUp(self):
1014++ super().setUp()
1015++ self._networks = []
1016++ self._network = network_fakes.create_one_network()
1017++ self._networks.append(self._network)
1018++
1019++ self._router = network_fakes.FakeRouter.create_one_router(
1020++ {
1021++ 'external_gateway_info': {
1022++ 'network_id': self._network.id,
1023++ },
1024++ }
1025++ )
1026++ self._subnet = network_fakes.FakeSubnet.create_one_subnet(
1027++ attrs={'network_id': self._network.id}
1028++ )
1029++ self._extensions = {
1030++ 'external-gateway-multihoming': network_fakes.create_one_extension(
1031++ attrs={'name': 'external-gateway-multihoming'}
1032++ )
1033++ }
1034++ self.network_client.find_extension = mock.Mock(
1035++ side_effect=lambda name: self._extensions.get(name)
1036++ )
1037++ self.network_client.find_router = mock.Mock(return_value=self._router)
1038++
1039++ def _find_network(name_or_id, ignore_missing):
1040++ for network in self._networks:
1041++ if name_or_id in (network.id, network.name):
1042++ return network
1043++ if ignore_missing:
1044++ return None
1045++ raise Exception('Test resource not found')
1046++
1047++ self.network_client.find_network = mock.Mock(side_effect=_find_network)
1048++
1049++ self.network_client.find_subnet = mock.Mock(return_value=self._subnet)
1050++ self.network_client.add_external_gateways = mock.Mock(
1051++ return_value=None
1052++ )
1053++ self.network_client.remove_external_gateways = mock.Mock(
1054++ return_value=None
1055++ )
1056++
1057++
1058++class TestCreateMultipleGateways(TestGatewayOps):
1059++ _columns = (
1060++ 'admin_state_up',
1061++ 'availability_zone_hints',
1062++ 'availability_zones',
1063++ 'description',
1064++ 'distributed',
1065++ 'external_gateway_info',
1066++ 'ha',
1067++ 'id',
1068++ 'name',
1069++ 'project_id',
1070++ 'routes',
1071++ 'status',
1072++ 'tags',
1073++ )
1074++
1075++ def setUp(self):
1076++ super().setUp()
1077++ self._second_network = network_fakes.create_one_network()
1078++ self._networks.append(self._second_network)
1079++
1080++ self.network_client.create_router = mock.Mock(
1081++ return_value=self._router
1082++ )
1083++ self.network_client.update_router = mock.Mock(return_value=None)
1084++ self.network_client.update_external_gateways = mock.Mock(
1085++ return_value=None
1086++ )
1087++
1088++ self._data = (
1089++ router.AdminStateColumn(self._router.admin_state_up),
1090++ format_columns.ListColumn(self._router.availability_zone_hints),
1091++ format_columns.ListColumn(self._router.availability_zones),
1092++ self._router.description,
1093++ self._router.distributed,
1094++ router.RouterInfoColumn(self._router.external_gateway_info),
1095++ self._router.ha,
1096++ self._router.id,
1097++ self._router.name,
1098++ self._router.project_id,
1099++ router.RoutesColumn(self._router.routes),
1100++ self._router.status,
1101++ format_columns.ListColumn(self._router.tags),
1102++ )
1103++ self.cmd = router.CreateRouter(self.app, self.namespace)
1104++
1105++ def test_create_one_gateway(self):
1106++ arglist = [
1107++ "--external-gateway",
1108++ self._network.id,
1109++ self._router.name,
1110++ ]
1111++ verifylist = [
1112++ ('name', self._router.name),
1113++ ('external_gateways', [self._network.id]),
1114++ ]
1115++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1116++
1117++ columns, data = self.cmd.take_action(parsed_args)
1118++ self.network_client.update_external_gateways.assert_called_with(
1119++ self._router,
1120++ body={
1121++ 'router': {
1122++ 'external_gateways': [
1123++ {
1124++ 'network_id': self._network.id,
1125++ }
1126++ ]
1127++ }
1128++ },
1129++ )
1130++ self.assertEqual(self._columns, columns)
1131++ self.assertCountEqual(self._data, data)
1132++
1133++ def test_create_multiple_gateways(self):
1134++ arglist = [
1135++ self._router.name,
1136++ "--external-gateway",
1137++ self._network.id,
1138++ "--external-gateway",
1139++ self._network.id,
1140++ "--external-gateway",
1141++ self._second_network.id,
1142++ '--fixed-ip',
1143++ 'subnet={},ip-address=10.0.1.1'.format(self._subnet.id),
1144++ '--fixed-ip',
1145++ 'subnet={},ip-address=10.0.1.2'.format(self._subnet.id),
1146++ ]
1147++ verifylist = [
1148++ ('name', self._router.name),
1149++ (
1150++ 'external_gateways',
1151++ [self._network.id, self._network.id, self._second_network.id],
1152++ ),
1153++ ]
1154++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1155++ columns, data = self.cmd.take_action(parsed_args)
1156++
1157++ # The router will not have a gateway after the create call, but it
1158++ # will be added after the update call.
1159++ self.network_client.create_router.assert_called_once_with(
1160++ **{
1161++ 'admin_state_up': True,
1162++ 'name': self._router.name,
1163++ }
1164++ )
1165++ self.network_client.update_external_gateways.assert_called_with(
1166++ self._router,
1167++ body={
1168++ 'router': {
1169++ 'external_gateways': [
1170++ {
1171++ 'network_id': self._network.id,
1172++ 'external_fixed_ips': [
1173++ {
1174++ 'subnet_id': self._subnet.id,
1175++ 'ip_address': '10.0.1.1',
1176++ }
1177++ ],
1178++ },
1179++ {
1180++ 'network_id': self._network.id,
1181++ 'external_fixed_ips': [
1182++ {
1183++ 'subnet_id': self._subnet.id,
1184++ 'ip_address': '10.0.1.2',
1185++ }
1186++ ],
1187++ },
1188++ {
1189++ 'network_id': self._second_network.id,
1190++ },
1191++ ]
1192++ }
1193++ },
1194++ )
1195++ self.assertEqual(self._columns, columns)
1196++ self.assertCountEqual(self._data, data)
1197++
1198++
1199++class TestUpdateMultipleGateways(TestGatewayOps):
1200++ def setUp(self):
1201++ super().setUp()
1202++ self._second_network = network_fakes.create_one_network()
1203++ self._networks.append(self._second_network)
1204++
1205++ self.network_client.update_router = mock.Mock(return_value=None)
1206++ self.network_client.update_external_gateways = mock.Mock(
1207++ return_value=None
1208++ )
1209++ self.cmd = router.SetRouter(self.app, self.namespace)
1210++
1211++ def test_update_one_gateway(self):
1212++ arglist = [
1213++ "--external-gateway",
1214++ self._network.id,
1215++ "--no-qos-policy",
1216++ self._router.name,
1217++ ]
1218++ verifylist = [
1219++ ('router', self._router.name),
1220++ ('external_gateways', [self._network.id]),
1221++ ('no_qos_policy', True),
1222++ ]
1223++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1224++ result = self.cmd.take_action(parsed_args)
1225++ self.network_client.update_external_gateways.assert_called_with(
1226++ self._router,
1227++ body={
1228++ 'router': {
1229++ 'external_gateways': [
1230++ {'network_id': self._network.id, 'qos_policy_id': None}
1231++ ]
1232++ }
1233++ },
1234++ )
1235++ self.assertIsNone(result)
1236++
1237++ def test_update_multiple_gateways(self):
1238++ arglist = [
1239++ self._router.name,
1240++ "--external-gateway",
1241++ self._network.id,
1242++ "--external-gateway",
1243++ self._network.id,
1244++ "--external-gateway",
1245++ self._second_network.id,
1246++ '--fixed-ip',
1247++ 'subnet={},ip-address=10.0.1.1'.format(self._subnet.id),
1248++ '--fixed-ip',
1249++ 'subnet={},ip-address=10.0.1.2'.format(self._subnet.id),
1250++ "--no-qos-policy",
1251++ ]
1252++ verifylist = [
1253++ ('router', self._router.name),
1254++ (
1255++ 'external_gateways',
1256++ [self._network.id, self._network.id, self._second_network.id],
1257++ ),
1258++ ('no_qos_policy', True),
1259++ ]
1260++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1261++ result = self.cmd.take_action(parsed_args)
1262++ self.network_client.update_external_gateways.assert_called_with(
1263++ self._router,
1264++ body={
1265++ 'router': {
1266++ 'external_gateways': [
1267++ {
1268++ 'network_id': self._network.id,
1269++ 'external_fixed_ips': [
1270++ {
1271++ 'subnet_id': self._subnet.id,
1272++ 'ip_address': '10.0.1.1',
1273++ }
1274++ ],
1275++ 'qos_policy_id': None,
1276++ },
1277++ {
1278++ 'network_id': self._network.id,
1279++ 'external_fixed_ips': [
1280++ {
1281++ 'subnet_id': self._subnet.id,
1282++ 'ip_address': '10.0.1.2',
1283++ }
1284++ ],
1285++ 'qos_policy_id': None,
1286++ },
1287++ {
1288++ 'network_id': self._second_network.id,
1289++ 'qos_policy_id': None,
1290++ },
1291++ ]
1292++ }
1293++ },
1294++ )
1295++ self.assertIsNone(result)
1296++
1297++
1298++class TestAddGatewayRouter(TestGatewayOps):
1299++ def setUp(self):
1300++ super().setUp()
1301++ # Get the command object to test
1302++ self.cmd = router.AddGatewayToRouter(self.app, self.namespace)
1303++
1304++ self.network_client.add_external_gateways.return_value = self._router
1305++
1306++ def test_add_gateway_network_only(self):
1307++ arglist = [
1308++ self._router.name,
1309++ self._network.id,
1310++ ]
1311++ verifylist = [
1312++ ('router', self._router.name),
1313++ ('external_gateways', [self._network.id]),
1314++ ]
1315++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1316++ result = self.cmd.take_action(parsed_args)
1317++ self.network_client.add_external_gateways.assert_called_with(
1318++ self._router,
1319++ body={
1320++ 'router': {
1321++ 'external_gateways': [{'network_id': self._network.id}]
1322++ }
1323++ },
1324++ )
1325++ self.assertEqual(result[1][result[0].index('id')], self._router.id)
1326++
1327++ def test_add_gateway_network_fixed_ip(self):
1328++ arglist = [
1329++ self._router.name,
1330++ self._network.id,
1331++ '--fixed-ip',
1332++ 'subnet={},ip-address=10.0.1.1'.format(self._subnet.id),
1333++ ]
1334++ verifylist = [
1335++ ('router', self._router.name),
1336++ ('external_gateways', [self._network.id]),
1337++ ]
1338++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1339++ result = self.cmd.take_action(parsed_args)
1340++ self.network_client.add_external_gateways.assert_called_with(
1341++ self._router,
1342++ body={
1343++ 'router': {
1344++ 'external_gateways': [
1345++ {
1346++ 'network_id': self._network.id,
1347++ 'external_fixed_ips': [
1348++ {
1349++ 'subnet_id': self._subnet.id,
1350++ 'ip_address': '10.0.1.1',
1351++ }
1352++ ],
1353++ }
1354++ ]
1355++ }
1356++ },
1357++ )
1358++ self.assertEqual(result[1][result[0].index('id')], self._router.id)
1359++
1360++ def test_add_gateway_network_multiple_fixed_ips(self):
1361++ arglist = [
1362++ self._router.name,
1363++ self._network.id,
1364++ '--fixed-ip',
1365++ 'subnet={},ip-address=10.0.1.1'.format(self._subnet.id),
1366++ '--fixed-ip',
1367++ 'subnet={},ip-address=10.0.1.2'.format(self._subnet.id),
1368++ ]
1369++ verifylist = [
1370++ ('router', self._router.name),
1371++ ('external_gateways', [self._network.id]),
1372++ (
1373++ 'fixed_ips',
1374++ [
1375++ {'ip-address': '10.0.1.1', 'subnet': self._subnet.id},
1376++ {'ip-address': '10.0.1.2', 'subnet': self._subnet.id},
1377++ ],
1378++ ),
1379++ ]
1380++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1381++ result = self.cmd.take_action(parsed_args)
1382++ self.network_client.add_external_gateways.assert_called_with(
1383++ self._router,
1384++ body={
1385++ 'router': {
1386++ 'external_gateways': [
1387++ {
1388++ 'network_id': self._network.id,
1389++ 'external_fixed_ips': [
1390++ {
1391++ 'subnet_id': self._subnet.id,
1392++ 'ip_address': '10.0.1.1',
1393++ },
1394++ {
1395++ 'subnet_id': self._subnet.id,
1396++ 'ip_address': '10.0.1.2',
1397++ },
1398++ ],
1399++ }
1400++ ]
1401++ }
1402++ },
1403++ )
1404++ self.assertEqual(result[1][result[0].index('id')], self._router.id)
1405++
1406++ def test_add_gateway_network_only_no_extension(self):
1407++ self._extensions = {}
1408++ arglist = [
1409++ self._router.name,
1410++ self._network.id,
1411++ ]
1412++ verifylist = [
1413++ ('router', self._router.name),
1414++ ('external_gateways', [self._network.id]),
1415++ ]
1416++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1417++ self.assertRaises(
1418++ exceptions.CommandError, self.cmd.take_action, parsed_args
1419++ )
1420++
1421++
1422++class TestRemoveGatewayRouter(TestGatewayOps):
1423++ def setUp(self):
1424++ super().setUp()
1425++ # Get the command object to test
1426++ self.cmd = router.RemoveGatewayFromRouter(self.app, self.namespace)
1427++
1428++ self.network_client.remove_external_gateways.return_value = (
1429++ self._router
1430++ )
1431++
1432++ def test_remove_gateway_network_only(self):
1433++ arglist = [
1434++ self._router.name,
1435++ self._network.id,
1436++ ]
1437++ verifylist = [
1438++ ('router', self._router.name),
1439++ ('external_gateways', [self._network.id]),
1440++ ]
1441++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1442++ result = self.cmd.take_action(parsed_args)
1443++ self.network_client.remove_external_gateways.assert_called_with(
1444++ self._router,
1445++ body={
1446++ 'router': {
1447++ 'external_gateways': [{'network_id': self._network.id}]
1448++ }
1449++ },
1450++ )
1451++ self.assertEqual(result[1][result[0].index('id')], self._router.id)
1452++
1453++ def test_remove_gateway_network_fixed_ip(self):
1454++ arglist = [
1455++ self._router.name,
1456++ self._network.id,
1457++ '--fixed-ip',
1458++ 'subnet={},ip-address=10.0.1.1'.format(self._subnet.id),
1459++ ]
1460++ verifylist = [
1461++ ('router', self._router.name),
1462++ ('external_gateways', [self._network.id]),
1463++ ]
1464++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1465++ result = self.cmd.take_action(parsed_args)
1466++ self.network_client.remove_external_gateways.assert_called_with(
1467++ self._router,
1468++ body={
1469++ 'router': {
1470++ 'external_gateways': [
1471++ {
1472++ 'network_id': self._network.id,
1473++ 'external_fixed_ips': [
1474++ {
1475++ 'subnet_id': self._subnet.id,
1476++ 'ip_address': '10.0.1.1',
1477++ }
1478++ ],
1479++ }
1480++ ]
1481++ }
1482++ },
1483++ )
1484++ self.assertEqual(result[1][result[0].index('id')], self._router.id)
1485++
1486++ def test_remove_gateway_network_multiple_fixed_ips(self):
1487++ arglist = [
1488++ self._router.name,
1489++ self._network.id,
1490++ '--fixed-ip',
1491++ 'subnet={},ip-address=10.0.1.1'.format(self._subnet.id),
1492++ '--fixed-ip',
1493++ 'subnet={},ip-address=10.0.1.2'.format(self._subnet.id),
1494++ ]
1495++ verifylist = [
1496++ ('router', self._router.name),
1497++ ('external_gateways', [self._network.id]),
1498++ (
1499++ 'fixed_ips',
1500++ [
1501++ {'ip-address': '10.0.1.1', 'subnet': self._subnet.id},
1502++ {'ip-address': '10.0.1.2', 'subnet': self._subnet.id},
1503++ ],
1504++ ),
1505++ ]
1506++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1507++ result = self.cmd.take_action(parsed_args)
1508++ self.network_client.remove_external_gateways.assert_called_with(
1509++ self._router,
1510++ body={
1511++ 'router': {
1512++ 'external_gateways': [
1513++ {
1514++ 'network_id': self._network.id,
1515++ 'external_fixed_ips': [
1516++ {
1517++ 'subnet_id': self._subnet.id,
1518++ 'ip_address': '10.0.1.1',
1519++ },
1520++ {
1521++ 'subnet_id': self._subnet.id,
1522++ 'ip_address': '10.0.1.2',
1523++ },
1524++ ],
1525++ }
1526++ ]
1527++ }
1528++ },
1529++ )
1530++ self.assertEqual(result[1][result[0].index('id')], self._router.id)
1531++
1532++ def test_remove_gateway_network_only_no_extension(self):
1533++ self._extensions = {}
1534++ arglist = [
1535++ self._router.name,
1536++ self._network.id,
1537++ ]
1538++ verifylist = [
1539++ ('router', self._router.name),
1540++ ('external_gateways', [self._network.id]),
1541++ ]
1542++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1543++ self.assertRaises(
1544++ exceptions.CommandError, self.cmd.take_action, parsed_args
1545++ )
1546+diff -ru a/setup.cfg b/setup.cfg
1547+--- a/setup.cfg 2024-03-18 14:06:34.385786800 +0000
1548++++ b/setup.cfg 2024-04-09 06:36:40.483069266 +0000
1549+@@ -551,12 +551,14 @@
1550+ port_show = openstackclient.network.v2.port:ShowPort
1551+ port_unset = openstackclient.network.v2.port:UnsetPort
1552+
1553++ router_add_gateway = openstackclient.network.v2.router:AddGatewayToRouter
1554+ router_add_port = openstackclient.network.v2.router:AddPortToRouter
1555+ router_add_route = openstackclient.network.v2.router:AddExtraRoutesToRouter
1556+ router_add_subnet = openstackclient.network.v2.router:AddSubnetToRouter
1557+ router_create = openstackclient.network.v2.router:CreateRouter
1558+ router_delete = openstackclient.network.v2.router:DeleteRouter
1559+ router_list = openstackclient.network.v2.router:ListRouter
1560++ router_remove_gateway = openstackclient.network.v2.router:RemoveGatewayFromRouter
1561+ router_remove_port = openstackclient.network.v2.router:RemovePortFromRouter
1562+ router_remove_route = openstackclient.network.v2.router:RemoveExtraRoutesFromRouter
1563+ router_remove_subnet = openstackclient.network.v2.router:RemoveSubnetFromRouter
1564+--
1565+2.43.0
1566+
1567diff --git a/debian/patches/lp-2002687-4-Add-router-default-route-BFD-ECMP-options.patch b/debian/patches/lp-2002687-4-Add-router-default-route-BFD-ECMP-options.patch
1568new file mode 100644
1569index 0000000..aceb773
1570--- /dev/null
1571+++ b/debian/patches/lp-2002687-4-Add-router-default-route-BFD-ECMP-options.patch
1572@@ -0,0 +1,220 @@
1573+From 5495c82ec8c1fbbf2f4da962b879366645ca58e7 Mon Sep 17 00:00:00 2001
1574+From: Frode Nordahl <fnordahl@ubuntu.com>
1575+Date: Thu, 26 Oct 2023 11:50:55 +0200
1576+Subject: [PATCH 4/4] Add router default route BFD/ECMP options
1577+
1578+From: Frode Nordahl <frode.nordahl@canonical.com>
1579+
1580+Add the `--enable-default-route-bfd`, `--disable-default-route-bfd`
1581+`--enable-default-route-ecmp` and `--disable-default-route-ecmp`
1582+options for `router create` and `router set` commands.
1583+
1584+Related-Bug: #2002687
1585+Signed-off-by: Frode Nordahl <frode.nordahl@canonical.com>
1586+Change-Id: Ia5a196daa87d29445dc5514dcb91544f9d470795
1587+(cherry picked from commit 7184e876a5b79ea226c75a267af2dfca7c011f62)
1588+
1589+Origin: upstream, https://opendev.org/openstack/python-openstackclient/commit/7184e876a5b79ea226c75a267af2dfca7c011f62
1590+Bug: https://launchpad.net/bugs/2002687
1591+Description: The main feature went into OpenStack Neutron at Caracal, and due
1592+ to review capacity the client patches did unfortunately not make it. Enable
1593+ the feature for our users while we await the next python-openstackclient
1594+
1595+---
1596+ openstackclient/network/v2/router.py | 69 +++++++++++++++++++
1597+ .../tests/unit/network/v2/test_router.py | 68 ++++++++++++++++++
1598+ 2 files changed, 137 insertions(+)
1599+
1600+diff --git a/openstackclient/network/v2/router.py b/openstackclient/network/v2/router.py
1601+index 52b7d25f..51e2aba4 100644
1602+--- a/openstackclient/network/v2/router.py
1603++++ b/openstackclient/network/v2/router.py
1604+@@ -231,9 +231,72 @@ def _get_attrs(client_manager, parsed_args):
1605+ if 'flavor_id' in parsed_args and parsed_args.flavor_id is not None:
1606+ attrs['flavor_id'] = parsed_args.flavor_id
1607+
1608++ for attr in ('enable_default_route_bfd', 'enable_default_route_ecmp'):
1609++ value = getattr(parsed_args, attr, None)
1610++ if value is not None:
1611++ attrs[attr] = value
1612++
1613+ return attrs
1614+
1615+
1616++def _parser_add_bfd_ecmp_arguments(parser):
1617++ """Helper to add BFD and ECMP args for CreateRouter and SetRouter."""
1618++ parser.add_argument(
1619++ '--enable-default-route-bfd',
1620++ dest='enable_default_route_bfd',
1621++ default=None,
1622++ action='store_true',
1623++ help=_(
1624++ "Enable BFD sessions for default routes inferred from "
1625++ "the external gateway port subnets for this router."
1626++ ),
1627++ )
1628++ parser.add_argument(
1629++ '--disable-default-route-bfd',
1630++ dest='enable_default_route_bfd',
1631++ default=None,
1632++ action='store_false',
1633++ help=_(
1634++ "Disable BFD sessions for default routes inferred from "
1635++ "the external gateway port subnets for this router."
1636++ ),
1637++ )
1638++ parser.add_argument(
1639++ '--enable-default-route-ecmp',
1640++ dest='enable_default_route_ecmp',
1641++ default=None,
1642++ action='store_true',
1643++ help=_(
1644++ "Add ECMP default routes if multiple are available via "
1645++ "different gateway ports."
1646++ ),
1647++ )
1648++ parser.add_argument(
1649++ '--disable-default-route-ecmp',
1650++ dest='enable_default_route_ecmp',
1651++ default=None,
1652++ action='store_false',
1653++ help=_("Add default route only for first gateway port."),
1654++ )
1655++
1656++
1657++def _command_check_bfd_ecmp_supported(attrs, client):
1658++ """Helper to check for server side support when bfd/ecmp attrs provided.
1659++
1660++ :raises: exceptions.CommandError
1661++ """
1662++ if (
1663++ 'enable_default_route_bfd' in attrs
1664++ or 'enable_default_route_ecmp' in attrs
1665++ ) and not is_multiple_gateways_supported(client):
1666++ msg = _(
1667++ 'The external-gateway-multihoming extension is not enabled at '
1668++ 'the Neutron side, cannot use --enable-default-route-bfd or '
1669++ '--enable-default-route-ecmp arguments.'
1670++ )
1671++ raise exceptions.CommandError(msg)
1672++
1673++
1674+ class AddPortToRouter(command.Command):
1675+ _description = _("Add a port to a router")
1676+
1677+@@ -502,6 +565,7 @@ class CreateRouter(command.ShowOne, common.NeutronCommandWithExtraArgs):
1678+ metavar='<flavor-id>',
1679+ help=_("Associate the router to a flavor by ID"),
1680+ )
1681++ _parser_add_bfd_ecmp_arguments(parser)
1682+
1683+ return parser
1684+
1685+@@ -527,6 +591,8 @@ class CreateRouter(command.ShowOne, common.NeutronCommandWithExtraArgs):
1686+ if parsed_args.enable_ndp_proxy is not None:
1687+ attrs['enable_ndp_proxy'] = parsed_args.enable_ndp_proxy
1688+
1689++ _command_check_bfd_ecmp_supported(attrs, client)
1690++
1691+ external_gateways = attrs.pop('external_gateways', None)
1692+ obj = client.create_router(**attrs)
1693+ # tags cannot be set when created, so tags need to be set later.
1694+@@ -943,6 +1009,7 @@ class SetRouter(common.NeutronCommandWithExtraArgs):
1695+ help=_("Remove QoS policy from router gateway IPs"),
1696+ )
1697+ _tag.add_tag_option_to_parser_for_set(parser, _('router'))
1698++ _parser_add_bfd_ecmp_arguments(parser)
1699+ return parser
1700+
1701+ def take_action(self, parsed_args):
1702+@@ -1015,6 +1082,8 @@ class SetRouter(common.NeutronCommandWithExtraArgs):
1703+ if parsed_args.enable_ndp_proxy is not None:
1704+ attrs['enable_ndp_proxy'] = parsed_args.enable_ndp_proxy
1705+
1706++ _command_check_bfd_ecmp_supported(attrs, client)
1707++
1708+ if attrs:
1709+ external_gateways = attrs.pop('external_gateways', None)
1710+ client.update_router(obj, **attrs)
1711+diff --git a/openstackclient/tests/unit/network/v2/test_router.py b/openstackclient/tests/unit/network/v2/test_router.py
1712+index 33956b3e..256fd9b7 100644
1713+--- a/openstackclient/tests/unit/network/v2/test_router.py
1714++++ b/openstackclient/tests/unit/network/v2/test_router.py
1715+@@ -410,6 +410,74 @@ class TestCreateRouter(TestRouter):
1716+ self.assertEqual(self.columns, columns)
1717+ self.assertCountEqual(self.data, data)
1718+
1719++ def test_create_with_enable_default_route_bfd(self):
1720++ self.network_client.find_extension = mock.Mock(
1721++ return_value=network_fakes.create_one_extension(
1722++ attrs={'name': 'external-gateway-multihoming'}
1723++ )
1724++ )
1725++ arglist = [self.new_router.name, '--enable-default-route-bfd']
1726++ verifylist = [
1727++ ('name', self.new_router.name),
1728++ ('enable_default_route_bfd', True),
1729++ ]
1730++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1731++ columns, data = self.cmd.take_action(parsed_args)
1732++ self.network_client.create_router.assert_called_once_with(
1733++ name=self.new_router.name,
1734++ admin_state_up=True,
1735++ enable_default_route_bfd=True,
1736++ )
1737++ self.assertEqual(self.columns, columns)
1738++ self.assertCountEqual(self.data, data)
1739++
1740++ def test_create_with_enable_default_route_bfd_no_extension(self):
1741++ arglist = [self.new_router.name, '--enable-default-route-bfd']
1742++ verifylist = [
1743++ ('name', self.new_router.name),
1744++ ('enable_default_route_bfd', True),
1745++ ]
1746++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1747++ self.assertRaises(
1748++ exceptions.CommandError,
1749++ self.cmd.take_action,
1750++ parsed_args,
1751++ )
1752++
1753++ def test_create_with_enable_default_route_ecmp(self):
1754++ self.network_client.find_extension = mock.Mock(
1755++ return_value=network_fakes.create_one_extension(
1756++ attrs={'name': 'external-gateway-multihoming'}
1757++ )
1758++ )
1759++ arglist = [self.new_router.name, '--enable-default-route-ecmp']
1760++ verifylist = [
1761++ ('name', self.new_router.name),
1762++ ('enable_default_route_ecmp', True),
1763++ ]
1764++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1765++ columns, data = self.cmd.take_action(parsed_args)
1766++ self.network_client.create_router.assert_called_once_with(
1767++ name=self.new_router.name,
1768++ admin_state_up=True,
1769++ enable_default_route_ecmp=True,
1770++ )
1771++ self.assertEqual(self.columns, columns)
1772++ self.assertCountEqual(self.data, data)
1773++
1774++ def test_create_with_enable_default_route_ecmp_no_extension(self):
1775++ arglist = [self.new_router.name, '--enable-default-route-ecmp']
1776++ verifylist = [
1777++ ('name', self.new_router.name),
1778++ ('enable_default_route_ecmp', True),
1779++ ]
1780++ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
1781++ self.assertRaises(
1782++ exceptions.CommandError,
1783++ self.cmd.take_action,
1784++ parsed_args,
1785++ )
1786++
1787+
1788+ class TestDeleteRouter(TestRouter):
1789+ # The routers to delete.
1790+--
1791+2.43.0
1792+
1793diff --git a/debian/patches/series b/debian/patches/series
1794new file mode 100644
1795index 0000000..91772f3
1796--- /dev/null
1797+++ b/debian/patches/series
1798@@ -0,0 +1,4 @@
1799+lp-2002687-1-Parse-external-gateway-argument-in-separate-helper.patch
1800+lp-2002687-2-router-Use-plural-form-for-storage-of-fixed_ip-argum.patch
1801+lp-2002687-3-Add-support-for-managing-external-gateways.patch
1802+lp-2002687-4-Add-router-default-route-BFD-ECMP-options.patch

Subscribers

People subscribed via source and target branches