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,12 +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 = 'module' )
54- def empty_network (host : Host ) -> Generator [Network , None , None ]:
55- net = host .create_network (label = "empty_network for tests" )
56- yield net
57- net .destroy ()
58154
155+ # ---- Bond ----
59156@pytest .fixture (scope = 'function' )
60157def bond_lacp (host : Host , empty_network : Network ):
61158 if len (HOST_FREE_NICS ) < 2 :
@@ -103,3 +200,100 @@ def bond_balanceslb(host: Host, empty_network: Network):
103200 bond = host .create_bond (empty_network , pifs , mode = "balance-slb" )
104201 yield bond
105202 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+ # we could have several pifs on one device (due to VLANs for example)
240+ pifs = [pif for pif in host .pifs (device = tunnel_device ) if pif .ip_configuration_mode () != "None" ]
241+ if len (pifs ) == 0 :
242+ pytest .fail (f"'tunnel' fixture requires tunnel_device={ tunnel_device } to have configured IP" )
243+
244+ # use the first usable pif
245+ pif = pifs [0 ]
246+
247+ existing_tunnels = [t .uuid for t in host .tunnels ()]
248+ logging .info (f"tunnel: existing tunnels: { existing_tunnels } " )
249+
250+ xo_cli ('sdnController.createPrivateNetwork' , {
251+ 'poolIds' : f"json:[\" { host .pool .uuid } \" ]" ,
252+ 'pifIds' : f"json:[\" { pif .uuid } \" ]" ,
253+ 'name' : 'test-tunnel' ,
254+ 'description' : 'tunnel for test' ,
255+ 'encapsulation' : tunnel_protocol ,
256+ 'encrypted' : 'true' if tunnel_encryption else 'false' ,
257+ })
258+
259+ # sdnController.createPrivateNetwork might have created several Tunnel (one per host)
260+ # so get all created Tunnel
261+ created_tunnels = list (set ([t .uuid for t in host .tunnels ()]) - set (existing_tunnels ))
262+ logging .info (f"tunnel: created tunnels: { created_tunnels } " )
263+
264+ # yield only the first tunnel
265+ yield Tunnel (host , created_tunnels [0 ])
266+
267+ # teardown created_tunnels (and associated networks)
268+ network_uuids : set [str ] = set ()
269+
270+ for tunnel_uuid in created_tunnels :
271+ tunnel = Tunnel (host , tunnel_uuid )
272+
273+ # get network linked to the tunnel
274+ network_uuids .add (tunnel .access_PIF ().network_uuid ())
275+
276+ # destroy the tunnel
277+ tunnel .destroy ()
278+
279+ # destroy networks associated to destroyed tunnels
280+ for network_uuid in network_uuids :
281+ Network (host , network_uuid ).destroy ()
282+
283+
284+ # ---- VLAN ----
285+ @pytest .fixture
286+ def vlan (host : Host , empty_network : Network ) -> Generator [VLAN , None , None ]:
287+ logging .info (f"vlan: resolve PIF on { host .hostname_or_ip } using \
288+ { [(pif .network_uuid (), pif .param_get ('device' )) for pif in host .pifs ()]} " )
289+
290+ if len (HOST_FREE_NICS ) < 1 :
291+ pytest .fail ("This fixture needs at least 1 free NICs" )
292+
293+ # randomly chosen tag
294+ vlan_tag = 42
295+
296+ [pif ] = host .pifs (device = HOST_FREE_NICS [0 ])
297+ vlan = host .create_vlan (empty_network , pif , vlan_tag )
298+ yield vlan
299+ vlan .destroy ()
0 commit comments