spl2-testing-framework 1.8.2__tar.gz → 1.8.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/PKG-INFO +61 -1
  2. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/README.md +60 -0
  3. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/pyproject.toml +2 -1
  4. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/conftest.py +13 -1
  5. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/spl2_test_config.json +1 -1
  6. spl2_testing_framework-1.8.4/spl2_testing_framework/splunk_setup.py +356 -0
  7. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/jobs/cli_job.py +12 -0
  8. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/results.py +6 -1
  9. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/search_clients/cli_search_client.py +20 -6
  10. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/spl2test_runner.py +26 -11
  11. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/test_discovery.py +30 -0
  12. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/test_types.py +2 -0
  13. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/LICENSE +0 -0
  14. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/__init__.py +0 -0
  15. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/launch_remotely_spl2.py +0 -0
  16. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/logger_manager.py +0 -0
  17. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/pytest.ini.sample +0 -0
  18. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/single_spl2_file_runner.py +0 -0
  19. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/spl2_utils/assertions.spl2 +0 -0
  20. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/spl2_utils/logs_to_metrics.spl2 +0 -0
  21. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/test_runner.py +0 -0
  22. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/__init__.py +0 -0
  23. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/create_diff_sheet.py +0 -0
  24. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/jobs/__init__.py +0 -0
  25. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/jobs/cloud_job.py +0 -0
  26. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/jobs/job.py +0 -0
  27. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/jobs/splunk_job.py +0 -0
  28. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/jobs/test_cloud_job.py +0 -0
  29. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/performance.py +0 -0
  30. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/search_clients/__init__.py +0 -0
  31. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/search_clients/cloud_search_client.py +0 -0
  32. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/search_clients/search_client.py +0 -0
  33. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/search_clients/splunk_search_client.py +0 -0
  34. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/splunk_integration/__init__.py +0 -0
  35. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/splunk_integration/splunk_handler.py +0 -0
  36. {spl2_testing_framework-1.8.2 → spl2_testing_framework-1.8.4}/spl2_testing_framework/tools/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: spl2-testing-framework
3
- Version: 1.8.2
3
+ Version: 1.8.4
4
4
  Summary: SPL2 Testing Framework
5
5
  Home-page: https://github.com/splunk/spl2-testing-framework
6
6
  Keywords: SPL2 testing framework
@@ -137,6 +137,7 @@ Additionally, the following options are supported:
137
137
  * `--ignore_additional_fields_in_actual` - to ignore fields present in actual results but not in expected results (useful when actual results contain extra fields that should not affect comparison)
138
138
  * `--create_comparison_sheet` - to create a comparison sheet in `comparison_box_test` folder using actual and expected outputs (works only when running box tests)
139
139
  * `--cli_bench` - when running tests with CLI, this option enables benchmarking mode by adding `-b` and `-n` flags to the spl2-processor-cli command. The value specifies the number of events to test (e.g., `--cli_bench=1000` will add `-b -n 1000` to the CLI command). **In bench mode, all tests always succeed and CLI output is printed to logs** - this is useful for performance testing and benchmarking. This is only applicable when using `--type cli`.
140
+ * `--lookups_root_dir` - root directory passed to `spl2-processor-cli` as `--lookupsRootDir` for resolving lookup `transforms.conf`. Defaults to `.` (current working directory). For a standard TA layout use `--lookups_root_dir=package/default`. Only applicable when using `--type cli`.
140
141
  * `--check_splunk_results` - option to validate box test output by sending events to a Splunk instance (using HEC) and querying them back, with support for **CIM** (Common Information Model) and **TA** (Technology Add-on) checks.
141
142
  * `--reduction_stats` - when set, **box tests** record a reduction rate per run: input `_raw` vs output `_raw` (after pairing input/output events). Rates are grouped by **pipeline** (the `.spl2` code module stem) and **sourcetype** (from the input event if set, else the output event, else `UNKNOWN`). Off by default.
142
143
  * `--reduction_stats_file` - path to a JSON file written at the **end of the pytest session** with collected rates (default `reduction_stats.json`). Only used when `--reduction_stats` is set.
@@ -307,6 +308,26 @@ You can name top-level fields to drop on **both** the pipeline/actual row and ea
307
308
 
308
309
  If the key is omitted, no fields are ignored. It applies to the **default in-memory** box test assert (no `--check_splunk_results`) and to **`--check_splunk_results=TA`**. It does **not** apply to **`--check_splunk_results=CIM`**. If the file or key is missing, no fields are ignored.
309
310
 
311
+ #### Skipping a test for specific run modes
312
+
313
+ Use **`skip_on`** in the `test` block to skip an individual test entry depending on how the framework is invoked:
314
+
315
+ ```json
316
+ "test": {
317
+ "source": "...",
318
+ "expected_destination_result": [ ... ],
319
+ "skip_on": ["splunk_ta", "splunk_cim"]
320
+ }
321
+ ```
322
+
323
+ | Value | Skipped when |
324
+ |-------|-------------|
325
+ | `"standard"` | Running without `--check_splunk_results` (default in-memory compare) |
326
+ | `"splunk_ta"` | Running with `--check_splunk_results=TA` |
327
+ | `"splunk_cim"` | Running with `--check_splunk_results=CIM` |
328
+
329
+ A single value can be given as a plain string instead of a list. Unrecognised values are ignored with a warning.
330
+
310
331
  #### Configuration:
311
332
 
312
333
  `SPLUNK_INSTANCE` in `spl2_test_config.json`
@@ -351,6 +372,45 @@ Any `SPLUNK_INSTANCE` field can be set via the environment; config file values o
351
372
  | `SPL2_TF_SPLUNK_INSTANCE_HEC_TOKEN` | `hec_token` |
352
373
 
353
374
 
375
+ ## Starting a local Splunk instance for TA testing
376
+
377
+ `spl2_splunk_start` spins up a `splunk/splunk` Docker container with the TA mounted as an installed app and writes a ready-to-use `spl2_test_config.json`.
378
+
379
+ ```bash
380
+ spl2_splunk_start --ta-path /path/to/ta
381
+ ```
382
+
383
+ The command:
384
+ 1. Detects the TA app directory — prefers `<ta-path>/package/` (standard TA layout), falls back to `<ta-path>` itself.
385
+ 2. Reads `app.manifest` to derive the Splunk app name.
386
+ 3. Starts the container with the app directory mounted as a volume.
387
+ 4. Waits for Ansible provisioning (`PLAY RECAP`) to complete, then polls the HEC health endpoint until Splunk is ready.
388
+ 5. Writes `spl2_test_config.json` in the current directory with all connection details pre-filled.
389
+
390
+ **Options**
391
+
392
+ | Option | Default | Description |
393
+ |--------|---------|-------------|
394
+ | `--ta-path` | `.` | Path to the TA root directory. |
395
+ | `--splunk-version` | `10.4` | Splunk Docker image tag. |
396
+ | `--no-config` | off | Skip writing `spl2_test_config.json`. |
397
+ | `--config-dest` | `spl2_test_config.json` | Where to write the config file. |
398
+
399
+ Once the container is up, run your tests with:
400
+
401
+ ```bash
402
+ spl2_tests_run splunk --check_splunk_results=TA
403
+ ```
404
+
405
+ **Requirements:** Docker must be running. The container exposes ports `8088` (HEC) and `8089` (API). If a container named `spl2-splunk` already exists with a matching app mount and Splunk version it is reused (started if stopped); if it exists with different parameters it is removed and recreated.
406
+
407
+ Override the default credentials via environment variables if needed:
408
+
409
+ | Variable | Default | Purpose |
410
+ |----------|---------|---------|
411
+ | `SPL2_TF_LOCAL_SPLUNK_PASSWORD` | `Chang3d!` | Splunk admin password |
412
+ | `SPL2_TF_LOCAL_SPLUNK_HEC_TOKEN` | *(built-in token)* | HEC token |
413
+
354
414
  ## Format all files
355
415
  To format all files run:
356
416
 
@@ -104,6 +104,7 @@ Additionally, the following options are supported:
104
104
  * `--ignore_additional_fields_in_actual` - to ignore fields present in actual results but not in expected results (useful when actual results contain extra fields that should not affect comparison)
105
105
  * `--create_comparison_sheet` - to create a comparison sheet in `comparison_box_test` folder using actual and expected outputs (works only when running box tests)
106
106
  * `--cli_bench` - when running tests with CLI, this option enables benchmarking mode by adding `-b` and `-n` flags to the spl2-processor-cli command. The value specifies the number of events to test (e.g., `--cli_bench=1000` will add `-b -n 1000` to the CLI command). **In bench mode, all tests always succeed and CLI output is printed to logs** - this is useful for performance testing and benchmarking. This is only applicable when using `--type cli`.
107
+ * `--lookups_root_dir` - root directory passed to `spl2-processor-cli` as `--lookupsRootDir` for resolving lookup `transforms.conf`. Defaults to `.` (current working directory). For a standard TA layout use `--lookups_root_dir=package/default`. Only applicable when using `--type cli`.
107
108
  * `--check_splunk_results` - option to validate box test output by sending events to a Splunk instance (using HEC) and querying them back, with support for **CIM** (Common Information Model) and **TA** (Technology Add-on) checks.
108
109
  * `--reduction_stats` - when set, **box tests** record a reduction rate per run: input `_raw` vs output `_raw` (after pairing input/output events). Rates are grouped by **pipeline** (the `.spl2` code module stem) and **sourcetype** (from the input event if set, else the output event, else `UNKNOWN`). Off by default.
109
110
  * `--reduction_stats_file` - path to a JSON file written at the **end of the pytest session** with collected rates (default `reduction_stats.json`). Only used when `--reduction_stats` is set.
@@ -274,6 +275,26 @@ You can name top-level fields to drop on **both** the pipeline/actual row and ea
274
275
 
275
276
  If the key is omitted, no fields are ignored. It applies to the **default in-memory** box test assert (no `--check_splunk_results`) and to **`--check_splunk_results=TA`**. It does **not** apply to **`--check_splunk_results=CIM`**. If the file or key is missing, no fields are ignored.
276
277
 
278
+ #### Skipping a test for specific run modes
279
+
280
+ Use **`skip_on`** in the `test` block to skip an individual test entry depending on how the framework is invoked:
281
+
282
+ ```json
283
+ "test": {
284
+ "source": "...",
285
+ "expected_destination_result": [ ... ],
286
+ "skip_on": ["splunk_ta", "splunk_cim"]
287
+ }
288
+ ```
289
+
290
+ | Value | Skipped when |
291
+ |-------|-------------|
292
+ | `"standard"` | Running without `--check_splunk_results` (default in-memory compare) |
293
+ | `"splunk_ta"` | Running with `--check_splunk_results=TA` |
294
+ | `"splunk_cim"` | Running with `--check_splunk_results=CIM` |
295
+
296
+ A single value can be given as a plain string instead of a list. Unrecognised values are ignored with a warning.
297
+
277
298
  #### Configuration:
278
299
 
279
300
  `SPLUNK_INSTANCE` in `spl2_test_config.json`
@@ -318,6 +339,45 @@ Any `SPLUNK_INSTANCE` field can be set via the environment; config file values o
318
339
  | `SPL2_TF_SPLUNK_INSTANCE_HEC_TOKEN` | `hec_token` |
319
340
 
320
341
 
342
+ ## Starting a local Splunk instance for TA testing
343
+
344
+ `spl2_splunk_start` spins up a `splunk/splunk` Docker container with the TA mounted as an installed app and writes a ready-to-use `spl2_test_config.json`.
345
+
346
+ ```bash
347
+ spl2_splunk_start --ta-path /path/to/ta
348
+ ```
349
+
350
+ The command:
351
+ 1. Detects the TA app directory — prefers `<ta-path>/package/` (standard TA layout), falls back to `<ta-path>` itself.
352
+ 2. Reads `app.manifest` to derive the Splunk app name.
353
+ 3. Starts the container with the app directory mounted as a volume.
354
+ 4. Waits for Ansible provisioning (`PLAY RECAP`) to complete, then polls the HEC health endpoint until Splunk is ready.
355
+ 5. Writes `spl2_test_config.json` in the current directory with all connection details pre-filled.
356
+
357
+ **Options**
358
+
359
+ | Option | Default | Description |
360
+ |--------|---------|-------------|
361
+ | `--ta-path` | `.` | Path to the TA root directory. |
362
+ | `--splunk-version` | `10.4` | Splunk Docker image tag. |
363
+ | `--no-config` | off | Skip writing `spl2_test_config.json`. |
364
+ | `--config-dest` | `spl2_test_config.json` | Where to write the config file. |
365
+
366
+ Once the container is up, run your tests with:
367
+
368
+ ```bash
369
+ spl2_tests_run splunk --check_splunk_results=TA
370
+ ```
371
+
372
+ **Requirements:** Docker must be running. The container exposes ports `8088` (HEC) and `8089` (API). If a container named `spl2-splunk` already exists with a matching app mount and Splunk version it is reused (started if stopped); if it exists with different parameters it is removed and recreated.
373
+
374
+ Override the default credentials via environment variables if needed:
375
+
376
+ | Variable | Default | Purpose |
377
+ |----------|---------|---------|
378
+ | `SPL2_TF_LOCAL_SPLUNK_PASSWORD` | `Chang3d!` | Splunk admin password |
379
+ | `SPL2_TF_LOCAL_SPLUNK_HEC_TOKEN` | *(built-in token)* | HEC token |
380
+
321
381
  ## Format all files
322
382
  To format all files run:
323
383
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  [tool.poetry]
17
17
  name = "spl2-testing-framework"
18
- version = "1.8.2"
18
+ version = "1.8.4"
19
19
  description = "SPL2 Testing Framework"
20
20
  authors = ["mgazda-splunk <mgazda@splunk.com>"]
21
21
  readme = "README.md"
@@ -30,6 +30,7 @@ include = "spl2_testing_framework"
30
30
  [tool.poetry.scripts]
31
31
  spl2_tests_run = "spl2_testing_framework.test_runner:run"
32
32
  single_spl2_file_run = "spl2_testing_framework.single_spl2_file_runner:run"
33
+ spl2_splunk_start = "spl2_testing_framework.splunk_setup:run"
33
34
 
34
35
  [tool.poetry.dependencies]
35
36
  python = "^3.7.1"
@@ -60,6 +60,7 @@ TEST_DIR = "test_dir"
60
60
  CODE_DIR = "code_dir"
61
61
  LIMIT_TESTS = "limit_tests"
62
62
  CLI_BENCH = "cli_bench"
63
+ LOOKUPS_ROOT_DIR = "lookups_root_dir"
63
64
  REDUCTION_STATS = "reduction_stats"
64
65
 
65
66
  setup_logging()
@@ -88,6 +89,13 @@ def pytest_addoption(parser):
88
89
  parser.addoption(f"--{IGNORE_ADDITIONAL_FIELDS_IN_ACTUAL}", action="store_true")
89
90
  parser.addoption(f"--{LIMIT_TESTS}", action="store", type=int, default=None)
90
91
  parser.addoption(f"--{CLI_BENCH}", action="store", type=int, default=None)
92
+ parser.addoption(
93
+ f"--{LOOKUPS_ROOT_DIR}",
94
+ action="store",
95
+ type=str,
96
+ default=".",
97
+ help="Root directory for lookups transforms.conf passed to spl2-processor-cli as --lookupsRootDir. Defaults to '.' (current directory).",
98
+ )
91
99
  parser.addoption(
92
100
  f"--{REDUCTION_STATS}",
93
101
  action="store_true",
@@ -181,7 +189,10 @@ def spl2_test_runner(
181
189
  search_client = SplunkSearchClient(url=url, auth=basic_authentication)
182
190
  elif test_type == "cli":
183
191
  cli_bench = run_configurations.get(CLI_BENCH)
184
- search_client = CLISearchClient(cli_bench=cli_bench)
192
+ lookups_root_dir = run_configurations.get(LOOKUPS_ROOT_DIR)
193
+ search_client = CLISearchClient(
194
+ cli_bench=cli_bench, lookups_root_dir=lookups_root_dir
195
+ )
185
196
  elif test_type == "cloud":
186
197
  url = f"https://{config['tenant']}.api.{config['cloud_instance']}/{config['tenant']}"
187
198
  search_client = CloudSearchClient(url=url, auth=bearer_token_authentication)
@@ -204,6 +215,7 @@ def run_configurations(request):
204
215
  f"--{CREATE_COMPARISON_SHEET}"
205
216
  ),
206
217
  CLI_BENCH: request.config.getoption(f"--{CLI_BENCH}"),
218
+ LOOKUPS_ROOT_DIR: request.config.getoption(f"--{LOOKUPS_ROOT_DIR}"),
207
219
  CHECK_SPLUNK_RESULTS: request.config.getoption(f"--{CHECK_SPLUNK_RESULTS}"),
208
220
  REDUCTION_STATS: request.config.getoption(
209
221
  f"--{REDUCTION_STATS}", default=False
@@ -12,7 +12,7 @@
12
12
  "api_port": 8089,
13
13
  "username": "admin",
14
14
  "password": "Chang3d!",
15
- "index": "testx",
15
+ "index": "spl2tf",
16
16
  "hec_token": "4a8a737d-5452-426c-a6f7-106dca4e813f"
17
17
  }
18
18
  }
@@ -0,0 +1,356 @@
1
+ # Copyright 2026 Splunk Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ spl2_splunk_start — spin up a local Splunk container with a TA mounted and
17
+ dump a ready-to-use spl2_test_config.json.
18
+
19
+ Usage:
20
+ spl2_splunk_start [--ta-path /path/to/ta] [--splunk-version 10.4] [--no-config]
21
+
22
+ The TA's ``package/`` directory is mounted into the container as an installed
23
+ Splunk app. Splunk is pre-configured with a HEC token so you can immediately
24
+ run:
25
+
26
+ spl2_tests_run splunk --check_splunk_results=TA
27
+ """
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import select
33
+ import subprocess
34
+ import sys
35
+ import time
36
+ from pathlib import Path
37
+
38
+ _SPLUNK_PASSWORD = os.environ.get("SPL2_TF_LOCAL_SPLUNK_PASSWORD", "Chang3d!")
39
+ _HEC_TOKEN = os.environ.get(
40
+ "SPL2_TF_LOCAL_SPLUNK_HEC_TOKEN", "4a8a737d-5452-426c-a6f7-106dca4e813f"
41
+ )
42
+ _INDEX = "spl2tf"
43
+ _CONTAINER_NAME = "spl2-splunk"
44
+ _HEC_PORT = 8088
45
+ _API_PORT = 8089
46
+ _CONFIG_FILENAME = "spl2_test_config.json"
47
+
48
+
49
+ def _find_ta_app_dir(ta_path: Path) -> Path:
50
+ """Return the directory to mount as a Splunk app.
51
+
52
+ Prefers ``<ta_path>/package`` (standard TA layout). Falls back to
53
+ ``ta_path`` itself so the command also works when pointed directly at a
54
+ built/extracted app directory.
55
+ """
56
+ candidate = ta_path / "package"
57
+ if candidate.is_dir():
58
+ return candidate
59
+ return ta_path
60
+
61
+
62
+ def _resolve_app_name(app_dir: Path) -> str:
63
+ """Derive a Splunk app name from app.manifest or the directory name."""
64
+ manifest = app_dir / "app.manifest"
65
+ if manifest.exists():
66
+ try:
67
+ data = json.loads(manifest.read_text())
68
+ name = data.get("info", {}).get("id", {}).get("name") or data.get("app_id")
69
+ if name:
70
+ return name
71
+ except (json.JSONDecodeError, AttributeError):
72
+ pass
73
+ return app_dir.name
74
+
75
+
76
+ def _docker(*args, check=True, capture=False):
77
+ cmd = ["docker", *args]
78
+ return subprocess.run(
79
+ cmd,
80
+ check=check,
81
+ capture_output=capture,
82
+ text=True,
83
+ )
84
+
85
+
86
+ def _container_exists(name: str) -> bool:
87
+ result = _docker(
88
+ "ps",
89
+ "-a",
90
+ "--filter",
91
+ f"name=^{name}$",
92
+ "--format",
93
+ "{{.Names}}",
94
+ capture=True,
95
+ )
96
+ return name in result.stdout.strip().splitlines()
97
+
98
+
99
+ def _container_inspect(name: str) -> dict:
100
+ result = _docker("inspect", name, capture=True, check=False)
101
+ if result.returncode != 0:
102
+ return {}
103
+ try:
104
+ return json.loads(result.stdout)[0]
105
+ except (json.JSONDecodeError, IndexError):
106
+ return {}
107
+
108
+
109
+ def _container_matches(name: str, app_dir_abs: str, splunk_version: str) -> bool:
110
+ """Return True if the existing container was started with the same app mount and image."""
111
+ info = _container_inspect(name)
112
+ if not info:
113
+ return False
114
+
115
+ image = info.get("Config", {}).get("Image", "")
116
+ if f"splunk/splunk:{splunk_version}" not in image:
117
+ return False
118
+
119
+ mounts = info.get("Mounts", [])
120
+ for mount in mounts:
121
+ if mount.get("Source") == app_dir_abs:
122
+ return True
123
+ return False
124
+
125
+
126
+ def _write_config(dest: Path) -> None:
127
+ config = {
128
+ "host": "https://localhost",
129
+ "cloud_instance": "",
130
+ "tenant": "",
131
+ "bearer_token": "",
132
+ "port": str(_API_PORT),
133
+ "user": "admin",
134
+ "password": _SPLUNK_PASSWORD,
135
+ "SPLUNK_INSTANCE": {
136
+ "ip": "localhost",
137
+ "port": _HEC_PORT,
138
+ "api_port": _API_PORT,
139
+ "username": "admin",
140
+ "password": _SPLUNK_PASSWORD,
141
+ "index": _INDEX,
142
+ "hec_token": _HEC_TOKEN,
143
+ },
144
+ }
145
+ dest.write_text(json.dumps(config, indent=2))
146
+ print(f"Config written to {dest}")
147
+
148
+
149
+ def _wait_for_ansible(timeout: int = 300) -> bool:
150
+ """Stream container logs until Ansible finishes (PLAY RECAP line appears).
151
+
152
+ Returns True on success, False on timeout or container stop.
153
+ Uses a polling read loop so the timeout is always enforced.
154
+ """
155
+ print("Waiting for Splunk Ansible provisioning to finish", end="", flush=True)
156
+ deadline = time.time() + timeout
157
+ proc = subprocess.Popen(
158
+ ["docker", "logs", "-f", _CONTAINER_NAME],
159
+ stdout=subprocess.PIPE,
160
+ stderr=subprocess.STDOUT,
161
+ )
162
+ try:
163
+ buf = b""
164
+ while time.time() < deadline:
165
+ remaining = deadline - time.time()
166
+ ready, _, _ = select.select([proc.stdout], [], [], min(remaining, 2.0))
167
+ if ready:
168
+ chunk = proc.stdout.read(4096)
169
+ if not chunk:
170
+ break
171
+ buf += chunk
172
+ lines = buf.split(b"\n")
173
+ buf = lines[-1]
174
+ for line in lines[:-1]:
175
+ text = line.decode("utf-8", errors="replace")
176
+ if "PLAY RECAP" in text:
177
+ print(" done.")
178
+ return True
179
+ print(".", end="", flush=True)
180
+ else:
181
+ if proc.poll() is not None:
182
+ break
183
+ finally:
184
+ try:
185
+ proc.kill()
186
+ except OSError:
187
+ pass
188
+ proc.wait()
189
+
190
+ print()
191
+ print(
192
+ "ERROR: Ansible provisioning did not complete within the timeout. "
193
+ "Check container logs with: docker logs spl2-splunk",
194
+ file=sys.stderr,
195
+ )
196
+ return False
197
+
198
+
199
+ def _wait_for_hec(timeout: int = 60) -> bool:
200
+ """Poll the HEC health endpoint — returns True once splunkd is fully up."""
201
+ import urllib.request
202
+ import ssl
203
+
204
+ hec_health = f"https://localhost:{_HEC_PORT}/services/collector/health"
205
+ ctx = ssl.create_default_context()
206
+ ctx.check_hostname = False
207
+ ctx.verify_mode = ssl.CERT_NONE
208
+
209
+ print("Waiting for Splunk HEC to become ready", end="", flush=True)
210
+ deadline = time.time() + timeout
211
+ while time.time() < deadline:
212
+ try:
213
+ with urllib.request.urlopen(hec_health, context=ctx, timeout=3) as resp:
214
+ if resp.status == 200:
215
+ print(" ready.")
216
+ return True
217
+ except Exception:
218
+ pass
219
+ print(".", end="", flush=True)
220
+ time.sleep(3)
221
+
222
+ print()
223
+ print(
224
+ "ERROR: HEC did not become ready within the timeout. "
225
+ "Check container logs with: docker logs spl2-splunk",
226
+ file=sys.stderr,
227
+ )
228
+ return False
229
+
230
+
231
+ def start(
232
+ ta_path: Path, splunk_version: str, write_config: bool, config_dest: Path
233
+ ) -> None:
234
+ app_dir = _find_ta_app_dir(ta_path)
235
+ if not app_dir.is_dir():
236
+ print(f"ERROR: TA app directory not found at {app_dir}", file=sys.stderr)
237
+ sys.exit(1)
238
+
239
+ app_name = _resolve_app_name(app_dir)
240
+ app_dir_abs = str(app_dir.resolve())
241
+ container_app_path = f"/opt/splunk/etc/apps/{app_name}"
242
+
243
+ print(f"TA app dir : {app_dir_abs}")
244
+ print(f"App name : {app_name}")
245
+ print(f"Container : {_CONTAINER_NAME}")
246
+ print(f"Splunk : splunk/splunk:{splunk_version}")
247
+
248
+ already_running = False
249
+ if _container_exists(_CONTAINER_NAME):
250
+ info = _container_inspect(_CONTAINER_NAME)
251
+ if _container_matches(_CONTAINER_NAME, app_dir_abs, splunk_version):
252
+ if info.get("State", {}).get("Running"):
253
+ print(
254
+ f"Container '{_CONTAINER_NAME}' already running with matching app and version."
255
+ )
256
+ already_running = True
257
+ else:
258
+ print(
259
+ f"Container '{_CONTAINER_NAME}' exists with matching config — starting it."
260
+ )
261
+ _docker("start", _CONTAINER_NAME)
262
+ else:
263
+ print(
264
+ f"Container '{_CONTAINER_NAME}' exists but has a different app or Splunk version — removing it."
265
+ )
266
+ _docker("rm", "-f", _CONTAINER_NAME)
267
+ _launch(app_dir_abs, container_app_path, splunk_version)
268
+ else:
269
+ _launch(app_dir_abs, container_app_path, splunk_version)
270
+
271
+ if not already_running and not _wait_for_ansible():
272
+ sys.exit(1)
273
+
274
+ if not _wait_for_hec():
275
+ sys.exit(1)
276
+
277
+ if write_config:
278
+ _write_config(config_dest)
279
+
280
+ print()
281
+ print("Splunk is up. Run your tests with:")
282
+ print(" spl2_tests_run splunk --check_splunk_results=TA")
283
+
284
+
285
+ def _launch(app_dir_abs: str, container_app_path: str, splunk_version: str) -> None:
286
+ _docker(
287
+ "run",
288
+ "-d",
289
+ "--name",
290
+ _CONTAINER_NAME,
291
+ "--platform",
292
+ "linux/amd64",
293
+ "-p",
294
+ f"{_HEC_PORT}:{_HEC_PORT}",
295
+ "-p",
296
+ f"{_API_PORT}:{_API_PORT}",
297
+ "-e",
298
+ f"SPLUNK_PASSWORD={_SPLUNK_PASSWORD}",
299
+ "-e",
300
+ "SPLUNK_START_ARGS=--accept-license",
301
+ "-e",
302
+ "SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com",
303
+ "-e",
304
+ f"SPLUNK_HEC_TOKEN={_HEC_TOKEN}",
305
+ "-v",
306
+ f"{app_dir_abs}:{container_app_path}",
307
+ f"splunk/splunk:{splunk_version}",
308
+ )
309
+ print(f"Container '{_CONTAINER_NAME}' started.")
310
+
311
+
312
+ def run() -> None:
313
+ """Entry point for the ``spl2_splunk_start`` console script."""
314
+ parser = argparse.ArgumentParser(
315
+ description=(
316
+ "Start a local Splunk container with a TA mounted and write "
317
+ "spl2_test_config.json ready for --check_splunk_results."
318
+ )
319
+ )
320
+ parser.add_argument(
321
+ "--ta-path",
322
+ default=".",
323
+ help=(
324
+ "Path to the TA root directory (must contain a 'package/' subdirectory "
325
+ "or be the app directory itself). Defaults to the current directory."
326
+ ),
327
+ )
328
+ parser.add_argument(
329
+ "--splunk-version",
330
+ default="10.4",
331
+ help="Splunk Docker image tag (default: 10.4).",
332
+ )
333
+ parser.add_argument(
334
+ "--no-config",
335
+ action="store_true",
336
+ default=False,
337
+ help="Skip writing spl2_test_config.json.",
338
+ )
339
+ parser.add_argument(
340
+ "--config-dest",
341
+ default=_CONFIG_FILENAME,
342
+ help=f"Where to write the config file (default: {_CONFIG_FILENAME} in CWD).",
343
+ )
344
+
345
+ args = parser.parse_args()
346
+
347
+ start(
348
+ ta_path=Path(args.ta_path).resolve(),
349
+ splunk_version=args.splunk_version,
350
+ write_config=not args.no_config,
351
+ config_dest=Path(args.config_dest),
352
+ )
353
+
354
+
355
+ if __name__ == "__main__":
356
+ run()
@@ -19,6 +19,10 @@ from .job import Job
19
19
  class CLIAssertionJob(Job):
20
20
  """A job that is executed on CLI in unit tests."""
21
21
 
22
+ def __init__(self, *args, lookups_root_dir=None, **kwargs):
23
+ super().__init__(*args, **kwargs)
24
+ self.lookups_root_dir = lookups_root_dir
25
+
22
26
  def create(self) -> "CLIAssertionJob":
23
27
  """Creates a job. To run the job use run_job() method."""
24
28
 
@@ -30,6 +34,9 @@ class CLIAssertionJob(Job):
30
34
  self.test_name,
31
35
  ]
32
36
 
37
+ if self.lookups_root_dir is not None:
38
+ command.extend(["--lookupsRootDir", self.lookups_root_dir])
39
+
33
40
  input_data = "\n\n".join(
34
41
  [
35
42
  ( # for assertion tests we don't add assertions module
@@ -56,10 +63,12 @@ class CLISimpleJob(Job):
56
63
  code_module_content,
57
64
  test_name,
58
65
  cli_bench=None,
66
+ lookups_root_dir=None,
59
67
  ):
60
68
  code_module_content += f"\n$source = from {str(source)};"
61
69
  super().__init__("", code_module_name, code_module_content, test_name)
62
70
  self.cli_bench = cli_bench
71
+ self.lookups_root_dir = lookups_root_dir
63
72
 
64
73
  def create(self) -> "CLISimpleJob":
65
74
  """Creates a job. To run the job use run_job() method."""
@@ -71,6 +80,9 @@ class CLISimpleJob(Job):
71
80
  if self.cli_bench is not None:
72
81
  command.extend(["-b", "-n", str(self.cli_bench)])
73
82
 
83
+ if self.lookups_root_dir is not None:
84
+ command.extend(["--lookupsRootDir", self.lookups_root_dir])
85
+
74
86
  input_data = "\n\n".join(
75
87
  [
76
88
  self.commands_module,
@@ -73,9 +73,14 @@ class Results(UserDict):
73
73
  data[key] = Results._cast_types(value)
74
74
  if key == "_time" and data[key] != "":
75
75
  data[key] = Results._convert_timestamp(data[key])
76
- if isinstance(data, (int, float)):
76
+ if isinstance(data, bool):
77
+ data = str(data).lower()
78
+ elif isinstance(data, (int, float)):
77
79
  data = str(data)
78
80
 
81
+ if isinstance(data, str) and data in ("True", "False"):
82
+ data = data.lower()
83
+
79
84
  return data
80
85
 
81
86
  @staticmethod
@@ -29,21 +29,30 @@ class CLISearchClient(SearchClient):
29
29
 
30
30
  jobs_dict = {}
31
31
 
32
- def __init__(self, cli_bench=None):
33
- """Initialize CLISearchClient with optional bench parameter.
32
+ def __init__(self, cli_bench=None, lookups_root_dir=None):
33
+ """Initialize CLISearchClient with optional bench and lookups parameters.
34
34
 
35
35
  Args:
36
36
  cli_bench: Number of events to use for benchmarking (adds -b and -n flags)
37
+ lookups_root_dir: Root directory for lookups transforms.conf (adds --lookupsRootDir)
37
38
  """
38
39
  self.cli_bench = cli_bench
40
+ self.lookups_root_dir = lookups_root_dir
39
41
 
40
- @staticmethod
41
42
  def create_job(
42
- test_module: str, code_file_name: str, code_to_test: str, test_statement: str
43
+ self,
44
+ test_module: str,
45
+ code_file_name: str,
46
+ code_to_test: str,
47
+ test_statement: str,
43
48
  ) -> Job:
44
49
  """Creates a job for unit test run."""
45
50
  return CLIAssertionJob(
46
- test_module, code_file_name, code_to_test, test_statement
51
+ test_module,
52
+ code_file_name,
53
+ code_to_test,
54
+ test_statement,
55
+ lookups_root_dir=self.lookups_root_dir,
47
56
  ).create()
48
57
 
49
58
  def create_simple_job(
@@ -52,7 +61,12 @@ class CLISearchClient(SearchClient):
52
61
  """Creates a job for box test run."""
53
62
 
54
63
  return CLISimpleJob(
55
- source, code_module_name, code_module_content, test_name, self.cli_bench
64
+ source,
65
+ code_module_name,
66
+ code_module_content,
67
+ test_name,
68
+ self.cli_bench,
69
+ self.lookups_root_dir,
56
70
  ).create()
57
71
 
58
72
  def run_job(self, job):
@@ -159,6 +159,16 @@ class SPL2TestRunner:
159
159
 
160
160
  def run_box_test(self, box_test: BoxTest) -> None:
161
161
  """Run a box test"""
162
+ if box_test.skip_on:
163
+ if self.check_splunk_results == "TA" and "splunk_ta" in box_test.skip_on:
164
+ pytest.skip("Skipped for splunk_ta (skip_on)")
165
+ elif (
166
+ self.check_splunk_results == "CIM" and "splunk_cim" in box_test.skip_on
167
+ ):
168
+ pytest.skip("Skipped for splunk_cim (skip_on)")
169
+ elif not self.check_splunk_results and "standard" in box_test.skip_on:
170
+ pytest.skip("Skipped for standard (skip_on)")
171
+
162
172
  code_to_test = _make_functions_visible_for_testing(box_test.code_module_path)
163
173
 
164
174
  perf = PerformanceCheck(box_test)
@@ -203,9 +213,10 @@ class SPL2TestRunner:
203
213
  ignore_keys = frozenset(
204
214
  box_test.ignore_fields_in_destination_compare.keys()
205
215
  )
206
- for i, actual_result in enumerate(output_result):
207
- if i < len(box_test.output):
208
- actual_result.remove_fields_not_in_expected(box_test.output[i])
216
+ for actual_result, expected_result in zip(
217
+ sorted(output_result), sorted(box_test.output)
218
+ ):
219
+ actual_result.remove_fields_not_in_expected(expected_result)
209
220
 
210
221
  if self.create_comparison_sheet:
211
222
  self.comp_obj.create_comparison_sheet(output_result, box_test)
@@ -238,9 +249,10 @@ class SPL2TestRunner:
238
249
 
239
250
  if box_test.metrics:
240
251
  # Remove fields from actual metrics that are not in expected metrics if configured
241
- for i, actual_metric in enumerate(metrics_result):
242
- if i < len(box_test.metrics):
243
- actual_metric.remove_fields_not_in_expected(box_test.metrics[i])
252
+ for actual_metric, expected_metric in zip(
253
+ sorted(metrics_result), sorted(box_test.metrics)
254
+ ):
255
+ actual_metric.remove_fields_not_in_expected(expected_metric)
244
256
 
245
257
  assert sorted(metrics_result) == sorted(box_test.metrics)
246
258
  _LOGGER.info(f"Metric check passed: {box_test.name}")
@@ -259,14 +271,13 @@ class SPL2TestRunner:
259
271
 
260
272
  ``Results._cast_types`` updates dicts in place. Splunk-shaped rows are
261
273
  JSON-like; cloning via :func:`json.dumps` / :func:`json.loads` is
262
- typically faster than :func:`copy.deepcopy`. Non-JSON values use
263
- ``default=str`` (same idea as serializing for HEC). Falls back to
264
- deepcopy only if JSON cloning fails.
274
+ typically faster than :func:`copy.deepcopy`. Falls back to deepcopy for
275
+ any row that contains non-JSON-serializable values.
265
276
  """
266
277
  if not d:
267
278
  return {}
268
279
  try:
269
- return json.loads(json.dumps(d, default=str))
280
+ return json.loads(json.dumps(d))
270
281
  except (TypeError, ValueError):
271
282
  return copy.deepcopy(d)
272
283
 
@@ -339,7 +350,11 @@ class SPL2TestRunner:
339
350
  expected_data = self._row_as_dict(expected)
340
351
  # Splunk adds many search-time fields; mirror in-memory compare by
341
352
  # dropping keys not present on the expected row before asserting.
342
- actual_dict = dict(processed_event)
353
+ # Apply the same Results normalization (e.g. "True"->"true") that
354
+ # the non-Splunk path gets automatically via Results(_cast_types).
355
+ actual_dict = Results._cast_types(
356
+ self._tree_copy_for_results(dict(processed_event))
357
+ )
343
358
  Results._remove_extra_fields(actual_dict, expected_data)
344
359
  actual_cmp = self._strip_ignored_fields_for_destination_compare(
345
360
  actual_dict, ignore_keys
@@ -180,6 +180,34 @@ class BoxTestDiscovery(TestDiscovery):
180
180
  """Discovers tests for box tests, looking into module.test.json files"""
181
181
 
182
182
  _IGNORE_FIELDS_IN_DESTINATION_COMPARE_KEY = "ignore_fields_in_destination_compare"
183
+ _VALID_SKIP_ON_VALUES = frozenset({"standard", "splunk_ta", "splunk_cim"})
184
+
185
+ @classmethod
186
+ def _parse_skip_on(cls, test_block: dict) -> frozenset:
187
+ raw = test_block.get("skip_on")
188
+ if raw is None:
189
+ return frozenset()
190
+ if isinstance(raw, str):
191
+ raw = [raw]
192
+ if not isinstance(raw, list):
193
+ _LOGGER.warning(
194
+ "skip_on must be a string or list of strings; ignoring %r", raw
195
+ )
196
+ return frozenset()
197
+ result = set()
198
+ for v in raw:
199
+ if not isinstance(v, str):
200
+ _LOGGER.warning("skip_on entries must be strings; skipping %r", v)
201
+ continue
202
+ if v not in cls._VALID_SKIP_ON_VALUES:
203
+ _LOGGER.warning(
204
+ "Unknown skip_on value %r; valid values: %s",
205
+ v,
206
+ sorted(cls._VALID_SKIP_ON_VALUES),
207
+ )
208
+ continue
209
+ result.add(v)
210
+ return frozenset(result)
183
211
 
184
212
  @classmethod
185
213
  def _parse_ignore_fields_in_destination_compare(cls, test_block: dict) -> dict:
@@ -255,6 +283,7 @@ class BoxTestDiscovery(TestDiscovery):
255
283
  ignore_fields_in_destination_compare = (
256
284
  self._parse_ignore_fields_in_destination_compare(test_block)
257
285
  )
286
+ skip_on = self._parse_skip_on(test_block)
258
287
  box_test = BoxTest(
259
288
  name=name,
260
289
  input=test_input,
@@ -264,6 +293,7 @@ class BoxTestDiscovery(TestDiscovery):
264
293
  code_module_path=code_module_path,
265
294
  cim_fields=cim_fields,
266
295
  ignore_fields_in_destination_compare=ignore_fields_in_destination_compare,
296
+ skip_on=skip_on,
267
297
  )
268
298
  box_test.meta["performance"] = self._performance_check
269
299
  self.tests.add(box_test)
@@ -48,6 +48,8 @@ class BoxTest(Test):
48
48
  cim_fields: dict = field(default_factory=dict)
49
49
  # Field name -> reason; stripped on both sides before destination asserts (not used for CIM).
50
50
  ignore_fields_in_destination_compare: dict = field(default_factory=dict)
51
+ # Set of test modes to skip: "standard", "splunk_ta", "splunk_cim"
52
+ skip_on: frozenset = field(default_factory=frozenset)
51
53
 
52
54
 
53
55
  @dataclass