xref: /dpdk/dts/framework/testbed_model/os_session.py (revision e9fd1ebf981f361844aea9ec94e17f4bda5e1479)
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
5"""OS-aware remote session.
6
7DPDK supports multiple different operating systems, meaning it can run on these different operating
8systems. This module defines the common API that OS-unaware layers use and translates the API into
9OS-aware calls/utility usage.
10
11Note:
12    Running commands with administrative privileges requires OS awareness. This is the only layer
13    that's aware of OS differences, so this is where non-privileged command get converted
14    to privileged commands.
15
16Example:
17    A user wishes to remove a directory on a remote :class:`~.sut_node.SutNode`.
18    The :class:`~.sut_node.SutNode` object isn't aware what OS the node is running - it delegates
19    the OS translation logic to :attr:`~.node.Node.main_session`. The SUT node calls
20    :meth:`~OSSession.remove_remote_dir` with a generic, OS-unaware path and
21    the :attr:`~.node.Node.main_session` translates that to ``rm -rf`` if the node's OS is Linux
22    and other commands for other OSs. It also translates the path to match the underlying OS.
23"""
24
25from abc import ABC, abstractmethod
26from collections.abc import Iterable
27from ipaddress import IPv4Interface, IPv6Interface
28from pathlib import PurePath
29from typing import Type, TypeVar, Union
30
31from framework.config import Architecture, NodeConfiguration, NodeInfo
32from framework.logger import DTSLOG
33from framework.remote_session import (
34    CommandResult,
35    InteractiveRemoteSession,
36    InteractiveShell,
37    RemoteSession,
38    create_interactive_session,
39    create_remote_session,
40)
41from framework.settings import SETTINGS
42from framework.utils import MesonArgs
43
44from .cpu import LogicalCore
45from .port import Port
46
47InteractiveShellType = TypeVar("InteractiveShellType", bound=InteractiveShell)
48
49
50class OSSession(ABC):
51    """OS-unaware to OS-aware translation API definition.
52
53    The OSSession classes create a remote session to a DTS node and implement OS specific
54    behavior. There a few control methods implemented by the base class, the rest need
55    to be implemented by subclasses.
56
57    Attributes:
58        name: The name of the session.
59        remote_session: The remote session maintaining the connection to the node.
60        interactive_session: The interactive remote session maintaining the connection to the node.
61    """
62
63    _config: NodeConfiguration
64    name: str
65    _logger: DTSLOG
66    remote_session: RemoteSession
67    interactive_session: InteractiveRemoteSession
68
69    def __init__(
70        self,
71        node_config: NodeConfiguration,
72        name: str,
73        logger: DTSLOG,
74    ):
75        """Initialize the OS-aware session.
76
77        Connect to the node right away and also create an interactive remote session.
78
79        Args:
80            node_config: The test run configuration of the node to connect to.
81            name: The name of the session.
82            logger: The logger instance this session will use.
83        """
84        self._config = node_config
85        self.name = name
86        self._logger = logger
87        self.remote_session = create_remote_session(node_config, name, logger)
88        self.interactive_session = create_interactive_session(node_config, logger)
89
90    def close(self, force: bool = False) -> None:
91        """Close the underlying remote session.
92
93        Args:
94            force: Force the closure of the connection.
95        """
96        self.remote_session.close(force)
97
98    def is_alive(self) -> bool:
99        """Check whether the underlying remote session is still responding."""
100        return self.remote_session.is_alive()
101
102    def send_command(
103        self,
104        command: str,
105        timeout: float = SETTINGS.timeout,
106        privileged: bool = False,
107        verify: bool = False,
108        env: dict | None = None,
109    ) -> CommandResult:
110        """An all-purpose API for OS-agnostic commands.
111
112        This can be used for an execution of a portable command that's executed the same way
113        on all operating systems, such as Python.
114
115        The :option:`--timeout` command line argument and the :envvar:`DTS_TIMEOUT`
116        environment variable configure the timeout of command execution.
117
118        Args:
119            command: The command to execute.
120            timeout: Wait at most this long in seconds for `command` execution to complete.
121            privileged: Whether to run the command with administrative privileges.
122            verify: If :data:`True`, will check the exit code of the command.
123            env: A dictionary with environment variables to be used with the command execution.
124
125        Raises:
126            RemoteCommandExecutionError: If verify is :data:`True` and the command failed.
127        """
128        if privileged:
129            command = self._get_privileged_command(command)
130
131        return self.remote_session.send_command(command, timeout, verify, env)
132
133    def create_interactive_shell(
134        self,
135        shell_cls: Type[InteractiveShellType],
136        timeout: float,
137        privileged: bool,
138        app_args: str,
139    ) -> InteractiveShellType:
140        """Factory for interactive session handlers.
141
142        Instantiate `shell_cls` according to the remote OS specifics.
143
144        Args:
145            shell_cls: The class of the shell.
146            timeout: Timeout for reading output from the SSH channel. If you are
147                reading from the buffer and don't receive any data within the timeout
148                it will throw an error.
149            privileged: Whether to run the shell with administrative privileges.
150            app_args: The arguments to be passed to the application.
151
152        Returns:
153            An instance of the desired interactive application shell.
154        """
155        return shell_cls(
156            self.interactive_session.session,
157            self._logger,
158            self._get_privileged_command if privileged else None,
159            app_args,
160            timeout,
161        )
162
163    @staticmethod
164    @abstractmethod
165    def _get_privileged_command(command: str) -> str:
166        """Modify the command so that it executes with administrative privileges.
167
168        Args:
169            command: The command to modify.
170
171        Returns:
172            The modified command that executes with administrative privileges.
173        """
174
175    @abstractmethod
176    def guess_dpdk_remote_dir(self, remote_dir: str | PurePath) -> PurePath:
177        """Try to find DPDK directory in `remote_dir`.
178
179        The directory is the one which is created after the extraction of the tarball. The files
180        are usually extracted into a directory starting with ``dpdk-``.
181
182        Returns:
183            The absolute path of the DPDK remote directory, empty path if not found.
184        """
185
186    @abstractmethod
187    def get_remote_tmp_dir(self) -> PurePath:
188        """Get the path of the temporary directory of the remote OS.
189
190        Returns:
191            The absolute path of the temporary directory.
192        """
193
194    @abstractmethod
195    def get_dpdk_build_env_vars(self, arch: Architecture) -> dict:
196        """Create extra environment variables needed for the target architecture.
197
198        Different architectures may require different configuration, such as setting 32-bit CFLAGS.
199
200        Returns:
201            A dictionary with keys as environment variables.
202        """
203
204    @abstractmethod
205    def join_remote_path(self, *args: str | PurePath) -> PurePath:
206        """Join path parts using the path separator that fits the remote OS.
207
208        Args:
209            args: Any number of paths to join.
210
211        Returns:
212            The resulting joined path.
213        """
214
215    @abstractmethod
216    def copy_from(
217        self,
218        source_file: str | PurePath,
219        destination_file: str | PurePath,
220    ) -> None:
221        """Copy a file from the remote node to the local filesystem.
222
223        Copy `source_file` from the remote node associated with this remote
224        session to `destination_file` on the local filesystem.
225
226        Args:
227            source_file: the file on the remote node.
228            destination_file: a file or directory path on the local filesystem.
229        """
230
231    @abstractmethod
232    def copy_to(
233        self,
234        source_file: str | PurePath,
235        destination_file: str | PurePath,
236    ) -> None:
237        """Copy a file from local filesystem to the remote node.
238
239        Copy `source_file` from local filesystem to `destination_file`
240        on the remote node associated with this remote session.
241
242        Args:
243            source_file: the file on the local filesystem.
244            destination_file: a file or directory path on the remote node.
245        """
246
247    @abstractmethod
248    def remove_remote_dir(
249        self,
250        remote_dir_path: str | PurePath,
251        recursive: bool = True,
252        force: bool = True,
253    ) -> None:
254        """Remove remote directory, by default remove recursively and forcefully.
255
256        Args:
257            remote_dir_path: The path of the directory to remove.
258            recursive: If :data:`True`, also remove all contents inside the directory.
259            force: If :data:`True`, ignore all warnings and try to remove at all costs.
260        """
261
262    @abstractmethod
263    def extract_remote_tarball(
264        self,
265        remote_tarball_path: str | PurePath,
266        expected_dir: str | PurePath | None = None,
267    ) -> None:
268        """Extract remote tarball in its remote directory.
269
270        Args:
271            remote_tarball_path: The path of the tarball on the remote node.
272            expected_dir: If non-empty, check whether `expected_dir` exists after extracting
273                the archive.
274        """
275
276    @abstractmethod
277    def build_dpdk(
278        self,
279        env_vars: dict,
280        meson_args: MesonArgs,
281        remote_dpdk_dir: str | PurePath,
282        remote_dpdk_build_dir: str | PurePath,
283        rebuild: bool = False,
284        timeout: float = SETTINGS.compile_timeout,
285    ) -> None:
286        """Build DPDK on the remote node.
287
288        An extracted DPDK tarball must be present on the node. The build consists of two steps::
289
290            meson setup <meson args> remote_dpdk_dir remote_dpdk_build_dir
291            ninja -C remote_dpdk_build_dir
292
293        The :option:`--compile-timeout` command line argument and the :envvar:`DTS_COMPILE_TIMEOUT`
294        environment variable configure the timeout of DPDK build.
295
296        Args:
297            env_vars: Use these environment variables when building DPDK.
298            meson_args: Use these meson arguments when building DPDK.
299            remote_dpdk_dir: The directory on the remote node where DPDK will be built.
300            remote_dpdk_build_dir: The target build directory on the remote node.
301            rebuild: If :data:`True`, do a subsequent build with ``meson configure`` instead
302                of ``meson setup``.
303            timeout: Wait at most this long in seconds for the build execution to complete.
304        """
305
306    @abstractmethod
307    def get_dpdk_version(self, version_path: str | PurePath) -> str:
308        """Inspect the DPDK version on the remote node.
309
310        Args:
311            version_path: The path to the VERSION file containing the DPDK version.
312
313        Returns:
314            The DPDK version.
315        """
316
317    @abstractmethod
318    def get_remote_cpus(self, use_first_core: bool) -> list[LogicalCore]:
319        r"""Get the list of :class:`~.cpu.LogicalCore`\s on the remote node.
320
321        Args:
322            use_first_core: If :data:`False`, the first physical core won't be used.
323
324        Returns:
325            The logical cores present on the node.
326        """
327
328    @abstractmethod
329    def kill_cleanup_dpdk_apps(self, dpdk_prefix_list: Iterable[str]) -> None:
330        """Kill and cleanup all DPDK apps.
331
332        Args:
333            dpdk_prefix_list: Kill all apps identified by `dpdk_prefix_list`.
334                If `dpdk_prefix_list` is empty, attempt to find running DPDK apps to kill and clean.
335        """
336
337    @abstractmethod
338    def get_dpdk_file_prefix(self, dpdk_prefix: str) -> str:
339        """Make OS-specific modification to the DPDK file prefix.
340
341        Args:
342           dpdk_prefix: The OS-unaware file prefix.
343
344        Returns:
345            The OS-specific file prefix.
346        """
347
348    @abstractmethod
349    def setup_hugepages(self, hugepage_count: int, force_first_numa: bool) -> None:
350        """Configure hugepages on the node.
351
352        Get the node's Hugepage Size, configure the specified count of hugepages
353        if needed and mount the hugepages if needed.
354
355        Args:
356            hugepage_count: Configure this many hugepages.
357            force_first_numa:  If :data:`True`, configure hugepages just on the first numa node.
358        """
359
360    @abstractmethod
361    def get_compiler_version(self, compiler_name: str) -> str:
362        """Get installed version of compiler used for DPDK.
363
364        Args:
365            compiler_name: The name of the compiler executable.
366
367        Returns:
368            The compiler's version.
369        """
370
371    @abstractmethod
372    def get_node_info(self) -> NodeInfo:
373        """Collect additional information about the node.
374
375        Returns:
376            Node information.
377        """
378
379    @abstractmethod
380    def update_ports(self, ports: list[Port]) -> None:
381        """Get additional information about ports from the operating system and update them.
382
383        The additional information is:
384
385            * Logical name (e.g. ``enp7s0``) if applicable,
386            * Mac address.
387
388        Args:
389            ports: The ports to update.
390        """
391
392    @abstractmethod
393    def configure_port_state(self, port: Port, enable: bool) -> None:
394        """Enable/disable `port` in the operating system.
395
396        Args:
397            port: The port to configure.
398            enable: If :data:`True`, enable the port, otherwise shut it down.
399        """
400
401    @abstractmethod
402    def configure_port_ip_address(
403        self,
404        address: Union[IPv4Interface, IPv6Interface],
405        port: Port,
406        delete: bool,
407    ) -> None:
408        """Configure an IP address on `port` in the operating system.
409
410        Args:
411            address: The address to configure.
412            port: The port to configure.
413            delete: If :data:`True`, remove the IP address, otherwise configure it.
414        """
415
416    @abstractmethod
417    def configure_ipv4_forwarding(self, enable: bool) -> None:
418        """Enable IPv4 forwarding in the operating system.
419
420        Args:
421            enable: If :data:`True`, enable the forwarding, otherwise disable it.
422        """
423