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