xref: /dpdk/dts/framework/testbed_model/os_session.py (revision e3ab9dd5cd5d5e7cb117507ba9580dae9706c1f5)
1# SPDX-License-Identifier: BSD-3-Clause
2# Copyright(c) 2023 PANTHEON.tech s.r.o.
3# Copyright(c) 2023 University of New Hampshire
4# Copyright(c) 2024 Arm Limited
5
6"""OS-aware remote session.
7
8DPDK supports multiple different operating systems, meaning it can run on these different operating
9systems. This module defines the common API that OS-unaware layers use and translates the API into
10OS-aware calls/utility usage.
11
12Note:
13    Running commands with administrative privileges requires OS awareness. This is the only layer
14    that's aware of OS differences, so this is where non-privileged command get converted
15    to privileged commands.
16
17Example:
18    A user wishes to remove a directory on a remote :class:`~.sut_node.SutNode`.
19    The :class:`~.sut_node.SutNode` object isn't aware what OS the node is running - it delegates
20    the OS translation logic to :attr:`~.node.Node.main_session`. The SUT node calls
21    :meth:`~OSSession.remove_remote_dir` with a generic, OS-unaware path and
22    the :attr:`~.node.Node.main_session` translates that to ``rm -rf`` if the node's OS is Linux
23    and other commands for other OSs. It also translates the path to match the underlying OS.
24"""
25from abc import ABC, abstractmethod
26from collections.abc import Iterable
27from dataclasses import dataclass
28from pathlib import Path, PurePath, PurePosixPath
29
30from framework.config import Architecture, NodeConfiguration
31from framework.logger import DTSLogger
32from framework.remote_session import (
33    InteractiveRemoteSession,
34    RemoteSession,
35    create_interactive_session,
36    create_remote_session,
37)
38from framework.remote_session.remote_session import CommandResult
39from framework.settings import SETTINGS
40from framework.utils import MesonArgs, TarCompressionFormat
41
42from .cpu import LogicalCore
43from .port import Port
44
45
46@dataclass(slots=True, frozen=True)
47class OSSessionInfo:
48    """Supplemental OS session information.
49
50    Attributes:
51        os_name: The name of the running operating system of
52            the :class:`~framework.testbed_model.node.Node`.
53        os_version: The version of the running operating system of
54            the :class:`~framework.testbed_model.node.Node`.
55        kernel_version: The kernel version of the running operating system of
56            the :class:`~framework.testbed_model.node.Node`.
57    """
58
59    os_name: str
60    os_version: str
61    kernel_version: str
62
63
64class OSSession(ABC):
65    """OS-unaware to OS-aware translation API definition.
66
67    The OSSession classes create a remote session to a DTS node and implement OS specific
68    behavior. There a few control methods implemented by the base class, the rest need
69    to be implemented by subclasses.
70
71    Attributes:
72        name: The name of the session.
73        remote_session: The remote session maintaining the connection to the node.
74        interactive_session: The interactive remote session maintaining the connection to the node.
75    """
76
77    _config: NodeConfiguration
78    name: str
79    _logger: DTSLogger
80    remote_session: RemoteSession
81    interactive_session: InteractiveRemoteSession
82    hugepage_size: int
83
84    def __init__(
85        self,
86        node_config: NodeConfiguration,
87        name: str,
88        logger: DTSLogger,
89    ):
90        """Initialize the OS-aware session.
91
92        Connect to the node right away and also create an interactive remote session.
93
94        Args:
95            node_config: The test run configuration of the node to connect to.
96            name: The name of the session.
97            logger: The logger instance this session will use.
98        """
99        self.hugepage_size = 2048
100        self._config = node_config
101        self.name = name
102        self._logger = logger
103        self.remote_session = create_remote_session(node_config, name, logger)
104        self.interactive_session = create_interactive_session(node_config, logger)
105
106    def is_alive(self) -> bool:
107        """Check whether the underlying remote session is still responding."""
108        return self.remote_session.is_alive()
109
110    def send_command(
111        self,
112        command: str,
113        timeout: float = SETTINGS.timeout,
114        privileged: bool = False,
115        verify: bool = False,
116        env: dict | None = None,
117    ) -> CommandResult:
118        """An all-purpose API for OS-agnostic commands.
119
120        This can be used for an execution of a portable command that's executed the same way
121        on all operating systems, such as Python.
122
123        The :option:`--timeout` command line argument and the :envvar:`DTS_TIMEOUT`
124        environment variable configure the timeout of command execution.
125
126        Args:
127            command: The command to execute.
128            timeout: Wait at most this long in seconds for `command` execution to complete.
129            privileged: Whether to run the command with administrative privileges.
130            verify: If :data:`True`, will check the exit code of the command.
131            env: A dictionary with environment variables to be used with the command execution.
132
133        Raises:
134            RemoteCommandExecutionError: If verify is :data:`True` and the command failed.
135        """
136        if privileged:
137            command = self._get_privileged_command(command)
138
139        return self.remote_session.send_command(command, timeout, verify, env)
140
141    def close(self) -> None:
142        """Close the underlying remote session."""
143        self.remote_session.close()
144
145    @staticmethod
146    @abstractmethod
147    def _get_privileged_command(command: str) -> str:
148        """Modify the command so that it executes with administrative privileges.
149
150        Args:
151            command: The command to modify.
152
153        Returns:
154            The modified command that executes with administrative privileges.
155        """
156
157    @abstractmethod
158    def get_remote_tmp_dir(self) -> PurePath:
159        """Get the path of the temporary directory of the remote OS.
160
161        Returns:
162            The absolute path of the temporary directory.
163        """
164
165    @abstractmethod
166    def get_dpdk_build_env_vars(self, arch: Architecture) -> dict:
167        """Create extra environment variables needed for the target architecture.
168
169        Different architectures may require different configuration, such as setting 32-bit CFLAGS.
170
171        Returns:
172            A dictionary with keys as environment variables.
173        """
174
175    @abstractmethod
176    def join_remote_path(self, *args: str | PurePath) -> PurePath:
177        """Join path parts using the path separator that fits the remote OS.
178
179        Args:
180            args: Any number of paths to join.
181
182        Returns:
183            The resulting joined path.
184        """
185
186    @abstractmethod
187    def remote_path_exists(self, remote_path: str | PurePath) -> bool:
188        """Check whether `remote_path` exists on the remote system.
189
190        Args:
191            remote_path: The path to check.
192
193        Returns:
194            :data:`True` if the path exists, :data:`False` otherwise.
195        """
196
197    @abstractmethod
198    def copy_from(self, source_file: str | PurePath, destination_dir: str | Path) -> None:
199        """Copy a file from the remote node to the local filesystem.
200
201        Copy `source_file` from the remote node associated with this remote
202        session to `destination_dir` on the local filesystem.
203
204        Args:
205            source_file: The file on the remote node.
206            destination_dir: The directory path on the local filesystem where the `source_file`
207                will be saved.
208        """
209
210    @abstractmethod
211    def copy_to(self, source_file: str | Path, destination_dir: str | PurePath) -> None:
212        """Copy a file from local filesystem to the remote node.
213
214        Copy `source_file` from local filesystem to `destination_dir`
215        on the remote node associated with this remote session.
216
217        Args:
218            source_file: The file on the local filesystem.
219            destination_dir: The directory path on the remote Node where the `source_file`
220                will be saved.
221        """
222
223    @abstractmethod
224    def copy_dir_from(
225        self,
226        source_dir: str | PurePath,
227        destination_dir: str | Path,
228        compress_format: TarCompressionFormat = TarCompressionFormat.none,
229        exclude: str | list[str] | None = None,
230    ) -> None:
231        """Copy a directory from the remote node to the local filesystem.
232
233        Copy `source_dir` from the remote node associated with this remote session to
234        `destination_dir` on the local filesystem. The new local directory will be created
235        at `destination_dir` path.
236
237        Example:
238            source_dir = '/remote/path/to/source'
239            destination_dir = '/local/path/to/destination'
240            compress_format = TarCompressionFormat.xz
241
242            The method will:
243                1. Create a tarball from `source_dir`, resulting in:
244                    '/remote/path/to/source.tar.xz',
245                2. Copy '/remote/path/to/source.tar.xz' to
246                    '/local/path/to/destination/source.tar.xz',
247                3. Extract the contents of the tarball, resulting in:
248                    '/local/path/to/destination/source/',
249                4. Remove the tarball after extraction
250                    ('/local/path/to/destination/source.tar.xz').
251
252            Final Path Structure:
253                '/local/path/to/destination/source/'
254
255        Args:
256            source_dir: The directory on the remote node.
257            destination_dir: The directory path on the local filesystem.
258            compress_format: The compression format to use. Defaults to no compression.
259            exclude: Patterns for files or directories to exclude from the tarball.
260                These patterns are used with `tar`'s `--exclude` option.
261        """
262
263    @abstractmethod
264    def copy_dir_to(
265        self,
266        source_dir: str | Path,
267        destination_dir: str | PurePath,
268        compress_format: TarCompressionFormat = TarCompressionFormat.none,
269        exclude: str | list[str] | None = None,
270    ) -> None:
271        """Copy a directory from the local filesystem to the remote node.
272
273        Copy `source_dir` from the local filesystem to `destination_dir` on the remote node
274        associated with this remote session. The new remote directory will be created at
275        `destination_dir` path.
276
277        Example:
278            source_dir = '/local/path/to/source'
279            destination_dir = '/remote/path/to/destination'
280            compress_format = TarCompressionFormat.xz
281
282            The method will:
283                1. Create a tarball from `source_dir`, resulting in:
284                    '/local/path/to/source.tar.xz',
285                2. Copy '/local/path/to/source.tar.xz' to
286                    '/remote/path/to/destination/source.tar.xz',
287                3. Extract the contents of the tarball, resulting in:
288                    '/remote/path/to/destination/source/',
289                4. Remove the tarball after extraction
290                    ('/remote/path/to/destination/source.tar.xz').
291
292            Final Path Structure:
293                '/remote/path/to/destination/source/'
294
295        Args:
296            source_dir: The directory on the local filesystem.
297            destination_dir: The directory path on the remote node.
298            compress_format: The compression format to use. Defaults to no compression.
299            exclude: Patterns for files or directories to exclude from the tarball.
300                These patterns are used with `fnmatch.fnmatch` to filter out files.
301        """
302
303    @abstractmethod
304    def remove_remote_file(self, remote_file_path: str | PurePath, force: bool = True) -> None:
305        """Remove remote file, by default remove forcefully.
306
307        Args:
308            remote_file_path: The file path to remove.
309            force: If :data:`True`, ignore all warnings and try to remove at all costs.
310        """
311
312    @abstractmethod
313    def remove_remote_dir(
314        self,
315        remote_dir_path: str | PurePath,
316        recursive: bool = True,
317        force: bool = True,
318    ) -> None:
319        """Remove remote directory, by default remove recursively and forcefully.
320
321        Args:
322            remote_dir_path: The directory path to remove.
323            recursive: If :data:`True`, also remove all contents inside the directory.
324            force: If :data:`True`, ignore all warnings and try to remove at all costs.
325        """
326
327    @abstractmethod
328    def create_remote_tarball(
329        self,
330        remote_dir_path: str | PurePath,
331        compress_format: TarCompressionFormat = TarCompressionFormat.none,
332        exclude: str | list[str] | None = None,
333    ) -> PurePosixPath:
334        """Create a tarball from the contents of the specified remote directory.
335
336        This method creates a tarball containing all files and directories
337        within `remote_dir_path`. The tarball will be saved in the directory of
338        `remote_dir_path` and will be named based on `remote_dir_path`.
339
340        Args:
341            remote_dir_path: The directory path on the remote node.
342            compress_format: The compression format to use. Defaults to no compression.
343            exclude: Patterns for files or directories to exclude from the tarball.
344                These patterns are used with `tar`'s `--exclude` option.
345
346        Returns:
347            The path to the created tarball on the remote node.
348        """
349
350    @abstractmethod
351    def extract_remote_tarball(
352        self,
353        remote_tarball_path: str | PurePath,
354        expected_dir: str | PurePath | None = None,
355    ) -> None:
356        """Extract remote tarball in its remote directory.
357
358        Args:
359            remote_tarball_path: The tarball path on the remote node.
360            expected_dir: If non-empty, check whether `expected_dir` exists after extracting
361                the archive.
362        """
363
364    @abstractmethod
365    def is_remote_dir(self, remote_path: PurePath) -> bool:
366        """Check if the `remote_path` is a directory.
367
368        Args:
369            remote_tarball_path: The path to the remote tarball.
370
371        Returns:
372            If :data:`True` the `remote_path` is a directory, otherwise :data:`False`.
373        """
374
375    @abstractmethod
376    def is_remote_tarfile(self, remote_tarball_path: PurePath) -> bool:
377        """Check if the `remote_tarball_path` is a tar archive.
378
379        Args:
380            remote_tarball_path: The path to the remote tarball.
381
382        Returns:
383            If :data:`True` the `remote_tarball_path` is a tar archive, otherwise :data:`False`.
384        """
385
386    @abstractmethod
387    def get_tarball_top_dir(
388        self, remote_tarball_path: str | PurePath
389    ) -> str | PurePosixPath | None:
390        """Get the top directory of the remote tarball.
391
392        Examines the contents of a tarball located at the given `remote_tarball_path` and
393        determines the top-level directory. If all files and directories in the tarball share
394        the same top-level directory, that directory name is returned. If the tarball contains
395        multiple top-level directories or is empty, the method return None.
396
397        Args:
398            remote_tarball_path: The path to the remote tarball.
399
400        Returns:
401            The top directory of the tarball. If there are multiple top directories
402            or the tarball is empty, returns :data:`None`.
403        """
404
405    @abstractmethod
406    def build_dpdk(
407        self,
408        env_vars: dict,
409        meson_args: MesonArgs,
410        remote_dpdk_dir: str | PurePath,
411        remote_dpdk_build_dir: str | PurePath,
412        rebuild: bool = False,
413        timeout: float = SETTINGS.compile_timeout,
414    ) -> None:
415        """Build DPDK on the remote node.
416
417        An extracted DPDK tarball must be present on the node. The build consists of two steps::
418
419            meson setup <meson args> remote_dpdk_dir remote_dpdk_build_dir
420            ninja -C remote_dpdk_build_dir
421
422        The :option:`--compile-timeout` command line argument and the :envvar:`DTS_COMPILE_TIMEOUT`
423        environment variable configure the timeout of DPDK build.
424
425        Args:
426            env_vars: Use these environment variables when building DPDK.
427            meson_args: Use these meson arguments when building DPDK.
428            remote_dpdk_dir: The directory on the remote node where DPDK will be built.
429            remote_dpdk_build_dir: The target build directory on the remote node.
430            rebuild: If :data:`True`, do a subsequent build with ``meson configure`` instead
431                of ``meson setup``.
432            timeout: Wait at most this long in seconds for the build execution to complete.
433        """
434
435    @abstractmethod
436    def get_dpdk_version(self, version_path: str | PurePath) -> str:
437        """Inspect the DPDK version on the remote node.
438
439        Args:
440            version_path: The path to the VERSION file containing the DPDK version.
441
442        Returns:
443            The DPDK version.
444        """
445
446    @abstractmethod
447    def get_remote_cpus(self, use_first_core: bool) -> list[LogicalCore]:
448        r"""Get the list of :class:`~.cpu.LogicalCore`\s on the remote node.
449
450        Args:
451            use_first_core: If :data:`False`, the first physical core won't be used.
452
453        Returns:
454            The logical cores present on the node.
455        """
456
457    @abstractmethod
458    def kill_cleanup_dpdk_apps(self, dpdk_prefix_list: Iterable[str]) -> None:
459        """Kill and cleanup all DPDK apps.
460
461        Args:
462            dpdk_prefix_list: Kill all apps identified by `dpdk_prefix_list`.
463                If `dpdk_prefix_list` is empty, attempt to find running DPDK apps to kill and clean.
464        """
465
466    @abstractmethod
467    def get_dpdk_file_prefix(self, dpdk_prefix: str) -> str:
468        """Make OS-specific modification to the DPDK file prefix.
469
470        Args:
471           dpdk_prefix: The OS-unaware file prefix.
472
473        Returns:
474            The OS-specific file prefix.
475        """
476
477    @abstractmethod
478    def setup_hugepages(self, number_of: int, hugepage_size: int, force_first_numa: bool) -> None:
479        """Configure hugepages on the node.
480
481        Get the node's Hugepage Size, configure the specified count of hugepages
482        if needed and mount the hugepages if needed.
483
484        Args:
485            number_of: Configure this many hugepages.
486            hugepage_size: Configure hugepages of this size.
487            force_first_numa:  If :data:`True`, configure just on the first numa node.
488        """
489
490    @abstractmethod
491    def get_compiler_version(self, compiler_name: str) -> str:
492        """Get installed version of compiler used for DPDK.
493
494        Args:
495            compiler_name: The name of the compiler executable.
496
497        Returns:
498            The compiler's version.
499        """
500
501    @abstractmethod
502    def get_node_info(self) -> OSSessionInfo:
503        """Collect additional information about the node.
504
505        Returns:
506            Node information.
507        """
508
509    @abstractmethod
510    def update_ports(self, ports: list[Port]) -> None:
511        """Get additional information about ports from the operating system and update them.
512
513        The additional information is:
514
515            * Logical name (e.g. ``enp7s0``) if applicable,
516            * Mac address.
517
518        Args:
519            ports: The ports to update.
520        """
521
522    @abstractmethod
523    def configure_port_mtu(self, mtu: int, port: Port) -> None:
524        """Configure `mtu` on `port`.
525
526        Args:
527            mtu: Desired MTU value.
528            port: Port to set `mtu` on.
529        """
530