diff --git a/lisa/mixin_modules.py b/lisa/mixin_modules.py index 21f1c7e9a..7296f024e 100644 --- a/lisa/mixin_modules.py +++ b/lisa/mixin_modules.py @@ -30,6 +30,19 @@ try: except ModuleNotFoundError as e: print(f"azure package is not installed. [{e}]") +# Baremetal modules +try: + import lisa.sut_orchestrator.baremetal.build # noqa: F401 + import lisa.sut_orchestrator.baremetal.cluster.cluster # noqa: F401 + import lisa.sut_orchestrator.baremetal.cluster.idrac # noqa: F401 + import lisa.sut_orchestrator.baremetal.cluster.rackmanager # noqa: F401 + import lisa.sut_orchestrator.baremetal.ip_getter # noqa: F401 + import lisa.sut_orchestrator.baremetal.platform_ # noqa: F401 + import lisa.sut_orchestrator.baremetal.readychecker # noqa: F401 + import lisa.sut_orchestrator.baremetal.source # noqa: F401 +except ModuleNotFoundError as e: + print(f"baremetal package is not installed. [{e}]") + # Aws modules try: import lisa.sut_orchestrator.aws.platform_ # noqa: F401 diff --git a/lisa/operating_system.py b/lisa/operating_system.py index 902f74643..4e7456e9d 100644 --- a/lisa/operating_system.py +++ b/lisa/operating_system.py @@ -126,6 +126,7 @@ class OperatingSystem: __debian_issue_pattern = re.compile(r"^([^ ]+) ?.*$") __release_pattern = re.compile(r"^DISTRIB_ID='?([^ \n']+).*$", re.M) __suse_release_pattern = re.compile(r"^(SUSE).*$", re.M) + __bmc_release_pattern = re.compile(r".*(wcscli).*$", re.M) __posix_factory: Optional[Factory[Any]] = None @@ -208,6 +209,9 @@ class OperatingSystem: @classmethod def _get_detect_string(cls, node: Any) -> Iterable[str]: typed_node: Node = node + cmd_result = typed_node.execute(cmd="wcscli", no_error_log=True) + yield get_matched_str(cmd_result.stdout, cls.__bmc_release_pattern) + cmd_result = typed_node.execute(cmd="lsb_release -d", no_error_log=True) yield get_matched_str(cmd_result.stdout, cls.__lsb_release_pattern) @@ -663,6 +667,12 @@ class BSD(Posix): ... +class BMC(Posix): + @classmethod + def name_pattern(cls) -> Pattern[str]: + return re.compile("^wcscli$") + + class MacOS(Posix): @classmethod def name_pattern(cls) -> Pattern[str]: diff --git a/lisa/schema.py b/lisa/schema.py index 05240baa2..f98a1dd49 100644 --- a/lisa/schema.py +++ b/lisa/schema.py @@ -1311,7 +1311,7 @@ class Platform(TypedSchema, ExtendableSchemaMixin): add_secret(self.admin_username, PATTERN_HEADTAIL) add_secret(self.admin_password) - if self.type != constants.PLATFORM_READY: + if self.type not in [constants.PLATFORM_READY, constants.PLATFORM_BAREMETAL]: if not self.admin_password and not self.admin_private_key_file: raise LisaException( "one of admin_password and admin_private_key_file must be set" diff --git a/lisa/sut_orchestrator/__init__.py b/lisa/sut_orchestrator/__init__.py index 7ace00264..d98e6c9d3 100644 --- a/lisa/sut_orchestrator/__init__.py +++ b/lisa/sut_orchestrator/__init__.py @@ -1,9 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from lisa.util.constants import PLATFORM_READY +from lisa.util.constants import PLATFORM_BAREMETAL, PLATFORM_READY AZURE = "azure" QEMU = "qemu" CLOUD_HYPERVISOR = "cloud-hypervisor" AWS = "aws" READY = PLATFORM_READY +BAREMETAL = PLATFORM_BAREMETAL diff --git a/lisa/sut_orchestrator/baremetal/__init__.py b/lisa/sut_orchestrator/baremetal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lisa/sut_orchestrator/baremetal/build.py b/lisa/sut_orchestrator/baremetal/build.py new file mode 100644 index 000000000..8a8a1ba47 --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/build.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import os +import re +from pathlib import Path +from typing import Dict, List, Type + +from smb.SMBConnection import SMBConnection # type: ignore + +from lisa import schema +from lisa.util import ContextMixin, InitializableMixin, subclasses +from lisa.util.logger import get_logger + +from .schema import BuildSchema, FileSchema, SMBBuildSchema + + +class Build(subclasses.BaseClassWithRunbookMixin, ContextMixin, InitializableMixin): + def __init__(self, runbook: BuildSchema) -> None: + super().__init__(runbook=runbook) + self._log = get_logger("cluster", self.__class__.__name__) + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return BuildSchema + + def copy(self, sources_path: List[Path], files_map: List[FileSchema]) -> None: + raise NotImplementedError() + + +class SMBBuild(Build): + def __init__(self, runbook: SMBBuildSchema) -> None: + super().__init__(runbook) + self.smb_runbook: SMBBuildSchema = self.runbook + + @classmethod + def type_name(cls) -> str: + return "smb" + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return SMBBuildSchema + + def copy(self, sources_path: List[Path], files_map: List[FileSchema]) -> None: + username = self.smb_runbook.username + password = self.smb_runbook.password + server_name = self.smb_runbook.server_name + share_name = self.smb_runbook.share + + with SMBConnection( + username=username, + password=password, + my_name="", + remote_name=server_name, + ) as conn: + conn.connect(server_name) + + for file, file_map in self._find_matched_files( + sources_path, files_map + ).items(): + with open(file, "rb") as f: + if file_map.destination: + attrs = conn.getAttributes( + share_name, file_map.destination, timeout=30 + ) + if attrs.isDirectory: + file_name = ( + file_map.destination + "\\" + file.rsplit("\\")[-1] + ) + else: + file_name = file_map.destination + conn.storeFile(share_name, file_name, f) + else: + file_name = file.rsplit("\\")[-1] + conn.storeFile( + share_name, + file_name, + f, + ) + self._log.debug(f"copy file {file} to {share_name}\\{file_name}") + + def _find_matched_files( + self, sources_path: List[Path], files_map: List[FileSchema] + ) -> Dict[str, FileSchema]: + all_files = [] + match_files: Dict[str, FileSchema] = {} + for source_path in sources_path: + for root, _, files in os.walk(source_path): + for file in files: + all_files.append(os.path.join(root, file)) + + for file_map in files_map: + file_path = rf"{source_path}\{file_map.source}".replace("\\", "\\\\") + pattern = re.compile( + file_path, + re.I | re.M, + ) + for file in all_files: + if pattern.match(file): + match_files[file] = file_map + return match_files diff --git a/lisa/sut_orchestrator/baremetal/cluster/__init__.py b/lisa/sut_orchestrator/baremetal/cluster/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lisa/sut_orchestrator/baremetal/cluster/cluster.py b/lisa/sut_orchestrator/baremetal/cluster/cluster.py new file mode 100644 index 000000000..c8fd607e7 --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/cluster/cluster.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import Any, Type + +from lisa import features, schema +from lisa.environment import Environment +from lisa.util import InitializableMixin, subclasses +from lisa.util.logger import get_logger + +from ..schema import ClusterSchema + + +class Cluster(subclasses.BaseClassWithRunbookMixin, InitializableMixin): + def __init__( + self, + runbook: ClusterSchema, + ) -> None: + super().__init__(runbook=runbook) + self.cluster_runbook: ClusterSchema = self.runbook + self._log = get_logger("cluster", self.__class__.__name__) + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return ClusterSchema + + def deploy(self, environment: Environment) -> Any: + raise NotImplementedError() + + def has_serial_console(self) -> bool: + raise NotImplementedError() + + def get_serial_console(self) -> Type[features.SerialConsole]: + raise NotImplementedError() + + def get_start_stop(self) -> Type[features.StartStop]: + raise NotImplementedError() diff --git a/lisa/sut_orchestrator/baremetal/cluster/idrac.py b/lisa/sut_orchestrator/baremetal/cluster/idrac.py new file mode 100644 index 000000000..10c794ee3 --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/cluster/idrac.py @@ -0,0 +1,186 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import time +import xml.etree.ElementTree as ETree +from typing import Any, Type + +import redfish # type: ignore +from assertpy import assert_that + +from lisa import features, schema +from lisa.environment import Environment +from lisa.util import LisaException +from lisa.util.logger import get_logger +from lisa.util.perf_timer import create_timer + +from ..platform_ import BareMetalPlatform +from ..schema import ClusterSchema, IdracSchema +from .cluster import Cluster + + +class IdracStartStop(features.StartStop): + def _login(self) -> None: + platform: BareMetalPlatform = self._platform # type: ignore + self.cluster: Idrac = platform.cluster # type: ignore + self.cluster.login() + + def _logout(self) -> None: + platform: BareMetalPlatform = self._platform # type: ignore + self.cluster = platform.cluster # type: ignore + self.cluster.logout() + + def _stop( + self, wait: bool = True, state: features.StopState = features.StopState.Shutdown + ) -> None: + if state == features.StopState.Hibernate: + raise NotImplementedError( + "baremetal orchestrator does not support hibernate stop" + ) + self._login() + if self.cluster.get_power_state() == "Off": + self._log.debug("System is already off.") + return + self.cluster.reset("GracefulShutdown") + self._logout() + + def _start(self, wait: bool = True) -> None: + self._login() + if self.cluster.get_power_state() == "On": + self._log.debug("System is already powered on.") + return + self.cluster.reset("On") + self._logout() + + def _restart(self, wait: bool = True) -> None: + self._login() + self.cluster.reset("ForceRestart") + self._logout() + + +class Idrac(Cluster): + def __init__(self, runbook: ClusterSchema) -> None: + super().__init__(runbook) + self.idrac_runbook: IdracSchema = self.runbook + self._log = get_logger("idrac", self.__class__.__name__) + assert_that(len(self.idrac_runbook.client)).described_as( + "only one client is supported for idrac, don't specify more than one client" + ).is_equal_to(1) + + self.client = self.idrac_runbook.client[0] + + @classmethod + def type_name(cls) -> str: + return "idrac" + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return IdracSchema + + def get_start_stop(self) -> Type[features.StartStop]: + return IdracStartStop + + def deploy(self, environment: Environment) -> Any: + self.login() + self._eject_virtual_media() + self._change_boot_order_once("VCD-DVD") + assert self.client.iso_http_url, "iso_http_url is required for idrac client" + if self.get_power_state() == "Off": + self._log.debug("System is already off.") + else: + self.reset("GracefulShutdown") + self._insert_virtual_media(self.client.iso_http_url) + self.reset("On") + self.logout() + + def reset(self, operation: str) -> None: + body = {"ResetType": operation} + response = self.redfish_instance.post( + "/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset", + body=body, + ) + self._wait_for_completion(response) + self._log.debug(f"{operation} initiated successfully.") + + def get_power_state(self) -> str: + response = self.redfish_instance.get( + "/redfish/v1/Systems/System.Embedded.1/", + ) + return str(response.dict["PowerState"]) + + def login(self) -> None: + self.redfish_instance = redfish.redfish_client( + base_url="https://" + self.idrac_runbook.address, + username=self.idrac_runbook.username, + password=self.idrac_runbook.password, + ) + self.redfish_instance.login(auth="session") + self._log.debug(f"Login to {self.redfish_instance.get_base_url()} successful.") + + def logout(self) -> None: + self._log.debug("Logging out...") + self.redfish_instance.logout() + + def _wait_for_completion(self, response: Any, timeout: int = 600) -> None: + if response.is_processing: + task = response.monitor(self.redfish_instance) + timer = create_timer() + while task.is_processing and timer.elapsed(False) < timeout: + retry_time = task.retry_after + time.sleep(retry_time if retry_time else 5) + task = response.monitor(self.redfish_instance) + + if response.status not in [200, 202, 204]: + raise LisaException("Failed to complete task! - status:", response.status) + + def _insert_virtual_media(self, iso_http_url: str) -> None: + self._log.debug("Inserting virtual media...") + body = {"Image": iso_http_url} + response = self.redfish_instance.post( + "/redfish/v1/Managers/iDRAC.Embedded.1/VirtualMedia/CD/Actions/" + "VirtualMedia.InsertMedia", + body=body, + ) + self._wait_for_completion(response) + self._log.debug("Inserting virtual media completed...") + + def _eject_virtual_media(self) -> None: + self._log.debug("Ejecting virtual media...") + response = self.redfish_instance.post( + "/redfish/v1/Managers/iDRAC.Embedded.1/VirtualMedia/CD/Actions/" + "VirtualMedia.EjectMedia", + body={}, + ) + + # Ignore return on failure as it is ok if no media was attached + if response.status in [200, 202, 204]: + self._wait_for_completion(response) + + def _change_boot_order_once(self, boot_from: str) -> None: + self._log.debug(f"Updating boot source to {boot_from}") + sys_config = ETree.Element("SystemConfiguration") + component = ETree.SubElement( + sys_config, "Component", {"FQDD": "iDRAC.Embedded.1"} + ) + boot_once_attribute = ETree.SubElement( + component, "Attribute", {"Name": "VirtualMedia.1#BootOnce"} + ) + boot_once_attribute.text = "Enabled" + first_boot_attribute = ETree.SubElement( + component, "Attribute", {"Name": "ServerBoot.1#FirstBootDevice"} + ) + first_boot_attribute.text = boot_from + import_buffer = ETree.tostring( + sys_config, encoding="utf8", method="html" + ).decode() + + body = {"ShareParameters": {"Target": "ALL"}, "ImportBuffer": import_buffer} + response = self.redfish_instance.post( + "/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Oem/" + "EID_674_Manager.ImportSystemConfiguration", + body=body, + ) + + self._log.debug("Waiting for boot order override task to complete...") + self._wait_for_completion(response) + self._log.debug(f"Updating boot source to {boot_from} completed") diff --git a/lisa/sut_orchestrator/baremetal/cluster/rackmanager.py b/lisa/sut_orchestrator/baremetal/cluster/rackmanager.py new file mode 100644 index 000000000..7915d4bc6 --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/cluster/rackmanager.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import Any, Type + +from lisa import features, schema +from lisa.environment import Environment +from lisa.node import quick_connect +from lisa.util.logger import get_logger + +from ..platform_ import BareMetalPlatform +from ..schema import RackManagerSchema +from .cluster import Cluster + + +class RackManagerStartStop(features.StartStop): + def init_rack_manager(self) -> None: + platform: BareMetalPlatform = self._platform # type: ignore + self.cluster: RackManager = platform.cluster # type: ignore + + def _stop( + self, wait: bool = True, state: features.StopState = features.StopState.Shutdown + ) -> None: + if state == features.StopState.Hibernate: + raise NotImplementedError( + "baremetal orchestrator does not support hibernate stop" + ) + self.init_rack_manager() + self.cluster.reset("off") + + def _start(self, wait: bool = True) -> None: + self.init_rack_manager() + self.cluster.reset("on") + + def _restart(self, wait: bool = True) -> None: + self.init_rack_manager() + self.cluster.reset("reset") + + +class RackManager(Cluster): + def __init__(self, runbook: RackManagerSchema) -> None: + super().__init__(runbook) + self.rm_runbook: RackManagerSchema = self.runbook + self._log = get_logger("rackmanager", self.__class__.__name__) + + @classmethod + def type_name(cls) -> str: + return "rackmanager" + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return RackManagerSchema + + def get_start_stop(self) -> Type[features.StartStop]: + return RackManagerStartStop + + def deploy(self, environment: Environment) -> Any: + self.reset("off") + self.reset("on") + + def reset(self, operation: str) -> None: + assert self.rm_runbook.connection, "connection is required for rackmanager" + self.rm_runbook.connection.name = "rackmanager" + rm_node = quick_connect(self.rm_runbook.connection, logger_name="rackmanager") + assert self.rm_runbook.client, "client is required for rackmanager" + for client in self.rm_runbook.client: + assert ( + client.management_port + ), "management_port is required for rackmanager client" + rm_node.execute(f"set system {operation} -i {client.management_port}") + + self._log.debug(f"client has been {operation} successfully") diff --git a/lisa/sut_orchestrator/baremetal/context.py b/lisa/sut_orchestrator/baremetal/context.py new file mode 100644 index 000000000..fe68db647 --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/context.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass + +from lisa import schema +from lisa.environment import Environment +from lisa.node import Node + +from .build import Build + + +@dataclass +class EnvironmentContext: + ssh_public_key: str = "" + + +@dataclass +class NodeContext: + connection: schema.ConnectionInfo = schema.ConnectionInfo(password="mock") + + +@dataclass +class BuildContext: + is_copied: bool = False + + +def get_environment_context(environment: Environment) -> EnvironmentContext: + return environment.get_context(EnvironmentContext) + + +def get_node_context(node: Node) -> NodeContext: + return node.get_context(NodeContext) + + +def get_build_context(build: Build) -> BuildContext: + return build.get_context(BuildContext) diff --git a/lisa/sut_orchestrator/baremetal/features.py b/lisa/sut_orchestrator/baremetal/features.py new file mode 100644 index 000000000..f676b4c4b --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/features.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + + +from typing import TYPE_CHECKING, Any + +from lisa import schema +from lisa.feature import Feature + +if TYPE_CHECKING: + from .platform_ import BareMetalPlatform + + +class ClusterFeature(Feature): + def __getattr__(self, key: str) -> Any: + assert self._inner, "inner is not set" + return getattr(self._inner, key) + + def _initialize(self, *args: Any, **kwargs: Any) -> None: + _feature_type = self._get_inner_type() + self._inner = _feature_type( + schema.FeatureSettings.create(_feature_type.name()), + self._node, + self._platform, + *args, + **kwargs, + ) + + +class StartStop(ClusterFeature): + def _get_inner_type(self) -> Feature: + platform: BareMetalPlatform = self._platform # type: ignore + return platform.cluster.get_start_stop() # type: ignore + + +class SerialConsole(ClusterFeature): + def _get_inner_type(self) -> Feature: + platform: BareMetalPlatform = self._platform # type: ignore + return platform.cluster.get_serial_console() # type: ignore diff --git a/lisa/sut_orchestrator/baremetal/ip_getter.py b/lisa/sut_orchestrator/baremetal/ip_getter.py new file mode 100644 index 000000000..3b7a59815 --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/ip_getter.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import re +from dataclasses import dataclass +from typing import Type + +from dataclasses_json import dataclass_json + +from lisa import schema +from lisa.util import InitializableMixin, get_matched_str, subclasses +from lisa.util.logger import get_logger + +from .schema import IpGetterSchema + + +class IpGetterChecker(subclasses.BaseClassWithRunbookMixin, InitializableMixin): + def __init__( + self, + runbook: IpGetterSchema, + ) -> None: + super().__init__(runbook=runbook) + self.ip_getter_runbook: IpGetterSchema = self.runbook + self._log = get_logger("ip_getter", self.__class__.__name__) + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return IpGetterSchema + + def get_ip(self) -> str: + raise NotImplementedError() + + +@dataclass_json() +@dataclass +class FileSingleSchema(IpGetterSchema): + file: str = "" + + +class FileSingleChecker(IpGetterChecker): + # ipaddr=X.XX.XXX.X + __ip_addr_regex = re.compile(r"(?P[\d.]+)", re.M) + + def __init__( + self, + runbook: FileSingleSchema, + ) -> None: + super().__init__(runbook=runbook) + self.file_single_runbook: FileSingleSchema = self.runbook + self._log = get_logger("file_single", self.__class__.__name__) + + @classmethod + def type_name(cls) -> str: + return "file_single" + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return FileSingleSchema + + def get_ip(self) -> str: + with open(self.file_single_runbook.file) as f: + lines = f.readlines() + matched = get_matched_str(" ".join(lines), self.__ip_addr_regex, True) + assert matched is not None, ( + f"Could not get ip from content of file {self.file_single_runbook.file}" + f" {' '.join(lines)}" + ) + return matched diff --git a/lisa/sut_orchestrator/baremetal/key_loader.py b/lisa/sut_orchestrator/baremetal/key_loader.py new file mode 100644 index 000000000..a49453a1d --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/key_loader.py @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import List, Type + +from dataclasses_json import dataclass_json + +from lisa import schema +from lisa.util import InitializableMixin, subclasses +from lisa.util.logger import get_logger + +from .schema import KeyLoaderSchema + + +class KeyLoader(subclasses.BaseClassWithRunbookMixin, InitializableMixin): + def __init__( + self, + runbook: KeyLoaderSchema, + ) -> None: + super().__init__(runbook=runbook) + self.key_loader_runbook: KeyLoaderSchema = self.runbook + self._log = get_logger("key_loader", self.__class__.__name__) + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return KeyLoaderSchema + + def load_key(self, sources_path: List[Path]) -> str: + raise NotImplementedError() + + +@dataclass_json() +@dataclass +class BuildSchema(KeyLoaderSchema): + file: str = "" + pattern: str = "id_rsa.*" + + +class BuildLoader(KeyLoader): + def __init__( + self, + runbook: BuildSchema, + ) -> None: + super().__init__(runbook=runbook) + self.key_file_runbook: BuildSchema = self.runbook + self._log = get_logger("build", self.__class__.__name__) + + @classmethod + def type_name(cls) -> str: + return "build" + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return BuildSchema + + def load_key(self, sources_path: List[Path]) -> str: + pattern = re.compile( + self.key_file_runbook.pattern, + re.I | re.M, + ) + for source_path in sources_path: + for filename in os.listdir(source_path): + if pattern.match(filename): + return os.path.join(source_path, filename) + return "" diff --git a/lisa/sut_orchestrator/baremetal/platform_.py b/lisa/sut_orchestrator/baremetal/platform_.py new file mode 100644 index 000000000..6a50b5384 --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/platform_.py @@ -0,0 +1,233 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from pathlib import Path +from typing import Any, List, Optional, Type + +from lisa import RemoteNode, feature, schema, search_space +from lisa.environment import Environment +from lisa.platform_ import Platform +from lisa.util import fields_to_dict +from lisa.util.logger import Logger +from lisa.util.shell import try_connect +from lisa.util.subclasses import Factory + +from .. import BAREMETAL +from .build import Build +from .cluster.cluster import Cluster +from .context import get_build_context, get_node_context +from .features import SerialConsole, StartStop +from .ip_getter import IpGetterChecker +from .key_loader import KeyLoader +from .readychecker import ReadyChecker +from .schema import BareMetalPlatformSchema, BuildSchema +from .source import Source + + +class BareMetalPlatform(Platform): + def __init__( + self, + runbook: schema.Platform, + ) -> None: + super().__init__(runbook=runbook) + + @classmethod + def type_name(cls) -> str: + return BAREMETAL + + @classmethod + def supported_features(cls) -> List[Type[feature.Feature]]: + return [StartStop, SerialConsole] + + def _initialize(self, *args: Any, **kwargs: Any) -> None: + baremetal_runbook: BareMetalPlatformSchema = self.runbook.get_extended_runbook( + BareMetalPlatformSchema + ) + assert baremetal_runbook, "platform runbook cannot be empty" + self._baremetal_runbook = baremetal_runbook + self.local_artifacts_path: Optional[List[Path]] = None + self.ready_checker_factory = Factory[ReadyChecker](ReadyChecker) + self.cluster_factory = Factory[Cluster](Cluster) + self.ip_getter_factory = Factory[IpGetterChecker](IpGetterChecker) + self.key_loader_factory = Factory[KeyLoader](KeyLoader) + self.source_factory = Factory[Source](Source) + self.build_factory = Factory[Build](Build) + + def _prepare_environment(self, environment: Environment, log: Logger) -> bool: + return self._configure_node_capabilities(environment, log) + + def _deploy_environment(self, environment: Environment, log: Logger) -> None: + # currently only support one cluster + assert self._baremetal_runbook.cluster, "no cluster is specified in the runbook" + cluster_instance = self._baremetal_runbook.cluster[0] + + self.cluster = self.cluster_factory.create_by_runbook(cluster_instance) + assert self.cluster.runbook.client, "no client is specified in the runbook" + + # copy build (shared, check if it's copied) + if self._baremetal_runbook.source: + if not self.local_artifacts_path: + source = self.source_factory.create_by_runbook( + self._baremetal_runbook.source + ) + self._log.debug(f"source build '{source.type_name()}'") + self.local_artifacts_path = source.download() + else: + self._log.debug( + "build source has been downloaded in " + f"'{self.local_artifacts_path}'," + " skip download again" + ) + else: + self._log.debug("no build source is specified in the runbook") + + ready_checker: Optional[ReadyChecker] = None + # ready checker cleanup + if cluster_instance.ready_checker: + ready_checker = self.ready_checker_factory.create_by_runbook( + cluster_instance.ready_checker + ) + ready_checker.clean_up() + + # copy build if source exists + if self.cluster.runbook.build: + build = self.build_factory.create_by_runbook(self.cluster.runbook.build) + build_context = get_build_context(build) + if build_context.is_copied: + self._log.debug("build is already copied, skip copy") + else: + assert self.local_artifacts_path, "no build source is specified" + self.copy( + self.cluster.runbook.build, sources_path=self.local_artifacts_path + ) + build_context.is_copied = True + + if self.cluster.runbook.key_loader: + key_loader = self.key_loader_factory.create_by_runbook( + self.cluster.runbook.key_loader + ) + if self.local_artifacts_path: + key_file = key_loader.load_key(self.local_artifacts_path) + + assert environment.runbook.nodes_requirement, "no node is specified" + for node_space in environment.runbook.nodes_requirement: + assert isinstance( + node_space, schema.NodeSpace + ), f"actual: {type(node_space)}" + environment.create_node_from_requirement(node_space) + + for index, node in enumerate(environment.nodes.list()): + node_context = get_node_context(node) + + if ( + not self.cluster.runbook.client[index].connection.password + and self.cluster.runbook.client[index].connection.private_key_file == "" + ): + self.cluster.runbook.client[ + index + ].connection.private_key_file = key_file + + connection_info = schema.ConnectionInfo( + address=self.cluster.runbook.client[index].connection.address, + port=self.cluster.runbook.client[index].connection.port, + username=self.cluster.runbook.client[index].connection.username, + private_key_file=self.cluster.runbook.client[ + index + ].connection.private_key_file, + password=self.cluster.runbook.client[index].connection.password, + ) + + node_context.connection = connection_info + index = index + 1 + + # deploy cluster + self.cluster.deploy(environment) + + if cluster_instance.ready_checker: + ready_checker = self.ready_checker_factory.create_by_runbook( + cluster_instance.ready_checker + ) + + for index, node in enumerate(environment.nodes.list()): + node_context = get_node_context(node) + + # ready checker + if ready_checker: + ready_checker.is_ready(node) + + # get ip address + if cluster_instance.ip_getter: + ip_getter = self.ip_getter_factory.create_by_runbook( + cluster_instance.ip_getter + ) + node_context.connection.address = ip_getter.get_ip() + + assert isinstance(node, RemoteNode), f"actual: {type(node)}" + node.name = f"node_{index}" + node.set_connection_info( + **fields_to_dict( + node_context.connection, + ["address", "port", "username", "password", "private_key_file"], + ), + ) + try_connect(connection_info) + + self._log.debug(f"deploy environment {environment.name} successfully") + + def copy(self, build_schema: BuildSchema, sources_path: List[Path]) -> None: + if sources_path: + build = self.build_factory.create_by_runbook(build_schema) + build.copy( + sources_path=sources_path, + files_map=build_schema.files, + ) + else: + self._log.debug("no copied source path specified, skip copy") + + def _configure_node_capabilities( + self, environment: Environment, log: Logger + ) -> bool: + if not environment.runbook.nodes_requirement: + return True + + nodes_capabilities = self._create_node_capabilities() + + nodes_requirement = [] + for node_space in environment.runbook.nodes_requirement: + if not node_space.check(nodes_capabilities): + return False + + node_requirement = node_space.generate_min_capability(nodes_capabilities) + nodes_requirement.append(node_requirement) + + environment.runbook.nodes_requirement = nodes_requirement + return True + + def _create_node_capabilities(self) -> schema.NodeSpace: + node_capabilities = schema.NodeSpace() + node_capabilities.name = "baremetal" + node_capabilities.node_count = 1 + node_capabilities.core_count = search_space.IntRange(min=1, max=1) + node_capabilities.disk = schema.DiskOptionSettings( + data_disk_count=search_space.IntRange(min=0), + data_disk_size=search_space.IntRange(min=1), + ) + node_capabilities.network_interface = schema.NetworkInterfaceOptionSettings() + node_capabilities.network_interface.max_nic_count = 1 + node_capabilities.network_interface.nic_count = 1 + node_capabilities.network_interface.data_path = search_space.SetSpace[ + schema.NetworkDataPath + ]( + is_allow_set=True, + items=[schema.NetworkDataPath.Sriov, schema.NetworkDataPath.Synthetic], + ) + node_capabilities.gpu_count = 0 + node_capabilities.features = search_space.SetSpace[schema.FeatureSettings]( + is_allow_set=True, + items=[ + schema.FeatureSettings.create(SerialConsole.name()), + schema.FeatureSettings.create(StartStop.name()), + ], + ) + + return node_capabilities diff --git a/lisa/sut_orchestrator/baremetal/readychecker.py b/lisa/sut_orchestrator/baremetal/readychecker.py new file mode 100644 index 000000000..eed06107e --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/readychecker.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import os +from dataclasses import dataclass +from typing import Type, cast + +from dataclasses_json import dataclass_json + +from lisa import schema +from lisa.node import Node, RemoteNode +from lisa.util import InitializableMixin, check_till_timeout, fields_to_dict, subclasses +from lisa.util.logger import get_logger +from lisa.util.shell import try_connect + +from .context import get_node_context +from .schema import ReadyCheckerSchema + + +class ReadyChecker(subclasses.BaseClassWithRunbookMixin, InitializableMixin): + def __init__( + self, + runbook: ReadyCheckerSchema, + ) -> None: + super().__init__(runbook=runbook) + self.ready_checker_runbook: ReadyCheckerSchema = self.runbook + self._log = get_logger("ready_checker", self.__class__.__name__) + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return ReadyCheckerSchema + + def clean_up(self) -> None: + pass + + def is_ready(self, node: Node) -> bool: + return False + + +@dataclass_json() +@dataclass +class FileSingleSchema(ReadyCheckerSchema): + file: str = "" + + +class FileSingleChecker(ReadyChecker): + def __init__( + self, + runbook: FileSingleSchema, + ) -> None: + super().__init__(runbook=runbook) + self.file_single_runbook: FileSingleSchema = self.runbook + self._log = get_logger("file_single", self.__class__.__name__) + + @classmethod + def type_name(cls) -> str: + return "file_single" + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return FileSingleSchema + + def clean_up(self) -> None: + if os.path.exists(self.file_single_runbook.file): + os.remove(self.file_single_runbook.file) + self._log.debug( + f"The file {self.file_single_runbook.file} has been removed" + ) + else: + self._log.debug( + f"The file {self.file_single_runbook.file} does not exist," + " so it doesn't need to be cleaned up." + ) + + def is_ready(self, node: Node) -> bool: + check_till_timeout( + lambda: os.path.exists(self.file_single_runbook.file) is True, + timeout_message="wait for ready check ready", + timeout=self.file_single_runbook.timeout, + ) + return os.path.exists(self.file_single_runbook.file) + + +@dataclass_json() +@dataclass +class SshSchema(ReadyCheckerSchema): + ... + + +class SshChecker(ReadyChecker): + def __init__( + self, + runbook: SshSchema, + ) -> None: + super().__init__(runbook=runbook) + self.ssh_runbook: SshSchema = self.runbook + self._log = get_logger("ssh", self.__class__.__name__) + + @classmethod + def type_name(cls) -> str: + return "ssh" + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return SshSchema + + def is_ready(self, node: Node) -> bool: + context = get_node_context(node) + remote_node = cast(RemoteNode, node) + remote_node.set_connection_info( + **fields_to_dict( + context.connection, + ["address", "port", "username", "password", "private_key_file"], + ), + ) + self._log.debug("try to connect to the client") + try_connect(context.connection, ssh_timeout=self.ssh_runbook.timeout) + self._log.debug("client has been connected successfully") + return True diff --git a/lisa/sut_orchestrator/baremetal/schema.py b/lisa/sut_orchestrator/baremetal/schema.py new file mode 100644 index 000000000..5840fcc85 --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/schema.py @@ -0,0 +1,155 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +from dataclasses import dataclass, field +from typing import Any, List, Optional + +from dataclasses_json import dataclass_json + +from lisa import schema +from lisa.secret import PATTERN_HEADTAIL, add_secret +from lisa.util import field_metadata + + +@dataclass_json() +@dataclass +class ClientSchema: + connection: Optional[schema.RemoteNode] = field( + default=None, metadata=field_metadata(required=True) + ) + + +@dataclass_json() +@dataclass +class RackManagerClientSchema(ClientSchema): + management_port: Optional[int] = field(default=-1) + + +@dataclass_json() +@dataclass +class IdracClientSchema(ClientSchema): + iso_http_url: Optional[str] = field(default="") + + +@dataclass_json() +@dataclass +class ReadyCheckerSchema(schema.TypedSchema, schema.ExtendableSchemaMixin): + type: str = field(default="file_single", metadata=field_metadata(required=True)) + timeout: int = 300 + + +@dataclass_json() +@dataclass +class FileSchema: + source: str = field(default="") + destination: Optional[str] = field(default="") + + +@dataclass_json() +@dataclass +class BuildSchema(schema.TypedSchema, schema.ExtendableSchemaMixin): + type: str = field(default="smb", metadata=field_metadata(required=True)) + name: str = "" + share: str = "" + files: List[FileSchema] = field(default_factory=list) + + +@dataclass_json() +@dataclass +class IpGetterSchema(schema.TypedSchema, schema.ExtendableSchemaMixin): + type: str = field(default="file_single", metadata=field_metadata(required=True)) + + +@dataclass_json() +@dataclass +class KeyLoaderSchema(schema.TypedSchema, schema.ExtendableSchemaMixin): + type: str = field(default="build", metadata=field_metadata(required=True)) + + +@dataclass_json() +@dataclass +class ClusterSchema(schema.TypedSchema, schema.ExtendableSchemaMixin): + type: str = field(default="rackmanager", metadata=field_metadata(required=True)) + build: Optional[BuildSchema] = None + ready_checker: Optional[ReadyCheckerSchema] = None + ip_getter: Optional[IpGetterSchema] = None + key_loader: Optional[KeyLoaderSchema] = None + + +@dataclass_json() +@dataclass +class SourceSchema(schema.TypedSchema, schema.ExtendableSchemaMixin): + type: str = field(default="ado", metadata=field_metadata(required=True)) + name: str = "" + + +@dataclass_json() +@dataclass +class Artifact: + artifact_name: str = "" + extract: bool = True + + +@dataclass_json() +@dataclass +class ADOSourceSchema(SourceSchema): + organization_url: str = field(default="", metadata=field_metadata(required=True)) + project: str = field(default="", metadata=field_metadata(required=True)) + build_id: int = 0 + pipeline_name: str = "" + pat: str = field(default="", metadata=field_metadata(required=True)) + artifacts: List[Artifact] = field(default_factory=list) + + def __post_init__(self, *args: Any, **kwargs: Any) -> None: + if not self.organization_url: + raise ValueError("organization_url cannot be empty") + if not self.project: + raise ValueError("project cannot be empty") + if not self.pat: + raise ValueError("pat cannot be empty") + if not self.artifacts: + raise ValueError("artifacts cannot be empty") + if not self.build_id and not self.pipeline_name: + raise ValueError("build_id and pipeline_name are both empty") + add_secret(self.pat) + + +@dataclass_json() +@dataclass +class SMBBuildSchema(BuildSchema): + username: str = "" + password: str = "" + share: str = "" + server_name: str = "" + + def __post_init__(self, *args: Any, **kwargs: Any) -> None: + add_secret(self.username, PATTERN_HEADTAIL) + add_secret(self.password) + + +@dataclass_json() +@dataclass +class IdracSchema(ClusterSchema): + address: str = "" + username: str = "" + password: str = "" + client: List[IdracClientSchema] = field(default_factory=list) + + def __post_init__(self, *args: Any, **kwargs: Any) -> None: + add_secret(self.username, PATTERN_HEADTAIL) + add_secret(self.password) + + +@dataclass_json() +@dataclass +class RackManagerSchema(ClusterSchema): + connection: Optional[schema.RemoteNode] = field( + default=None, metadata=field_metadata(required=True) + ) + client: List[RackManagerClientSchema] = field(default_factory=list) + + +@dataclass_json() +@dataclass +class BareMetalPlatformSchema: + source: Optional[SourceSchema] = field(default=None) + cluster: List[ClusterSchema] = field(default_factory=list) diff --git a/lisa/sut_orchestrator/baremetal/source.py b/lisa/sut_orchestrator/baremetal/source.py new file mode 100644 index 000000000..b3daf0e99 --- /dev/null +++ b/lisa/sut_orchestrator/baremetal/source.py @@ -0,0 +1,132 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +import os +import re +import zipfile +from pathlib import Path +from typing import List, Type + +import requests +from assertpy import assert_that +from azure.devops.connection import Connection # type: ignore +from msrest.authentication import BasicAuthentication + +from lisa import schema +from lisa.util import InitializableMixin, constants, get_matched_str, subclasses +from lisa.util.logger import get_logger + +from .schema import ADOSourceSchema, SourceSchema + + +class Source(subclasses.BaseClassWithRunbookMixin, InitializableMixin): + def __init__(self, runbook: SourceSchema) -> None: + super().__init__(runbook=runbook) + self._log = get_logger("source", self.__class__.__name__) + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return SourceSchema + + def download(self, timeout: int = 600) -> List[Path]: + raise NotImplementedError() + + +class ADOSource(Source): + __file_format = re.compile(r"format=(?P.*)", re.M) + + def __init__(self, runbook: ADOSourceSchema) -> None: + super().__init__(runbook) + self.ado_runbook: ADOSourceSchema = self.runbook + self._log = get_logger("ado", self.__class__.__name__) + + @classmethod + def type_name(cls) -> str: + return "ado" + + @classmethod + def type_schema(cls) -> Type[schema.TypedSchema]: + return ADOSourceSchema + + def download(self, timeout: int = 600) -> List[Path]: + personal_access_token = self.ado_runbook.pat + organization_url = self.ado_runbook.organization_url + project_name = self.ado_runbook.project + artifacts = self.ado_runbook.artifacts + build_id = self.ado_runbook.build_id + pipeline_name = self.ado_runbook.pipeline_name + + working_path = constants.RUN_LOCAL_WORKING_PATH + credentials = BasicAuthentication("", personal_access_token) + connection = Connection(base_url=organization_url, creds=credentials) + + pipeline_client = connection.clients.get_pipelines_client() + pipelines = pipeline_client.list_pipelines(project_name) + + if pipeline_name: + found_pipeline = False + pipeline = None + for pipeline in pipelines: + if pipeline.name == pipeline_name: + found_pipeline = True + break + assert_that(found_pipeline).described_as( + ( + f"cannot found pipeline {pipeline_name} in project {project_name}, " + "please double check the names" + ) + ).is_true() + assert pipeline is not None, "pipeline cannot be None" + pipeline_runs = pipeline_client.list_runs( + pipeline_id=pipeline.id, project=project_name + ) + assert_that(len(pipeline_runs)).described_as( + f"no runs found for pipeline {pipeline_name}" + ).is_not_zero() + + pipeline_run = [ + run + for run in pipeline_runs + if run.result == "succeeded" and run.state == "completed" + ] + assert_that(len(pipeline_run)).described_as( + f"no succeeded and completed run found for pipeline {pipeline_name}" + ).is_not_zero() + build_id = pipeline_run[0].id + + build_client = connection.clients.get_build_client() + artifacts_path: List[Path] = [] + for artifact in artifacts: + artifact_name = artifact.artifact_name + build_artifact = build_client.get_artifact( + project_name, build_id, artifact_name + ) + download_url = build_artifact.resource.download_url + self._log.debug(f"artifact download url: {download_url}") + working_path.mkdir(parents=True, exist_ok=True) + file_extension = get_matched_str(download_url, self.__file_format) + artifact_path = working_path / f"{build_artifact.name}.{file_extension}" + self._log.debug(f"start to download artifact to {artifact_path}") + with open( + artifact_path, + "wb", + ) as download_file: + response = requests.get( + download_url, auth=("", personal_access_token), timeout=timeout + ) + download_file.write(response.content) + self._log.debug(f"downloaded artifact to {artifact_path}") + if artifact.extract: + source_path = self.extract(artifact_path) + artifacts_path.append(Path(source_path)) + else: + artifacts_path.append(artifact_path) + return artifacts_path + + def extract(self, artifact_path: Path) -> str: + file_extension = artifact_path.suffix + if file_extension == ".zip": + with zipfile.ZipFile(str(artifact_path), "r") as zip_ref: + zip_ref.extractall(str(artifact_path.parent)) + source_path = os.path.splitext(str(artifact_path))[0] + self._log.info(f"Artifact extracted to {str(source_path)}") + return source_path diff --git a/lisa/util/constants.py b/lisa/util/constants.py index 732e39df5..083fe2e75 100644 --- a/lisa/util/constants.py +++ b/lisa/util/constants.py @@ -110,6 +110,7 @@ SECURITY_PROFILE_CVM = "cvm" PLATFORM = "platform" PLATFORM_READY = "ready" +PLATFORM_BAREMETAL = "baremetal" PLATFORM_MOCK = "mock" TESTCASE = "testcase" diff --git a/noxfile.py b/noxfile.py index d8437dd9b..7866baa19 100644 --- a/noxfile.py +++ b/noxfile.py @@ -101,6 +101,7 @@ def pylint(session: nox.Session) -> None: *NOX_DEPENDENCIES, *OPTIONAL_DEPENDENCIES["aws"], *OPTIONAL_DEPENDENCIES["azure"], + *OPTIONAL_DEPENDENCIES["baremetal"], *OPTIONAL_DEPENDENCIES["libvirt"], *OPTIONAL_DEPENDENCIES["pylint"], *OPTIONAL_DEPENDENCIES["typing"], diff --git a/pyproject.toml b/pyproject.toml index 4a57cf0a1..6dae697a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,12 @@ libvirt = [ "pycdlib ~= 1.12.0; platform_system != 'Windows'", ] +baremetal = [ + "azure-devops ~= 7.1.0b3", + "pysmb ~= 1.2.9.1", + "redfish ~= 3.2.1", +] + mypy = [ "mypy == 0.942", ]