1. support remote file operations with shell
2. add shell to deal with different between local and remote
3. add git and ntttcp tools, ntttcp is a complex tool example
4. support remote file operations with shell
5. support shell execution on command
This commit is contained in:
Chi Song 2020-08-10 17:31:48 +08:00
Родитель 09e060b84b
Коммит 2d62443079
19 изменённых файлов: 374 добавлений и 115 удалений

Просмотреть файл

@ -0,0 +1,24 @@
from lisa import CaseMetadata, SuiteMetadata
from lisa.core.testSuite import TestSuite
from lisa.tool import Ntttcp
@SuiteMetadata(
area="demo",
category="simple",
description="""
This test suite run a script
""",
tags=["demo"],
)
class WithScript(TestSuite):
@CaseMetadata(
description="""
this test case run script on test node.
""",
priority=1,
)
def script(self) -> None:
node = self.environment.defaultNode
ntttcp = node.getTool(Ntttcp)
ntttcp.help()

Просмотреть файл

@ -19,6 +19,6 @@ class CaseMetadata:
start = timer()
func(*args)
end = timer()
log.info(f"case '{func.__name__}' ended with {end - start:.3f}")
log.info(f"case '{func.__name__}' ended with {end - start:.3f} sec")
return wrapper

Просмотреть файл

@ -35,7 +35,8 @@ class Environment(object):
List[Dict[str, object]], spec.get(constants.ENVIRONMENTS_NODES)
)
for node_config in nodes_config:
node = NodeFactory.createNodeFromConfig(node_config)
index = str(len(environment.nodes))
node = NodeFactory.createNodeFromConfig(index, node_config)
if node is not None:
environment.nodes.append(node)
else:

Просмотреть файл

@ -1,42 +1,37 @@
from __future__ import annotations
import pathlib
import random
from timeit import default_timer as timer
from typing import Dict, Optional, Type, TypeVar, cast
import spur
import spurplus
from lisa.core.tool import Tool
from lisa.tool import Echo, Uname
from lisa.util import constants
from lisa.util import constants, env
from lisa.util.connectionInfo import ConnectionInfo
from lisa.util.executableResult import ExecutableResult
from lisa.util.logger import log
from lisa.util.process import Process
from lisa.util.shell import Shell
T = TypeVar("T")
class Node:
builtinTools = [Uname, Echo]
def __init__(
self,
id: str,
isRemote: bool = True,
spec: Optional[Dict[str, object]] = None,
isDefault: bool = False,
) -> None:
self.name: Optional[str] = None
self.id = id
self.name: str = ""
self.isDefault = isDefault
self.isRemote = isRemote
self.spec = spec
self.connection_info: Optional[ConnectionInfo] = None
self.tempFolder: str = ""
if self.isRemote:
self.shell: Optional[spurplus.SshShell] = None
else:
self.shell: Optional[spur.LocalShell] = None
self.workingPath: pathlib.Path = ""
self.shell = Shell()
self._isInitialized: bool = False
self._isLinux: bool = True
@ -46,11 +41,10 @@ class Node:
self.hardwarePlatform: str = ""
self.tools: Dict[Type[Tool], Tool] = dict()
for tool_class in self.builtinTools:
self.tools[tool_class] = tool_class(self)
@staticmethod
def createNode(
id: str,
spec: Optional[Dict[str, object]] = None,
node_type: str = constants.ENVIRONMENTS_NODES_REMOTE,
isDefault: bool = False,
@ -61,7 +55,7 @@ class Node:
isRemote = False
else:
raise Exception(f"unsupported node_type '{node_type}'")
node = Node(spec=spec, isRemote=isRemote, isDefault=isDefault)
node = Node(id, spec=spec, isRemote=isRemote, isDefault=isDefault)
log.debug(
f"created node '{node_type}', isDefault: {isDefault}, isRemote: {isRemote}"
)
@ -99,27 +93,68 @@ class Node:
self.connection_info = ConnectionInfo(
publicAddress, publicPort, username, password, privateKeyFile,
)
self.shell.setConnectionInfo(self.connection_info)
self.internalAddress = address
self.internalPort = port
def getToolPath(self, tool: Optional[Tool] = None) -> pathlib.Path:
assert self.workingPath
if tool:
tool_name = tool.__class__.__name__.lower()
tool_path = self.workingPath.joinpath(constants.PATH_TOOL, tool_name)
else:
tool_path = self.workingPath.joinpath(constants.PATH_TOOL)
return tool_path
def getTool(self, tool_type: Type[T]) -> T:
tool = cast(T, self.tools.get(tool_type))
if tool is None:
tool_prefix = f"tool '{tool_type.__name__}'"
log.debug(f"{tool_prefix} is initializing")
# the Tool is not installed on current node, try to install it.
tool = cast(Tool, T(self))
tool = cast(Tool, tool_type(self))
if not tool.isInstalled:
log.debug(f"{tool_prefix} is not installed")
if tool.canInstall:
tool.install()
if not tool.isInstalled:
raise Exception(
f"Tool {tool_type.__name__} is not found on node, "
f"and cannot be installed or is install failed."
)
log.debug(f"{tool_prefix} installing")
success = tool.install()
if not success:
raise Exception(f"{tool_prefix} install failed")
else:
raise Exception(
f"{tool_prefix} doesn't support install on Node({self.id}), "
f"Linux({self.isLinux}), Remote({self.isRemote})"
)
else:
log.debug(f"{tool_prefix} is installed already")
return tool
def execute(self, cmd: str, noErrorLog: bool = False) -> ExecutableResult:
def execute(
self,
cmd: str,
useBash: bool = False,
noErrorLog: bool = False,
noInfoLog: bool = False,
cwd: pathlib.Path = None,
) -> ExecutableResult:
self._initialize()
return self._execute(cmd, noErrorLog)
process = self._execute(
cmd, useBash=useBash, noErrorLog=noErrorLog, noInfoLog=noInfoLog, cwd=cwd
)
return process.waitResult()
def executeAsync(
self,
cmd: str,
useBash: bool = False,
noErrorLog: bool = False,
noInfoLog: bool = False,
cwd: pathlib.Path = None,
) -> Process:
self._initialize()
return self._execute(
cmd, useBash=useBash, noErrorLog=noErrorLog, noInfoLog=noInfoLog, cwd=cwd
)
@property
def isLinux(self) -> bool:
@ -128,11 +163,11 @@ class Node:
def _initialize(self) -> None:
if not self._isInitialized:
# prevent loop calls, put it at top
# prevent loop calls, set _isInitialized to True first
self._isInitialized = True
log.debug(f"initializing node {self.name}")
self.shell.initialize()
uname = self.getTool(Uname)
(
self.kernelRelease,
self.kernelVersion,
@ -150,33 +185,45 @@ class Node:
else:
log.info(f"initialized Windows node '{self.name}', ")
def _execute(self, cmd: str, noErrorLog: bool = False) -> ExecutableResult:
cmd_prefix = f"cmd[{str(random.randint(0, 10000))}]"
start_timer = timer()
log.debug(f"{cmd_prefix}remote({self.isRemote}) '{cmd}'")
if self.shell is None:
# set working path
if self.isRemote:
assert self.connection_info is not None
self.shell = spurplus.connect_with_retries(
self.connection_info.address,
port=self.connection_info.port,
username=self.connection_info.username,
password=self.connection_info.password,
private_key_file=self.connection_info.privateKeyFile,
missing_host_key=spur.ssh.MissingHostKey.accept,
)
assert self.shell
assert self.connection_info
if self.isLinux:
remote_root_path = pathlib.Path("$HOME")
else:
remote_root_path = pathlib.Path("%TEMP%")
working_path = remote_root_path.joinpath(
constants.PATH_REMOTE_ROOT, env.get_run_path()
).as_posix()
# expand environment variables in path
echo = self.getTool(Echo)
result = echo.run(working_path, useBash=True)
# PurePath is more reasonable here, but spurplus doesn't support it.
self.workingPath = pathlib.Path(result.stdout)
else:
self.shell = spur.LocalShell()
self.workingPath = pathlib.Path(env.get_run_local_path())
log.debug(f"working path is: {self.workingPath.as_posix()}")
self.shell.mkdir(self.workingPath, parents=True, exist_ok=True)
process = Process(cmd_prefix, self.shell)
process.start(cmd, noErrorLog=noErrorLog)
result = process.waitResult()
end_timer = timer()
log.info(f"{cmd_prefix}executed with {end_timer - start_timer:.3f} sec")
return result
def _execute(
self,
cmd: str,
useBash: bool = False,
noErrorLog: bool = False,
noInfoLog: bool = False,
cwd: pathlib.Path = None,
) -> Process:
cmd_prefix = f"cmd[{str(random.randint(0, 10000))}]"
log.debug(f"{cmd_prefix}remote({self.isRemote}) '{cmd}'")
process = Process(cmd_prefix, self.shell, self.isLinux)
process.start(
cmd, useBash=useBash, noErrorLog=noErrorLog, noInfoLog=noInfoLog, cwd=cwd
)
return process
def close(self) -> None:
if self.shell and isinstance(self.shell, spurplus.SshShell):
self.shell.close()
self.shell.close()

Просмотреть файл

@ -7,7 +7,7 @@ from .node import Node
class NodeFactory:
@staticmethod
def createNodeFromConfig(config: Dict[str, object]) -> Optional[Node]:
def createNodeFromConfig(id: str, config: Dict[str, object]) -> Optional[Node]:
node_type = config.get(constants.TYPE)
node = None
if node_type is None:
@ -16,8 +16,8 @@ class NodeFactory:
constants.ENVIRONMENTS_NODES_LOCAL,
constants.ENVIRONMENTS_NODES_REMOTE,
]:
is_default = NodeFactory._isDefault(config)
node = Node.createNode(node_type=node_type, isDefault=is_default)
is_default = cast(bool, config.get(constants.IS_DEFAULT, False))
node = Node.createNode(id, node_type=node_type, isDefault=is_default)
if node.isRemote:
fields = [
constants.ENVIRONMENTS_NODES_REMOTE_ADDRESS,
@ -39,13 +39,8 @@ class NodeFactory:
def createNodeFromSpec(
spec: Dict[str, object], node_type: str = constants.ENVIRONMENTS_NODES_REMOTE
) -> Node:
is_default = NodeFactory._isDefault(spec)
node = Node.createNode(spec=spec, node_type=node_type, isDefault=is_default)
is_default = cast(bool, spec.get(constants.IS_DEFAULT, False))
node = Node.createNode(
"spec", spec=spec, node_type=node_type, isDefault=is_default
)
return node
@staticmethod
def _isDefault(config: Dict[str, object]) -> bool:
default = cast(bool, config.get(constants.IS_DEFAULT))
if default is not True:
default = False
return default

Просмотреть файл

@ -1,43 +1,93 @@
from __future__ import annotations
import pathlib
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, List, Optional, TypeVar
from lisa.util.executableResult import ExecutableResult
from lisa.util.process import Process
if TYPE_CHECKING:
from lisa.core.node import Node
T = TypeVar("T")
class Tool(ABC):
def __init__(self, node: Node) -> None:
self.node: Node = node
self._isInstalled: Optional[bool] = None
self.initialize()
def initialize(self) -> None:
pass
@property
def dependentedTools(self) -> List[T]:
"""
declare all dependencies here
they can be batch check and installed.
"""
return []
@property
@abstractmethod
def command(self) -> str:
raise NotImplementedError()
@property
@abstractmethod
def canInstall(self) -> bool:
raise NotImplementedError()
@abstractmethod
def installed(self) -> bool:
@property
def isInstalledInternal(self) -> bool:
result = self.node.execute(f'bash -c "command -v {self.command}"')
self._isInstalled = result.exitCode == 0
return self._isInstalled
@property
def isInstalled(self) -> bool:
# the check may need extra cost, so cache it's result.
if self._isInstalled is None:
self._isInstalled = self.isInstalledInternal
return self._isInstalled
def installInternal(self) -> bool:
raise NotImplementedError()
def install(self) -> bool:
pass
# check dependencies
for dependency in self.dependentedTools:
self.node.getTool(dependency)
result = self.installInternal()
return result
def runAsync(
self,
extraParameters: str = "",
useBash: bool = False,
noErrorLog: bool = False,
cwd: pathlib.Path = None,
) -> Process:
command = f"{self.command} {extraParameters}"
result: ExecutableResult = self.node.execute(
command, useBash, noErrorLog=noErrorLog, cwd=cwd
)
return result
def run(
self, extraParameters: str = "", noErrorLog: bool = False
self,
extraParameters: str = "",
useBash: bool = False,
noErrorLog: bool = False,
noInfoLog: bool = False,
cwd: pathlib.Path = None,
) -> ExecutableResult:
command = f"{self.command} {extraParameters}"
result: ExecutableResult = self.node.execute(command, noErrorLog)
result: ExecutableResult = self.node.execute(
command, useBash, noErrorLog=noErrorLog, noInfoLog=noInfoLog, cwd=cwd
)
return result

Просмотреть файл

@ -11,21 +11,24 @@ from lisa.util.logger import init_log, log
@retry(FileExistsError, tries=10, delay=0) # type: ignore
def create_run_root_path() -> Path:
def create_run_path(root_path: Path) -> Path:
date = datetime.utcnow().strftime("%Y%m%d")
time = datetime.utcnow().strftime("%H%M%S-%f")[:-3]
current_path = f"runtime/runs/{date}/{date}-{time}"
path_obj = Path(current_path)
if path_obj.exists():
raise FileExistsError(f"{current_path} exists, and not found an unique path.")
return path_obj
run_path = Path(f"{date}/{date}-{time}")
local_path = root_path.joinpath(run_path)
if local_path.exists():
raise FileExistsError(f"{local_path} exists, and not found an unique path.")
return run_path
def main() -> None:
local_path = Path("runtime").joinpath("runs").absolute()
# create run root path
run_root_path = create_run_root_path().absolute()
run_root_path.mkdir(parents=True)
env.set_env(env.KEY_RUN_ROOT_PATH, str(run_root_path))
run_path = create_run_path(local_path)
local_path = local_path.joinpath(run_path)
local_path.mkdir(parents=True)
env.set_env(env.KEY_RUN_LOCAL_PATH, str(local_path))
env.set_env(env.KEY_RUN_PATH, str(run_path))
args = parse_args()
@ -33,7 +36,7 @@ def main() -> None:
log.info(f"Python version: {sys.version}")
log.info(f"local time: {datetime.now().astimezone()}")
log.info(f"command line args: {sys.argv}")
log.info(f"run root path: {env.get_env(env.KEY_RUN_ROOT_PATH)}")
log.info(f"run local path: {env.get_run_local_path()}")
if args.debug:
log_level = DEBUG

Просмотреть файл

@ -1,5 +1,6 @@
from .echo import Echo
from .ping import Ping
from .git import Git
from .ntttcp import Ntttcp
from .uname import Uname
__all__ = ["Uname", "Ping", "Echo"]
__all__ = ["Uname", "Ntttcp", "Echo", "Git"]

Просмотреть файл

@ -4,10 +4,15 @@ from lisa.core.tool import Tool
class Echo(Tool):
@property
def command(self) -> str:
return "echo"
command = "echo"
if not self.node.isLinux:
command = "cmd /c echo"
return command
@property
def canInstall(self) -> bool:
return False
def installed(self) -> bool:
@property
def isInstalledInternal(self) -> bool:
return True

15
lisa/tool/git.py Normal file
Просмотреть файл

@ -0,0 +1,15 @@
from lisa.core.tool import Tool
class Git(Tool):
@property
def command(self) -> str:
return "git"
@property
def canInstall(self) -> bool:
# TODO support installation later
return False
def clone(self, url: str, cwd: str) -> None:
self.run(f"clone {url}", cwd=cwd)

40
lisa/tool/ntttcp.py Normal file
Просмотреть файл

@ -0,0 +1,40 @@
from typing import List, TypeVar
from lisa.core.tool import Tool
from lisa.tool import Git
from lisa.util.executableResult import ExecutableResult
T = TypeVar("T")
class Ntttcp(Tool):
repo = "https://github.com/microsoft/ntttcp-for-linux"
@property
def dependentedTools(self) -> List[T]:
return [Git]
@property
def command(self) -> str:
return "ntttcp"
@property
def canInstall(self) -> bool:
can_install = False
if self.node.isLinux:
can_install = True
return can_install
def install(self) -> bool:
tool_path = self.node.getToolPath(self)
self.node.shell.mkdir(tool_path)
git = self.node.getTool(Git)
git.clone(self.repo, tool_path)
code_path = tool_path.joinpath("ntttcp-for-linux/src")
self.node.execute(
f"cd {code_path.as_posix()} && make && sudo make install", useBash=True
)
return self.isInstalledInternal
def help(self) -> ExecutableResult:
return self.run("-h")

Просмотреть файл

@ -1,5 +0,0 @@
from lisa.core.tool import Tool
class Ping(Tool):
pass

Просмотреть файл

@ -21,10 +21,12 @@ class Uname(Tool):
def command(self) -> str:
return "uname"
@property
def canInstall(self) -> bool:
return False
def installed(self) -> bool:
@property
def isInstalledInternal(self) -> bool:
return True
def getLinuxInformation(

Просмотреть файл

@ -3,11 +3,16 @@ CONFIG_CONFIG = "config"
CONFIG_PLATFORM = "platform"
CONFIG_ENVIRONMENT_FACTORY = "environmentFactory"
# path related
PATH_REMOTE_ROOT = "lisa_working"
PATH_TOOL = "tool"
# list types
LIST = "list"
LIST_CASE = "case"
# common
NODE = "node"
NAME = "name"
VALUE = "value"
TYPE = "type"

Просмотреть файл

@ -1,19 +1,18 @@
import os
from pathlib import PurePath
WORKING_PATH = "working"
KEY_RUN_ROOT_PATH = "RUN_ROOT_PATH"
KEY_RUN_LOCAL_PATH = "RUN_LOCAL_PATH"
KEY_RUN_PATH = "RUN_PATH"
__prefix = "LISA_"
def get_run_root_path() -> PurePath:
return get_env(KEY_RUN_ROOT_PATH)
def get_run_local_path() -> PurePath:
return PurePath(get_env(KEY_RUN_LOCAL_PATH))
def get_working_path() -> PurePath:
return get_run_root_path().joinpath(WORKING_PATH)
def get_run_path() -> PurePath:
return PurePath(get_env(KEY_RUN_PATH))
def set_env(name: str, value: str, isSecret: bool = False) -> None:

Просмотреть файл

@ -7,3 +7,4 @@ class ExecutableResult:
stdout: str
stderr: str
exitCode: Optional[int]
elapsed: float

Просмотреть файл

@ -3,7 +3,7 @@ import os
import time
# to prevent circular import, hard code it here.
env_key_run_root_path = "LISA_RUN_ROOT_PATH"
env_key_run_local_path = "LISA_RUN_LOCAL_PATH"
def log_lines(logLevel: int, content: str, prefix: str = "") -> None:
@ -21,7 +21,7 @@ def init_log() -> None:
format=format,
datefmt="%m%d %H:%M:%S",
handlers=[
logging.FileHandler(f"{os.getenv(env_key_run_root_path)}/lisa-host.log"),
logging.FileHandler(f"{os.getenv(env_key_run_local_path)}/lisa-host.log"),
logging.StreamHandler(),
],
)

Просмотреть файл

@ -1,14 +1,15 @@
import logging
import pathlib
import shlex
import time
from timeit import default_timer as timer
from typing import TYPE_CHECKING, Dict, Optional, Type, Union
from typing import TYPE_CHECKING, Dict, Optional, Type
import spur
from spurplus import SshShell # type: ignore
from lisa.util.executableResult import ExecutableResult
from lisa.util.logger import log
from lisa.util.shell import Shell
if TYPE_CHECKING:
BaseExceptionType = Type[BaseException]
@ -35,35 +36,48 @@ class LogWriter:
class Process:
def __init__(
self, cmd_prefix: str, shell: Union[SshShell, spur.LocalShell]
) -> None:
def __init__(self, cmd_prefix: str, shell: Shell, isLinux: bool = True) -> None:
# the shell can be LocalShell or SshShell
self.shell = shell
self.cmd_prefix = cmd_prefix
self.isLinux = isLinux
self._running: bool = False
def start(
self,
command: str,
cwd: Optional[str] = None,
useBash: bool = False,
cwd: Optional[pathlib.Path] = None,
new_envs: Optional[Dict[str, str]] = None,
noErrorLog: bool = False,
noInfoLog: bool = False,
) -> None:
"""
command include all parameters also.
"""
self.stdout_writer = LogWriter(logging.INFO, f"{self.cmd_prefix}stdout: ")
stdoutLogLevel = logging.INFO
stderrLogLevel = logging.ERROR
if noInfoLog:
stdoutLogLevel = logging.DEBUG
if noErrorLog:
logLevel = logging.INFO
else:
logLevel = logging.ERROR
self.stderr_writer = LogWriter(logLevel, f"{self.cmd_prefix}stderr: ")
stderrLogLevel = stdoutLogLevel
self.stdout_writer = LogWriter(stdoutLogLevel, f"{self.cmd_prefix}stdout: ")
self.stderr_writer = LogWriter(stderrLogLevel, f"{self.cmd_prefix}stderr: ")
if useBash:
if self.isLinux:
command = f'bash -c "{command}"'
else:
command = f'cmd /c "{command}"'
split_command = shlex.split(command)
log.debug(f"split command: {split_command}")
try:
self.process = self.shell.spawn(
real_shell = self.shell.innerShell
assert real_shell
self._start_timer = timer()
self.process = real_shell.spawn(
command=split_command,
stdout=self.stdout_writer,
stderr=self.stderr_writer,
@ -78,12 +92,15 @@ class Process:
except (FileNotFoundError, spur.errors.NoSuchCommandError) as identifier:
# FileNotFoundError: not found command on Windows
# NoSuchCommandError: not found command on remote Linux
self.process = ExecutableResult("", identifier.strerror, 1,)
self._end_timer = timer()
self.process = ExecutableResult(
"", identifier.strerror, 1, self._end_timer - self._start_timer
)
log.debug(f"{self.cmd_prefix} not found command")
def waitResult(self, timeout: float = 600) -> ExecutableResult:
budget_time = timeout
# wait for all content read
while self.isRunning() and budget_time >= 0:
start = timer()
time.sleep(0.01)
@ -98,16 +115,19 @@ class Process:
if not isinstance(self.process, ExecutableResult):
assert self.process
proces_result = self.process.wait_for_result()
self._end_timer = timer()
self.stdout_writer.close()
self.stderr_writer.close()
result = ExecutableResult(
proces_result.output.strip(),
proces_result.stderr_output.strip(),
proces_result.return_code,
self._end_timer - self._start_timer,
)
else:
result = self.process
log.info(f"{self.cmd_prefix}executed with {result.elapsed:.3f} sec")
return result
def stop(self) -> None:

56
lisa/util/shell.py Normal file
Просмотреть файл

@ -0,0 +1,56 @@
from pathlib import Path
from typing import Optional, Union
import spur
import spurplus
from lisa.util.connectionInfo import ConnectionInfo
class Shell:
"""
this class wraps local and remote file operations with similar behavior.
"""
def __init__(self):
self.isRemote = False
self.innerShell: Optional[Union[spurplus.SshShell, spur.LocalShell]] = None
self._isInitialized = False
def setConnectionInfo(self, connectionInfo: ConnectionInfo) -> None:
self.connectionInfo = connectionInfo
self.isRemote = True
def initialize(self):
if not self._isInitialized:
self._isInitialized = True
if self.isRemote:
assert self.connectionInfo
self.innerShell = spurplus.connect_with_retries(
self.connectionInfo.address,
port=self.connectionInfo.port,
username=self.connectionInfo.username,
password=self.connectionInfo.password,
private_key_file=self.connectionInfo.privateKeyFile,
missing_host_key=spur.ssh.MissingHostKey.accept,
)
else:
self.innerShell = spur.LocalShell()
def close(self):
if self.innerShell and isinstance(self.innerShell, spurplus.SshShell):
self.innerShell.close()
def mkdir(
self,
path: Path,
mode: int = 0o777,
parents: bool = True,
exist_ok: bool = False,
):
self.initialize()
if self.isRemote:
assert self.innerShell
self.innerShell.mkdir(path, mode=mode, parents=parents, exist_ok=exist_ok)
else:
path.mkdir(mode=mode, parents=parents, exist_ok=exist_ok)