Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,40 @@ Examples:
./pytest_start.sh -s claret -d http_simple -fm stl -f norules -sb
```

### rss (functional)

Functional test in `functional_tests/rss/` that verifies RSS (Receive Side Scaling) flow-to-queue
placement on a multi-queue DPDK interface. Suricata uses the test-local `functional_tests/rss/suricata.yaml`
(symmetric L3+L4 RSS hash, multiple RX queues); placement is asserted on the per-worker packet
counts from `eve-stats.json` (queue id = worker name minus interface suffix, e.g. `W#02`
of `W#02-0000:3b:00.1`).

The test is parametrized by stage, so each stage is a separate pytest node and can be run
(and selected) in isolation:

| Stage | Replayed pcap | Assertion |
|-------|---------------|-----------|
| 1 | `rss_flow_a_1000p.pcap` (flow A) | flow A is handled by exactly one queue |
| 2 | `rss_flows_bcd_6000p.pcap` (flows B/C/D) | flows spread over more than one queue |
| 3 | `rss_flow_ra_500p.pcap` (reversed flow rA) | rA shares A's queue (symmetric RSS hash); A's reference queue is learned inline first, so the stage is standalone-safe |

Full run and per-stage runs:

```bash
# All stages
./pytest_start.sh -s dpdk-test2 -d 'functional_tests/rss/test_rss.py::test_rss' -tg trex

# Single stage (quote the node ID - brackets would otherwise be globbed by the shell);
# the paramsN prefix comes from param.py combinations, verify with --collect-only first
./pytest_start.sh -s dpdk-test2 -d 'functional_tests/rss/test_rss.py::test_rss[params0-2]' -tg trex
```

Notes:

- the test forces the STL TRex mode and replays each pcap exactly once (single pass), so
traffic volume is bounded by the pcap content, not by `--traffic-duration`
- rules are not used (`/dev/null`), only packet placement is inspected

---

### Binary search
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
Author(s): Dávid Hanko <david.hanko@cesnet.cz>

Copyright: (C) 2026 CESNET, z.s.p.o.
SPDX-License-Identifier: BSD-3-Clause
"""

from lbr_testsuite.trex import TRexManager
from pytest import FixtureRequest

from assets.trex.traffic_profiles.trex_client_manager import BaseTrexClientManager
from util.trex_util import TrexMode


class RssProfile(
BaseTrexClientManager,
pcaps=[
("rss_flow_a_1000p.pcap", 1),
("rss_flows_bcd_6000p.pcap", 1),
("rss_flow_ra_500p.pcap", 1),
],
):
def __init__(
self,
manager: TRexManager,
request: FixtureRequest,
target_mac: str,
target_vlan: int = 0,
mode=TrexMode.STL,
):
super().__init__(manager, request, target_mac, target_vlan, mode=mode)
3 changes: 3 additions & 0 deletions assets/trex/traffic_profiles/pcaps/rss_flow_a_1000p.pcap
Git LFS file not shown
3 changes: 3 additions & 0 deletions assets/trex/traffic_profiles/pcaps/rss_flow_ra_500p.pcap
Git LFS file not shown
3 changes: 3 additions & 0 deletions assets/trex/traffic_profiles/pcaps/rss_flows_bcd_6000p.pcap
Git LFS file not shown
141 changes: 121 additions & 20 deletions assets/trex/traffic_profiles/trex_client_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""
Author(s): Matyáš Sedmidubský <matyas.sedmidubsky@cesnet.cz>
Dávid Hanko <david.hanko@cesnet.cz>

Copyright: (C) 2026 CESNET, z.s.p.o.
SPDX-License-Identifier: BSD-3-Clause
Expand Down Expand Up @@ -73,6 +74,8 @@ class BaseTrexClientManager:
multiplier: float | None = None
duration: int | None = None
_stf_config_path: Path | None = None
_selected_pcap: str | None = None
_selectable_pcaps: PcapList | None = None

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annotation bug, but with no impact. Still addressed.


BASE_IPG_USEC = 12.0 # ~1 Gbps at 1500 bytes per packet
PCAP_PATH_PREFIX = Path(__file__).parent / "pcaps"
Expand Down Expand Up @@ -130,6 +133,7 @@ def __init__(
trex_host = trex_gen[0].split(",")
trex_hostname = trex_host[0]
trex_pcie = trex_host[1]
self.trex_hostname = trex_hostname

match self.mode:
case TrexMode.STL:
Expand All @@ -145,6 +149,12 @@ def __init__(
parent_dir_path = self.get_remote_data_path(Path(""))
mkdir_remote(parent_dir_path, trex_hostname)

# snapshot of the individual pcaps *before* the merge below,
# so that later `set_pcap` calls can re-select any one of
# them (the merge collapses them into a single replay pcap;
# set_pcap uploads the selected one on demand)
self._selectable_pcaps = list(self.pcaps)

# Merge first, then apply the VLAN edit to the single final pcap.
if len(self.pcaps) > 1:
local_paths = [p.path for p in self.pcaps]
Expand Down Expand Up @@ -237,12 +247,19 @@ def __init__(
pcap_path, trex_hostname, pcap_remote_path, force=force_upload
)

# snapshot after VLAN renaming so that later `set_pcap` calls can
# re-select any pcap, not just the currently narrowed-down one
self._selectable_pcaps = list(self.pcaps)

profile_path = self.get_stf_profile()
profile_remote_path = self.get_remote_data_path(profile_path)
send_to_remote(
profile_path, trex_hostname, profile_remote_path, force=force_upload
)

case TrexMode.ASTF:
self._selectable_pcaps = list(self.pcaps)

def get_remote_data_path(self, local_path: Path) -> Path:
"""
Translates `local_path` into a path on the remote TRex server.
Expand Down Expand Up @@ -350,9 +367,13 @@ def stf_config_hook(self, config: ConfigBuilder) -> ConfigBuilder:
"""
return config

def set_props(self, multiplier: float, duration: int) -> None:
def set_props(self, multiplier: float, duration: int | None) -> None:
"""
Sets the internal multiplier and duration for later use in other functions.

A `duration` of None makes STL traffic single-pass: every pcap is
transmitted exactly once, so the traffic volume is bounded by the
pcap content instead of time. ASTF and STF still require a duration.
"""
self.multiplier = multiplier
self.duration = duration
Expand All @@ -362,10 +383,79 @@ def set_props(self, multiplier: float, duration: int) -> None:
duration,
)

@staticmethod
def _pcap_matches(pcap_entry: str, requested: str) -> bool:
"""
Checks whether `requested` refers to the pcap file `pcap_entry`.

Matching is done by filename only, since the profile's pcaps all live
in `PCAP_PATH_PREFIX`. VLAN-tagged copies (`name.vlanNNN.pcap` created
by `edit_vlan`) are matched by their original name as well.
"""
entry = Path(pcap_entry)
req = Path(requested)
if entry.name == req.name:
return True
return entry.stem.startswith(f"{req.stem}.vlan")

Comment on lines +395 to +400

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed so it matches the documentation (intented) way.

def set_pcap(self, pcap: str | Path) -> None:
"""
Restricts traffic to a single pcap out of the profile's pcap list.

`pcap` must refer to one of the profile's pcaps, either by the original
filename or by the VLAN-tagged one. Selecting a different pcap later is
supported - the list is always re-filtered from the snapshot taken in
`__init__` (before the STL merge / after the STF VLAN renaming).

STL mode merges all profile pcaps into one replay pcap in `__init__`,
so the selected pcap is VLAN-tagged and uploaded on demand here.
STF mode builds its profile from the pcap list upfront, so the profile
is rebuilt and re-uploaded here instead.

Raises a ValueError if `pcap` is not part of the profile.
"""
if self._selected_pcap is not None and self._pcap_matches(
self._selected_pcap, pcap
):
return

selectable = (
self._selectable_pcaps if self._selectable_pcaps is not None else self.pcaps
)
selected = [p for p in selectable if self._pcap_matches(p.path, pcap)]
if not selected:
raise ValueError(
f"'{pcap}' is not one of this profile's pcaps: "
f"{[p.path.name for p in selectable]}"
)
self._selected_pcap = str(pcap)
self.pcaps = selected
logger.debug("TRex traffic restricted to pcap: %s", selected[0].path.name)

if self.mode == TrexMode.STL:
pcap_path = self.pcaps[0].path
if self.vlan_id != 0:
pcap_path = Path(edit_vlan(str(pcap_path), self.vlan_id))
self.pcaps[0] = Pcap(pcap_path, self.pcaps[0].weight)
send_to_remote(
pcap_path,
self.trex_hostname,
self.get_remote_data_path(pcap_path),
force=self.request.config.getoption("--force-pcap-upload"),
)
elif self.mode == TrexMode.STF:
self._stf_config_path = None
profile_path = self.get_stf_profile()
send_to_remote(
profile_path,
self.trex_hostname,
self.get_remote_data_path(profile_path),
)

def prepare(self) -> None:
"""
Reset TRex instances and load profiles.
Will raise a ValueError if `multiplier` and `duration` haven't been set with `set_props`
Will raise a ValueError if `multiplier` hasn't been set with `set_props`
"""

logger.debug("Preparing TRex traffic: mode=%s", self.mode.name)
Expand All @@ -380,10 +470,8 @@ def prepare(self) -> None:
self.client.reset()
self.server.reset()

if self.multiplier is None or self.duration is None:
raise ValueError(
"you need to specify multiplier and duration with `set_props`"
)
if self.multiplier is None:
raise ValueError("you need to specify multiplier with `set_props`")

profile = self.get_astf_profile(self.multiplier)
client_handler: ASTFClient = self.client.get_handler()
Expand All @@ -404,7 +492,9 @@ def run(
"""
Start traffic from TRex and block until finished.
Optionally only start traffic with `blocking=False`.
Will raise a ValueError if `multiplier` and `duration` haven't been set with `set_props`
Will raise a ValueError if `multiplier` hasn't been set with `set_props`.
ASTF and STF modes additionally require a `duration`; STL accepts
`duration=None` for a single-pass replay bounded by the pcap content.

`heatup` (seconds) and `on_measurement_start` let the caller sample
TRex's own transmit counters at the start of the measurement window
Expand All @@ -422,10 +512,8 @@ def run(
blocking,
)

if self.multiplier is None or self.duration is None:
raise ValueError(
"you need to specify multiplier and duration with `set_props`"
)
if self.multiplier is None:
raise ValueError("you need to specify multiplier with `set_props`")

def _mark_measurement_start() -> None:
if not blocking:
Expand Down Expand Up @@ -490,30 +578,43 @@ def _mark_measurement_start() -> None:
pcap = self.pcaps[0]
start = time()
elapsed = 0
while elapsed < self.duration:
# with a duration set we replay the pcap until it elapses;
# with duration=None (single pass, functional tests) it is
# transmitted exactly once, bounded by the pcap content
while self.duration is None or elapsed < self.duration:
try:
client.push_remote(
pcap_filename=str(self.get_remote_data_path(pcap.path)),
ports=[0],
ipg_usec=self.BASE_IPG_USEC / pcap.weight,
speedup=self.multiplier,
count=1,
duration=int(self.duration - elapsed),
)
push_args = {
"pcap_filename": str(
self.get_remote_data_path(pcap.path)
),
"ports": [0],
"ipg_usec": self.BASE_IPG_USEC / pcap.weight,
"speedup": self.multiplier,
"count": 1,
}
if self.duration is not None:
push_args["duration"] = int(self.duration - elapsed)
client.push_remote(**push_args)
except TRexError:
# wait if port was not cleared yet
sleep(0.05)
elapsed = time() - start
if elapsed >= heatup and on_measurement_start is not None:
on_measurement_start()
on_measurement_start = None
if self.duration is None:
break # single pass done

case TrexMode.ASTF:
if self.duration is None:
raise ValueError("ASTF mode requires a duration, use `set_props`")
self.server.start()
self.client.start(duration=self.duration)
_mark_measurement_start()

case TrexMode.STF:
if self.duration is None:
raise ValueError("STF mode requires a duration, use `set_props`")
if self.duration < 30:
warnings.warn(
UserWarning(
Expand Down
4 changes: 4 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,10 +514,14 @@ def suricata_conf_file(request) -> ConfigBuilder:

os.makedirs(str(destination_dir), exist_ok=True)

test_local_cfg = Path(request.node.path).parent / "suricata.yaml"
if request.config.getoption("--suricata-cfg"):
builder = ConfigBuilder(
editable_yaml, request.config.getoption("--suricata-cfg")
)
elif test_local_cfg.is_file():
logger.debug("Using test-local Suricata config: %s", test_local_cfg)
builder = ConfigBuilder(editable_yaml, str(test_local_cfg))
else:
builder = ConfigBuilder(editable_yaml)

Expand Down
Loading
Loading