xref: /dpdk/dts/framework/utils.py (revision 4d23d39fd06ed89b2d2566273b95bbecbd48ed83)
1# SPDX-License-Identifier: BSD-3-Clause
2# Copyright(c) 2010-2014 Intel Corporation
3# Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
4# Copyright(c) 2022-2023 University of New Hampshire
5# Copyright(c) 2024 Arm Limited
6
7"""Various utility classes and functions.
8
9These are used in multiple modules across the framework. They're here because
10they provide some non-specific functionality, greatly simplify imports or just don't
11fit elsewhere.
12
13Attributes:
14    REGEX_FOR_PCI_ADDRESS: The regex representing a PCI address, e.g. ``0000:00:08.0``.
15"""
16
17import atexit
18import json
19import os
20import random
21import subprocess
22from enum import Enum, Flag
23from pathlib import Path
24from subprocess import SubprocessError
25
26from scapy.layers.inet import IP, TCP, UDP, Ether  # type: ignore[import-untyped]
27from scapy.packet import Packet  # type: ignore[import-untyped]
28
29from .exception import ConfigurationError, InternalError
30
31REGEX_FOR_PCI_ADDRESS: str = "/[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}.[0-9]{1}/"
32_REGEX_FOR_COLON_OR_HYPHEN_SEP_MAC: str = r"(?:[\da-fA-F]{2}[:-]){5}[\da-fA-F]{2}"
33_REGEX_FOR_DOT_SEP_MAC: str = r"(?:[\da-fA-F]{4}.){2}[\da-fA-F]{4}"
34REGEX_FOR_MAC_ADDRESS: str = rf"{_REGEX_FOR_COLON_OR_HYPHEN_SEP_MAC}|{_REGEX_FOR_DOT_SEP_MAC}"
35REGEX_FOR_BASE64_ENCODING: str = "[-a-zA-Z0-9+\\/]*={0,3}"
36
37
38def expand_range(range_str: str) -> list[int]:
39    """Process `range_str` into a list of integers.
40
41    There are two possible formats of `range_str`:
42
43        * ``n`` - a single integer,
44        * ``n-m`` - a range of integers.
45
46    The returned range includes both ``n`` and ``m``. Empty string returns an empty list.
47
48    Args:
49        range_str: The range to expand.
50
51    Returns:
52        All the numbers from the range.
53    """
54    expanded_range: list[int] = []
55    if range_str:
56        range_boundaries = range_str.split("-")
57        # will throw an exception when items in range_boundaries can't be converted,
58        # serving as type check
59        expanded_range.extend(range(int(range_boundaries[0]), int(range_boundaries[-1]) + 1))
60
61    return expanded_range
62
63
64def get_packet_summaries(packets: list[Packet]) -> str:
65    """Format a string summary from `packets`.
66
67    Args:
68        packets: The packets to format.
69
70    Returns:
71        The summary of `packets`.
72    """
73    if len(packets) == 1:
74        packet_summaries = packets[0].summary()
75    else:
76        packet_summaries = json.dumps(list(map(lambda pkt: pkt.summary(), packets)), indent=4)
77    return f"Packet contents: \n{packet_summaries}"
78
79
80def get_commit_id(rev_id: str) -> str:
81    """Given a Git revision ID, return the corresponding commit ID.
82
83    Args:
84        rev_id: The Git revision ID.
85
86    Raises:
87        ConfigurationError: The ``git rev-parse`` command failed, suggesting
88            an invalid or ambiguous revision ID was supplied.
89    """
90    result = subprocess.run(
91        ["git", "rev-parse", "--verify", rev_id],
92        text=True,
93        capture_output=True,
94    )
95    if result.returncode != 0:
96        raise ConfigurationError(
97            f"{rev_id} is not a valid git reference.\n"
98            f"Command: {result.args}\n"
99            f"Stdout: {result.stdout}\n"
100            f"Stderr: {result.stderr}"
101        )
102    return result.stdout.strip()
103
104
105class StrEnum(Enum):
106    """Enum with members stored as strings."""
107
108    @staticmethod
109    def _generate_next_value_(name: str, start: int, count: int, last_values: object) -> str:
110        return name
111
112    def __str__(self) -> str:
113        """The string representation is the name of the member."""
114        return self.name
115
116
117class MesonArgs:
118    """Aggregate the arguments needed to build DPDK."""
119
120    _default_library: str
121
122    def __init__(self, default_library: str | None = None, **dpdk_args: str | bool):
123        """Initialize the meson arguments.
124
125        Args:
126            default_library: The default library type, Meson supports ``shared``, ``static`` and
127                ``both``. Defaults to :data:`None`, in which case the argument won't be used.
128            dpdk_args: The arguments found in ``meson_options.txt`` in root DPDK directory.
129                Do not use ``-D`` with them.
130
131        Example:
132            ::
133
134                meson_args = MesonArgs(enable_kmods=True).
135        """
136        self._default_library = f"--default-library={default_library}" if default_library else ""
137        self._dpdk_args = " ".join(
138            (
139                f"-D{dpdk_arg_name}={dpdk_arg_value}"
140                for dpdk_arg_name, dpdk_arg_value in dpdk_args.items()
141            )
142        )
143
144    def __str__(self) -> str:
145        """The actual args."""
146        return " ".join(f"{self._default_library} {self._dpdk_args}".split())
147
148
149class _TarCompressionFormat(StrEnum):
150    """Compression formats that tar can use.
151
152    Enum names are the shell compression commands
153    and Enum values are the associated file extensions.
154    """
155
156    gzip = "gz"
157    compress = "Z"
158    bzip2 = "bz2"
159    lzip = "lz"
160    lzma = "lzma"
161    lzop = "lzo"
162    xz = "xz"
163    zstd = "zst"
164
165
166class DPDKGitTarball:
167    """Compressed tarball of DPDK from the repository.
168
169    The class supports the :class:`os.PathLike` protocol,
170    which is used to get the Path of the tarball::
171
172        from pathlib import Path
173        tarball = DPDKGitTarball("HEAD", "output")
174        tarball_path = Path(tarball)
175    """
176
177    _git_ref: str
178    _tar_compression_format: _TarCompressionFormat
179    _tarball_dir: Path
180    _tarball_name: str
181    _tarball_path: Path | None
182
183    def __init__(
184        self,
185        git_ref: str,
186        output_dir: str,
187        tar_compression_format: _TarCompressionFormat = _TarCompressionFormat.xz,
188    ):
189        """Create the tarball during initialization.
190
191        The DPDK version is specified with `git_ref`. The tarball will be compressed with
192        `tar_compression_format`, which must be supported by the DTS execution environment.
193        The resulting tarball will be put into `output_dir`.
194
195        Args:
196            git_ref: A git commit ID, tag ID or tree ID.
197            output_dir: The directory where to put the resulting tarball.
198            tar_compression_format: The compression format to use.
199        """
200        self._git_ref = git_ref
201        self._tar_compression_format = tar_compression_format
202
203        self._tarball_dir = Path(output_dir, "tarball")
204
205        self._create_tarball_dir()
206
207        self._tarball_name = (
208            f"dpdk-tarball-{self._git_ref}.tar.{self._tar_compression_format.value}"
209        )
210        self._tarball_path = self._check_tarball_path()
211        if not self._tarball_path:
212            self._create_tarball()
213
214    def _create_tarball_dir(self) -> None:
215        os.makedirs(self._tarball_dir, exist_ok=True)
216
217    def _check_tarball_path(self) -> Path | None:
218        if self._tarball_name in os.listdir(self._tarball_dir):
219            return Path(self._tarball_dir, self._tarball_name)
220        return None
221
222    def _create_tarball(self) -> None:
223        self._tarball_path = Path(self._tarball_dir, self._tarball_name)
224
225        atexit.register(self._delete_tarball)
226
227        result = subprocess.run(
228            'git -C "$(git rev-parse --show-toplevel)" archive '
229            f'{self._git_ref} --prefix="dpdk-tarball-{self._git_ref + os.sep}" | '
230            f"{self._tar_compression_format} > {Path(self._tarball_path.absolute())}",
231            shell=True,
232            text=True,
233            capture_output=True,
234        )
235
236        if result.returncode != 0:
237            raise SubprocessError(
238                f"Git archive creation failed with exit code {result.returncode}.\n"
239                f"Command: {result.args}\n"
240                f"Stdout: {result.stdout}\n"
241                f"Stderr: {result.stderr}"
242            )
243
244        atexit.unregister(self._delete_tarball)
245
246    def _delete_tarball(self) -> None:
247        if self._tarball_path and os.path.exists(self._tarball_path):
248            os.remove(self._tarball_path)
249
250    def __fspath__(self) -> str:
251        """The os.PathLike protocol implementation."""
252        return str(self._tarball_path)
253
254
255class PacketProtocols(Flag):
256    """Flag specifying which protocols to use for packet generation."""
257
258    #:
259    IP = 1
260    #:
261    TCP = 2 | IP
262    #:
263    UDP = 4 | IP
264    #:
265    ALL = TCP | UDP
266
267
268def generate_random_packets(
269    number_of: int,
270    payload_size: int = 1500,
271    protocols: PacketProtocols = PacketProtocols.ALL,
272    ports_range: range = range(1024, 49152),
273    mtu: int = 1500,
274) -> list[Packet]:
275    """Generate a number of random packets.
276
277    The payload of the packets will consist of random bytes. If `payload_size` is too big, then the
278    maximum payload size allowed for the specific packet type is used. The size is calculated based
279    on the specified `mtu`, therefore it is essential that `mtu` is set correctly to match the MTU
280    of the port that will send out the generated packets.
281
282    If `protocols` has any L4 protocol enabled then all the packets are generated with any of
283    the specified L4 protocols chosen at random. If only :attr:`~PacketProtocols.IP` is set, then
284    only L3 packets are generated.
285
286    If L4 packets will be generated, then the TCP/UDP ports to be used will be chosen at random from
287    `ports_range`.
288
289    Args:
290        number_of: The number of packets to generate.
291        payload_size: The packet payload size to generate, capped based on `mtu`.
292        protocols: The protocols to use for the generated packets.
293        ports_range: The range of L4 port numbers to use. Used only if `protocols` has L4 protocols.
294        mtu: The MTU of the NIC port that will send out the generated packets.
295
296    Raises:
297        InternalError: If the `payload_size` is invalid.
298
299    Returns:
300        A list containing the randomly generated packets.
301    """
302    if payload_size < 0:
303        raise InternalError(f"An invalid payload_size of {payload_size} was given.")
304
305    l4_factories = []
306    if protocols & PacketProtocols.TCP:
307        l4_factories.append(TCP)
308    if protocols & PacketProtocols.UDP:
309        l4_factories.append(UDP)
310
311    def _make_packet() -> Packet:
312        packet = Ether()
313
314        if protocols & PacketProtocols.IP:
315            packet /= IP()
316
317        if len(l4_factories) > 0:
318            src_port, dst_port = random.choices(ports_range, k=2)
319            packet /= random.choice(l4_factories)(sport=src_port, dport=dst_port)
320
321        max_payload_size = mtu - len(packet)
322        usable_payload_size = payload_size if payload_size < max_payload_size else max_payload_size
323        return packet / random.randbytes(usable_payload_size)
324
325    return [_make_packet() for _ in range(number_of)]
326
327
328class MultiInheritanceBaseClass:
329    """A base class for classes utilizing multiple inheritance.
330
331    This class enables it's subclasses to support both single and multiple inheritance by acting as
332    a stopping point in the tree of calls to the constructors of superclasses. This class is able
333    to exist at the end of the Method Resolution Order (MRO) so that subclasses can call
334    :meth:`super.__init__` without repercussion.
335    """
336
337    def __init__(self, *args, **kwargs) -> None:
338        """Call the init method of :class:`object`."""
339        super().__init__()
340