Implement load environment and platform

1. Add Config class to hold config data and helper methods.
2. Add RuntimeObject, hold running objects.
3. Improve schema of platform and environment.
4. Rename decorators
5. Load node, environment, platform from config
6. Implement Ready env.
7. Add connection class to manage ssh session of node.
8. other minor improvements.
This commit is contained in:
Chi Song 2020-08-04 17:00:45 +08:00
Родитель c0e8def98c
Коммит 4ba1f0c100
24 изменённых файлов: 487 добавлений и 94 удалений

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

@ -27,9 +27,9 @@ Make sure below settings are in root level of `.vscode/settings.json`
"python.linting.enabled": true,
"python.formatting.provider": "black",
"python.linting.mypyEnabled": true,
"python.formatting.blackArgs": [
"--line-length",
"80"
"python.linting.flake8Args": [
"--max-line-length",
"88"
]
}
```

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

@ -1,21 +1,21 @@
# run name prefix to help grouping results and put it in title.
name: full example
# share configurations for similar runs.
parent:
parent:
- path: parentconfig.yaml
# for simple merge, this part is optional.
# for simple merge, this part is optional.
# operations include:
# overwrite: default behavior. add non-exist items and replace exist.
# remove: remove specified path totally
# add: add non-exist, not replace exist.
strategy:
strategy:
- path: platform.template
operation: remove
# add extended classes can be put in folders and include here.
# it doesn't matter how those files are organized, lisa loads by their inherits relationship.
# if there is any conflict on type name, there should be an error message.
extends:
paths:
extensions:
paths:
- "./myextends"
# it uses to support variables in other fields.
# duplicate items will be overwritten one by one.
@ -36,36 +36,57 @@ variables:
# it's not recommended highly to put secret in configurations directly.
isSecret: true
# supports multiple artifacts in future.
artifacts:
artifact:
# name is optional. artifacts can be referred by name or index.
- name: default
type: vhd
locations:
http: https://path-to-azure-storage/aaa.vhd
- type: http
path: https://path-to-azure-storage/aaa.vhd
- name: dsvm
type: vhd
locations:
http: https://path-to-azure-storage/bbb.vhd
- type: http
path: https://path-to-azure-storage/bbb.vhd
# it's spec of environment and node.
# for ready type, it's optional
# for ready type, it's optional.
environment:
topology: subnet
# template and nodes conflicts, they should have only one.
# it uses to prevent duplicate content for big amount nodes.
template:
- vcpu: 4
# optional, if there is only one artifact
nodeCount: 2
# optional, if there is only one artifact.
artifact: default
# sometimes, it may need special configuration for a platform
# we should avoid this kind of setting, it limits configuration to other platforms.
azure:
vmSize: testSize
flag: "test=true"
nodeCount:
nodes:
- memoryGB: 4
isDefault: true
- memoryGB: 8
- type: spec
azure:
vmSize: testSize
- type: spec
memoryGB: 8
gpuCount: 1
artifact: dsvm
# local and remote are node type, and don't need platform to handle
- type: local
# If test suite doesn't specify where to run a case,
# it should be run on default node.
isDefault: true
# it's optional
- type: remote
name: secondary
address: 10.0.0.2
port: 22
publicAddress: 23.36.36.36
publicPort: 1112
username: $$linuxTestUsername$$
password: $$linuxTestPassword$$
privateKeyFile: $$sshPrivateKey$$
# the environment spec may not be fully supported by each platform.
# If so, there is a warning message.
# Environment spec can be forced to apply, as error is loud.
@ -84,22 +105,9 @@ platform:
subscription: $$SubscriptionID$$
subscriptionKey: $$SubscriptionServicePrincipalKey$$
datacenter: wu
# for test case development, run test cases on current machine.
# It uses to pure existing environment.
# run test cases on existing machine.
- type: ready
nodes:
- type: local
# If test suite doesn't specify where to run a case,
# it should be run on default node.
isDefault: true
# it's optional
- type: ssh
name: secondary
ip: 10.0.0.2
port: 22
publicIp: 23.36.36.36
publicPort: 1112
password: $$linuxTestPassword$$
privateKey: $$sshPrivateKey$$
# rules apply ordered on previous selection.
# The order of test cases running is not guaranteed, until it set dependencies.
tests:

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

@ -2,13 +2,18 @@
name: local try
# share configurations for similar runs.
# it's examples for all platforms, but currently should leave single one only.
extends:
extensions:
paths:
- "examples/testsuites"
environment:
nodes:
- type: local
# If test suite doesn't specify where to run a case,
# it should be run on default node.
isDefault: true
# it's optional
platform:
- type: ready
nodes:
- type: local
tests:
- criteria:
# compatible with current test cases.

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

@ -4,11 +4,16 @@ name: local try
notifier:
- type: junit
- type: html
environment:
nodes:
- type: local
# If test suite doesn't specify where to run a case,
# it should be run on default node.
isDefault: true
# it's optional
# it's examples for all platforms, but currently should leave single one only.
platform:
- type: ready
nodes:
- type: local
tests:
- criteria:
# all rules in same criteria are AND condition.

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

@ -1,15 +1,14 @@
from lisa.core.decorator.testclass import TestClass
from lisa.core.testsuite import TestSuite
from lisa import TestMethod, log
from lisa import SuiteMetadata, CaseMetadata, log
@TestClass(area="demo", category="simple", tags=["demo"])
@SuiteMetadata(area="demo", category="simple", tags=["demo"])
class SimpleTestSuite(TestSuite):
@TestMethod(priority=1)
@CaseMetadata(priority=1)
def hello(self):
log.info("hello world")
@TestMethod(priority=1)
@CaseMetadata(priority=1)
def bye(self):
log.info("bye!")

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

@ -1,7 +1,8 @@
from .util import constants
from .common.logger import log
from .core.action import Action, ActionStatus
from .core.decorator.testmethod import TestMethod
from .core.decorator.testclass import TestClass
from .core.decorator.caseMetadata import CaseMetadata
from .core.decorator.suiteMetadata import SuiteMetadata
from .core.testrunner import TestRunner
from .core.testsuite import TestSuite
from .core.environment import Environment
@ -14,9 +15,10 @@ __all__ = [
"Environment",
"Node",
"TestRunner",
"TestClass",
"TestMethod",
"SuiteMetadata",
"CaseMetadata",
"TestSuite",
"Platform",
"log",
"constants",
]

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

@ -1,13 +1,15 @@
import asyncio
from lisa.core.platform_factory import platform_factory
from lisa.core.runtimeObject import RuntimeObject
from lisa.core.environment import Environment
import os
from lisa.core.package import import_module, packages
from lisa import log, Platform
from lisa.core.package import import_module
from lisa import log
from lisa.test_runner.lisarunner import LISARunner
from lisa.parameter_parser.parser import parse
from lisa.core.platform_factory import platformFactory
def load_extends(extends_config):
def _load_extends(extends_config):
if extends_config is not None:
paths = extends_config.get("paths")
if paths is not None:
@ -15,31 +17,46 @@ def load_extends(extends_config):
import_module(path)
def build_factories(package_name):
# platform factories
for sub_class in Platform.__subclasses__():
platformFactory.registerPlatform(sub_class)
def _initialize(args):
# make sure extensions in lisa is loaded
base_module_path = os.path.dirname(__file__)
import_module(base_module_path, logDetails=False)
config = parse(args)
extends_config = config.get("extends")
load_extends(extends_config)
for package_name in packages:
build_factories(package_name)
return config
# merge all parameters
config = parse(args)
runtime_object = RuntimeObject(config)
# load external extensions
extends_config = config.getExtensions()
_load_extends(extends_config)
# initialize environment
environment_config = config.getEnvironment()
environment = Environment.loadEnvironment(environment_config)
runtime_object.environment = environment
# initialize platform
platform_config = config.getPlatform()
runtime_object.platform = platform_factory.initializePlatform(platform_config)
runtime_object.validate()
return runtime_object
def run(args):
config = _initialize(args)
runtime_object = _initialize(args)
runner = LISARunner()
awaitable = runner.start()
asyncio.run(awaitable)
# check configs
def check(args):
_initialize(args)
def list_start(args):
config = _initialize(args)
runtime_object = _initialize(args)
log.info("list information here")

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

@ -3,7 +3,7 @@ from lisa import log
from lisa.core.testfactory import testFactory
class TestMethod(object):
class CaseMetadata(object):
def __init__(self, priority):
self.priority = priority

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

@ -2,7 +2,7 @@ from lisa.core.testfactory import testFactory
from typing import List
class TestClass:
class SuiteMetadata:
def __init__(self, area: str, category: str, tags: List[str], name=None):
self.area = area
self.category = category

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

@ -1,10 +1,71 @@
import copy
from lisa import log
from lisa.core.nodeFactory import NodeFactory
from .node import Node
from lisa import constants
class Environment:
class Environment(object):
CONFIG_NODES = "nodes"
CONFIG_TEMPLATE = "template"
CONFIG_TEMPLATE_NODE_COUNT = "nodeCount"
def __init__(self):
self.nodes: list[Node] = None
self.nodes: list[Node] = []
self.platform = None
self.spec = None
@staticmethod
def loadEnvironment(config):
environment = Environment()
spec = copy.deepcopy(config)
has_default_node = False
nodes_spec = []
node_config = spec.get(Environment.CONFIG_NODES)
if node_config is not None:
for node_config in config.get(Environment.CONFIG_NODES):
node = NodeFactory.createNodeFromConfig(node_config)
if node is not None:
environment.nodes.append(node)
else:
nodes_spec.append(node_config)
has_default_node = environment._validateSingleDefault(
has_default_node, node_config.get(constants.IS_DEFAULT)
)
# validate template and node not appear together
nodes_template = spec.get(Environment.CONFIG_TEMPLATE)
if nodes_template is not None:
for item in nodes_template:
node_count = item.get(Environment.CONFIG_TEMPLATE_NODE_COUNT)
if node_count is None:
node_count = 1
else:
del item[Environment.CONFIG_TEMPLATE_NODE_COUNT]
is_default = item.get(constants.IS_DEFAULT)
has_default_node = environment._validateSingleDefault(
has_default_node, is_default
)
for index in range(node_count):
copied_item = copy.deepcopy(item)
# only one default node for template also
if is_default is True and index > 0:
del copied_item[constants.IS_DEFAULT]
nodes_spec.append(copied_item)
del spec[Environment.CONFIG_TEMPLATE]
if len(nodes_spec) == 0 and len(environment.nodes) == 0:
raise Exception("not found any node in environment")
spec[Environment.CONFIG_NODES] = nodes_spec
environment.spec = spec
log.debug("environment spec is %s", environment.spec)
return environment
@property
def defaultNode(self):
@ -46,3 +107,10 @@ class Environment:
def setNodes(self, nodes):
self.nodes = nodes
def _validateSingleDefault(self, has_default, is_default):
if is_default is True:
if has_default is True:
raise Exception("only one node can set isDefault to True")
has_default = True
return has_default

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

@ -1,8 +1,41 @@
from lisa.core.sshConnection import SshConnection
from lisa.util import constants
class Node:
def __init__(self, isRemote=True, isDefault=False):
def __init__(self, isRemote=True, spec=None, isDefault=False):
self.isDefault: bool = isDefault
self.isRemote: bool = isRemote
self.sshSession = None
self.spec = spec
self.connection = None
self.publicSshSession = None
@staticmethod
def createNode(
spec=None, node_type=constants.ENVIRONMENT_NODES_REMOTE, isDefault=False
):
if node_type == constants.ENVIRONMENT_NODES_REMOTE:
isRemote = True
elif node_type == constants.ENVIRONMENT_NODES_LOCAL:
isRemote = False
else:
raise Exception("unsupported node_type '%s'", node_type)
node = Node(spec=spec, isRemote=isRemote, isDefault=isDefault)
return node
def setConnectionInfo(
self,
address: str = "",
port: int = 22,
publicAddress: str = "",
publicPort: int = 22,
username: str = "root",
password: str = "",
privateKeyFile: str = "",
):
self.connection = SshConnection(
address, port, publicAddress, publicPort, username, password, privateKeyFile
)
def connect(self):
if self.sshSession is None:

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

@ -0,0 +1,40 @@
from lisa import log, constants
from .node import Node
class NodeFactory:
@staticmethod
def createNodeFromConfig(config):
node_type = config.get(constants.TYPE)
node = None
if node_type is None:
raise Exception("type of node shouldn't be None")
if node_type in [
constants.ENVIRONMENT_NODES_LOCAL,
constants.ENVIRONMENT_NODES_REMOTE,
]:
is_default = NodeFactory._isDefault(config)
node = Node.createNode(node_type=node_type, isDefault=is_default)
if node is not None:
log.debug("created node '%s'", node_type)
return node
@staticmethod
def createNodeFromSpec(spec, node_type=constants.ENVIRONMENT_NODES_REMOTE):
if node_type == Node.TYPE_REMOTE:
isRemote = True
elif node_type == Node.TYPE_LOCAL:
isRemote = False
else:
raise Exception("unsupported node_type '%s'", node_type)
node = Node.createNode(spec=spec, node_type=node_type, isRemote=isRemote)
return node
@staticmethod
def _isDefault(config) -> bool:
default = config.get(constants.IS_DEFAULT)
if default is not None and default is True:
default = True
else:
default = False
return default

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

@ -17,7 +17,9 @@ def import_module(path, logDetails=True):
packages.append(package_name)
package_dir = os.path.dirname(path)
sys.path.append(package_dir)
log.info("loading extensions from %s", path)
if logDetails:
log.info("loading extensions from %s", path)
for file in glob(os.path.join(path, "**", "*.py"), recursive=True):
file_name = os.path.basename(file)
dir_name = os.path.dirname(file)
@ -40,9 +42,7 @@ def import_module(path, logDetails=True):
full_module_name,
local_module_name,
)
importlib.import_module(
local_module_name, package=local_package_name
)
importlib.import_module(local_module_name, package=local_package_name)
packages = ["lisa"]

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

@ -7,11 +7,14 @@ class Platform(ABC):
def platformType(cls) -> str:
pass
@abstractclassmethod
def config(self, key: str, value: object):
pass
@abstractclassmethod
def requestEnvironment(self, environmentSpec):
pass
@abstractclassmethod
def deleteEnvironment(self, environment: Environment):
pass

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

@ -1,3 +1,4 @@
from lisa.util import constants
from lisa.common.logger import log
from .platform import Platform
from typing import Dict
@ -8,22 +9,39 @@ class PlatformFactory:
self.platforms: Dict[str, Platform] = dict()
def registerPlatform(self, platform):
platform_type = platform.platformType(platform)
platform_type = platform.platformType(platform).lower()
if self.platforms.get(platform_type) is None:
log.info(
"registered platform '%s'", platform_type,
)
self.platforms[platform_type] = platform()
else:
# not sure what happens, subclass returns multiple items for
# same class
# so just log debug level here.
log.debug(
"platform type '%s' already registered", platform_type,
)
raise Exception("platform '%s' exists, cannot be registered again")
def loadPlatform(self, type_name, config):
pass
def initializePlatform(self, config):
# we may extend it later to support multiple platforms
platform_count = len(config)
if platform_count != 1:
raise Exception("There must be 1 and only 1 platform")
platform_type = config[0].get("type")
if platform_type is None:
raise Exception("type of platfrom shouldn't be None")
self._buildFactory()
log.debug(
"registered platforms [%s]",
", ".join([name for name in self.platforms.keys()]),
)
platform = self.platforms.get(platform_type.lower())
if platform is None:
raise Exception("cannot find platform type '%s'" % platform_type)
log.info("activated platform '%s'", platform_type)
platform.config(constants.CONFIG_CONFIG, config[0])
return platform
def _buildFactory(self):
for sub_class in Platform.__subclasses__():
self.registerPlatform(sub_class)
platformFactory = PlatformFactory()
platform_factory = PlatformFactory()

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

@ -0,0 +1,32 @@
from lisa.parameter_parser.config import Config
from lisa.common.logger import log
from lisa import Platform, Environment
from lisa.sut_orchestrator.ready import ReadyPlatform
from lisa import constants
class RuntimeObject:
def __init__(self, config: Config):
# global config
self.config: Config = config
self.environment: Environment = None
self.platform: Platform = None
# do some cross object validation
def validate(self):
environment_config = self.config.getEnvironment()
warn_as_error = None
if environment_config is not None:
warn_as_error = environment_config[constants.WARN_AS_ERROR]
if self.environment.spec is not None and isinstance(
self.platform, ReadyPlatform
):
self._validateMessage(
warn_as_error, "environment spec won't be processed by ready platform."
)
def _validateMessage(self, warn_as_error: bool, message, *args):
if warn_as_error:
raise Exception(message % args)
else:
log.warn(message, *args)

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

@ -0,0 +1,61 @@
import os
class SshConnection:
def __init__(
self,
address: str = "",
port: int = 22,
publicAddress: str = "",
publicPort: int = 22,
username: str = "root",
password: str = "",
privateKeyFile: str = "",
):
self.address = address
self.port = port
self.publicAddress = publicAddress
self.publicPort = publicPort
self.username = username
self.password = password
self.privateKeyFile = privateKeyFile
if (self.address is None or self.address == "") and (
self.publicAddress is None or self.publicAddress == ""
):
raise Exception("at least one of address and publicAddress need to be set")
elif self.address is None or self.address == "":
self.address = self.publicAddress
elif self.publicAddress is None or self.publicAddress == "":
self.publicAddress = self.address
if (self.port is None or self.port <= 0) and (
self.publicPort is None or self.publicPort <= 0
):
raise Exception("at least one of port and publicPort need to be set")
elif self.port is None or self.port <= 0:
self.port = self.publicPort
elif self.publicPort is None or self.publicPort <= 0:
self.publicPort = self.port
if (self.password is None or self.password == "") and (
self.privateKeyFile is None or self.privateKeyFile == ""
):
raise Exception(
"at least one of password and privateKeyFile need to be set"
)
elif self.password is not None and self.password != "":
self.usePassword = True
else:
if not os.path.exists(self.privateKeyFile):
raise FileNotFoundError(self.privateKeyFile)
self.usePassword = False
if self.username is None or self.username == "":
raise Exception("username must be set")
def getInternalConnection(self):
pass
def getPublicConnection(self):
pass

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

@ -53,7 +53,7 @@ class TestFactory:
test_suite = TestSuiteMetadata(test_class, area, category, tags)
self.suites[key] = test_suite
else:
raise Exception("TestFactory duplicate test class name: %s!" % key)
raise Exception("TestFactory duplicate test class name: %s" % key)
class_prefix = "%s." % key
for test_case in self.cases.values():
@ -72,7 +72,7 @@ class TestFactory:
if self.cases.get(full_name) is None:
self.cases[full_name] = test_case
else:
raise Exception("duplicate test class name: %s!" % full_name)
raise Exception("duplicate test class name: %s" % full_name)
# this should be None in current observation.
# the methods are loadded prior to test class

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

@ -8,11 +8,10 @@ from lisa.common import env
from lisa.common.logger import init_log, log
from retry import retry
path_template = "runtime/results/{0}/{0}-{1}"
@retry(FileExistsError, tries=10, delay=0)
def create_result_path():
path_template = "runtime/results/{0}/{0}-{1}"
date = datetime.utcnow().strftime("%Y%m%d")
time = datetime.utcnow().strftime("%H%M%S-%f")[:-3]
current_path = path_template.format(date, time)

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

@ -50,6 +50,11 @@ def parse_args():
support_config_file(list_parser)
support_variable(list_parser)
check_parser = subparsers.add_parser("check")
check_parser.set_defaults(func=command_entries.check)
support_config_file(check_parser)
support_variable(check_parser)
parser.set_defaults(func=command_entries.run)
for sub_parser in subparsers.choices.values():

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

@ -0,0 +1,22 @@
from lisa import constants
class Config(dict):
def __init__(self, config):
self.config = config
def validate(self):
# TODO implement config validation
pass
def getExtensions(self):
return self.config.get(constants.EXTENSIONS)
def getEnvironment(self):
return self.config.get(constants.ENVIRONMENT)
def getPlatform(self):
return self.config.get(constants.PLATFORM)
def getTests(self):
return self.config.get(constants.TESTS)

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

@ -1,3 +1,4 @@
from lisa.parameter_parser.config import Config
import os
import yaml
from lisa import log
@ -12,6 +13,7 @@ def parse(args):
with open(path, "r") as file:
data = yaml.safe_load(file)
log.debug("yaml content: %s", data)
return data
log.debug("final config data: %s", data)
config = Config(data)
return config

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

@ -1,4 +1,4 @@
from lisa import Platform
from lisa import Platform, Environment
class ReadyPlatform(Platform):
@ -6,4 +6,12 @@ class ReadyPlatform(Platform):
return "ready"
def config(self, key: str, value: object):
# ready platform has no config
pass
def requestEnvironment(self, environment: Environment):
return environment
def deleteEnvironment(self, environment: Environment):
# ready platform doesn't support delete environment
pass

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

@ -0,0 +1,66 @@
# config types
CONFIG_CONFIG = "config"
# common
NAME = "name"
VALUE = "value"
TYPE = "type"
PATH = "path"
PATHS = "paths"
STRATEGY = "strategy"
FILE = "file"
HTTP = "http"
IS_DEFAULT = "isDefault"
WARN_AS_ERROR = "warnAsError"
# by level
PARENT = "parent"
OPERATION = "operation"
OPERATION_REMOVE = "remove"
OPERATION_ADD = "add"
OPERATION_OVERWRITE = "overwrite"
EXTENSIONS = "extensions"
VARIABLES = "variables"
VARIABLES_ISSECRET = "isSecret"
ARTIFACT = "artifact"
ARTIFACT_TYPE_VHD = "vhd"
ARTIFACT_LOCATIONS = "locations"
ARTIFACT_LOCATIONS_TYPE_HTTP = "http"
ENVIRONMENT = "environment"
ENVIRONMENT_TOPOLOGY = "topology"
ENVIRONMENT_TEMPLATE = "template"
ENVIRONMENT_TEMPLATE_NODECOUNT = "nodeCount"
ENVIRONMENT_NODES = "nodes"
ENVIRONMENT_NODES_SPEC = "spec"
ENVIRONMENT_NODES_REMOTE = "remote"
ENVIRONMENT_NODES_LOCAL = "local"
ENVIRONMENT_NODES_REMOTE_ADDRESS = "address"
ENVIRONMENT_NODES_REMOTE_PORT = "port"
ENVIRONMENT_NODES_REMOTE_PUBLIC_ADDRESS = "publicAddress"
ENVIRONMENT_NODES_REMOTE_PUBLIC_PORT = "publicPort"
ENVIRONMENT_NODES_REMOTE_USERNAME = "username"
ENVIRONMENT_NODES_REMOTE_PASSWORD = "password"
ENVIRONMENT_NODES_REMOTE_PRIVATEKEYFILE = "privateKeyFile"
NOTIFIER = "notifier"
PLATFORM = "platform"
PLATFORM_AZURE = "azure"
PLATFORM_READY = "ready"
TESTS = "tests"
TESTS_CRITERIA = "criteria"
TESTS_CRITERIA_AREA = "area"
TESTS_CRITERIA_CATEGORY = "category"
TESTS_CRITERIA_PRIORITY = "priority"
TESTS_CRITERIA_TAG = "tag"
TESTS_FORCEINCLUDE = "forceInclude"
TESTS_ITERATION = "iteration"
TESTS_IGNOREFAILURE = "ignoreFailure"
TESTS_INCLUDE = "include"
TESTS_RETRY = "retry"