spl2-testing-framework 1.5.0__tar.gz → 1.6.0__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.
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/PKG-INFO +76 -3
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/README.md +74 -3
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/pyproject.toml +2 -1
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/conftest.py +85 -2
- spl2_testing_framework-1.6.0/spl2_testing_framework/spl2_test_config.json +18 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/performance.py +93 -12
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/search_clients/cli_search_client.py +7 -2
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/spl2test_runner.py +90 -27
- spl2_testing_framework-1.6.0/spl2_testing_framework/tools/splunk_integration/__init__.py +13 -0
- spl2_testing_framework-1.6.0/spl2_testing_framework/tools/splunk_integration/splunk_handler.py +119 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/test_discovery.py +60 -10
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/test_types.py +3 -0
- spl2_testing_framework-1.6.0/spl2_testing_framework/tools/utils.py +114 -0
- spl2_testing_framework-1.5.0/spl2_testing_framework/spl2_test_config.json +0 -9
- spl2_testing_framework-1.5.0/spl2_testing_framework/tools/utils.py +0 -51
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/LICENSE +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/__init__.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/launch_remotely_spl2.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/logger_manager.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/pytest.ini.sample +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/single_spl2_file_runner.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/spl2_utils/assertions.spl2 +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/spl2_utils/logs_to_metrics.spl2 +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/test_runner.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/__init__.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/create_diff_sheet.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/jobs/__init__.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/jobs/cli_job.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/jobs/cloud_job.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/jobs/job.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/jobs/splunk_job.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/jobs/test_cloud_job.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/results.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/search_clients/__init__.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/search_clients/cloud_search_client.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/search_clients/search_client.py +0 -0
- {spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/tools/search_clients/splunk_search_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: spl2-testing-framework
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
4
4
|
Summary: SPL2 Testing Framework
|
|
5
5
|
Home-page: https://github.com/splunk/spl2-testing-framework
|
|
6
6
|
Keywords: SPL2 testing framework
|
|
@@ -25,6 +25,7 @@ Requires-Dist: pytest (==7.4.4)
|
|
|
25
25
|
Requires-Dist: pytest-subtests (==0.13.1)
|
|
26
26
|
Requires-Dist: pytest-xdist (==3.5.0)
|
|
27
27
|
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
28
|
+
Requires-Dist: splunk-sdk (>=2.1.1,<3.0.0)
|
|
28
29
|
Requires-Dist: tabulate (==0.9.0)
|
|
29
30
|
Requires-Dist: urllib3 (>=1.26.19,<2.0.0)
|
|
30
31
|
Project-URL: Repository, https://github.com/splunk/spl2-testing-framework
|
|
@@ -134,8 +135,9 @@ Additionally, the following options are supported:
|
|
|
134
135
|
* `--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)
|
|
135
136
|
* `--create_comparison_sheet` - to create a comparison sheet in `comparison_box_test` folder using actual and expected outputs (works only when running box tests)
|
|
136
137
|
* `--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`.
|
|
138
|
+
* `--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.
|
|
137
139
|
|
|
138
|
-
Note: The `pytest.ini.sample` file allows you to define command parameters. Just update the configurations, rename the
|
|
140
|
+
* Note: The `pytest.ini.sample` file allows you to define command parameters. Just update the configurations, rename the
|
|
139
141
|
file by removing the `.sample` extension, and execute the command.
|
|
140
142
|
|
|
141
143
|
### Run tests in IDE [PyCharm]
|
|
@@ -206,9 +208,80 @@ Content of this file will also be printed to stdout.
|
|
|
206
208
|
This checks can be applied only to box tests, as assertions which are used in unit tests may impact spl2 pipeline
|
|
207
209
|
performance.
|
|
208
210
|
|
|
211
|
+
### Check splunk results
|
|
212
|
+
|
|
213
|
+
When set (`--check_splunk_results=<option>`), box tests (after running the SPL2 pipeline) send pipeline output to the configured Splunk instance and validate results there instead of comparing only in-memory expected vs actual.
|
|
214
|
+
|
|
215
|
+
| Value | Behavior |
|
|
216
|
+
|-------|----------|
|
|
217
|
+
| **CIM** | For each output event, push to Splunk, query by host, then validate that the returned event has the expected CIM fields (from `expected_cim_fields.cim_fields` in the box test). Uses `validate_compatibility()`. Skips the test if the box test has no `cim_fields`. |
|
|
218
|
+
| **TA** | For each output event, push to Splunk, query by host, then assert that the returned event equals the expected output event for that index. |
|
|
219
|
+
|
|
220
|
+
For **TA** only, you can name top-level fields to drop on **both** the queried event and each `expected_destination_result` row by adding **`ignore_fields_in_splunk_check`**: an object mapping **field name → short reason**. Use the same key under `test` in each entry in `*.test.json` (same file as `expected_destination_result`):
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
"test": {
|
|
224
|
+
"source": "...",
|
|
225
|
+
"expected_destination_result": [ ... ],
|
|
226
|
+
"ignore_fields_in_splunk_check": {
|
|
227
|
+
"_raw": "Raw event differs after Splunk indexing",
|
|
228
|
+
"_time": "Timestamp may vary due to ingestion latency"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
If the key is omitted, no fields are ignored. Used only when `--check_splunk_results=TA` (ignored for other modes and for the default
|
|
235
|
+
in-memory box test assert). If the file or key is missing, no fields are ignored.
|
|
236
|
+
|
|
237
|
+
#### Configuration:
|
|
238
|
+
|
|
239
|
+
`SPLUNK_INSTANCE` in `spl2_test_config.json`
|
|
240
|
+
|
|
241
|
+
New top-level key for the Splunk instance used by `--check_splunk_results`:
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
{
|
|
245
|
+
"SPLUNK_INSTANCE": {
|
|
246
|
+
"ip": "<hostname-or-ip>",
|
|
247
|
+
"port": 8088,
|
|
248
|
+
"api_port": 8089,
|
|
249
|
+
"username": "<splunk-user>",
|
|
250
|
+
"password": "<splunk-password>",
|
|
251
|
+
"index": "<index-name>",
|
|
252
|
+
"hec_token": "<hec-token>"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
- **ip** – Splunk server host (e.g. `localhost` or hostname).
|
|
258
|
+
- **port** – HEC port (e.g. 8088).
|
|
259
|
+
- **api_port** – Management/API port (e.g. 8089) for Search API and index creation.
|
|
260
|
+
- **username** / **password** – Used for Search API and index creation (HTTP Basic).
|
|
261
|
+
- **index** – Index where HEC events are sent and then searched.
|
|
262
|
+
- **hec_token** – Token for HEC (`Authorization: Splunk <token>`).
|
|
263
|
+
|
|
264
|
+
Do not commit real credentials. Prefer environment variables (below) or a local config that is not in version control.
|
|
265
|
+
|
|
266
|
+
#### Environment variables
|
|
267
|
+
|
|
268
|
+
Any `SPLUNK_INSTANCE` field can be set via the environment; config file values override these only when non-empty.
|
|
269
|
+
|
|
270
|
+
| Variable | Maps to |
|
|
271
|
+
|----------|--------|
|
|
272
|
+
| `SPL2_TF_SPLUNK_INSTANCE_IP` | `ip` |
|
|
273
|
+
| `SPL2_TF_SPLUNK_INSTANCE_PORT` | `port` |
|
|
274
|
+
| `SPL2_TF_SPLUNK_INSTANCE_API_PORT` | `api_port` |
|
|
275
|
+
| `SPL2_TF_SPLUNK_INSTANCE_USERNAME` | `username` |
|
|
276
|
+
| `SPL2_TF_SPLUNK_INSTANCE_PASSWORD` | `password` |
|
|
277
|
+
| `SPL2_TF_SPLUNK_INSTANCE_INDEX` | `index` |
|
|
278
|
+
| `SPL2_TF_SPLUNK_INSTANCE_HEC_TOKEN` | `hec_token` |
|
|
279
|
+
|
|
280
|
+
|
|
209
281
|
## Format all files
|
|
210
|
-
To format all files
|
|
282
|
+
To format all files run:
|
|
211
283
|
|
|
212
284
|
```bash
|
|
213
285
|
black spl2_testing_framework tests
|
|
214
286
|
```
|
|
287
|
+
|
|
@@ -102,8 +102,9 @@ Additionally, the following options are supported:
|
|
|
102
102
|
* `--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)
|
|
103
103
|
* `--create_comparison_sheet` - to create a comparison sheet in `comparison_box_test` folder using actual and expected outputs (works only when running box tests)
|
|
104
104
|
* `--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`.
|
|
105
|
+
* `--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.
|
|
105
106
|
|
|
106
|
-
Note: The `pytest.ini.sample` file allows you to define command parameters. Just update the configurations, rename the
|
|
107
|
+
* Note: The `pytest.ini.sample` file allows you to define command parameters. Just update the configurations, rename the
|
|
107
108
|
file by removing the `.sample` extension, and execute the command.
|
|
108
109
|
|
|
109
110
|
### Run tests in IDE [PyCharm]
|
|
@@ -174,9 +175,79 @@ Content of this file will also be printed to stdout.
|
|
|
174
175
|
This checks can be applied only to box tests, as assertions which are used in unit tests may impact spl2 pipeline
|
|
175
176
|
performance.
|
|
176
177
|
|
|
178
|
+
### Check splunk results
|
|
179
|
+
|
|
180
|
+
When set (`--check_splunk_results=<option>`), box tests (after running the SPL2 pipeline) send pipeline output to the configured Splunk instance and validate results there instead of comparing only in-memory expected vs actual.
|
|
181
|
+
|
|
182
|
+
| Value | Behavior |
|
|
183
|
+
|-------|----------|
|
|
184
|
+
| **CIM** | For each output event, push to Splunk, query by host, then validate that the returned event has the expected CIM fields (from `expected_cim_fields.cim_fields` in the box test). Uses `validate_compatibility()`. Skips the test if the box test has no `cim_fields`. |
|
|
185
|
+
| **TA** | For each output event, push to Splunk, query by host, then assert that the returned event equals the expected output event for that index. |
|
|
186
|
+
|
|
187
|
+
For **TA** only, you can name top-level fields to drop on **both** the queried event and each `expected_destination_result` row by adding **`ignore_fields_in_splunk_check`**: an object mapping **field name → short reason**. Use the same key under `test` in each entry in `*.test.json` (same file as `expected_destination_result`):
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
"test": {
|
|
191
|
+
"source": "...",
|
|
192
|
+
"expected_destination_result": [ ... ],
|
|
193
|
+
"ignore_fields_in_splunk_check": {
|
|
194
|
+
"_raw": "Raw event differs after Splunk indexing",
|
|
195
|
+
"_time": "Timestamp may vary due to ingestion latency"
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
If the key is omitted, no fields are ignored. Used only when `--check_splunk_results=TA` (ignored for other modes and for the default
|
|
202
|
+
in-memory box test assert). If the file or key is missing, no fields are ignored.
|
|
203
|
+
|
|
204
|
+
#### Configuration:
|
|
205
|
+
|
|
206
|
+
`SPLUNK_INSTANCE` in `spl2_test_config.json`
|
|
207
|
+
|
|
208
|
+
New top-level key for the Splunk instance used by `--check_splunk_results`:
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"SPLUNK_INSTANCE": {
|
|
213
|
+
"ip": "<hostname-or-ip>",
|
|
214
|
+
"port": 8088,
|
|
215
|
+
"api_port": 8089,
|
|
216
|
+
"username": "<splunk-user>",
|
|
217
|
+
"password": "<splunk-password>",
|
|
218
|
+
"index": "<index-name>",
|
|
219
|
+
"hec_token": "<hec-token>"
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
- **ip** – Splunk server host (e.g. `localhost` or hostname).
|
|
225
|
+
- **port** – HEC port (e.g. 8088).
|
|
226
|
+
- **api_port** – Management/API port (e.g. 8089) for Search API and index creation.
|
|
227
|
+
- **username** / **password** – Used for Search API and index creation (HTTP Basic).
|
|
228
|
+
- **index** – Index where HEC events are sent and then searched.
|
|
229
|
+
- **hec_token** – Token for HEC (`Authorization: Splunk <token>`).
|
|
230
|
+
|
|
231
|
+
Do not commit real credentials. Prefer environment variables (below) or a local config that is not in version control.
|
|
232
|
+
|
|
233
|
+
#### Environment variables
|
|
234
|
+
|
|
235
|
+
Any `SPLUNK_INSTANCE` field can be set via the environment; config file values override these only when non-empty.
|
|
236
|
+
|
|
237
|
+
| Variable | Maps to |
|
|
238
|
+
|----------|--------|
|
|
239
|
+
| `SPL2_TF_SPLUNK_INSTANCE_IP` | `ip` |
|
|
240
|
+
| `SPL2_TF_SPLUNK_INSTANCE_PORT` | `port` |
|
|
241
|
+
| `SPL2_TF_SPLUNK_INSTANCE_API_PORT` | `api_port` |
|
|
242
|
+
| `SPL2_TF_SPLUNK_INSTANCE_USERNAME` | `username` |
|
|
243
|
+
| `SPL2_TF_SPLUNK_INSTANCE_PASSWORD` | `password` |
|
|
244
|
+
| `SPL2_TF_SPLUNK_INSTANCE_INDEX` | `index` |
|
|
245
|
+
| `SPL2_TF_SPLUNK_INSTANCE_HEC_TOKEN` | `hec_token` |
|
|
246
|
+
|
|
247
|
+
|
|
177
248
|
## Format all files
|
|
178
|
-
To format all files
|
|
249
|
+
To format all files run:
|
|
179
250
|
|
|
180
251
|
```bash
|
|
181
252
|
black spl2_testing_framework tests
|
|
182
|
-
```
|
|
253
|
+
```
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
[tool.poetry]
|
|
17
17
|
name = "spl2-testing-framework"
|
|
18
|
-
version = "1.
|
|
18
|
+
version = "1.6.0"
|
|
19
19
|
description = "SPL2 Testing Framework"
|
|
20
20
|
authors = ["mgazda-splunk <mgazda@splunk.com>"]
|
|
21
21
|
readme = "README.md"
|
|
@@ -52,6 +52,7 @@ numpy = [
|
|
|
52
52
|
{ version = ">=1.26,<2.0", python = ">=3.13" }
|
|
53
53
|
]
|
|
54
54
|
openpyxl = "^3.1.2"
|
|
55
|
+
splunk-sdk = "^2.1.1"
|
|
55
56
|
|
|
56
57
|
[tool.poetry.group.dev.dependencies]
|
|
57
58
|
pre-commit = "*"
|
{spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/conftest.py
RENAMED
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
17
|
import os
|
|
18
|
+
from collections import defaultdict
|
|
18
19
|
|
|
20
|
+
import pandas as pd
|
|
19
21
|
import pytest
|
|
20
22
|
|
|
21
23
|
from .tools.results import Results
|
|
24
|
+
from .tools.splunk_integration.splunk_handler import SplunkHandler
|
|
22
25
|
|
|
23
26
|
pytest.register_assert_rewrite("spl2_testing_framework")
|
|
24
27
|
|
|
@@ -48,6 +51,7 @@ SINGLE_SPL2_FILE = "single_spl2_file"
|
|
|
48
51
|
IGNORE_EMPTY_STRINGS = "ignore_empty_strings"
|
|
49
52
|
IGNORE_ADDITIONAL_FIELDS_IN_ACTUAL = "ignore_additional_fields_in_actual"
|
|
50
53
|
CREATE_COMPARISON_SHEET = "create_comparison_sheet"
|
|
54
|
+
CHECK_SPLUNK_RESULTS = "check_splunk_results"
|
|
51
55
|
PERFORMANCE_CHECK = "performance_check"
|
|
52
56
|
TEST_TYPE = "type"
|
|
53
57
|
TEST_DIR = "test_dir"
|
|
@@ -75,6 +79,9 @@ def pytest_addoption(parser):
|
|
|
75
79
|
parser.addoption("--template_file", action="store", default=None)
|
|
76
80
|
parser.addoption("--sample_file", action="store", default=None)
|
|
77
81
|
parser.addoption("--sample_delimiter", action="store", default="\n")
|
|
82
|
+
parser.addoption(
|
|
83
|
+
f"--{CHECK_SPLUNK_RESULTS}", action="store", type=str, default=None
|
|
84
|
+
)
|
|
78
85
|
parser.addoption(f"--{CREATE_COMPARISON_SHEET}", action="store_true", default=False)
|
|
79
86
|
parser.addoption(f"--{IGNORE_EMPTY_STRINGS}", action="store_true")
|
|
80
87
|
parser.addoption(f"--{IGNORE_ADDITIONAL_FIELDS_IN_ACTUAL}", action="store_true")
|
|
@@ -139,6 +146,8 @@ def spl2_test_runner(
|
|
|
139
146
|
basic_authentication,
|
|
140
147
|
bearer_token_authentication,
|
|
141
148
|
run_configurations,
|
|
149
|
+
splunk_handler,
|
|
150
|
+
stats,
|
|
142
151
|
):
|
|
143
152
|
"""This fixture is used to create a test runner object based on the test type specified by the --type option
|
|
144
153
|
3 types of tests are supported: 'splunk', 'cloud', 'cli'
|
|
@@ -160,7 +169,7 @@ def spl2_test_runner(
|
|
|
160
169
|
)
|
|
161
170
|
)
|
|
162
171
|
|
|
163
|
-
x = SPL2TestRunner(search_client, run_configurations)
|
|
172
|
+
x = SPL2TestRunner(search_client, run_configurations, splunk_handler, stats)
|
|
164
173
|
return x
|
|
165
174
|
|
|
166
175
|
|
|
@@ -172,11 +181,35 @@ def run_configurations(request):
|
|
|
172
181
|
f"--{CREATE_COMPARISON_SHEET}"
|
|
173
182
|
),
|
|
174
183
|
CLI_BENCH: request.config.getoption(f"--{CLI_BENCH}"),
|
|
184
|
+
CHECK_SPLUNK_RESULTS: request.config.getoption(f"--{CHECK_SPLUNK_RESULTS}"),
|
|
175
185
|
}
|
|
176
186
|
|
|
177
187
|
|
|
178
188
|
@pytest.fixture(scope="session")
|
|
179
|
-
def
|
|
189
|
+
def splunk_instance_config():
|
|
190
|
+
splunk_instance_ip = os.environ.get("SPL2_TF_SPLUNK_INSTANCE_IP", None)
|
|
191
|
+
splunk_instance_port = os.environ.get("SPL2_TF_SPLUNK_INSTANCE_PORT", None)
|
|
192
|
+
splunk_instance_api_port = os.environ.get("SPL2_TF_SPLUNK_INSTANCE_API_PORT", None)
|
|
193
|
+
splunk_instance_username = os.environ.get("SPL2_TF_SPLUNK_INSTANCE_USERNAME", None)
|
|
194
|
+
splunk_instance_password = os.environ.get("SPL2_TF_SPLUNK_INSTANCE_PASSWORD", None)
|
|
195
|
+
splunk_instance_index = os.environ.get("SPL2_TF_SPLUNK_INSTANCE_INDEX", None)
|
|
196
|
+
splunk_instance_hec_token = os.environ.get(
|
|
197
|
+
"SPL2_TF_SPLUNK_INSTANCE_HEC_TOKEN", None
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
"ip": splunk_instance_ip,
|
|
202
|
+
"port": splunk_instance_port,
|
|
203
|
+
"api_port": splunk_instance_api_port,
|
|
204
|
+
"username": splunk_instance_username,
|
|
205
|
+
"password": splunk_instance_password,
|
|
206
|
+
"index": splunk_instance_index,
|
|
207
|
+
"hec_token": splunk_instance_hec_token,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.fixture(scope="session")
|
|
212
|
+
def config(splunk_instance_config):
|
|
180
213
|
"""This fixture is used to read the configuration from the environment variables or from the spl2_test_config.json
|
|
181
214
|
Configuration set using environment variables will be overwritten by settings from spl2_test_config.json file,
|
|
182
215
|
however empty values in spl2_test_config.json will be ignored.
|
|
@@ -219,6 +252,12 @@ def config():
|
|
|
219
252
|
if conf_file.get("cloud_instance", None):
|
|
220
253
|
cloud_instance = conf_file["cloud_instance"]
|
|
221
254
|
|
|
255
|
+
for k, v in splunk_instance_config.items():
|
|
256
|
+
if v is None:
|
|
257
|
+
splunk_instance_config[k] = conf_file.get("SPLUNK_INSTANCE", {}).get(
|
|
258
|
+
k, None
|
|
259
|
+
)
|
|
260
|
+
|
|
222
261
|
return {
|
|
223
262
|
"host": host,
|
|
224
263
|
"port": port,
|
|
@@ -227,6 +266,7 @@ def config():
|
|
|
227
266
|
"bearer_token": bearer_token,
|
|
228
267
|
"tenant": tenant,
|
|
229
268
|
"cloud_instance": cloud_instance,
|
|
269
|
+
"SPLUNK_INSTANCE": splunk_instance_config,
|
|
230
270
|
}
|
|
231
271
|
|
|
232
272
|
|
|
@@ -251,6 +291,49 @@ def bearer_token_authentication(config):
|
|
|
251
291
|
return HTTPBearerTokenAuth(token)
|
|
252
292
|
|
|
253
293
|
|
|
294
|
+
@pytest.fixture(scope="session")
|
|
295
|
+
def splunk_token_authentication(config):
|
|
296
|
+
class SplunkTokenAuth(AuthBase):
|
|
297
|
+
"""Attaches HTTP Basic Authentication to the given Request object."""
|
|
298
|
+
|
|
299
|
+
def __init__(self, token):
|
|
300
|
+
self.token = token
|
|
301
|
+
|
|
302
|
+
def __call__(self, r):
|
|
303
|
+
r.headers["Authorization"] = f"Splunk {self.token}"
|
|
304
|
+
return r
|
|
305
|
+
|
|
306
|
+
token = config["SPLUNK_INSTANCE"].get("hec_token", "")
|
|
307
|
+
splunk_auth = SplunkTokenAuth(token)
|
|
308
|
+
return splunk_auth
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@pytest.fixture(scope="session")
|
|
312
|
+
def splunk_handler(config, splunk_token_authentication):
|
|
313
|
+
config["SPLUNK_INSTANCE"][
|
|
314
|
+
"url"
|
|
315
|
+
] = f"https://{config['SPLUNK_INSTANCE']['ip']}:{config['SPLUNK_INSTANCE']['port']}/services/collector/raw"
|
|
316
|
+
config["SPLUNK_INSTANCE"][
|
|
317
|
+
"index_url"
|
|
318
|
+
] = f"https://{config['SPLUNK_INSTANCE']['ip']}:{config['SPLUNK_INSTANCE']['api_port']}/services/data/indexes"
|
|
319
|
+
|
|
320
|
+
sh = SplunkHandler(config, splunk_token_authentication)
|
|
321
|
+
return sh
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@pytest.fixture(scope="session")
|
|
325
|
+
def stats():
|
|
326
|
+
s = defaultdict(lambda: defaultdict(list))
|
|
327
|
+
yield s
|
|
328
|
+
|
|
329
|
+
for template, v in s.items():
|
|
330
|
+
x = pd.DataFrame(v)
|
|
331
|
+
_LOGGER.info(" *** *** ***")
|
|
332
|
+
_LOGGER.info("Collected statistics: ")
|
|
333
|
+
_LOGGER.info("Template name: %s ", template)
|
|
334
|
+
_LOGGER.info(x.describe())
|
|
335
|
+
|
|
336
|
+
|
|
254
337
|
def _set_results_options(metafunc):
|
|
255
338
|
ignore_empty_strings = metafunc.config.getoption(IGNORE_EMPTY_STRINGS)
|
|
256
339
|
Results._ignore_empty_strings = ignore_empty_strings
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"host": "",
|
|
3
|
+
"cloud_instance": "",
|
|
4
|
+
"tenant": "",
|
|
5
|
+
"bearer_token": "",
|
|
6
|
+
"port": "",
|
|
7
|
+
"user": "",
|
|
8
|
+
"password": "",
|
|
9
|
+
"SPLUNK_INSTANCE": {
|
|
10
|
+
"ip": "",
|
|
11
|
+
"port": "",
|
|
12
|
+
"api_port": "",
|
|
13
|
+
"username": "",
|
|
14
|
+
"password": "",
|
|
15
|
+
"index": "",
|
|
16
|
+
"hec_token": ""
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -38,7 +38,7 @@ class PerformanceCheck:
|
|
|
38
38
|
PIPELINE_PATTERN = re.compile(
|
|
39
39
|
r"(?P<fullmatch>"
|
|
40
40
|
r"^\s*?\$pipeline\s*?=\s*?\|?\s*?from\s*?\$source\s*?" # first line of pipeline
|
|
41
|
-
r"(.|\n)
|
|
41
|
+
r"(.|\n)*" # greedy: match everything up to the LAST into $destination
|
|
42
42
|
r"(\|\s*?into\s*?\$destination)" # last line of pipeline definition
|
|
43
43
|
r")",
|
|
44
44
|
flags=re.M | re.VERBOSE,
|
|
@@ -46,8 +46,8 @@ class PerformanceCheck:
|
|
|
46
46
|
|
|
47
47
|
FUNCTION_PATTERN = re.compile(
|
|
48
48
|
r"^(?P<fullmatch>"
|
|
49
|
-
r"(export\s+)?function\s+(?P<f_name>\S+)\((?P<source
|
|
50
|
-
r"
|
|
49
|
+
r"(export\s+)?function\s+(?P<f_name>\S+)\((?P<source>\$\w+)" # function header (source may lack type)
|
|
50
|
+
r"[^{]*{\n\s+return\s+(\|\s+)?(?:from|FROM)\s+(?P=source)\n*" # return statement
|
|
51
51
|
r"(?P<statements>(.|\n)*?)" # anything before closing bracket
|
|
52
52
|
r"(?P<closing_bracket>^\s*})" # closing bracket [ASSUMED IT'S IN THE NEW LINE]
|
|
53
53
|
r")",
|
|
@@ -56,6 +56,20 @@ class PerformanceCheck:
|
|
|
56
56
|
|
|
57
57
|
STATEMENT_PATTERN = re.compile(r"^(?P<lx>\s*?\|.*)$", re.M)
|
|
58
58
|
|
|
59
|
+
SCALAR_FUNCTION_PATTERN = re.compile(
|
|
60
|
+
r"^(?P<fullmatch>"
|
|
61
|
+
r"(?:export\s+)?function\s+(?P<f_name>\S+)\("
|
|
62
|
+
r"[^{]*{\n"
|
|
63
|
+
r"(?P<statements>[\s\S]*?)"
|
|
64
|
+
r"(?P<closing_bracket>^\s*})"
|
|
65
|
+
r")",
|
|
66
|
+
flags=re.M | re.VERBOSE,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
SCALAR_STATEMENT_PATTERN = re.compile(
|
|
70
|
+
r"^(?P<lx>\s*(?:\$\w+\s*=(?!=)|return\s).+)$", re.M
|
|
71
|
+
)
|
|
72
|
+
|
|
59
73
|
def __init__(self, test: Test):
|
|
60
74
|
self._test = test
|
|
61
75
|
self._performance_check_type = test.meta.get("performance", None)
|
|
@@ -135,13 +149,26 @@ class PerformanceCheck:
|
|
|
135
149
|
output = copy(self.code_module)
|
|
136
150
|
|
|
137
151
|
for timestamp_name, value, relative in calculated_diffs:
|
|
152
|
+
marker = f">>> {(value * 1000):.6f}ms [{relative:.2%}] <<< "
|
|
138
153
|
output = re.sub(
|
|
139
154
|
rf"\| eval {timestamp_name}=time\(\)",
|
|
140
|
-
|
|
155
|
+
marker,
|
|
156
|
+
output,
|
|
157
|
+
)
|
|
158
|
+
output = re.sub(
|
|
159
|
+
rf"^\${timestamp_name}\s*=\s*time\(\)\s*\n",
|
|
160
|
+
marker,
|
|
141
161
|
output,
|
|
162
|
+
flags=re.M,
|
|
142
163
|
)
|
|
143
164
|
|
|
144
165
|
output = re.sub(r"\| eval _timestamp_\w+_\d{3}=time\(\)", r"", output)
|
|
166
|
+
output = re.sub(
|
|
167
|
+
r"^\$_timestamp_\w+_\d{3}\s*=\s*time\(\)\s*\n?",
|
|
168
|
+
r"",
|
|
169
|
+
output,
|
|
170
|
+
flags=re.M,
|
|
171
|
+
)
|
|
145
172
|
|
|
146
173
|
_LOGGER.info(output)
|
|
147
174
|
|
|
@@ -154,7 +181,10 @@ class PerformanceCheck:
|
|
|
154
181
|
def _save_output(self, output: str, event_number: int) -> None:
|
|
155
182
|
"""Save output to text file"""
|
|
156
183
|
os.makedirs(self.OUTPUT_DIR, exist_ok=True)
|
|
157
|
-
|
|
184
|
+
safe_name = (
|
|
185
|
+
self._test.name.replace(os.sep, "_").replace("/", "_").replace("\\", "_")
|
|
186
|
+
)
|
|
187
|
+
filename = f"{self.OUTPUT_DIR}/performance_{safe_name}_{event_number}_{datetime.now()}.txt"
|
|
158
188
|
|
|
159
189
|
with open(filename, "w") as f:
|
|
160
190
|
f.write(output)
|
|
@@ -181,13 +211,18 @@ class PerformanceCheck:
|
|
|
181
211
|
flags=re.M,
|
|
182
212
|
)
|
|
183
213
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
214
|
+
pattern = re.compile(r"(\|\s*?into\s*?\$destination)", re.M)
|
|
215
|
+
matches = list(pattern.finditer(code_module))
|
|
216
|
+
if matches:
|
|
217
|
+
last = matches[-1]
|
|
218
|
+
replacement = (
|
|
219
|
+
"| eval _timestamp_perf_stop = time()"
|
|
220
|
+
"| eval _timestamp_perf_exec = _timestamp_perf_stop - _timestamp_perf_start "
|
|
221
|
+
+ last.group(1)
|
|
222
|
+
)
|
|
223
|
+
code_module = (
|
|
224
|
+
code_module[: last.start()] + replacement + code_module[last.end() :]
|
|
225
|
+
)
|
|
191
226
|
|
|
192
227
|
return code_module
|
|
193
228
|
|
|
@@ -196,6 +231,7 @@ class PerformanceCheck:
|
|
|
196
231
|
|
|
197
232
|
functions = self.__parse_functions(code_module)
|
|
198
233
|
pipeline = self.__parse_pipeline(code_module)
|
|
234
|
+
scalar_functions = self.__parse_scalar_functions(code_module)
|
|
199
235
|
|
|
200
236
|
functions.append(pipeline)
|
|
201
237
|
|
|
@@ -203,14 +239,37 @@ class PerformanceCheck:
|
|
|
203
239
|
new_function = self.__add_timestamps_to_function(f)
|
|
204
240
|
code_module = code_module.replace(f["fullmatch"], new_function)
|
|
205
241
|
|
|
242
|
+
for f in scalar_functions:
|
|
243
|
+
new_function = self.__add_timestamps_to_scalar_function(f)
|
|
244
|
+
code_module = code_module.replace(f["fullmatch"], new_function)
|
|
245
|
+
|
|
246
|
+
code_module = self._preserve_timestamps_through_fields(code_module)
|
|
247
|
+
|
|
206
248
|
return code_module
|
|
207
249
|
|
|
250
|
+
@staticmethod
|
|
251
|
+
def _preserve_timestamps_through_fields(code_module: str) -> str:
|
|
252
|
+
"""Replace '| fields' keep-mode with a no-op so _timestamp_perf* fields are not stripped."""
|
|
253
|
+
return re.sub(
|
|
254
|
+
r"\|\s*fields\s+(?!-)(.+)",
|
|
255
|
+
r"| eval _perf_fields_preserved=1",
|
|
256
|
+
code_module,
|
|
257
|
+
)
|
|
258
|
+
|
|
208
259
|
def __parse_functions(self, code_module: str) -> List[Dict]:
|
|
209
260
|
"""Parse all functions from code module. The finding contains groups necessary for parsing it
|
|
210
261
|
and adding timestamps"""
|
|
211
262
|
functions = [m.groupdict() for m in self.FUNCTION_PATTERN.finditer(code_module)]
|
|
212
263
|
return functions
|
|
213
264
|
|
|
265
|
+
def __parse_scalar_functions(self, code_module: str) -> List[Dict]:
|
|
266
|
+
"""Parse scalar functions (no FROM $source) that use $variable = expr assignments."""
|
|
267
|
+
dataset_fn_names = {f["f_name"] for f in self.__parse_functions(code_module)}
|
|
268
|
+
all_fns = [
|
|
269
|
+
m.groupdict() for m in self.SCALAR_FUNCTION_PATTERN.finditer(code_module)
|
|
270
|
+
]
|
|
271
|
+
return [f for f in all_fns if f["f_name"] not in dataset_fn_names]
|
|
272
|
+
|
|
214
273
|
def __parse_pipeline(self, code_module: str) -> Dict:
|
|
215
274
|
"""Parse pipeline definition from code module. The finding contains groups necessary for parsing it
|
|
216
275
|
and adding timestamps"""
|
|
@@ -249,3 +308,25 @@ class PerformanceCheck:
|
|
|
249
308
|
)
|
|
250
309
|
|
|
251
310
|
return new_function
|
|
311
|
+
|
|
312
|
+
def __add_timestamps_to_scalar_function(self, f: Dict) -> str:
|
|
313
|
+
"""Add timestamps to scalar functions that use $variable = expr assignments.
|
|
314
|
+
Injects $-prefixed variable assignments instead of | eval commands."""
|
|
315
|
+
new_function = self.SCALAR_STATEMENT_PATTERN.sub(
|
|
316
|
+
rf"$_time_{f['f_name']}_placeholder = time()\n\g<lx>",
|
|
317
|
+
f["fullmatch"],
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
func_timestamps = re.findall(
|
|
321
|
+
rf"\$_time_{f['f_name']}_placeholder", new_function
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
for n, timestamp in enumerate(func_timestamps):
|
|
325
|
+
new_function = re.sub(
|
|
326
|
+
re.escape(timestamp),
|
|
327
|
+
rf"$_timestamp_perf{f['f_name']}_{n:03d}",
|
|
328
|
+
new_function,
|
|
329
|
+
count=1,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return new_function
|
|
@@ -75,8 +75,13 @@ class CLISearchClient(SearchClient):
|
|
|
75
75
|
# Normal mode - raise errors as before
|
|
76
76
|
if result.stderr:
|
|
77
77
|
raise RuntimeError(result.stderr)
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
try:
|
|
79
|
+
job.result = json.loads(result.stdout)
|
|
80
|
+
except json.decoder.JSONDecodeError:
|
|
81
|
+
_LOGGER.error(
|
|
82
|
+
"Can't decode output for %s. Details: %s ", job.test_name, result
|
|
83
|
+
)
|
|
84
|
+
raise
|
|
80
85
|
return
|
|
81
86
|
|
|
82
87
|
def _print_bench_results(self, job, result):
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
import logging
|
|
17
|
+
import pytest
|
|
17
18
|
|
|
18
19
|
from tabulate import tabulate
|
|
19
20
|
|
|
@@ -23,7 +24,13 @@ from spl2_testing_framework.tools.search_clients.cli_search_client import (
|
|
|
23
24
|
CLISearchClient,
|
|
24
25
|
)
|
|
25
26
|
from spl2_testing_framework.tools.test_types import UnitTest, BoxTest, SingleSPL2
|
|
26
|
-
from spl2_testing_framework.tools.utils import
|
|
27
|
+
from spl2_testing_framework.tools.utils import (
|
|
28
|
+
_make_functions_visible_for_testing,
|
|
29
|
+
validate_compatibility,
|
|
30
|
+
calculate_reduction_rate,
|
|
31
|
+
parse_raw_from_input,
|
|
32
|
+
parse_raw_from_reduced_events,
|
|
33
|
+
)
|
|
27
34
|
|
|
28
35
|
_LOGGER = logging.getLogger(__name__)
|
|
29
36
|
|
|
@@ -31,10 +38,15 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
31
38
|
class SPL2TestRunner:
|
|
32
39
|
"""Main class responsible for running tests"""
|
|
33
40
|
|
|
34
|
-
def __init__(self, search_client, run_configurations):
|
|
41
|
+
def __init__(self, search_client, run_configurations, splunk_handler, stats):
|
|
35
42
|
self._search_client = search_client
|
|
36
43
|
self.create_comparison_sheet = run_configurations.get("create_comparison_sheet")
|
|
44
|
+
self.check_splunk_results = run_configurations.get("check_splunk_results")
|
|
37
45
|
self.comp_obj = ComparisonSheet()
|
|
46
|
+
self.reduction_stats = stats
|
|
47
|
+
if self.check_splunk_results:
|
|
48
|
+
splunk_handler.setup()
|
|
49
|
+
self.splunk_handler = splunk_handler
|
|
38
50
|
|
|
39
51
|
def run_single_spl2_file(self, single_spl2_file: SingleSPL2) -> None:
|
|
40
52
|
"""Run a single spl2 file and print the output"""
|
|
@@ -163,30 +175,81 @@ class SPL2TestRunner:
|
|
|
163
175
|
|
|
164
176
|
perf.check_performance(output_result)
|
|
165
177
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
_LOGGER.debug("Received: \n%s", str(output_result))
|
|
177
|
-
_LOGGER.debug("Expected: \n%s", str(box_test.output))
|
|
178
|
+
reduction_rate = calculate_reduction_rate(
|
|
179
|
+
parse_raw_from_input(box_test.input),
|
|
180
|
+
parse_raw_from_reduced_events(output_result),
|
|
181
|
+
)
|
|
182
|
+
self.reduction_stats[box_test.name]["reduction_rate"].extend(reduction_rate)
|
|
183
|
+
|
|
184
|
+
_LOGGER.info("Reduction rate for this event: %s", reduction_rate)
|
|
185
|
+
|
|
186
|
+
if self.check_splunk_results:
|
|
187
|
+
self._check_splunk_results(box_test, job)
|
|
178
188
|
else:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
if box_test.output:
|
|
190
|
+
# Remove fields from actual results that are not in expected results if configured
|
|
191
|
+
for i, actual_result in enumerate(output_result):
|
|
192
|
+
if i < len(box_test.output):
|
|
193
|
+
actual_result.remove_fields_not_in_expected(box_test.output[i])
|
|
194
|
+
|
|
195
|
+
if self.create_comparison_sheet:
|
|
196
|
+
self.comp_obj.create_comparison_sheet(output_result, box_test)
|
|
197
|
+
assert sorted(output_result) == sorted(box_test.output)
|
|
198
|
+
_LOGGER.info(f"Output check passed: {box_test.name}")
|
|
199
|
+
_LOGGER.debug("Received: \n%s", str(output_result))
|
|
200
|
+
_LOGGER.debug("Expected: \n%s", str(box_test.output))
|
|
201
|
+
else:
|
|
202
|
+
_LOGGER.info(f"Output check skipped (expected empty): {box_test.name}")
|
|
203
|
+
_LOGGER.debug("Actual results received: \n%s", str(output_result))
|
|
204
|
+
|
|
205
|
+
if box_test.metrics:
|
|
206
|
+
# Remove fields from actual metrics that are not in expected metrics if configured
|
|
207
|
+
for i, actual_metric in enumerate(metrics_result):
|
|
208
|
+
if i < len(box_test.metrics):
|
|
209
|
+
actual_metric.remove_fields_not_in_expected(box_test.metrics[i])
|
|
210
|
+
|
|
211
|
+
assert sorted(metrics_result) == sorted(box_test.metrics)
|
|
212
|
+
_LOGGER.info(f"Metric check passed: {box_test.name}")
|
|
213
|
+
_LOGGER.debug("Received: \n%s", str(metrics_result))
|
|
214
|
+
_LOGGER.debug("Expected: \n%s", str(box_test.metrics))
|
|
215
|
+
else:
|
|
216
|
+
_LOGGER.info(f"Metric check skipped (expected empty): {box_test.name}")
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _row_as_dict(row):
|
|
220
|
+
return row.data if hasattr(row, "data") else dict(row)
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _strip_ignored_fields_for_splunk_compare(
|
|
224
|
+
row: dict, ignore_keys: frozenset
|
|
225
|
+
) -> dict:
|
|
226
|
+
if not ignore_keys:
|
|
227
|
+
return row
|
|
228
|
+
return {k: v for k, v in row.items() if k not in ignore_keys}
|
|
229
|
+
|
|
230
|
+
def _check_splunk_results(self, box_test, job):
|
|
231
|
+
output_result = job.result.get("destination", [])
|
|
232
|
+
if self.check_splunk_results == "CIM":
|
|
233
|
+
if not box_test.cim_fields:
|
|
234
|
+
pytest.skip("CIM output not available for this test")
|
|
235
|
+
|
|
236
|
+
for event in output_result:
|
|
237
|
+
processed_event = self.splunk_handler.process_event(event)
|
|
238
|
+
validate_compatibility(box_test.cim_fields, processed_event)
|
|
239
|
+
elif self.check_splunk_results == "TA":
|
|
240
|
+
ignore_spec = box_test.ignore_fields_in_splunk_check
|
|
241
|
+
ignore_keys = frozenset(ignore_spec.keys())
|
|
242
|
+
for event, expected in zip(output_result, box_test.output):
|
|
243
|
+
processed_event = self.splunk_handler.process_event(event)
|
|
244
|
+
actual = self._strip_ignored_fields_for_splunk_compare(
|
|
245
|
+
processed_event, ignore_keys
|
|
246
|
+
)
|
|
247
|
+
expected_cmp = self._strip_ignored_fields_for_splunk_compare(
|
|
248
|
+
self._row_as_dict(expected), ignore_keys
|
|
249
|
+
)
|
|
250
|
+
assert actual == expected_cmp, (
|
|
251
|
+
f"TA Splunk compare mismatch for {box_test.name}; "
|
|
252
|
+
f"ignored_fields={ignore_spec!r}"
|
|
253
|
+
)
|
|
191
254
|
else:
|
|
192
|
-
|
|
255
|
+
return
|
|
@@ -0,0 +1,13 @@
|
|
|
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.
|
spl2_testing_framework-1.6.0/spl2_testing_framework/tools/splunk_integration/splunk_handler.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
import logging
|
|
16
|
+
import uuid
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
import splunklib.client as client
|
|
20
|
+
import splunklib.results as results
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from requests.auth import HTTPBasicAuth
|
|
25
|
+
|
|
26
|
+
_LOGGER = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SplunkHandler:
|
|
30
|
+
def __init__(self, config, auth):
|
|
31
|
+
self._config = config
|
|
32
|
+
self.splunk_config = config.get("SPLUNK_INSTANCE", {})
|
|
33
|
+
self._auth = auth
|
|
34
|
+
|
|
35
|
+
def setup(self):
|
|
36
|
+
self._create_index()
|
|
37
|
+
|
|
38
|
+
def process_event(self, event):
|
|
39
|
+
host = f"test_{uuid.uuid4()}"
|
|
40
|
+
|
|
41
|
+
self.push_to_hec(event, host)
|
|
42
|
+
res = self.get_events_by_host_and_index(host)
|
|
43
|
+
if not res:
|
|
44
|
+
raise Exception("No event found.")
|
|
45
|
+
|
|
46
|
+
if len(res) > 1:
|
|
47
|
+
_LOGGER.error("Found more than one event, the result may not be correct")
|
|
48
|
+
return res[0]
|
|
49
|
+
|
|
50
|
+
def push_to_hec(self, data, host):
|
|
51
|
+
sourcetype = data.get("sourcetype", "UNKNOWN")
|
|
52
|
+
|
|
53
|
+
if sourcetype == "UNKNOWN":
|
|
54
|
+
_LOGGER.warning("Source type is UNKNOWN. The result may be not correct")
|
|
55
|
+
|
|
56
|
+
source = data.get("source", None)
|
|
57
|
+
|
|
58
|
+
params = {
|
|
59
|
+
"sourcetype": sourcetype,
|
|
60
|
+
"index": self.splunk_config["index"],
|
|
61
|
+
"host": host,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if source is not None:
|
|
65
|
+
params["source"] = source
|
|
66
|
+
|
|
67
|
+
event_data = data["_raw"].encode("utf-8")
|
|
68
|
+
|
|
69
|
+
response = requests.post(
|
|
70
|
+
url=self.splunk_config["url"],
|
|
71
|
+
data=event_data,
|
|
72
|
+
params=params,
|
|
73
|
+
auth=self._auth,
|
|
74
|
+
verify=False,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
|
|
79
|
+
def get_events_by_host_and_index(self, host):
|
|
80
|
+
SPL_TEMPLATE = "| search index={index} host={host} | fields *"
|
|
81
|
+
# Connect to Splunk
|
|
82
|
+
service = client.connect(
|
|
83
|
+
host=self.splunk_config["ip"],
|
|
84
|
+
port=self.splunk_config["api_port"],
|
|
85
|
+
username=self.splunk_config["username"],
|
|
86
|
+
password=self.splunk_config["password"],
|
|
87
|
+
app="search",
|
|
88
|
+
)
|
|
89
|
+
SPL = SPL_TEMPLATE.format(index=self.splunk_config["index"], host=host)
|
|
90
|
+
|
|
91
|
+
results_all = []
|
|
92
|
+
retry = 0
|
|
93
|
+
while not results_all and retry < 20:
|
|
94
|
+
kwargs = {"adhoc_search_level": "verbose"}
|
|
95
|
+
job = service.jobs.create(SPL, **kwargs)
|
|
96
|
+
while not job.is_done():
|
|
97
|
+
time.sleep(0.25)
|
|
98
|
+
results_splunk = job.results(output_mode="json")
|
|
99
|
+
rr = results.JSONResultsReader(results_splunk)
|
|
100
|
+
for result in rr:
|
|
101
|
+
if isinstance(result, results.Message):
|
|
102
|
+
_LOGGER.info("%s: %s", result.type, result.message)
|
|
103
|
+
elif isinstance(result, dict):
|
|
104
|
+
# Normal events are returned as dicts
|
|
105
|
+
results_all.append(result)
|
|
106
|
+
retry = retry + 1
|
|
107
|
+
time.sleep(0.25)
|
|
108
|
+
return results_all
|
|
109
|
+
|
|
110
|
+
def _create_index(self):
|
|
111
|
+
response = requests.post(
|
|
112
|
+
url=self.splunk_config["index_url"],
|
|
113
|
+
data={"name": self.splunk_config["index"], "output_mode": "json"},
|
|
114
|
+
auth=HTTPBasicAuth(
|
|
115
|
+
self.splunk_config["username"], self.splunk_config["password"]
|
|
116
|
+
),
|
|
117
|
+
verify=False,
|
|
118
|
+
)
|
|
119
|
+
return response.json()
|
|
@@ -56,11 +56,52 @@ class TestDiscovery(abc.ABC):
|
|
|
56
56
|
class BoxTestDiscovery(TestDiscovery):
|
|
57
57
|
"""Discovers tests for box tests, looking into module.test.json files"""
|
|
58
58
|
|
|
59
|
+
_IGNORE_FIELDS_IN_SPLUNK_CHECK_KEY = "ignore_fields_in_splunk_check"
|
|
60
|
+
|
|
59
61
|
def __init__(self, path: str, performance_check: str, limit_tests: int = None):
|
|
60
62
|
super().__init__(path, performance_check, limit_tests)
|
|
61
63
|
|
|
64
|
+
@classmethod
|
|
65
|
+
def _parse_ignore_fields_in_splunk_check(cls, test_block: dict) -> dict:
|
|
66
|
+
raw = test_block.get(cls._IGNORE_FIELDS_IN_SPLUNK_CHECK_KEY)
|
|
67
|
+
if raw is None:
|
|
68
|
+
return {}
|
|
69
|
+
if isinstance(raw, list):
|
|
70
|
+
out = {}
|
|
71
|
+
for k in raw:
|
|
72
|
+
if isinstance(k, str):
|
|
73
|
+
out[k] = ""
|
|
74
|
+
else:
|
|
75
|
+
_LOGGER.warning(
|
|
76
|
+
"%s list entries must be strings; skipping %r",
|
|
77
|
+
cls._IGNORE_FIELDS_IN_SPLUNK_CHECK_KEY,
|
|
78
|
+
k,
|
|
79
|
+
)
|
|
80
|
+
return out
|
|
81
|
+
if not isinstance(raw, dict):
|
|
82
|
+
_LOGGER.warning(
|
|
83
|
+
"%s must be an object (field -> reason) or a list of field names; ignoring %r",
|
|
84
|
+
cls._IGNORE_FIELDS_IN_SPLUNK_CHECK_KEY,
|
|
85
|
+
raw,
|
|
86
|
+
)
|
|
87
|
+
return {}
|
|
88
|
+
out = {}
|
|
89
|
+
for key, reason in raw.items():
|
|
90
|
+
if not isinstance(key, str):
|
|
91
|
+
_LOGGER.warning(
|
|
92
|
+
"%s keys must be strings; skipping %r",
|
|
93
|
+
cls._IGNORE_FIELDS_IN_SPLUNK_CHECK_KEY,
|
|
94
|
+
key,
|
|
95
|
+
)
|
|
96
|
+
continue
|
|
97
|
+
if isinstance(reason, str):
|
|
98
|
+
out[key] = reason
|
|
99
|
+
else:
|
|
100
|
+
out[key] = str(reason)
|
|
101
|
+
return out
|
|
102
|
+
|
|
62
103
|
def discover_tests(self) -> None:
|
|
63
|
-
self._read_tests("
|
|
104
|
+
self._read_tests("*.test.json")
|
|
64
105
|
self._parse_tests()
|
|
65
106
|
|
|
66
107
|
def _parse_tests(self) -> None:
|
|
@@ -71,33 +112,42 @@ class BoxTestDiscovery(TestDiscovery):
|
|
|
71
112
|
for module_path, content in self._parsed_modules.items():
|
|
72
113
|
path = module_path.parent
|
|
73
114
|
tests_added = 0
|
|
74
|
-
for
|
|
115
|
+
for entry in content:
|
|
75
116
|
if self.limit_tests is not None and tests_added >= self.limit_tests:
|
|
76
117
|
_LOGGER.info(
|
|
77
118
|
f"Limit of {self.limit_tests} tests reached for {module_path}. Stopping."
|
|
78
119
|
)
|
|
79
120
|
break
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
121
|
+
test_block = entry["test"]
|
|
122
|
+
name = entry["filename"].replace(".spl2", "")
|
|
123
|
+
test_input = test_block["source"]
|
|
124
|
+
code_module_path = path / entry["filename"]
|
|
83
125
|
output = [
|
|
84
126
|
Results(x)
|
|
85
|
-
for x in
|
|
127
|
+
for x in test_block.get("expected_destination_result", [])
|
|
86
128
|
]
|
|
129
|
+
cim_fields = test_block.get("expected_cim_fields", {}).get(
|
|
130
|
+
"cim_fields", {}
|
|
131
|
+
)
|
|
87
132
|
metrics = [
|
|
88
133
|
Metrics(x)
|
|
89
|
-
for x in
|
|
134
|
+
for x in test_block.get("expected_metrics_destination_result", [])
|
|
90
135
|
]
|
|
91
|
-
|
|
136
|
+
ignore_fields_in_splunk_check = (
|
|
137
|
+
self._parse_ignore_fields_in_splunk_check(test_block)
|
|
138
|
+
)
|
|
139
|
+
box_test = BoxTest(
|
|
92
140
|
name=name,
|
|
93
141
|
input=test_input,
|
|
94
142
|
output=output,
|
|
95
143
|
metrics=metrics,
|
|
96
144
|
module_path=module_path,
|
|
97
145
|
code_module_path=code_module_path,
|
|
146
|
+
cim_fields=cim_fields,
|
|
147
|
+
ignore_fields_in_splunk_check=ignore_fields_in_splunk_check,
|
|
98
148
|
)
|
|
99
|
-
|
|
100
|
-
self.tests.add(
|
|
149
|
+
box_test.meta["performance"] = self._performance_check
|
|
150
|
+
self.tests.add(box_test)
|
|
101
151
|
tests_added += 1
|
|
102
152
|
|
|
103
153
|
|
|
@@ -45,6 +45,9 @@ class BoxTest(Test):
|
|
|
45
45
|
module_path: Path
|
|
46
46
|
code_module_path: Path
|
|
47
47
|
metrics: list = field(default_factory=list)
|
|
48
|
+
cim_fields: dict = field(default_factory=dict)
|
|
49
|
+
# TA Splunk check: field name -> why it is ignored (comparison-only; keys stripped from both sides)
|
|
50
|
+
ignore_fields_in_splunk_check: dict = field(default_factory=dict)
|
|
48
51
|
|
|
49
52
|
|
|
50
53
|
@dataclass
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
import ast
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import pathlib
|
|
18
|
+
import re
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_framework_root() -> pathlib.Path:
|
|
25
|
+
framework_root = pathlib.Path(__file__).parents[1]
|
|
26
|
+
return framework_root
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _make_functions_visible_for_testing(code_module_path):
|
|
30
|
+
code_module = code_module_path.read_text(encoding="utf-8")
|
|
31
|
+
functions = re.findall(r"^function\s+([^(]+)\(", code_module, flags=re.M)
|
|
32
|
+
for function in functions:
|
|
33
|
+
if not re.search(r"export\s" + function, code_module):
|
|
34
|
+
code_module = re.sub(
|
|
35
|
+
r"^function\s+" + function,
|
|
36
|
+
f"export function {function}",
|
|
37
|
+
code_module,
|
|
38
|
+
flags=re.M,
|
|
39
|
+
)
|
|
40
|
+
return code_module
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@lru_cache(None)
|
|
44
|
+
def read_assertions_module():
|
|
45
|
+
root_folder = get_framework_root()
|
|
46
|
+
assertions_file = root_folder / "spl2_utils" / "assertions.spl2"
|
|
47
|
+
return assertions_file.read_text(encoding="utf-8")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@lru_cache(None)
|
|
51
|
+
def read_commands_modules():
|
|
52
|
+
root_folder = get_framework_root()
|
|
53
|
+
commands_module = root_folder / "spl2_utils" / "logs_to_metrics.spl2"
|
|
54
|
+
return commands_module.read_text(encoding="utf-8")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def calculate_reduction_rate(original_event, reduced_event):
|
|
58
|
+
"""Calculate how much the event was reduced."""
|
|
59
|
+
reduction_rates = []
|
|
60
|
+
for original, reduced in zip(original_event, reduced_event):
|
|
61
|
+
original_event_len = len(str(original))
|
|
62
|
+
reduced_event_len = len(str(reduced))
|
|
63
|
+
|
|
64
|
+
reduction_rates.append(
|
|
65
|
+
(original_event_len - reduced_event_len) / original_event_len
|
|
66
|
+
)
|
|
67
|
+
return reduction_rates
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_compatibility(expected, parsed_output):
|
|
71
|
+
"""Validate if all fields from parsed_output are present in expected and have the same value."""
|
|
72
|
+
extracted_but_wrong = {}
|
|
73
|
+
not_extracted = {}
|
|
74
|
+
for field, value in expected.items():
|
|
75
|
+
if field in parsed_output:
|
|
76
|
+
if parsed_output[field] == value:
|
|
77
|
+
_LOGGER.info(f"FIELD {field} in reduced event")
|
|
78
|
+
continue
|
|
79
|
+
else:
|
|
80
|
+
extracted_but_wrong[field] = value
|
|
81
|
+
_LOGGER.error(f"FIELD {field} in reduced event has wrong value")
|
|
82
|
+
|
|
83
|
+
else:
|
|
84
|
+
not_extracted[field] = value
|
|
85
|
+
_LOGGER.error(f"FIELD {field} is not present in the reduced event")
|
|
86
|
+
|
|
87
|
+
_LOGGER.info(f"Checking wrong extractions")
|
|
88
|
+
assert extracted_but_wrong == {}
|
|
89
|
+
_LOGGER.info(f"Checking missing extractions")
|
|
90
|
+
assert not_extracted == {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_raw_from_input(input_str):
|
|
94
|
+
"""Parse SPL2-style or JSON array of events and return list of _raw field values.
|
|
95
|
+
Handles unquoted keys (e.g. {_raw: "x"}) by converting them to JSON-quoted keys.
|
|
96
|
+
"""
|
|
97
|
+
# Only replace unquoted keys: key must be preceded by { or , (and optional space)
|
|
98
|
+
key_pattern = re.compile(
|
|
99
|
+
r"(\{|,)\s*(_raw|source|sourcetype|host)\s*:", re.MULTILINE
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def quote_key(match):
|
|
103
|
+
return f'{match.group(1)}"{match.group(2)}":'
|
|
104
|
+
|
|
105
|
+
normalized = key_pattern.sub(quote_key, input_str)
|
|
106
|
+
try:
|
|
107
|
+
parsed = json.loads(normalized)
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
parsed = ast.literal_eval(normalized)
|
|
110
|
+
return [item["_raw"] for item in parsed]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_raw_from_reduced_events(events):
|
|
114
|
+
return [str(x["_raw"]) for x in events]
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
import pathlib
|
|
17
|
-
import re
|
|
18
|
-
from functools import lru_cache
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def get_framework_root() -> pathlib.Path:
|
|
22
|
-
framework_root = pathlib.Path(__file__).parents[1]
|
|
23
|
-
return framework_root
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _make_functions_visible_for_testing(code_module_path):
|
|
27
|
-
code_module = code_module_path.read_text(encoding="utf-8")
|
|
28
|
-
functions = re.findall(r"^function\s+([^(]+)\(", code_module, flags=re.M)
|
|
29
|
-
for function in functions:
|
|
30
|
-
if not re.search(r"export\s" + function, code_module):
|
|
31
|
-
code_module = re.sub(
|
|
32
|
-
r"^function\s+" + function,
|
|
33
|
-
f"export function {function}",
|
|
34
|
-
code_module,
|
|
35
|
-
flags=re.M,
|
|
36
|
-
)
|
|
37
|
-
return code_module
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@lru_cache(None)
|
|
41
|
-
def read_assertions_module():
|
|
42
|
-
root_folder = get_framework_root()
|
|
43
|
-
assertions_file = root_folder / "spl2_utils" / "assertions.spl2"
|
|
44
|
-
return assertions_file.read_text(encoding="utf-8")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
@lru_cache(None)
|
|
48
|
-
def read_commands_modules():
|
|
49
|
-
root_folder = get_framework_root()
|
|
50
|
-
commands_module = root_folder / "spl2_utils" / "logs_to_metrics.spl2"
|
|
51
|
-
return commands_module.read_text(encoding="utf-8")
|
|
File without changes
|
{spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{spl2_testing_framework-1.5.0 → spl2_testing_framework-1.6.0}/spl2_testing_framework/test_runner.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|