xref: /dpdk/dts/framework/testbed_model/posix_session.py (revision b935bdc3da26ab86ec775dfad3aa63a1a61f5667)
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"""POSIX compliant OS translator.
6
7Translates OS-unaware calls into POSIX compliant calls/utilities. POSIX is a set of standards
8for portability between Unix operating systems which not all Linux distributions
9(or the tools most frequently bundled with said distributions) adhere to. Most of Linux
10distributions are mostly compliant though.
11This intermediate module implements the common parts of mostly POSIX compliant distributions.
12"""
13
14import re
15from collections.abc import Iterable
16from pathlib import Path, PurePath, PurePosixPath
17
18from framework.config import Architecture
19from framework.exception import DPDKBuildError, RemoteCommandExecutionError
20from framework.settings import SETTINGS
21from framework.utils import (
22    MesonArgs,
23    TarCompressionFormat,
24    convert_to_list_of_string,
25    create_tarball,
26    extract_tarball,
27)
28
29from .os_session import OSSession, OSSessionInfo
30
31
32class PosixSession(OSSession):
33    """An intermediary class implementing the POSIX standard."""
34
35    @staticmethod
36    def combine_short_options(**opts: bool) -> str:
37        """Combine shell options into one argument.
38
39        These are options such as ``-x``, ``-v``, ``-f`` which are combined into ``-xvf``.
40
41        Args:
42            opts: The keys are option names (usually one letter) and the bool values indicate
43                whether to include the option in the resulting argument.
44
45        Returns:
46            The options combined into one argument.
47        """
48        ret_opts = ""
49        for opt, include in opts.items():
50            if include:
51                ret_opts = f"{ret_opts}{opt}"
52
53        if ret_opts:
54            ret_opts = f" -{ret_opts}"
55
56        return ret_opts
57
58    def guess_dpdk_remote_dir(self, remote_dir: str | PurePath) -> PurePosixPath:
59        """Overrides :meth:`~.os_session.OSSession.guess_dpdk_remote_dir`."""
60        remote_guess = self.join_remote_path(remote_dir, "dpdk-*")
61        result = self.send_command(f"ls -d {remote_guess} | tail -1")
62        return PurePosixPath(result.stdout)
63
64    def get_remote_tmp_dir(self) -> PurePosixPath:
65        """Overrides :meth:`~.os_session.OSSession.get_remote_tmp_dir`."""
66        return PurePosixPath("/tmp")
67
68    def get_dpdk_build_env_vars(self, arch: Architecture) -> dict:
69        """Overrides :meth:`~.os_session.OSSession.get_dpdk_build_env_vars`.
70
71        Supported architecture: ``i686``.
72        """
73        env_vars = {}
74        if arch == Architecture.i686:
75            # find the pkg-config path and store it in PKG_CONFIG_LIBDIR
76            out = self.send_command("find /usr -type d -name pkgconfig")
77            pkg_path = ""
78            res_path = out.stdout.split("\r\n")
79            for cur_path in res_path:
80                if "i386" in cur_path:
81                    pkg_path = cur_path
82                    break
83            assert pkg_path != "", "i386 pkg-config path not found"
84
85            env_vars["CFLAGS"] = "-m32"
86            env_vars["PKG_CONFIG_LIBDIR"] = pkg_path
87
88        return env_vars
89
90    def join_remote_path(self, *args: str | PurePath) -> PurePosixPath:
91        """Overrides :meth:`~.os_session.OSSession.join_remote_path`."""
92        return PurePosixPath(*args)
93
94    def remote_path_exists(self, remote_path: str | PurePath) -> bool:
95        """Overrides :meth:`~.os_session.OSSession.remote_path_exists`."""
96        result = self.send_command(f"test -e {remote_path}")
97        return not result.return_code
98
99    def copy_from(self, source_file: str | PurePath, destination_dir: str | Path) -> None:
100        """Overrides :meth:`~.os_session.OSSession.copy_from`."""
101        self.remote_session.copy_from(source_file, destination_dir)
102
103    def copy_to(self, source_file: str | Path, destination_dir: str | PurePath) -> None:
104        """Overrides :meth:`~.os_session.OSSession.copy_to`."""
105        self.remote_session.copy_to(source_file, destination_dir)
106
107    def copy_dir_from(
108        self,
109        source_dir: str | PurePath,
110        destination_dir: str | Path,
111        compress_format: TarCompressionFormat = TarCompressionFormat.none,
112        exclude: str | list[str] | None = None,
113    ) -> None:
114        """Overrides :meth:`~.os_session.OSSession.copy_dir_from`."""
115        source_dir = PurePath(source_dir)
116        remote_tarball_path = self.create_remote_tarball(source_dir, compress_format, exclude)
117
118        self.copy_from(remote_tarball_path, destination_dir)
119        self.remove_remote_file(remote_tarball_path)
120
121        tarball_path = Path(destination_dir, f"{source_dir.name}.{compress_format.extension}")
122        extract_tarball(tarball_path)
123        tarball_path.unlink()
124
125    def copy_dir_to(
126        self,
127        source_dir: str | Path,
128        destination_dir: str | PurePath,
129        compress_format: TarCompressionFormat = TarCompressionFormat.none,
130        exclude: str | list[str] | None = None,
131    ) -> None:
132        """Overrides :meth:`~.os_session.OSSession.copy_dir_to`."""
133        source_dir = Path(source_dir)
134        tarball_path = create_tarball(source_dir, compress_format, exclude=exclude)
135        self.copy_to(tarball_path, destination_dir)
136        tarball_path.unlink()
137
138        remote_tar_path = self.join_remote_path(
139            destination_dir, f"{source_dir.name}.{compress_format.extension}"
140        )
141        self.extract_remote_tarball(remote_tar_path)
142        self.remove_remote_file(remote_tar_path)
143
144    def remove_remote_file(self, remote_file_path: str | PurePath, force: bool = True) -> None:
145        """Overrides :meth:`~.os_session.OSSession.remove_remote_dir`."""
146        opts = PosixSession.combine_short_options(f=force)
147        self.send_command(f"rm{opts} {remote_file_path}")
148
149    def remove_remote_dir(
150        self,
151        remote_dir_path: str | PurePath,
152        recursive: bool = True,
153        force: bool = True,
154    ) -> None:
155        """Overrides :meth:`~.os_session.OSSession.remove_remote_dir`."""
156        opts = PosixSession.combine_short_options(r=recursive, f=force)
157        self.send_command(f"rm{opts} {remote_dir_path}")
158
159    def create_remote_tarball(
160        self,
161        remote_dir_path: str | PurePath,
162        compress_format: TarCompressionFormat = TarCompressionFormat.none,
163        exclude: str | list[str] | None = None,
164    ) -> PurePosixPath:
165        """Overrides :meth:`~.os_session.OSSession.create_remote_tarball`."""
166
167        def generate_tar_exclude_args(exclude_patterns) -> str:
168            """Generate args to exclude patterns when creating a tarball.
169
170            Args:
171                exclude_patterns: Patterns for files or directories to exclude from the tarball.
172                    These patterns are used with `tar`'s `--exclude` option.
173
174            Returns:
175                The generated string args to exclude the specified patterns.
176            """
177            if exclude_patterns:
178                exclude_patterns = convert_to_list_of_string(exclude_patterns)
179                return "".join([f" --exclude={pattern}" for pattern in exclude_patterns])
180            return ""
181
182        posix_remote_dir_path = PurePosixPath(remote_dir_path)
183        target_tarball_path = PurePosixPath(f"{remote_dir_path}.{compress_format.extension}")
184
185        self.send_command(
186            f"tar caf {target_tarball_path}{generate_tar_exclude_args(exclude)} "
187            f"-C {posix_remote_dir_path.parent} {posix_remote_dir_path.name}",
188            60,
189        )
190
191        return target_tarball_path
192
193    def extract_remote_tarball(
194        self, remote_tarball_path: str | PurePath, expected_dir: str | PurePath | None = None
195    ) -> None:
196        """Overrides :meth:`~.os_session.OSSession.extract_remote_tarball`."""
197        self.send_command(
198            f"tar xfm {remote_tarball_path} -C {PurePosixPath(remote_tarball_path).parent}",
199            60,
200        )
201        if expected_dir:
202            self.send_command(f"ls {expected_dir}", verify=True)
203
204    def is_remote_dir(self, remote_path: PurePath) -> bool:
205        """Overrides :meth:`~.os_session.OSSession.is_remote_dir`."""
206        result = self.send_command(f"test -d {remote_path}")
207        return not result.return_code
208
209    def is_remote_tarfile(self, remote_tarball_path: PurePath) -> bool:
210        """Overrides :meth:`~.os_session.OSSession.is_remote_tarfile`."""
211        result = self.send_command(f"tar -tvf {remote_tarball_path}")
212        return not result.return_code
213
214    def get_tarball_top_dir(
215        self, remote_tarball_path: str | PurePath
216    ) -> str | PurePosixPath | None:
217        """Overrides :meth:`~.os_session.OSSession.get_tarball_top_dir`."""
218        members = self.send_command(f"tar tf {remote_tarball_path}").stdout.split()
219
220        top_dirs = []
221        for member in members:
222            parts_of_member = PurePosixPath(member).parts
223            if parts_of_member:
224                top_dirs.append(parts_of_member[0])
225
226        if len(set(top_dirs)) == 1:
227            return top_dirs[0]
228        return None
229
230    def build_dpdk(
231        self,
232        env_vars: dict,
233        meson_args: MesonArgs,
234        remote_dpdk_dir: str | PurePath,
235        remote_dpdk_build_dir: str | PurePath,
236        rebuild: bool = False,
237        timeout: float = SETTINGS.compile_timeout,
238    ) -> None:
239        """Overrides :meth:`~.os_session.OSSession.build_dpdk`."""
240        try:
241            if rebuild:
242                # reconfigure, then build
243                self._logger.info("Reconfiguring DPDK build.")
244                self.send_command(
245                    f"meson configure {meson_args} {remote_dpdk_build_dir}",
246                    timeout,
247                    verify=True,
248                    env=env_vars,
249                )
250            else:
251                # fresh build - remove target dir first, then build from scratch
252                self._logger.info("Configuring DPDK build from scratch.")
253                self.remove_remote_dir(remote_dpdk_build_dir)
254                self.send_command(
255                    f"meson setup {meson_args} {remote_dpdk_dir} {remote_dpdk_build_dir}",
256                    timeout,
257                    verify=True,
258                    env=env_vars,
259                )
260
261            self._logger.info("Building DPDK.")
262            self.send_command(
263                f"ninja -C {remote_dpdk_build_dir}", timeout, verify=True, env=env_vars
264            )
265        except RemoteCommandExecutionError as e:
266            raise DPDKBuildError(f"DPDK build failed when doing '{e.command}'.")
267
268    def get_dpdk_version(self, build_dir: str | PurePath) -> str:
269        """Overrides :meth:`~.os_session.OSSession.get_dpdk_version`."""
270        out = self.send_command(f"cat {self.join_remote_path(build_dir, 'VERSION')}", verify=True)
271        return out.stdout
272
273    def kill_cleanup_dpdk_apps(self, dpdk_prefix_list: Iterable[str]) -> None:
274        """Overrides :meth:`~.os_session.OSSession.kill_cleanup_dpdk_apps`."""
275        self._logger.info("Cleaning up DPDK apps.")
276        dpdk_runtime_dirs = self._get_dpdk_runtime_dirs(dpdk_prefix_list)
277        if dpdk_runtime_dirs:
278            # kill and cleanup only if DPDK is running
279            dpdk_pids = self._get_dpdk_pids(dpdk_runtime_dirs)
280            for dpdk_pid in dpdk_pids:
281                self.send_command(f"kill -9 {dpdk_pid}", 20)
282            self._check_dpdk_hugepages(dpdk_runtime_dirs)
283            self._remove_dpdk_runtime_dirs(dpdk_runtime_dirs)
284
285    def _get_dpdk_runtime_dirs(self, dpdk_prefix_list: Iterable[str]) -> list[PurePosixPath]:
286        """Find runtime directories DPDK apps are currently using.
287
288        Args:
289              dpdk_prefix_list: The prefixes DPDK apps were started with.
290
291        Returns:
292            The paths of DPDK apps' runtime dirs.
293        """
294        prefix = PurePosixPath("/var", "run", "dpdk")
295        if not dpdk_prefix_list:
296            remote_prefixes = self._list_remote_dirs(prefix)
297            if not remote_prefixes:
298                dpdk_prefix_list = []
299            else:
300                dpdk_prefix_list = remote_prefixes
301
302        return [PurePosixPath(prefix, dpdk_prefix) for dpdk_prefix in dpdk_prefix_list]
303
304    def _list_remote_dirs(self, remote_path: str | PurePath) -> list[str] | None:
305        """Contents of remote_path.
306
307        Args:
308            remote_path: List the contents of this path.
309
310        Returns:
311            The contents of remote_path. If remote_path doesn't exist, return None.
312        """
313        out = self.send_command(f"ls -l {remote_path} | awk '/^d/ {{print $NF}}'").stdout
314        if "No such file or directory" in out:
315            return None
316        else:
317            return out.splitlines()
318
319    def _get_dpdk_pids(self, dpdk_runtime_dirs: Iterable[str | PurePath]) -> list[int]:
320        """Find PIDs of running DPDK apps.
321
322        Look at each "config" file found in dpdk_runtime_dirs and find the PIDs of processes
323        that opened those file.
324
325        Args:
326            dpdk_runtime_dirs: The paths of DPDK apps' runtime dirs.
327
328        Returns:
329            The PIDs of running DPDK apps.
330        """
331        pids = []
332        pid_regex = r"p(\d+)"
333        for dpdk_runtime_dir in dpdk_runtime_dirs:
334            dpdk_config_file = PurePosixPath(dpdk_runtime_dir, "config")
335            if self.remote_path_exists(dpdk_config_file):
336                out = self.send_command(f"lsof -Fp {dpdk_config_file}").stdout
337                if out and "No such file or directory" not in out:
338                    for out_line in out.splitlines():
339                        match = re.match(pid_regex, out_line)
340                        if match:
341                            pids.append(int(match.group(1)))
342        return pids
343
344    def _check_dpdk_hugepages(self, dpdk_runtime_dirs: Iterable[str | PurePath]) -> None:
345        """Check there aren't any leftover hugepages.
346
347        If any hugepages are found, emit a warning. The hugepages are investigated in the
348        "hugepage_info" file of dpdk_runtime_dirs.
349
350        Args:
351            dpdk_runtime_dirs: The paths of DPDK apps' runtime dirs.
352        """
353        for dpdk_runtime_dir in dpdk_runtime_dirs:
354            hugepage_info = PurePosixPath(dpdk_runtime_dir, "hugepage_info")
355            if self.remote_path_exists(hugepage_info):
356                out = self.send_command(f"lsof -Fp {hugepage_info}").stdout
357                if out and "No such file or directory" not in out:
358                    self._logger.warning("Some DPDK processes did not free hugepages.")
359                    self._logger.warning("*******************************************")
360                    self._logger.warning(out)
361                    self._logger.warning("*******************************************")
362
363    def _remove_dpdk_runtime_dirs(self, dpdk_runtime_dirs: Iterable[str | PurePath]) -> None:
364        for dpdk_runtime_dir in dpdk_runtime_dirs:
365            self.remove_remote_dir(dpdk_runtime_dir)
366
367    def get_dpdk_file_prefix(self, dpdk_prefix: str) -> str:
368        """Overrides :meth:`~.os_session.OSSession.get_dpdk_file_prefix`."""
369        return ""
370
371    def get_compiler_version(self, compiler_name: str) -> str:
372        """Overrides :meth:`~.os_session.OSSession.get_compiler_version`."""
373        match compiler_name:
374            case "gcc":
375                return self.send_command(
376                    f"{compiler_name} --version", SETTINGS.timeout
377                ).stdout.split("\n")[0]
378            case "clang":
379                return self.send_command(
380                    f"{compiler_name} --version", SETTINGS.timeout
381                ).stdout.split("\n")[0]
382            case "msvc":
383                return self.send_command("cl", SETTINGS.timeout).stdout
384            case "icc":
385                return self.send_command(f"{compiler_name} -V", SETTINGS.timeout).stdout
386            case _:
387                raise ValueError(f"Unknown compiler {compiler_name}")
388
389    def get_node_info(self) -> OSSessionInfo:
390        """Overrides :meth:`~.os_session.OSSession.get_node_info`."""
391        os_release_info = self.send_command(
392            "awk -F= '$1 ~ /^NAME$|^VERSION$/ {print $2}' /etc/os-release",
393            SETTINGS.timeout,
394        ).stdout.split("\n")
395        kernel_version = self.send_command("uname -r", SETTINGS.timeout).stdout
396        return OSSessionInfo(os_release_info[0].strip(), os_release_info[1].strip(), kernel_version)
397