Source code for lisa.executable

# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

from __future__ import annotations

import pathlib
from hashlib import sha256
from typing import (
    TYPE_CHECKING,
    Any,
    Dict,
    List,
    Optional,
    Tuple,
    Type,
    TypeVar,
    Union,
    cast,
)

from lisa.util import InitializableMixin, LisaException, constants
from lisa.util.logger import get_logger
from lisa.util.perf_timer import create_timer
from lisa.util.process import ExecutableResult, Process

if TYPE_CHECKING:
    from lisa.node import Node


T = TypeVar("T")


[docs] class Tool(InitializableMixin): """ The base class, which wraps an executable, package, or scripts on a node. A tool can be installed, and execute on a node. When a tool is needed, call Tools[] to get one object. The Tools[] checks if it's installed. If it's not installed, then check if it can be installed, and then install or fail. After the tool instance returned, the run/Async of the tool will call execute/Async of node. So that the command passes to current node. The must be implemented methods are marked with @abstractmethod, includes, command: it's the command name, like echo, ntttcp. it uses in run/Async to run it, and isInstalledInternal to check if it's installed. The should be implemented methods throws NotImplementedError, but not marked as abstract method, includes, can_install: specify if a tool can be installed or not. If a tool is not builtin, it must implement this method. _install: If a tool is not builtin, it must implement this method. This method needs to install a tool, and make sure it can be detected by isInstalledInternal. The may be implemented methods is empty, includes, initialize: It's called when a tool is created, and before to call any other methods. It can be used to initialize variables or time-costing operations. dependencies: All depended tools, they will be checked and installed before current tool installed. For example, ntttcp uses git to clone code and build. So it depends on Git tool. See details on method descriptions. """ def __init__(self, node: Node, *args: Any, **kwargs: Any) -> None: """ It's not recommended to replace this __init__ method. Anything need to be initialized, should be in initialize() method. """ super().__init__() self.node: Node = node # triple states, None means not checked. self._exists: Optional[bool] = None self._log = get_logger("tool", self.name, self.node.log) # specify the tool is in sudo or not. It may be set to True in # _check_exists self._use_sudo: bool = False # cache the processes with same command line, so that it reduce time to # rerun same commands. self.__cached_results: Dict[str, Process] = {} def __call__( self, parameters: str = "", shell: bool = False, no_error_log: bool = False, no_info_log: bool = True, cwd: Optional[pathlib.PurePath] = None, ) -> ExecutableResult: return self.run( parameters=parameters, shell=shell, no_error_log=no_error_log, no_info_log=no_info_log, cwd=cwd, ) @property def command(self) -> str: """ Return command string, which can be run in console. For example, echo. The command can be different under different conditions. For example, package management is 'yum' on CentOS, but 'apt' on Ubuntu. """ raise NotImplementedError("'command' is not implemented") @property def can_install(self) -> bool: """ Indicates if the tool supports installation or not. If it can return true, installInternal must be implemented. """ raise NotImplementedError("'can_install' is not implemented") @property def package_name(self) -> str: """ return package name, it may be different with command or different platform. """ return self.command @property def dependencies(self) -> List[Type[Tool]]: """ Declare all dependencies here, it can be other tools, but prevent to be a circle dependency. The dependent tools are checked and installed firstly. """ return [] @property def name(self) -> str: """ Unique name to a tool and used as path of tool. Don't change it, or there may be unpredictable behavior. """ return self.__class__.__name__.lower() @property def exists(self) -> bool: """ Return if a tool installed. In most cases, overriding inInstalledInternal is enough. But if want to disable cached result and check tool every time, override this method. Notice, remote operations take times, that why caching is necessary. """ # the check may need extra cost, so cache it's result. if self._exists is None: self._exists = self._check_exists() return self._exists
[docs] @classmethod def create(cls, node: Node, *args: Any, **kwargs: Any) -> Tool: """ if there is a windows version tool, return the windows instance. override this method if richer creation factory is needed. """ tool_cls = cls if not node.is_posix: windows_tool = cls._windows_tool() if windows_tool: tool_cls = windows_tool elif "FreeBSD" in node.os.name: freebsd_tool = cls._freebsd_tool() if freebsd_tool: tool_cls = freebsd_tool return tool_cls(node, *args, **kwargs)
[docs] @classmethod def _windows_tool(cls) -> Optional[Type[Tool]]: """ return a windows version tool class, if it's needed """ return None
[docs] @classmethod def _freebsd_tool(cls) -> Optional[Type[Tool]]: """ return a freebsd version tool class, if it's needed """ return None
[docs] def command_exists(self, command: str) -> Tuple[bool, bool]: exists = False use_sudo = False if self.node.is_posix: where_command = "command -v" else: where_command = "where" where_command = f"{where_command} {command}" result = self.node.execute(where_command, shell=True, no_info_log=True) if result.exit_code == 0: exists = True use_sudo = False elif self.node.is_posix: result = self.node.execute( where_command, shell=True, no_info_log=True, sudo=True, ) if result.exit_code == 0: self._log.debug( "executable exists in root paths, " "sudo always brings in following commands." ) exists = True use_sudo = True else: # for Windows, where is not enough to check if a full path exists, # use dir to try again. test_command = f"powershell test-path '{command}'" result = self.node.execute(test_command, shell=True, no_info_log=True) exists = result.stdout == "True" return exists, use_sudo
[docs] def install(self) -> bool: """ Default behavior of install a tool, including dependencies. It doesn't need to be overridden. """ # check dependencies if self.dependencies: self._log.info("installing dependencies") list(map(self.node.tools.get, self.dependencies)) return self._install()
[docs] def run_async( self, parameters: str = "", force_run: bool = False, shell: bool = False, sudo: bool = False, no_error_log: bool = False, no_info_log: bool = True, no_debug_log: bool = False, cwd: Optional[pathlib.PurePath] = None, update_envs: Optional[Dict[str, str]] = None, # node uses for guest nodes. node: Optional["Node"] = None, encoding: str = "", ) -> Process: """ Run a command async and return the Process. The process is used for async, or kill directly. """ if parameters: command = f"{self.command} {parameters}" else: command = self.command # If the command exists in sbin, use the root permission, even the sudo # is not specified. sudo = sudo or self._use_sudo command_key = f"{command}|{shell}|{sudo}|{cwd}" process = self.__cached_results.get(command_key, None) if node is None: node = self.node if force_run or not process: process = node.execute_async( command, shell=shell, sudo=sudo, no_error_log=no_error_log, no_info_log=no_info_log, no_debug_log=no_debug_log, cwd=cwd, update_envs=update_envs, encoding=encoding, ) self.__cached_results[command_key] = process else: self._log.debug(f"loaded cached result for command: [{command}]") return process
[docs] def run( self, parameters: str = "", force_run: bool = False, shell: bool = False, sudo: bool = False, no_error_log: bool = False, no_info_log: bool = True, no_debug_log: bool = False, cwd: Optional[pathlib.PurePath] = None, update_envs: Optional[Dict[str, str]] = None, encoding: str = "", timeout: int = 600, expected_exit_code: Optional[int] = None, expected_exit_code_failure_message: str = "", ) -> ExecutableResult: """ Run a process and wait for result. """ process = self.run_async( parameters=parameters, force_run=force_run, shell=shell, sudo=sudo, no_error_log=no_error_log, no_info_log=no_info_log, no_debug_log=no_debug_log, cwd=cwd, update_envs=update_envs, encoding=encoding, ) return process.wait_result( timeout=timeout, expected_exit_code=expected_exit_code, expected_exit_code_failure_message=expected_exit_code_failure_message, )
[docs] def get_tool_path(self, use_global: bool = False) -> pathlib.PurePath: """ compose a path, if the tool need to be installed """ if use_global: # change from lisa_working/20220126/20220126-194017-621 to # lisa_working. The self.node.generate_working_path will determinate # if it's Windows or Linux. working_path = self.node.get_working_path().parent.parent else: assert self.node.working_path, "working path is not initialized" working_path = self.node.working_path path = working_path.joinpath(constants.PATH_TOOL, self.name) self.node.shell.mkdir(path, exist_ok=True) return path
[docs] def _install(self) -> bool: """ Execute installation process like build, install from packages. If other tools are depended, specify them in dependencies. Other tools can be used here, refer to ntttcp implementation. """ raise NotImplementedError("'install' is not implemented")
[docs] def _initialize(self, *args: Any, **kwargs: Any) -> None: """ Declare and initialize variables here, or some time costing initialization. This method is called before other methods, when initialing on a node. """ ...
[docs] def _check_exists(self) -> bool: """ Default implementation to check if a tool exists. This method is called by isInstalled, and cached result. Builtin tools can override it can return True directly to save time. """ exists, self._use_sudo = self.command_exists(self.command) return exists
[docs] class CustomScript(Tool): def __init__( self, name: str, node: Node, local_path: pathlib.Path, files: List[pathlib.PurePath], command: Optional[str] = None, dependencies: Optional[List[Type[Tool]]] = None, ) -> None: self._name = name self._command = command super().__init__(node) self._local_path = local_path self._files = files self._cwd: Union[pathlib.PurePath, pathlib.Path] if dependencies: self._dependencies = dependencies else: self._dependencies = []
[docs] def run_async( self, parameters: str = "", force_run: bool = False, shell: bool = False, sudo: bool = False, no_error_log: bool = False, no_info_log: bool = True, no_debug_log: bool = False, cwd: Optional[pathlib.PurePath] = None, update_envs: Optional[Dict[str, str]] = None, node: Optional["Node"] = None, encoding: str = "", ) -> Process: if cwd is not None: raise LisaException("don't set cwd for script") return super().run_async( parameters=parameters, force_run=force_run, shell=shell, sudo=sudo, no_error_log=no_error_log, no_info_log=no_info_log, no_debug_log=no_debug_log, cwd=self._cwd, update_envs=update_envs, node=node, encoding=encoding, )
@property def name(self) -> str: return self._name @property def command(self) -> str: assert self._command return self._command @property def can_install(self) -> bool: return True
[docs] def _check_exists(self) -> bool: # the underlying '_check_exists' doesn't work for script but once it's # cached in node, it won't be copied again. So it doesn't need to check # exists. return False
@property def dependencies(self) -> List[Type[Tool]]: return self._dependencies
[docs] def install(self) -> bool: if self.node.is_remote: # copy to remote node_script_path = self.get_tool_path() for file in self._files: remote_path = node_script_path.joinpath(file) source_path = self._local_path.joinpath(file) self.node.shell.copy(source_path, remote_path) self.node.shell.chmod(remote_path, 0o755) self._cwd = node_script_path else: self._cwd = self._local_path if not self._command: if self.node.is_posix: # in Linux, local script must to relative path. self._command = f"./{pathlib.PurePosixPath(self._files[0])}" else: # windows needs absolute path self._command = f"{self._cwd.joinpath(self._files[0])}" return True
class CustomScriptBuilder: """ With CustomScriptBuilder, provides variables is enough to use like a tool It needs some special handling in tool.py, but not much. """ def __init__( self, root_path: pathlib.Path, files: List[str], command: Optional[str] = None, dependencies: Optional[List[Type[Tool]]] = None, ) -> None: if not files: raise LisaException("CustomScriptSpec should have at least one file") self._dependencies = dependencies root_path = root_path.resolve().absolute() files_path: List[pathlib.PurePath] = [] for file_str in files: file = pathlib.PurePath(file_str) if file.is_absolute(): raise LisaException(f"file must be relative path: '{file_str}'") absolute_file = root_path.joinpath(file).resolve() if not absolute_file.exists(): raise LisaException(f"cannot find file {absolute_file}") try: file = absolute_file.relative_to(root_path) except ValueError: raise LisaException(f"file '{file_str}' must be in '{root_path}'") files_path.append(file) self._files = files_path self._local_rootpath: pathlib.Path = root_path self._command: Union[str, None] = None if command: command_identifier = command self._command = command else: command_identifier = files[0] # generate an unique name based on file names command_identifier = constants.NORMALIZE_PATTERN.sub("-", command_identifier) hash_source = "".join(files).encode("utf-8") hash_result = sha256(hash_source).hexdigest()[:8] self.name = f"custom-{command_identifier}-{hash_result}".lower() def build(self, node: Node) -> CustomScript: return CustomScript( self.name, node, self._local_rootpath, self._files, self._command ) class Tools: def __init__(self, node: Node) -> None: self._node = node self._cache: Dict[str, Tool] = {} def __getattr__(self, key: str) -> Tool: """ for shortcut access like node.tools.echo.call_method() """ return self.__getitem__(key) def __getitem__(self, tool_type: Union[Type[T], CustomScriptBuilder, str]) -> T: return self.get(tool_type=tool_type) def create( self, tool_type: Union[Type[T], CustomScriptBuilder, str], *args: Any, **kwargs: Any, ) -> T: """ Create a new tool with given arguments. Call it only when a new tool is needed. Otherwise, call the get method. """ tool_key = self._get_tool_key(tool_type) tool = self._cache.get(tool_key, None) if tool: del self._cache[tool_key] return self.get(tool_type, *args, **kwargs) def get( self, tool_type: Union[Type[T], Type[Tool], CustomScriptBuilder, str], *args: Any, **kwargs: Any, ) -> T: """ return a typed subclass of tool or script builder. for example, echo_tool = node.tools[Echo] echo_tool.run("hello") """ if tool_type is CustomScriptBuilder: raise LisaException( "CustomScriptBuilder should call build to create a script instance" ) tool_key = self._get_tool_key(tool_type) tool = self._cache.get(tool_key) if tool is None: # the Tool is not installed on current node, try to install it. tool_log = get_logger("tool", tool_key, self._node.log) tool_log.debug(f"initializing tool [{tool_key}]") if isinstance(tool_type, CustomScriptBuilder): tool = tool_type.build(self._node) elif isinstance(tool_type, str): raise LisaException( f"{tool_type} cannot be found. " f"short usage need to get with type before get with name." ) else: cast_tool_type = cast(Type[Tool], tool_type) tool = cast_tool_type.create(self._node, *args, **kwargs) tool.initialize() if not tool.exists: tool_log.debug(f"'{tool.name}' not installed") if tool.can_install: tool_log.debug(f"{tool.name} is installing") timer = create_timer() is_success = tool.install() if not is_success: raise LisaException( f"install '{tool.name}' failed. After installed, " f"it cannot be detected." ) tool_log.debug(f"installed in {timer}") else: raise LisaException( f"cannot find [{tool.name}] on [{self._node.name}], " f"{self._node.os.__class__.__name__}, " f"Remote({self._node.is_remote}) " f"and installation of [{tool.name}] isn't enabled in lisa." ) else: tool_log.debug("installed already") self._cache[tool_key] = tool return cast(T, tool) def _get_tool_key(self, tool_type: Union[type, CustomScriptBuilder, str]) -> str: if isinstance(tool_type, CustomScriptBuilder): tool_key = tool_type.name elif isinstance(tool_type, str): tool_key = tool_type.lower() else: tool_key = tool_type.__name__.lower() return tool_key