22
33import pytest
44
5+ import json
56import logging
7+ import re
68
79from data import HOST_FREE_NICS
810from lib .common import PackageManagerEnum
911from lib .host import Host
1012from lib .network import Network
13+ from lib .tunnel import Tunnel
14+ from lib .typing import JSONType
15+ from lib .vlan import VLAN
1116from lib .vm import VM
17+ from lib .xo import xo_cli
1218
1319from typing import Generator
1420
@@ -18,6 +24,102 @@ def host_no_sdn_controller(host: Host) -> None:
1824 if host .xe ('sdn-controller-list' , minimal = True ):
1925 pytest .fail ("This test requires an XCP-ng with no SDN controller" )
2026
27+ RE_digits = re .compile (r'(\d+)' )
28+
29+ def compare_versions (a : str , b : str ) -> int :
30+ """
31+ Return 1 if a > b, -1 if a < b, 0 if equal.
32+ Extracts successive digit groups from each string and compares them as integers.
33+ Non-digit separators are ignored. Missing groups are treated as 0.
34+ """
35+ match_a = RE_digits .search (a )
36+ match_b = RE_digits .search (b )
37+
38+ while match_a or match_b :
39+ num_a = int (match_a .group (1 )) if match_a else 0
40+ num_b = int (match_b .group (1 )) if match_b else 0
41+
42+ if num_a != num_b :
43+ return 1 if num_a > num_b else - 1
44+
45+ # advance positions past the matched groups
46+ pos_a = (match_a .end () if match_a else len (a ))
47+ pos_b = (match_b .end () if match_b else len (b ))
48+
49+ match_a = RE_digits .search (a , pos_a )
50+ match_b = RE_digits .search (b , pos_b )
51+
52+ return 0
53+
54+
55+ @pytest .fixture (scope = 'package' )
56+ @pytest .mark .usefixtures ('host_at_least_8_3' )
57+ def hosts_with_traffic_rules (hosts_with_xo : list [Host ]) -> Generator [list [Host ], None , None ]:
58+ """A list of XCP-ng hosts with proper traffic rules configuration."""
59+ hosts = hosts_with_xo
60+
61+ # check XO: check sdn-controller plugin (loaded + minimal version)
62+ minimal = "1.3.0"
63+
64+ plugin_found = False
65+ plugins = xo_cli ('plugin.get' , use_json = True )
66+ assert isinstance (plugins , list )
67+ for plugin in plugins :
68+ assert isinstance (plugin , dict )
69+ if plugin .get ('id' ) != 'sdn-controller' :
70+ continue
71+
72+ plugin_found = True
73+ loaded = plugin .get ('loaded' , False )
74+ assert isinstance (loaded , bool )
75+ if loaded :
76+ version = plugin .get ('version' , '' )
77+ assert isinstance (version , str )
78+ if compare_versions (minimal , version ) > 0 :
79+ pytest .fail (f"This test requires XO with at least sdn-controller version { minimal } " )
80+ else :
81+ pytest .fail ("This test requires XO with sdn-controller plugin loaded" )
82+
83+ if not plugin_found :
84+ pytest .fail ("This test requires XO with sdn-controller plugin" )
85+
86+ # check host: xcp-ng-xapi-plugins minimal version
87+ minimal = "1.16.0"
88+
89+ def host_with_xcp_ng_xapi_plugins (host : Host ):
90+ # get the package version
91+ packages = json .loads (host .xe ('host-call-plugin' , {
92+ 'host-uuid' : host .uuid ,
93+ 'plugin' : 'updater.py' ,
94+ 'fn' : 'query_installed' ,
95+ 'args:packages' : 'xcp-ng-xapi-plugins' ,
96+ }, minimal = True ))
97+
98+ return compare_versions (minimal , packages .get ('xcp-ng-xapi-plugins' , '' )) <= 0
99+
100+ hosts = list (filter (host_with_xcp_ng_xapi_plugins , hosts ))
101+ if len (hosts ) == 0 :
102+ pytest .fail (f"This test requires hosts with at least xcp-ng-xapi-plugins version { minimal } " )
103+
104+ # check XO: check sdn-controller configuration: should be using xapi-plugin method for OpenFlow rules
105+ def host_with_xapiplugin (host : Host ) -> bool :
106+ # the key 'xo:sdn-controller:of-method' is present since cycle XO 6.5c 2026-05-14 (xo-lite v0.21.0)
107+ of_method = host .pool .param_get (
108+ 'other-config' ,
109+ key = 'xo:sdn-controller:of-method' ,
110+ accept_unknown_key = True ,
111+ ) or 'channel'
112+
113+ return of_method == 'xapi-plugin'
114+
115+ hosts = list (filter (host_with_xapiplugin , hosts ))
116+ if len (hosts ) == 0 :
117+ pytest .fail ("This test requires XO to use of-method=xapi-plugin "
118+ "(see https://docs.xen-orchestra.com/xo5/configuration#sdn-controller-mode)" )
119+
120+ yield hosts
121+
122+
21123# a clone of imported_vm in which we've added tcpdump
22124# not to be used by tests directly
23125@pytest .fixture (scope = 'module' )
@@ -49,12 +151,8 @@ def vm_with_tcpdump_scope_function(vm_with_tcpdump_scope_module: VM):
49151 yield vm
50152 vm .destroy ()
51153
52- @pytest .fixture (scope = 'module' )
53- def empty_network (host : Host ) -> Generator [Network , None , None ]:
54- net = host .create_network (label = "empty_network for tests" )
55- yield net
56- net .destroy ()
57154
155+ # ---- Bond ----
58156@pytest .fixture (scope = 'function' )
59157def bond_lacp (host : Host , empty_network : Network ):
60158 if len (HOST_FREE_NICS ) < 2 :
@@ -102,3 +200,96 @@ def bond_balanceslb(host: Host, empty_network: Network):
102200 bond = host .create_bond (empty_network , pifs , mode = "balance-slb" )
103201 yield bond
104202 bond .destroy ()
203+
204+
205+ # ---- Network ----
206+ @pytest .fixture (scope = 'module' )
207+ def empty_network (host : Host ) -> Generator [Network , None , None ]:
208+ net = host .create_network (label = "empty_network for tests" )
209+ yield net
210+ net .destroy ()
211+
212+
213+ # ---- Tunnel ----
214+ @pytest .fixture (params = ["gre" , "vxlan" ])
215+ def tunnel_protocol (request : pytest .FixtureRequest ) -> str :
216+ return request .param
217+
218+ @pytest .fixture (params = [False , True ])
219+ def tunnel_encryption (request : pytest .FixtureRequest ) -> bool :
220+ return request .param
221+
222+ @pytest .fixture
223+ def tunnel (
224+ hosts_with_xo : list [Host ],
225+ tunnel_protocol : str , tunnel_encryption : bool ,
226+ ) -> Generator [Tunnel , None , None ]:
227+ host = hosts_with_xo [0 ]
228+
229+ # check system requirements
230+ if not host .is_package_installed ("openvswitch-ipsec" ):
231+ pytest .fail ("'tunnel' fixture requires configuration, see https://docs.xen-orchestra.com/sdn_controller" )
232+
233+ # create a tunnel over the management PIF
234+ tunnel_device = host .management_pif ().device ()
235+
236+ logging .info (f"tunnel: resolve PIF on { host .hostname_or_ip } using \
237+ { [(pif .network_uuid (), pif .device ()) for pif in host .pifs ()]} " )
238+
239+ [pif ] = host .pifs (device = tunnel_device )
240+ if pif .ip_configuration_mode () == "None" :
241+ pytest .fail (f"'tunnel' fixture requires tunnel_device={ tunnel_device } to have configured IP" )
242+
243+ existing_tunnels = [t .uuid for t in host .tunnels ()]
244+ logging .info (f"tunnel: existing tunnels: { existing_tunnels } " )
245+
246+ xo_cli ('sdnController.createPrivateNetwork' , {
247+ 'poolIds' : f"json:[\" { host .pool .uuid } \" ]" ,
248+ 'pifIds' : f"json:[\" { pif .uuid } \" ]" ,
249+ 'name' : 'test-tunnel' ,
250+ 'description' : 'tunnel for test' ,
251+ 'encapsulation' : tunnel_protocol ,
252+ 'encrypted' : 'true' if tunnel_encryption else 'false' ,
253+ })
254+
255+ # sdnController.createPrivateNetwork might have created several Tunnel (one per host)
256+ # so get all created Tunnel
257+ created_tunnels = list (set ([t .uuid for t in host .tunnels ()]) - set (existing_tunnels ))
258+ logging .info (f"tunnel: created tunnels: { created_tunnels } " )
259+
260+ # yield only the first tunnel
261+ yield Tunnel (host , created_tunnels [0 ])
262+
263+ # teardown created_tunnels (and associated networks)
264+ network_uuids : set [str ] = set ()
265+
266+ for tunnel_uuid in created_tunnels :
267+ tunnel = Tunnel (host , tunnel_uuid )
268+
269+ # get network linked to the tunnel
270+ network_uuids .add (tunnel .access_PIF ().network_uuid ())
271+
272+ # destroy the tunnel
273+ tunnel .destroy ()
274+
275+ # destroy networks associated to destroyed tunnels
276+ for network_uuid in network_uuids :
277+ Network (host , network_uuid ).destroy ()
278+
279+
280+ # ---- VLAN ----
281+ @pytest .fixture
282+ def vlan (host : Host , empty_network : Network ) -> Generator [VLAN , None , None ]:
283+ logging .info (f"vlan: resolve PIF on { host .hostname_or_ip } using \
284+ { [(pif .network_uuid (), pif .param_get ('device' )) for pif in host .pifs ()]} " )
285+
286+ if len (HOST_FREE_NICS ) < 1 :
287+ pytest .fail ("This fixture needs at least 1 free NICs" )
288+
289+ # randomly chosen tag
290+ vlan_tag = 42
291+
292+ [pif ] = host .pifs (device = HOST_FREE_NICS [0 ])
293+ vlan = host .create_vlan (empty_network , pif , vlan_tag )
294+ yield vlan
295+ vlan .destroy ()
0 commit comments