Added tftp deployment on BareMetal platform (#3422)

Co-authored-by: paull <paull@microsfot.com>
This commit is contained in:
paulli 2024-10-15 22:36:41 -07:00 коммит произвёл GitHub
Родитель ff7e40674a
Коммит 72de0270f4
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
6 изменённых файлов: 323 добавлений и 82 удалений

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

@ -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):