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,101 @@ 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+ def hosts_with_traffic_rules (hosts_with_xo : list [Host ]) -> Generator [list [Host ], None , None ]:
57+ """A list of XCP-ng hosts with proper traffic rules configuration."""
58+ hosts = hosts_with_xo
59+
60+ # check XO: check sdn-controller plugin (loaded + minimal version)
61+ minimal = "1.3.0"
62+
63+ plugin_found = False
64+ plugins = xo_cli ('plugin.get' , use_json = True )
65+ assert isinstance (plugins , list )
66+ for plugin in plugins :
67+ assert isinstance (plugin , dict )
68+ if plugin .get ('id' ) != 'sdn-controller' :
69+ continue
70+
71+ plugin_found = True
72+ loaded = plugin .get ('loaded' , False )
73+ assert isinstance (loaded , bool )
74+ if loaded :
75+ version = plugin .get ('version' , '' )
76+ assert isinstance (version , str )
77+ if compare_versions (minimal , version ) > 0 :
78+ pytest .fail (f"This test requires XO with at least sdn-controller version { minimal } " )
79+ else :
80+ pytest .fail ("This test requires XO with sdn-controller plugin loaded" )
81+
82+ if not plugin_found :
83+ pytest .fail ("This test requires XO with sdn-controller plugin" )
84+
85+ # check host: xcp-ng-xapi-plugins minimal version
86+ minimal = "1.17.0"
87+
88+ def host_with_xcp_ng_xapi_plugins (host : Host ):
89+ # get the package version
90+ packages = json .loads (host .xe ('host-call-plugin' , {
91+ 'host-uuid' : host .uuid ,
92+ 'plugin' : 'updater.py' ,
93+ 'fn' : 'query_installed' ,
94+ 'args:packages' : 'xcp-ng-xapi-plugins' ,
95+ }, minimal = True ))
96+
97+ return compare_versions (minimal , packages .get ('xcp-ng-xapi-plugins' , '' )) <= 0
98+
99+ hosts = list (filter (host_with_xcp_ng_xapi_plugins , hosts ))
100+ if len (hosts ) == 0 :
101+ pytest .fail (f"This test requires hosts with at least xcp-ng-xapi-plugins version { minimal } " )
102+
103+ # check XO: check sdn-controller configuration: should be using xapi-plugin method for OpenFlow rules
104+ def host_with_xapiplugin (host : Host ) -> bool :
105+ # the key 'xo:sdn-controller:of-method' is present since cycle XO 6.5c 2026-05-14 (xo-lite v0.21.0)
106+ of_method = host .pool .param_get (
107+ 'other-config' ,
108+ key = 'xo:sdn-controller:of-method' ,
109+ accept_unknown_key = True ,
110+ ) or 'channel'
111+
112+ return of_method == 'xapi-plugin'
113+
114+ hosts = list (filter (host_with_xapiplugin , hosts ))
115+ if len (hosts ) == 0 :
116+ pytest .fail ("This test requires XO to use of-method=xapi-plugin "
117+ "(see https://docs.xen-orchestra.com/xo5/configuration#sdn-controller-mode)" )
118+
119+ yield hosts
120+
121+
21122# a clone of imported_vm in which we've added tcpdump
22123# not to be used by tests directly
23124@pytest .fixture (scope = 'module' )
@@ -50,19 +151,8 @@ def vm_with_tcpdump_scope_function(vm_with_tcpdump_scope_module: VM):
50151 yield vm
51152 vm .destroy ()
52153
53- @pytest .fixture (scope = 'function' )
54- def empty_network (host : Host ) -> Generator [Network , None , None ]:
55- net = host .create_network (label = "empty_network for tests" )
56-
57- yield net
58-
59- for vif_uuid in net .vif_uuids ():
60- host .xe ("vif-unplug" , {
61- 'uuid' : vif_uuid ,
62- })
63-
64- net .destroy ()
65154
155+ # ---- Bond ----
66156@pytest .fixture (scope = 'function' )
67157def bond_lacp (host : Host , empty_network : Network ):
68158 if len (HOST_FREE_NICS ) < 2 :
@@ -110,3 +200,107 @@ def bond_balanceslb(host: Host, empty_network: Network):
110200 bond = host .create_bond (empty_network , pifs , mode = "balance-slb" )
111201 yield bond
112202 bond .destroy ()
203+
204+
205+ # ---- Network ----
206+ @pytest .fixture (scope = 'function' )
207+ def empty_network (host : Host ) -> Generator [Network , None , None ]:
208+ net = host .create_network (label = "empty_network for tests" )
209+
210+ yield net
211+
212+ for vif_uuid in net .vif_uuids ():
213+ host .xe ("vif-unplug" , {
214+ 'uuid' : vif_uuid ,
215+ })
216+
217+ net .destroy ()
218+
219+
220+ # ---- Tunnel ----
221+ @pytest .fixture (params = ["gre" , "vxlan" ])
222+ def tunnel_protocol (request : pytest .FixtureRequest ) -> str :
223+ return request .param
224+
225+ @pytest .fixture (params = [False , True ])
226+ def tunnel_encryption (request : pytest .FixtureRequest ) -> bool :
227+ return request .param
228+
229+ @pytest .fixture
230+ def tunnel (
231+ hosts_with_xo : list [Host ],
232+ tunnel_protocol : str , tunnel_encryption : bool ,
233+ ) -> Generator [Tunnel , None , None ]:
234+ host = hosts_with_xo [0 ]
235+
236+ # check system requirements
237+ if not host .is_package_installed ("openvswitch-ipsec" ):
238+ pytest .fail ("'tunnel' fixture requires configuration, see https://docs.xen-orchestra.com/sdn_controller" )
239+
240+ # create a tunnel over the management PIF
241+ tunnel_device = host .management_pif ().device ()
242+
243+ logging .info (f"tunnel: resolve PIF on { host .hostname_or_ip } using \
244+ { [(pif .network_uuid (), pif .device ()) for pif in host .pifs ()]} " )
245+
246+ # we could have several pifs on one device (due to VLANs for example)
247+ pifs = [pif for pif in host .pifs (device = tunnel_device ) if pif .ip_configuration_mode () != "None" ]
248+ if len (pifs ) == 0 :
249+ pytest .fail (f"'tunnel' fixture requires tunnel_device={ tunnel_device } to have configured IP" )
250+
251+ # use the first usable pif
252+ pif = pifs [0 ]
253+
254+ existing_tunnels = [t .uuid for t in host .tunnels ()]
255+ logging .info (f"tunnel: existing tunnels: { existing_tunnels } " )
256+
257+ xo_cli ('sdnController.createPrivateNetwork' , {
258+ 'poolIds' : f"json:[\" { host .pool .uuid } \" ]" ,
259+ 'pifIds' : f"json:[\" { pif .uuid } \" ]" ,
260+ 'name' : 'test-tunnel' ,
261+ 'description' : 'tunnel for test' ,
262+ 'encapsulation' : tunnel_protocol ,
263+ 'encrypted' : 'true' if tunnel_encryption else 'false' ,
264+ })
265+
266+ # sdnController.createPrivateNetwork might have created several Tunnel (one per host)
267+ # so get all created Tunnel
268+ created_tunnels = list (set ([t .uuid for t in host .tunnels ()]) - set (existing_tunnels ))
269+ logging .info (f"tunnel: created tunnels: { created_tunnels } " )
270+
271+ # yield only the first tunnel
272+ yield Tunnel (host , created_tunnels [0 ])
273+
274+ # teardown created_tunnels (and associated networks)
275+ network_uuids : set [str ] = set ()
276+
277+ for tunnel_uuid in created_tunnels :
278+ tunnel = Tunnel (host , tunnel_uuid )
279+
280+ # get network linked to the tunnel
281+ network_uuids .add (tunnel .access_PIF ().network_uuid ())
282+
283+ # destroy the tunnel
284+ tunnel .destroy ()
285+
286+ # destroy networks associated to destroyed tunnels
287+ for network_uuid in network_uuids :
288+ Network (host , network_uuid ).destroy ()
289+
290+
291+ # ---- VLAN ----
292+ @pytest .fixture
293+ def vlan (host : Host , empty_network : Network ) -> Generator [VLAN , None , None ]:
294+ logging .info (f"vlan: resolve PIF on { host .hostname_or_ip } using \
295+ { [(pif .network_uuid (), pif .param_get ('device' )) for pif in host .pifs ()]} " )
296+
297+ if len (HOST_FREE_NICS ) < 1 :
298+ pytest .fail ("This fixture needs at least 1 free NICs" )
299+
300+ # randomly chosen tag
301+ vlan_tag = 42
302+
303+ [pif ] = host .pifs (device = HOST_FREE_NICS [0 ])
304+ vlan = host .create_vlan (empty_network , pif , vlan_tag )
305+ yield vlan
306+ vlan .destroy ()
0 commit comments