33# Use of this source code is governed by a BSD-2-clause-style
44# license that can be found in the LICENSE-BSD2 file or at
55# https://opensource.org/licenses/BSD-2-Clause
6+ import json
7+ import logging
68import os
7- import uuid
9+ from typing import Tuple
810import shortuuid
911from time import sleep
1012import random
1113import pytest
1214import requests
1315import splunklib .client as client
16+ import subprocess
17+ from filelock import FileLock
18+
19+ logger = logging .getLogger (__name__ )
20+
21+
22+ def cleanup_docker_containers (keepalive = False ):
23+ """Cleanup Docker containers and volumes"""
24+ if keepalive :
25+ logger .info ("Keepalive enabled, skipping Docker cleanup" )
26+ return
27+
28+ logger .info ("Cleaning up Docker containers..." )
29+ try :
30+ # Get the project root directory (parent of tests directory)
31+ project_root = os .path .dirname (os .path .dirname (os .path .abspath (__file__ )))
32+ compose_file = os .path .join (project_root , "tests" , "docker-compose.yml" )
33+
34+ result = subprocess .run (
35+ ["docker" , "compose" , "-f" , compose_file , "down" , "-v" ],
36+ cwd = project_root ,
37+ check = False ,
38+ capture_output = True ,
39+ text = True ,
40+ timeout = 30
41+ )
42+ if result .returncode == 0 :
43+ logger .info ("Docker cleanup completed successfully" )
44+ else :
45+ logger .warning (f"Docker cleanup finished with warnings: { result .stderr } " )
46+ except subprocess .TimeoutExpired :
47+ logger .error ("Docker cleanup timed out after 30 seconds" )
48+ except Exception as e :
49+ logger .error (f"Failed to cleanup Docker containers: { e } " )
50+
51+
52+ def pytest_sessionfinish (session , exitstatus ):
53+ """
54+ Cleanup hook that runs after all tests complete (success or failure).
55+ This ensures Docker containers are stopped even if tests fail or are interrupted.
56+ """
57+ # Check if keepalive flag is set
58+ keepalive = session .config .getoption ("--keepalive" , default = False )
59+ cleanup_docker_containers (keepalive = keepalive )
1460
1561
1662@pytest .fixture
@@ -82,7 +128,7 @@ def pytest_addoption(parser):
82128 "--splunk_hec_token" ,
83129 action = "store" ,
84130 dest = "splunk_hec_token" ,
85- default = str ( uuid . uuid1 ()) ,
131+ default = "00000000-0000-0000-0000-0000000000000" ,
86132 help = "Splunk HEC token" ,
87133 )
88134 group .addoption (
@@ -92,16 +138,14 @@ def pytest_addoption(parser):
92138 default = "latest" ,
93139 help = "Splunk version" ,
94140 )
95-
96-
97- def is_responsive (url ):
98- try :
99- response = requests .get (url )
100- if response .status_code != 500 :
101- return True
102- except ConnectionError :
103- return False
104-
141+ group .addoption (
142+ "--splunk_docker_startup_wait" ,
143+ action = "store" ,
144+ dest = "splunk_docker_startup_wait" ,
145+ type = int ,
146+ default = 20 ,
147+ help = "Additional wait time in seconds after Splunk Docker is responsive" ,
148+ )
105149
106150def is_responsive_splunk (splunk ):
107151 try :
@@ -113,9 +157,24 @@ def is_responsive_splunk(splunk):
113157 )
114158 return True
115159 except Exception :
160+ logger .warning ("Splunk is unresponsive! Retrying..." )
116161 return False
117162
118163
164+ def is_responsive_sc4s (host : str , port : int ) -> bool :
165+ """Check SC4S health endpoint"""
166+ try :
167+ response = requests .get (f"http://{ host } :{ port } /health" , timeout = 5 )
168+ if response .status_code == 200 :
169+ data = response .json ()
170+ return data .get ("status" ) == "healthy"
171+ except Exception as e :
172+ logger .debug (f"Health check failed: { e } " )
173+ return False
174+ return False
175+
176+
177+
119178@pytest .fixture (scope = "session" )
120179def docker_compose_file (pytestconfig ):
121180 """Get an absolute path to the `docker-compose.yml` file. Override this
@@ -139,25 +198,16 @@ def splunk(request):
139198
140199 yield splunk
141200
142-
143201@pytest .fixture (scope = "session" )
144- def sc4s (request ):
145- if request .config .getoption ("splunk_type" ) == "external" :
146- request .fixturenames .append ("sc4s_external" )
147- sc4s = request .getfixturevalue ("sc4s_external" )
148- elif request .config .getoption ("splunk_type" ) == "docker" :
149- request .fixturenames .append ("sc4s_docker" )
150- sc4s = request .getfixturevalue ("sc4s_docker" )
151- else :
152- raise ValueError
153-
154- yield sc4s
155-
156-
157- @pytest .fixture (scope = "session" )
158- def splunk_docker (request , docker_services ):
202+ def start_splunk_docker (request , docker_services ):
159203 docker_services .start ("splunk" )
160- port = docker_services .port_for ("splunk" , 8089 )
204+ try :
205+ port = docker_services .port_for ("splunk" , 8089 )
206+ logger .info (port )
207+ except Exception as e :
208+ raise RuntimeError (
209+ f"Docker service 'splunk' failed to start or is not running: { e } "
210+ ) from e
161211
162212 splunk = {
163213 "host" : docker_services .docker_ip ,
@@ -169,9 +219,35 @@ def splunk_docker(request, docker_services):
169219 docker_services .wait_until_responsive (
170220 timeout = 180.0 , pause = 1.0 , check = lambda : is_responsive_splunk (splunk )
171221 )
222+ startup_wait = request .config .getoption ("splunk_docker_startup_wait" )
223+ if startup_wait > 0 :
224+ logger .info (
225+ "Splunk reported healthy; waiting %s seconds for full initialization..." ,
226+ startup_wait ,
227+ )
228+ sleep (startup_wait )
172229
173230 return splunk
174231
232+ @pytest .fixture (scope = "session" )
233+ def splunk_docker (request , worker_id , tmp_path_factory ):
234+ if worker_id == "master" :
235+ request .fixturenames .append ("start_splunk_docker" )
236+ splunk_docker = request .getfixturevalue ("start_splunk_docker" )
237+ return splunk_docker
238+
239+ root_tmp_dir = tmp_path_factory .getbasetemp ().parent
240+
241+ fn = root_tmp_dir / "splunk_docker.json"
242+ with FileLock (str (fn ) + ".lock" ):
243+ if fn .is_file ():
244+ splunk_docker = json .loads (fn .read_text ())
245+ else :
246+ request .fixturenames .append ("start_splunk_docker" )
247+ splunk_docker = request .getfixturevalue ("start_splunk_docker" )
248+ fn .write_text (json .dumps (splunk_docker ))
249+
250+ return splunk_docker
175251
176252@pytest .fixture (scope = "session" )
177253def splunk_external (request ):
@@ -183,9 +259,8 @@ def splunk_external(request):
183259 }
184260 return splunk
185261
186-
187262@pytest .fixture (scope = "session" )
188- def sc4s_docker (docker_services ) :
263+ def start_sc4s_docker (docker_services , setup_splunk ) -> Tuple [ str , dict ] :
189264 docker_services .start ("sc4s" )
190265
191266 ports = {
@@ -196,14 +271,48 @@ def sc4s_docker(docker_services):
196271 ports .update ({5514 : docker_services .port_for ("sc4s" , 5514 )})
197272 ports .update ({5601 : docker_services .port_for ("sc4s" , 5601 )})
198273 ports .update ({6000 : docker_services .port_for ("sc4s" , 6000 )})
199- # ports.update({6001: docker_services.port_for("sc4s", 6001)})
200274 ports .update ({6002 : docker_services .port_for ("sc4s" , 6002 )})
275+ ports .update ({8080 : docker_services .port_for ("sc4s" , 8080 )})
201276 ports .update ({9000 : docker_services .port_for ("sc4s" , 9000 )})
202277 ports .update ({9001 : docker_services .port_for ("sc4s" , 9001 )})
203278 ports .update ({9002 : docker_services .port_for ("sc4s" , 9002 )})
204279
205- return docker_services .docker_ip , ports
280+ docker_ip = docker_services .docker_ip
281+ health_port = ports [8080 ]
282+
283+ # Wait for SC4S health endpoint to report healthy status
284+ logger .info ("Waiting for SC4S health endpoint to be responsive..." )
285+ docker_services .wait_until_responsive (
286+ timeout = 180.0 ,
287+ pause = 2.0 ,
288+ check = lambda : is_responsive_sc4s (docker_ip , health_port )
289+ )
206290
291+ return docker_ip , ports
292+
293+ @pytest .fixture (scope = "session" )
294+ def sc4s_docker (request , worker_id , tmp_path_factory ):
295+ if worker_id == "master" :
296+ request .fixturenames .append ("start_sc4s_docker" )
297+ sc4s_docker = request .getfixturevalue ("start_sc4s_docker" )
298+ return sc4s_docker
299+
300+ root_tmp_dir = tmp_path_factory .getbasetemp ().parent
301+ fn = root_tmp_dir / "sc4s_docker.json"
302+
303+ with FileLock (str (fn ) + ".lock" ):
304+ if fn .is_file ():
305+ data = json .loads (fn .read_text ())
306+ # this type conversion is requried because json keys are strings
307+ # and in almost all tests we are refrencing the port by int e.g setup_sc4s[1][514]
308+ sc4s_docker = (data [0 ], {int (k ): v for k , v in data [1 ].items ()})
309+ else :
310+ request .fixturenames .append ("start_sc4s_docker" )
311+ sc4s_docker = request .getfixturevalue ("start_sc4s_docker" )
312+ fn .write_text (json .dumps (sc4s_docker ))
313+
314+ return sc4s_docker
315+
207316
208317@pytest .fixture (scope = "session" )
209318def sc4s_external (request ):
@@ -222,11 +331,18 @@ def sc4s_external(request):
222331
223332 return request .config .getoption ("sc4s_host" ), ports
224333
334+ @pytest .fixture (scope = "session" )
335+ def setup_sc4s (request ):
336+ if request .config .getoption ("splunk_type" ) == "external" :
337+ request .fixturenames .append ("sc4s_external" )
338+ sc4s = request .getfixturevalue ("sc4s_external" )
339+ elif request .config .getoption ("splunk_type" ) == "docker" :
340+ request .fixturenames .append ("sc4s_docker" )
341+ sc4s = request .getfixturevalue ("sc4s_docker" )
342+ else :
343+ raise ValueError
225344
226- @pytest .fixture ()
227- def setup_sc4s (sc4s ):
228- return sc4s
229-
345+ yield sc4s
230346
231347@pytest .fixture (scope = "session" )
232348def setup_splunk (splunk ):
0 commit comments