diff --git a/lisa/microsoft/testsuites/core/storage.py b/lisa/microsoft/testsuites/core/storage.py index 2baae15d61..26fc9a88a4 100644 --- a/lisa/microsoft/testsuites/core/storage.py +++ b/lisa/microsoft/testsuites/core/storage.py @@ -32,12 +32,26 @@ SecurityProfileType, ) from lisa.node import Node -from lisa.operating_system import BSD, Posix, Windows +from lisa.operating_system import BSD, AlmaLinux, CBLMariner, Posix, Redhat, Windows from lisa.schema import DiskControllerType, DiskOptionSettings, DiskType from lisa.sut_orchestrator import AZURE, HYPERV from lisa.sut_orchestrator.azure.features import AzureDiskOptionSettings, AzureFileShare from lisa.sut_orchestrator.azure.tools import Waagent -from lisa.tools import Blkid, Cat, Dmesg, Echo, Lsblk, Mount, NFSClient, Swap, Sysctl +from lisa.tools import ( + Blkid, + Cat, + Dmesg, + Echo, + Ls, + Lsblk, + Mount, + NFSClient, + Rm, + SmbClient, + SmbServer, + Swap, + Sysctl, +) from lisa.tools.blkid import PartitionInfo from lisa.tools.journalctl import Journalctl from lisa.tools.kernel_config import KernelConfig @@ -590,6 +604,186 @@ def after_case(self, log: Logger, **kwargs: Any) -> None: except Exception: raise BadEnvironmentStateException + @TestCaseMetadata( + description=""" + A comprehensive test to verify CIFS module and SMB share functionality between + two Linux VMs. + This test case will + 1. Create 2 VMs in Azure + 2. Check if CONFIG_CIFS is enabled in KCONFIG + 3. Configure one VM as SMB server and create a share + 4. Mount the other VM to the SMB share + 5. Verify mount is successful + 6. Write a test file to the SMB share and read it back to verify IO + 7. Clean up the SMB share and unmount + 8. repeat steps 4-7 for SMB versions ["2.0", "2.1", "3.0", "3.1.1"] + """, + timeout=TIME_OUT, + requirement=simple_requirement( + min_count=2, + unsupported_os=[Redhat, CBLMariner, AlmaLinux, BSD, Windows], + ), + priority=1, + ) + def verify_smb_linux( + self, log: Logger, node: Node, environment: Environment + ) -> None: + # Assign server and client roles to the 2 VMs + server_node = cast(RemoteNode, environment.nodes[0]) + client_node = cast(RemoteNode, environment.nodes[1]) + + # Check if CONFIG_CIFS is enabled in KCONFIG on both nodes + for role_name, role_node in (("server", server_node), ("client", client_node)): + if not role_node.tools[KernelConfig].is_enabled("CONFIG_CIFS"): + raise LisaException( + f"CIFS module must be present for SMB testing on {role_name} node" + ) + # Install and setup SMB tools on both nodes + smb_server = server_node.tools[SmbServer] + smb_client = client_node.tools[SmbClient] + + # SMB versions to test + smb_versions = ["3.0", "3.1.1", "2.1", "2.0"] + + # Test configuration + share_name = "testshare" + share_path = f"/tmp/{share_name}" + mount_point = f"/mnt/{share_name}" + + try: + # Step 3: Configure SMB server and create a share + smb_server.create_share(share_name, share_path) + + # Step 8: Repeat for different SMB versions + for smb_version in smb_versions: + log.info(f"Testing SMB version {smb_version}") + + # Step 4: Mount the SMB share on client + smb_client.mount_share( + server_node.internal_address, share_name, mount_point, smb_version + ) + + # Step 5 & 6: Verify mount is successful + self._verify_smb_mount( + client_node, + mount_point, + server_node, + share_path, + log, + ) + + # Step 7: Cleanup between version tests + smb_client.unmount_share(mount_point) + finally: + # Cleanup + self._cleanup_smb_test( + server_node, client_node, share_path, mount_point, log + ) + + def _verify_smb_mount( + self, + client_node: RemoteNode, + mount_point: str, + server_node: RemoteNode, + share_path: str, + log: Logger, + ) -> None: + """ + Verify SMB mount is working by creating and reading a file from + both client and server. + """ + test_file = "smb_test.txt" + test_content = "SMB test content" + mount = client_node.tools[Mount] + + # Verify mount point exists and is mounted + mount_point_exists = mount.check_mount_point_exist(mount_point) + if not mount_point_exists: + raise LisaException( + f"Mount point {mount_point} does not exist or is not mounted" + ) + + # Create test file on mounted share from client + test_file_path = f"{mount_point}/{test_file}" + echo = client_node.tools[Echo] + echo.write_to_file( + test_content, + client_node.get_pure_path(test_file_path), + sudo=True, + ignore_error=False, + ) + + # Read and verify file content from client side + file_content_client = client_node.tools[Cat].read( + test_file_path, sudo=True, force_run=True + ) + + assert_that(file_content_client).described_as( + "SMB file content should match written content on client" + ).is_equal_to(test_content) + log.info(f"Successfully verified file content on client: '{test_content}'") + + # Read and verify file content from server side + server_file_path = f"{share_path}/{test_file}" + + # Check if file exists on server + if not server_node.tools[Ls].path_exists(server_file_path, sudo=True): + raise LisaException(f"Test file {server_file_path} not found on server VM") + + # Read file content directly from server VM + file_content_server = server_node.tools[Cat].read( + server_file_path, sudo=True, force_run=True + ) + + assert_that(file_content_server).described_as( + "SMB file content should match on server VM" + ).is_equal_to(test_content) + + log.info( + f"Successfully verified file content on both client and server: " + f"'{test_content}'" + ) + + # Clean up test file from client (will also remove from server via SMB) + client_node.tools[Rm].remove_file(test_file_path, sudo=True) + + def _cleanup_smb_test( + self, + server_node: RemoteNode, + client_node: RemoteNode, + share_path: str, + mount_point: str, + log: Logger, + ) -> None: + """Clean up SMB test resources.""" + bad_cleanup = False + # Cleanup on client + try: + smb_client = client_node.tools[SmbClient] + if smb_client.is_mounted(mount_point): + smb_client.unmount_share(mount_point) + smb_client.cleanup_mount_point(mount_point) + except Exception as e: + log.error( + f"Failed to cleanup SMB client mount point {mount_point}: " + f"{e}. Continuing cleanup..." + ) + bad_cleanup = True + + # Cleanup on server + try: + smb_server = server_node.tools[SmbServer] + smb_server.stop() + smb_server.remove_share(share_path) + except Exception as e: + log.error( + f"Failed to remove share {share_path} from SMB server: " + f"{e}. Finishing cleanup..." + ) + bad_cleanup = True + if bad_cleanup: + raise BadEnvironmentStateException("SMB test cleanup encountered errors.") + @TestCaseMetadata( description=""" This test case will diff --git a/lisa/tools/__init__.py b/lisa/tools/__init__.py index 8484fa2e95..a432a31287 100644 --- a/lisa/tools/__init__.py +++ b/lisa/tools/__init__.py @@ -120,6 +120,7 @@ from .resize_partition import ResizePartition from .rm import Rm from .sar import Sar +from .smb import SmbClient, SmbServer from .sockperf import Sockperf from .ssh import Ssh from .sshpass import Sshpass @@ -281,6 +282,8 @@ "Sed", "Service", "ServiceInternal", + "SmbClient", + "SmbServer", "Sockperf", "Ssh", "Sshpass", diff --git a/lisa/tools/mkfs.py b/lisa/tools/mkfs.py index 5ffb0e4ed7..1195b346af 100644 --- a/lisa/tools/mkfs.py +++ b/lisa/tools/mkfs.py @@ -12,6 +12,7 @@ "mkfs", [ "xfs", + "cifs", "ext2", "ext3", "ext4", diff --git a/lisa/tools/smb.py b/lisa/tools/smb.py new file mode 100644 index 0000000000..a704bbe741 --- /dev/null +++ b/lisa/tools/smb.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from pathlib import PurePosixPath +from typing import Any, List, Optional + +from lisa.executable import Tool +from lisa.operating_system import ( + Alpine, + CBLMariner, + CoreOs, + Debian, + Fedora, + Oracle, + Redhat, + Suse, + Ubuntu, +) +from lisa.tools import Chmod, Echo, Mkdir, Mount, Rm, Service +from lisa.tools.firewall import Firewall +from lisa.tools.mkfs import FileSystem +from lisa.util import UnsupportedDistroException + + +class SmbServer(Tool): + SMB_CONF_FILE = "/etc/samba/smb.conf" + + def _initialize(self, *args: Any, **kwargs: Any) -> None: + # Set service names based on distribution + if isinstance(self.node.os, (CBLMariner, Redhat, Fedora, Oracle, Suse)): + self._smb_service = "smb" + self._nmb_service = "nmb" + elif isinstance(self.node.os, Alpine): + self._smb_service = "samba" + self._nmb_service = "nmbd" + else: + # Default fallback + self._smb_service = "smbd" + self._nmb_service = "nmbd" + + @property + def command(self) -> str: + return "" + + @property + def can_install(self) -> bool: + return True + + def _install(self) -> bool: + # Install samba server and client utilities + if isinstance(self.node.os, Ubuntu): + self.node.os.install_packages(["samba", "samba-common-bin", "cifs-utils"]) + elif isinstance(self.node.os, Alpine): + self.node.os.install_packages(["samba", "samba-client"]) + elif isinstance(self.node.os, (Debian, CoreOs, Fedora, Oracle, Redhat, Suse)): + self.node.os.install_packages(["samba", "cifs-utils"]) + else: + raise UnsupportedDistroException(self.node.os) + + return self._check_exists() + + def _check_exists(self) -> bool: + # Check if samba services exist + return self.command_exists("smbd")[0] and self.command_exists("nmbd")[0] + + def create_share( + self, + share_name: str, + share_path: str, + workgroup: str = "WORKGROUP", + server_string: str = "LISA SMB Test Server", + ) -> None: + """Configure SMB server and create a share.""" + # Create share directory + self.node.tools[Mkdir].create_directory(share_path, sudo=True) + + # Set permissions for the share directory + self.node.tools[Chmod].chmod(share_path, "777", sudo=True) + # Create SMB configuration + smb_config = f""" +[global] + workgroup = {workgroup} + server string = {server_string} + security = user + map to guest = bad user + dns proxy = no + +[{share_name}] + path = {share_path} + browsable = yes + writable = yes + guest ok = yes + read only = no + create mask = 0755 +""" + + # Write SMB configuration + self.node.tools[Echo].write_to_file( + smb_config, PurePosixPath(self.SMB_CONF_FILE), sudo=True + ) + + # Start SMB services + self.start() + + def start(self) -> None: + """Start SMB services.""" + service = self.node.tools[Service] + service.restart_service(self._smb_service) + service.restart_service(self._nmb_service) + # stop firewall to allow SMB traffic + self.node.tools[Firewall].stop() + + def stop(self) -> None: + """Stop SMB services.""" + service = self.node.tools[Service] + service.stop_service(self._smb_service) + service.stop_service(self._nmb_service) + + def is_running(self) -> bool: + """Check if SMB services are running.""" + service = self.node.tools[Service] + return service.is_service_running( + self._smb_service + ) and service.is_service_running(self._nmb_service) + + def remove_share(self, share_path: str) -> None: + """Remove a SMB share and its directory.""" + self.node.tools[Rm].remove_directory(share_path, sudo=True) + + +class SmbClient(Tool): + @property + def command(self) -> str: + return "mount.cifs" + + @property + def can_install(self) -> bool: + return True + + def _install(self) -> bool: + # Install client utilities + if isinstance( + self.node.os, + (Ubuntu, Debian, CBLMariner, CoreOs, Fedora, Oracle, Redhat, Suse, Alpine), + ): + self.node.os.install_packages(["cifs-utils"]) + else: + raise UnsupportedDistroException(self.node.os) + return self._check_exists() + + def mount_share( + self, + server_address: str, + share_name: str, + mount_point: str, + smb_version: str = "3.0", + options: Optional[List[str]] = None, + ) -> None: + """Mount SMB share on client node with specified SMB version.""" + # Create mount point + self.node.tools[Mkdir].create_directory(mount_point, sudo=True) + + # Build mount options + mount_options = [ + f"vers={smb_version}", + "file_mode=0777", + "dir_mode=0777", + "guest", + ] + + if options: + mount_options.extend(options) + + # Mount SMB share + self.node.tools[Mount].mount( + point=mount_point, + name=f"//{server_address}/{share_name}", + fs_type=FileSystem.cifs, + options=",".join(mount_options), + format_=False, + ) + + def unmount_share(self, mount_point: str) -> None: + """Unmount SMB share.""" + self.node.tools[Mount].umount(point=mount_point, disk_name="", erase=False) + + def is_mounted(self, mount_point: str) -> bool: + """Check if mount point exists and is mounted.""" + return self.node.tools[Mount].check_mount_point_exist(mount_point) + + def cleanup_mount_point(self, mount_point: str) -> None: + """Remove mount point directory.""" + self.node.tools[Rm].remove_directory(mount_point, sudo=True)