зеркало из https://github.com/microsoft/lisa.git
Added tftp deployment on BareMetal platform (#3422)
Co-authored-by: paull <paull@microsfot.com>
This commit is contained in:
Родитель
ff7e40674a
Коммит
72de0270f4
|
@ -47,12 +47,17 @@ class Sed(Tool):
|
|||
self,
|
||||
text: str,
|
||||
file: str,
|
||||
match_line: str = "",
|
||||
sudo: bool = False,
|
||||
) -> None:
|
||||
# always force run, make sure it happens every time.
|
||||
text = text.replace('"', '\\"')
|
||||
if match_line:
|
||||
append_line = f"{match_line}"
|
||||
else:
|
||||
append_line = "$"
|
||||
result = self.run(
|
||||
f"-i.bak '$a{text}' {file}",
|
||||
f"-i.bak '{append_line}a{text}' {file}",
|
||||
force_run=True,
|
||||
no_error_log=True,
|
||||
no_info_log=True,
|
||||
|
|
|
@ -0,0 +1,165 @@
|
|||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT license.
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Optional, Type
|
||||
|
||||
from dataclasses_json import dataclass_json
|
||||
|
||||
from lisa import schema
|
||||
from lisa.node import quick_connect
|
||||
from lisa.tools import Cat, Sed
|
||||
from lisa.util import (
|
||||
InitializableMixin,
|
||||
LisaException,
|
||||
field_metadata,
|
||||
find_group_in_lines,
|
||||
subclasses,
|
||||
)
|
||||
from lisa.util.logger import get_logger
|
||||
|
||||
from .schema import BootConfigSchema
|
||||
|
||||
BOOT_LABEL = "label lisa baremetal"
|
||||
|
||||
|
||||
class BootConfig(subclasses.BaseClassWithRunbookMixin, InitializableMixin):
|
||||
def __init__(
|
||||
self,
|
||||
runbook: BootConfigSchema,
|
||||
) -> None:
|
||||
super().__init__(runbook=runbook)
|
||||
self.boot_config_runbook: BootConfigSchema = self.runbook
|
||||
self._log = get_logger("boot_config", self.__class__.__name__)
|
||||
|
||||
@classmethod
|
||||
def type_schema(cls) -> Type[schema.TypedSchema]:
|
||||
return BootConfigSchema
|
||||
|
||||
def config(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@dataclass_json()
|
||||
@dataclass
|
||||
class PxeBootSchema(BootConfigSchema):
|
||||
connection: Optional[schema.RemoteNode] = field(
|
||||
default=None, metadata=field_metadata(required=True)
|
||||
)
|
||||
host_name: str = field(default="", metadata=field_metadata(required=True))
|
||||
image_source: str = field(default="", metadata=field_metadata(required=True))
|
||||
kernel_boot_params: Optional[str] = field(default="")
|
||||
|
||||
|
||||
class PxeBoot(BootConfig):
|
||||
def __init__(
|
||||
self,
|
||||
runbook: PxeBootSchema,
|
||||
) -> None:
|
||||
super().__init__(runbook=runbook)
|
||||
self.pxe_runbook: PxeBootSchema = self.runbook
|
||||
self._log = get_logger("pxe_boot", self.__class__.__name__)
|
||||
self._boot_dir = "/var/lib/tftpboot/"
|
||||
|
||||
@classmethod
|
||||
def type_name(cls) -> str:
|
||||
return "pxe_boot"
|
||||
|
||||
@classmethod
|
||||
def type_schema(cls) -> Type[schema.TypedSchema]:
|
||||
return PxeBootSchema
|
||||
|
||||
def config(self) -> None:
|
||||
assert self.pxe_runbook.connection, "connection is required for dhcp_server"
|
||||
|
||||
self.pxe_runbook.connection.name = "dhcp_server"
|
||||
self._dhcp_server = quick_connect(
|
||||
self.pxe_runbook.connection, logger_name="dhcp_server"
|
||||
)
|
||||
|
||||
node_name = f"{self.pxe_runbook.host_name}"
|
||||
pxe_boot_config_path = self._get_pxe_config_path(node_name)
|
||||
if not pxe_boot_config_path:
|
||||
with open(pxe_boot_config_path, "w") as f:
|
||||
f.write("timeout 150\n\nmenu title selections\n\n")
|
||||
|
||||
boot_image = PurePosixPath(
|
||||
self.pxe_runbook.image_source,
|
||||
).relative_to(self._boot_dir)
|
||||
boot_entry = f"kernel {boot_image}"
|
||||
|
||||
sed = self._dhcp_server.tools[Sed]
|
||||
# Delete the label if one is existed
|
||||
sed.delete_lines(
|
||||
f"^{BOOT_LABEL}$/,/^$",
|
||||
PurePosixPath(pxe_boot_config_path),
|
||||
)
|
||||
self._log.debug(
|
||||
"Deleted boot entry for LISA if existed"
|
||||
f"{pxe_boot_config_path}, "
|
||||
f"pointing to {boot_entry}"
|
||||
)
|
||||
|
||||
# Add one at the start
|
||||
params = self.pxe_runbook.kernel_boot_params
|
||||
append = f"\\n append {params}" if params else ""
|
||||
match_str = "/^menu.*/"
|
||||
sed.append(
|
||||
f" \\\\n{BOOT_LABEL}\\n {boot_entry}{append}",
|
||||
f"{pxe_boot_config_path}",
|
||||
f"{match_str}",
|
||||
)
|
||||
|
||||
self._log.debug(
|
||||
"Added boot entry for LISA at "
|
||||
f"{pxe_boot_config_path}, "
|
||||
f"pointing to {boot_entry}"
|
||||
)
|
||||
|
||||
def _get_pxe_config_path(self, node_name: str) -> str:
|
||||
cat = self._dhcp_server.tools[Cat]
|
||||
output_dhcp_info = cat.read(
|
||||
"/etc/dhcp/dhcpd.conf",
|
||||
force_run=True,
|
||||
)
|
||||
# Here is part of output_dhcp_info
|
||||
# ...
|
||||
# host dev-gp9 {
|
||||
# hardware ethernet 04:27:28:06:f7:88;
|
||||
# fixed-address 192.168.3.123;
|
||||
# option host-name "blade3";
|
||||
# }
|
||||
#
|
||||
# host dev-gp10 {
|
||||
# hardware ethernet 04:27:28:15:3f:f0;
|
||||
# fixed-address 192.168.3.117;
|
||||
# option host-name "blade4";
|
||||
# }
|
||||
# ...
|
||||
# if node 10 is used to be bootup from pxe_server,
|
||||
# configuration file for node 10 on pxe_server needs
|
||||
# to be modified. The configuration file's name for
|
||||
# node 10 is actually based on its physical address:
|
||||
# 04:27:28:15:3f:f0 as you can see from the above,
|
||||
# and it is called 01-04-27-28-15-3f-f0 under bootup
|
||||
# directory. Below is to find the node's physical
|
||||
# address string 04:27:28:15:3f:f0 from output_dhcp_info
|
||||
# and then obtain the configuration file name for
|
||||
# later bootup menu change.
|
||||
host_ref = f"host {node_name}"
|
||||
pattern = host_ref + r"\s+\{\r?\n\s+hardware ethernet\s+(?P<mac>[0-9a-f:]+);"
|
||||
node_pattern = re.compile(pattern, re.M)
|
||||
node_address = find_group_in_lines(
|
||||
lines=output_dhcp_info,
|
||||
pattern=node_pattern,
|
||||
single_line=False,
|
||||
)
|
||||
if node_address:
|
||||
config_file = "01-" + node_address["mac"].replace(":", "-")
|
||||
config_file_fullpath = self._boot_dir + f"pxelinux.cfg/{config_file}"
|
||||
else:
|
||||
raise LisaException(f"Failed to find DHCP entry for {node_name}")
|
||||
|
||||
return config_file_fullpath
|
|
@ -3,16 +3,41 @@
|
|||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePath
|
||||
from typing import Dict, List, Type
|
||||
|
||||
from smb.SMBConnection import SMBConnection # type: ignore
|
||||
|
||||
from lisa import schema
|
||||
from lisa.node import quick_connect
|
||||
from lisa.tools import Ls, RemoteCopy
|
||||
from lisa.util import ContextMixin, InitializableMixin, subclasses
|
||||
from lisa.util.logger import get_logger
|
||||
|
||||
from .schema import BuildSchema, FileSchema, SMBBuildSchema
|
||||
from .schema import BuildSchema, FileSchema, SMBBuildSchema, TftpBuildSchema
|
||||
|
||||
|
||||
def _find_matched_files(
|
||||
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
|
||||
|
||||
|
||||
class Build(subclasses.BaseClassWithRunbookMixin, ContextMixin, InitializableMixin):
|
||||
|
@ -55,8 +80,9 @@ class SMBBuild(Build):
|
|||
) as conn:
|
||||
conn.connect(server_name)
|
||||
|
||||
for file, file_map in self._find_matched_files(
|
||||
sources_path, files_map
|
||||
for file, file_map in _find_matched_files(
|
||||
sources_path,
|
||||
files_map,
|
||||
).items():
|
||||
with open(file, "rb") as f:
|
||||
if file_map.destination:
|
||||
|
@ -79,23 +105,43 @@ class SMBBuild(Build):
|
|||
)
|
||||
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
|
||||
class TftpBuild(Build):
|
||||
def __init__(self, runbook: TftpBuildSchema) -> None:
|
||||
super().__init__(runbook)
|
||||
self.pxe_runbook: TftpBuildSchema = self.runbook
|
||||
|
||||
@classmethod
|
||||
def type_name(cls) -> str:
|
||||
return "tftp"
|
||||
|
||||
@classmethod
|
||||
def type_schema(cls) -> Type[schema.TypedSchema]:
|
||||
return TftpBuildSchema
|
||||
|
||||
def copy(self, sources_path: List[Path], files_map: List[FileSchema]) -> None:
|
||||
assert self.pxe_runbook.connection, "The build server is not specified"
|
||||
|
||||
build_server = quick_connect(
|
||||
self.pxe_runbook.connection,
|
||||
logger_name="build_server",
|
||||
)
|
||||
|
||||
ls = build_server.tools[Ls]
|
||||
rc = build_server.tools[RemoteCopy]
|
||||
self._log.debug(f"Copying files to: {build_server}")
|
||||
for file, file_map in _find_matched_files(
|
||||
sources_path,
|
||||
files_map,
|
||||
).items():
|
||||
if file_map.destination:
|
||||
file_map_path = PurePath(file_map.destination)
|
||||
if ls.is_file(file_map_path):
|
||||
file_destination = file_map_path.parent
|
||||
else:
|
||||
file_destination = file_map_path
|
||||
else:
|
||||
file_destination = PurePath(file).parent
|
||||
|
||||
rc.copy_to_remote(PurePath(file), file_destination)
|
||||
self._log.debug(f"Copied files to: {build_server}")
|
||||
|
|
|
@ -7,12 +7,12 @@ 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 .bootconfig import BootConfig
|
||||
from .build import Build
|
||||
from .cluster.cluster import Cluster
|
||||
from .context import get_build_context, get_node_context
|
||||
|
@ -52,6 +52,7 @@ class BareMetalPlatform(Platform):
|
|||
self.key_loader_factory = Factory[KeyLoader](KeyLoader)
|
||||
self.source_factory = Factory[Source](Source)
|
||||
self.build_factory = Factory[Build](Build)
|
||||
self.boot_config_factory = Factory[BootConfig](BootConfig)
|
||||
# currently only support one cluster
|
||||
assert self._baremetal_runbook.cluster, "no cluster is specified in the runbook"
|
||||
self._cluster_runbook = self._baremetal_runbook.cluster[0]
|
||||
|
@ -66,7 +67,49 @@ class BareMetalPlatform(Platform):
|
|||
return self._configure_node_capabilities(environment, log, client_capabilities)
|
||||
|
||||
def _deploy_environment(self, environment: Environment, log: Logger) -> None:
|
||||
# copy build (shared, check if it's copied)
|
||||
# process the cluster elements from runbook
|
||||
self._predeploy_environment(environment, log)
|
||||
|
||||
# deploy cluster
|
||||
self.cluster.deploy(environment)
|
||||
|
||||
if self._cluster_runbook.ready_checker:
|
||||
ready_checker = self.ready_checker_factory.create_by_runbook(
|
||||
self._cluster_runbook.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 self._cluster_runbook.ip_getter:
|
||||
ip_getter = self.ip_getter_factory.create_by_runbook(
|
||||
self._cluster_runbook.ip_getter
|
||||
)
|
||||
node_context.connection.address = ip_getter.get_ip()
|
||||
|
||||
assert isinstance(node, RemoteNode), f"actual: {type(node)}"
|
||||
node.name = f"node_{index}"
|
||||
try_connect(node_context.connection)
|
||||
|
||||
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 _predeploy_environment(self, environment: Environment, log: Logger) -> None:
|
||||
# download source (shared, check if it's copied)
|
||||
if self._baremetal_runbook.source:
|
||||
if not self.local_artifacts_path:
|
||||
source = self.source_factory.create_by_runbook(
|
||||
|
@ -99,11 +142,17 @@ class BareMetalPlatform(Platform):
|
|||
self._log.debug("build is already copied, skip copy")
|
||||
else:
|
||||
assert self.local_artifacts_path, "no build source is specified"
|
||||
self.copy(
|
||||
self._copy(
|
||||
self.cluster.runbook.build, sources_path=self.local_artifacts_path
|
||||
)
|
||||
build_context.is_copied = True
|
||||
|
||||
if self.cluster.runbook.boot_config:
|
||||
boot_config = self.boot_config_factory.create_by_runbook(
|
||||
self.cluster.runbook.boot_config
|
||||
)
|
||||
boot_config.config()
|
||||
|
||||
if self.cluster.runbook.key_loader:
|
||||
key_loader = self.key_loader_factory.create_by_runbook(
|
||||
self.cluster.runbook.key_loader
|
||||
|
@ -142,50 +191,6 @@ class BareMetalPlatform(Platform):
|
|||
node_context.connection = connection_info
|
||||
index = index + 1
|
||||
|
||||
# deploy cluster
|
||||
self.cluster.deploy(environment)
|
||||
|
||||
if self._cluster_runbook.ready_checker:
|
||||
ready_checker = self.ready_checker_factory.create_by_runbook(
|
||||
self._cluster_runbook.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 self._cluster_runbook.ip_getter:
|
||||
ip_getter = self.ip_getter_factory.create_by_runbook(
|
||||
self._cluster_runbook.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,
|
||||
|
|
|
@ -10,13 +10,7 @@ from dataclasses_json import dataclass_json
|
|||
|
||||
from lisa import schema
|
||||
from lisa.node import Node, RemoteNode
|
||||
from lisa.util import (
|
||||
InitializableMixin,
|
||||
LisaException,
|
||||
check_till_timeout,
|
||||
fields_to_dict,
|
||||
subclasses,
|
||||
)
|
||||
from lisa.util import InitializableMixin, LisaException, check_till_timeout, subclasses
|
||||
from lisa.util.logger import get_logger
|
||||
from lisa.util.shell import try_connect
|
||||
|
||||
|
@ -115,12 +109,19 @@ class SshChecker(ReadyChecker):
|
|||
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"],
|
||||
address=context.connection.address,
|
||||
public_port=context.connection.port,
|
||||
username=context.connection.username,
|
||||
password=cast(
|
||||
str,
|
||||
context.connection.password,
|
||||
),
|
||||
private_key_file=cast(
|
||||
str,
|
||||
context.connection.private_key_file,
|
||||
),
|
||||
)
|
||||
self._log.debug("try to connect to the client")
|
||||
self._log.debug(f"try to connect to client: {node}")
|
||||
try_connect(context.connection, ssh_timeout=self.ssh_runbook.timeout)
|
||||
self._log.debug("client has been connected successfully")
|
||||
return True
|
||||
|
|
|
@ -73,6 +73,12 @@ class KeyLoaderSchema(schema.TypedSchema, schema.ExtendableSchemaMixin):
|
|||
type: str = field(default="build", metadata=field_metadata(required=True))
|
||||
|
||||
|
||||
@dataclass_json()
|
||||
@dataclass
|
||||
class BootConfigSchema(schema.TypedSchema, schema.ExtendableSchemaMixin):
|
||||
type: str = field(default="boot_config", metadata=field_metadata(required=True))
|
||||
|
||||
|
||||
@dataclass_json()
|
||||
@dataclass
|
||||
class ClusterSchema(schema.TypedSchema, schema.ExtendableSchemaMixin):
|
||||
|
@ -81,6 +87,7 @@ class ClusterSchema(schema.TypedSchema, schema.ExtendableSchemaMixin):
|
|||
ready_checker: Optional[ReadyCheckerSchema] = None
|
||||
ip_getter: Optional[IpGetterSchema] = None
|
||||
key_loader: Optional[KeyLoaderSchema] = None
|
||||
boot_config: Optional[BootConfigSchema] = None
|
||||
|
||||
|
||||
@dataclass_json()
|
||||
|
@ -134,6 +141,18 @@ class SMBBuildSchema(BuildSchema):
|
|||
add_secret(self.password)
|
||||
|
||||
|
||||
@dataclass_json()
|
||||
@dataclass
|
||||
class TftpBuildSchema(BuildSchema):
|
||||
connection: Optional[schema.RemoteNode] = field(
|
||||
default=None, metadata=field_metadata(required=True)
|
||||
)
|
||||
|
||||
def __post_init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
if self.connection and self.connection.password:
|
||||
add_secret(self.connection.password)
|
||||
|
||||
|
||||
@dataclass_json()
|
||||
@dataclass
|
||||
class IdracSchema(ClusterSchema):
|
||||
|
|
Загрузка…
Ссылка в новой задаче