diff --git a/.github/workflows/python_lint.yml b/.github/workflows/python_lint.yml new file mode 100644 index 0000000..ac241b9 --- /dev/null +++ b/.github/workflows/python_lint.yml @@ -0,0 +1,24 @@ +name: Python lint + +on: + - pull_request +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install Ruff + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Run Ruff + run: | + ruff check . diff --git a/frameworks/VboxMachine/VboxMachine.py b/frameworks/VboxMachine/VboxMachine.py new file mode 100644 index 0000000..d9e2890 --- /dev/null +++ b/frameworks/VboxMachine/VboxMachine.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from VBoxWrapper import VirtualMachine + +from frameworks.decorators import vm_is_turn_on +from .vm_data import VmData +from .configs import VmConfig + + +class VboxMachine: + + def __init__(self, name: str, config_path: str = None): + self.vm_config = VmConfig(config_path=config_path) + self.vm = VirtualMachine(name) + self.name = name + self.data = None + + @vm_is_turn_on + def create_data(self): + self.data = VmData( + ip=self.vm.network.get_ip(), + user=self.vm.get_logged_user(), + name=self.name, + local_dir=self.vm.get_parameter('CfgFile') + ) + + def run(self, headless: bool = True, status_bar: bool = False, timeout: int = 600): + if self.vm.power_status(): + self.vm.stop() + + self.vm.snapshot.restore() + self.configurate() + self.vm.run(headless=headless) + self.vm.network.wait_up(status_bar=status_bar, timeout=timeout) + self.vm.wait_logged_user(status_bar=status_bar, timeout=timeout) + self.create_data() + + def configurate(self): + self.vm.set_cpus(self.vm_config.cpus) + self.vm.nested_virtualization(self.vm_config.nested_virtualization) + self.vm.set_memory(self.vm_config.memory) + self.vm.audio(self.vm_config.audio) + self.vm.speculative_execution_control(self.vm_config.speculative_execution_control) + + def stop(self): + self.vm.stop() diff --git a/frameworks/VboxMachine/__init__.py b/frameworks/VboxMachine/__init__.py new file mode 100644 index 0000000..0caa01d --- /dev/null +++ b/frameworks/VboxMachine/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +from .VboxMachine import VboxMachine +from .configs import VmConfig + +__all__ = [VboxMachine, VmConfig] diff --git a/frameworks/VboxMachine/configs/__init__.py b/frameworks/VboxMachine/configs/__init__.py new file mode 100644 index 0000000..7572a5c --- /dev/null +++ b/frameworks/VboxMachine/configs/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +from .vm_config import VmConfig + +__all__ = [VmConfig] diff --git a/frameworks/VboxMachine/configs/vm_config.py b/frameworks/VboxMachine/configs/vm_config.py new file mode 100644 index 0000000..e0aaa89 --- /dev/null +++ b/frameworks/VboxMachine/configs/vm_config.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +import json +from rich import print +from pydantic import BaseModel, conint +from host_tools import singleton + + +class SystemConfigModel(BaseModel): + """ + A Pydantic model for validating the system configuration parameters. + + Attributes: + cpus (int): The number of CPUs allocated for the system. Must be an integer >= 1. + memory (int): The amount of memory in MB. Must be an integer >= 512. + """ + + cpus: conint(ge=1) + memory: conint(ge=512) + audio: bool + nested_virtualization: bool + speculative_execution_control: bool + + +@singleton +class VmConfig: + """ + Configuration class for system settings. + + Attributes: + cpus (int): The number of CPUs allocated for the system. + memory (int): The amount of memory in MB. + """ + def __init__(self, config_path: str): + self.config_path = config_path + self._config = self._load_config(self.config_path) + self.cpus = self._config.cpus + self.memory = self._config.memory + self.audio = self._config.audio + self.nested_virtualization = self._config.nested_virtualization + self.speculative_execution_control = self._config.speculative_execution_control + + @staticmethod + def _load_config(file_path: str) -> SystemConfigModel: + """ + Loads the system configuration from a JSON file and returns a SystemConfigModel instance. + + :param file_path: The path to the configuration JSON file. + :return: An instance of SystemConfigModel containing the loaded configuration. + """ + with open(file_path, 'r') as f: + return SystemConfigModel(**json.load(f)) + + def display_config(self): + """ + Displays the loaded system configuration. + """ + print( + f"[green]|INFO| System Configuration:\n" + f" CPUs: {self.cpus}\n" + f" Memory: {self.memory}MB\n" + f" Audio Enabled: {self.audio}\n" + f" Nested Virtualization: {self.nested_virtualization}\n" + f" Speculative Execution Control: {self.speculative_execution_control}" + ) diff --git a/frameworks/VboxMachine/vm_data.py b/frameworks/VboxMachine/vm_data.py new file mode 100644 index 0000000..d81ec00 --- /dev/null +++ b/frameworks/VboxMachine/vm_data.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from dataclasses import dataclass + + +@dataclass +class VmData: + user: str + ip: str + name: str + local_dir: str diff --git a/frameworks/__init__.py b/frameworks/__init__.py new file mode 100644 index 0000000..117ae9a --- /dev/null +++ b/frameworks/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +from .VboxMachine import VboxMachine, VmConfig +from .console import MyConsole +from .report import Report + +__all__ = [ + VboxMachine, + MyConsole, + Report, + VmConfig +] diff --git a/frameworks/decorators/__init__.py b/frameworks/decorators/__init__.py index e56e08d..5d21772 100644 --- a/frameworks/decorators/__init__.py +++ b/frameworks/decorators/__init__.py @@ -1,2 +1,4 @@ # -*- coding: utf-8 -*- -from .decorators import * +from .decorators import vm_data_created, vm_is_turn_on, retry + +__all__ = ['vm_data_created', 'vm_is_turn_on', 'retry'] diff --git a/frameworks/decorators/decorators.py b/frameworks/decorators/decorators.py index d8b2e7b..3e1a766 100644 --- a/frameworks/decorators/decorators.py +++ b/frameworks/decorators/decorators.py @@ -1,22 +1,32 @@ # -*- coding: utf-8 -*- from functools import wraps -from multiprocessing import Process -from time import perf_counter, sleep +from time import sleep +from VBoxWrapper import VirtualMachinException from rich import print -def singleton(class_): - __instances = {} +def vm_data_created(method): + @wraps(method) + def wrapper(self, *args, **kwargs): - @wraps(class_) - def getinstance(*args, **kwargs): - if class_ not in __instances: - __instances[class_] = class_(*args, **kwargs) - return __instances[class_] + if self.vm.data is None: + raise VirtualMachinException("Vm data has not been created, Please start the VM before creating data.") - return getinstance + return method(self, *args, **kwargs) + return wrapper + +def vm_is_turn_on(method): + @wraps(method) + def wrapper(self, *args, **kwargs): + + if not self.vm.power_status(): + raise VirtualMachinException("Virtual machine is not turned on. Please start the VM before creating data.") + + return method(self, *args, **kwargs) + + return wrapper def retry( max_attempts: int = 3, @@ -44,56 +54,3 @@ def inner(*args, **kwargs): return inner return wrapper - - -def highlighter(color: str = 'green'): - def wrapper(func): - @wraps(func) - def inner(*args, **kwargs): - print(f'[{color}]-' * 90) - result = func(*args, **kwargs) - print(f'[{color}]-' * 90) - return result - - return inner - - return wrapper - - -def async_processing(target): - def wrapper(func): - @wraps(func) - def inner(*args, **kwargs): - process = Process(target=target) - process.start() - result = func(*args, **kwargs) - process.terminate() - return result - - return inner - - return wrapper - - -def timer(func): - @wraps(func) - def wrapper(*args, **kwargs): - start_time = perf_counter() - result = func(*args, **kwargs) - print(f"[green]|INFO| Time existing the function `{func.__name__}`: {(perf_counter() - start_time):.02f}") - return result - - return wrapper - - -def memoize(func): - __cache = {} - - @wraps(func) - def wrapper(*args, **kwargs): - key = (args, tuple(kwargs.items())) - if key not in __cache: - __cache[key] = func(*args, **kwargs) - return __cache[key] - - return wrapper diff --git a/frameworks/report/__init__.py b/frameworks/report/__init__.py index 8afbe5b..e04b156 100644 --- a/frameworks/report/__init__.py +++ b/frameworks/report/__init__.py @@ -1,2 +1,4 @@ # -*- coding: utf-8 -*- from .report import Report + +__all__ = [Report] diff --git a/frameworks/report/report.py b/frameworks/report/report.py index bd96427..fa945fd 100644 --- a/frameworks/report/report.py +++ b/frameworks/report/report.py @@ -25,23 +25,24 @@ def value_count(df: pd.DataFrame, column_name: str) -> str: def insert_column(self, path: str, location: str, column_name: str, value: str, delimiter='\t') -> pd.DataFrame: df = self.read(path, delimiter=delimiter) - if column_name not in df.columns: + if column_name not in df.columns: df.insert(loc=df.columns.get_loc(location), column=column_name, value=value) else: - print(f"[green]|INFO| Column `{column_name}` already exists in `{path}`") + print(f"[cyan]|INFO| Column `{column_name}` already exists in `{path}`") return df def merge(self, reports: list, result_csv_path: str, delimiter='\t') -> str | None: - if reports: - merge_reports = [] - for csv_ in reports: - if isfile(csv_): - report = self.read(csv_, delimiter) - if report is not None: - merge_reports.append(report) + merge_reports = [ + self.read(csv_, delimiter) + for csv_ in reports + if isfile(csv_) and self.read(csv_, delimiter) is not None + ] + + if merge_reports: df = pd.concat(merge_reports, ignore_index=True) df.to_csv(result_csv_path, index=False, sep=delimiter) return result_csv_path + print('[green]|INFO| No files to merge') @staticmethod diff --git a/pyproject.toml b/pyproject.toml index bedf00d..ba7a2d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ host-tools = { git = "https://github.com/l8556/host_tools.git", branch = "master telegram = { git = "https://github.com/l8556/TelegramApi.git", branch = "master" } VBoxWrapper = { git = "https://github.com/l8556/VBoxWrapper.git", branch = "master" } ssh-wrapper = { git = "https://github.com/l8556/SshWrapper.git", branch = "master" } +ruff = "^0.6.4" +pydantic = "^2.9.1" [build-system] diff --git a/tasks.py b/tasks.py index e86a0f2..5f6514f 100644 --- a/tasks.py +++ b/tasks.py @@ -7,23 +7,18 @@ from rich import print from VBoxWrapper import VirtualMachine, Vbox -from tests.data import TestData -from tests.desktop_tests import DesktopTest -import tests.multiprocessing as multiprocess -from frameworks.console import MyConsole -from tests.tools.desktop_report import DesktopReport +from tests.desktop_tests.tools.test_data import TestData +from tests.desktop_tests import DesktopTest, DesktopReport +import tests.desktop_tests.multiprocessing as multiprocess from host_tools import Process, Service from elevate import elevate -console = MyConsole().console -print = console.print - @task def desktop_test( c, version=None, - update_from=None, + update_from_version=None, name=None, processes=None, detailed_telegram=False, @@ -34,7 +29,7 @@ def desktop_test( data = TestData( version=version if version else Prompt.ask('[red]Please enter version'), - update_from=update_from, + update_from=update_from_version, telegram=detailed_telegram, config_path=join(getcwd(), 'custom_config.json') if custom_config else join(getcwd(), 'config.json'), custom_config_mode=custom_config @@ -51,7 +46,6 @@ def desktop_test( report.get_full(data.version) report.send_to_tg(data.version, data.title, data.tg_token, data.tg_chat_id, data.update_from) if not name else ... - @task def run_vm(c, name: str = '', headless=False): vm = VirtualMachine(Vbox().check_vm_names(name)) @@ -60,7 +54,6 @@ def run_vm(c, name: str = '', headless=False): vm.wait_logged_user(status_bar=True) return print(f"[green]ip: [red]{vm.network.get_ip()}[/]\nuser: [red]{vm.get_logged_user()}[/]") - @task def stop_vm(c, name: str = None, group_name: str = None): if name: @@ -78,19 +71,16 @@ def stop_vm(c, name: str = None, group_name: str = None): print(f"[green]|INFO| Shutting down the virtual machine: [red]{vm_info[0]}[/]") virtualmachine.stop() - @task def vm_list(c, group_name: str = None): vm_names = Vbox().vm_list(group_name) print(vm_names) return vm_names - @task def out_info(c, name: str = '', full: bool = False): print(VirtualMachine(Vbox().check_vm_names(name)).get_info(full=full)) - @task def group_list(c): group_names = Vbox().get_group_list() diff --git a/tests/data/__init__.py b/tests/data/__init__.py deleted file mode 100644 index db1a60d..0000000 --- a/tests/data/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- -from .vm_data import LinuxData -from .test_data import TestData diff --git a/tests/data/test_data.py b/tests/data/test_data.py deleted file mode 100644 index ac7b8c6..0000000 --- a/tests/data/test_data.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- -from os import getcwd -from typing import Dict - -from dataclasses import dataclass -from os.path import join, isfile, expanduser -from host_tools import File - -from frameworks.console import MyConsole - -console = MyConsole().console -print = console.print - -@dataclass -class TestData: - version: str - config_path: str - project_dir: str = join(getcwd()) - tg_dir: str = join(expanduser('~'), '.telegram') - tmp_dir: str = join(project_dir, 'tmp') - know_hosts: str = join(expanduser('~'), '.ssh', 'known_hosts') - lic_file: str = join(project_dir, 'test_lic.lickey') - proxy_config_path: str = join(expanduser('~'), '.telegram', 'proxy.json') - status_bar: bool = True - telegram: bool = False - custom_config_mode: bool = False - update_from: str = None - - def __post_init__(self): - self.config: Dict = self._read_config() - self.vm_names: list = self.config.get('hosts', []) - self.title: str = self.config.get('title', 'Undefined_title') - self.report_dir: str = join(self.project_dir, 'reports', self.title, self.version) - self.report_path: str = join(self.report_dir, f"{self.version}_{self.title}_desktop_tests_report.csv") - - @property - def tg_token(self) -> str: - return File.read(self.token_file).strip() - - @property - def token_file(self): - token_filename = self.config.get('token_file').strip() - if token_filename: - file_path = join(self.tg_dir, token_filename) - if isfile(file_path): - return file_path - print(f"[red]|WARNING| Telegram Token from config file not exists: {file_path}") - return join(self.tg_dir, 'token') - - @property - def tg_chat_id(self) -> str: - return File.read(self.chat_id_file).strip() - - @property - def chat_id_file(self) -> str: - chat_id_filename = self.config.get('chat_id_file').strip() - if chat_id_filename: - file_path = join(self.tg_dir, chat_id_filename) - if isfile(file_path): - return file_path - print(f"[red]|WARNING| Telegram Chat id from config file not exists: {file_path}") - return join(self.tg_dir, 'chat') - - def _read_config(self): - if not isfile(self.config_path): - raise FileNotFoundError(f"[red]|ERROR| Configuration file not found: {self.config_path}") - return File.read_json(self.config_path) diff --git a/tests/data/vm_data/LinuxData.py b/tests/data/vm_data/LinuxData.py deleted file mode 100644 index 8628701..0000000 --- a/tests/data/vm_data/LinuxData.py +++ /dev/null @@ -1,65 +0,0 @@ -# -*- coding: utf-8 -*- -from dataclasses import dataclass -from os.path import basename, splitext -from posixpath import join - -from .vm_data import VmData - - -@dataclass -class LinuxData(VmData): - def __post_init__(self): - if not self.user or not self.version or not self.name or not self.ip: - raise ValueError("User, version, name, ip is a required parameter.") - self.home_dir = join('/home', self.user) - self.script_path = join(self.home_dir, 'script.sh') - self.script_dir = join(self.home_dir, 'scripts') - self.desktop_testing_path = join(self.script_dir, splitext(basename(self.desktop_testing_url))[0]) - self.report_dir = join(self.desktop_testing_path, 'reports') - self.custom_config_path = join(self.script_dir, 'custom_config.json') - self.tg_dir = join(self.home_dir, '.telegram') - self.tg_token_file = join(self.tg_dir, 'token') - self.tg_chat_id_file = join(self.tg_dir, 'chat') - self.proxy_config_file = join(self.tg_dir, 'proxy.json') - self.services_dir = join('/etc', 'systemd', 'system') - self.my_service_name = 'myscript.service' - self.my_service_path = join(self.services_dir, self.my_service_name) - self.lic_file = join(self.script_dir, 'test_lic.lickey') - - @property - def start_service_commands(self) -> list: - return [ - f'chmod +x {self.script_path}', - 'sudo systemctl daemon-reload', - f'sudo systemctl start {self.my_service_name}' - ] - - def my_service(self): - return f'''\ - [Unit] - Description=CustomBashScript - - [Service] - Type=simple - ExecStart=/bin/bash {self.script_path} - User={self.user} - - [Install] - WantedBy=multi-user.target\ - ''' - - def script_sh(self) -> str: - return f'''\ - #!/bin/bash - cd {self.script_dir} - git clone {'-b ' if self.branch else ''}{self.branch if self.branch else ''} {self.desktop_testing_url} - cd {self.desktop_testing_path} - python3 -m venv venv - source ./venv/bin/activate - python3 ./install_requirements.py - invoke open-test -d -v {self.version}\ -{' -u ' + self.old_version if self.old_version else ''}\ -{' -t' if self.telegram else ''}\ -{(' -c ' + self.custom_config_path) if self.custom_config else ''}\ -{(' -l ' + self.lic_file) if self.custom_config else ''}\ - ''' diff --git a/tests/data/vm_data/__init__.py b/tests/data/vm_data/__init__.py deleted file mode 100644 index 986e329..0000000 --- a/tests/data/vm_data/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -from .LinuxData import LinuxData diff --git a/tests/data/vm_data/vm_data.py b/tests/data/vm_data/vm_data.py deleted file mode 100644 index b604b34..0000000 --- a/tests/data/vm_data/vm_data.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -import os -from dataclasses import dataclass -from os.path import join - -from host_tools import File - - -@dataclass -class VmData: - telegram: bool - user: str - ip: str - name: str - version: str - old_version: str - custom_config: bool - desktop_testing_url: str = File.read_json(join(os.getcwd(), 'config.json'))['desktop_script'] - branch: str = File.read_json(join(os.getcwd(), 'config.json')).get('branch') diff --git a/tests/desktop_tests.py b/tests/desktop_tests.py deleted file mode 100644 index e950b1a..0000000 --- a/tests/desktop_tests.py +++ /dev/null @@ -1,187 +0,0 @@ -# -*- coding: utf-8 -*- -import signal -import time -from os.path import join, dirname, isfile -from typing import Optional - -from host_tools.utils import Dir - -from VBoxWrapper import VirtualMachine, VirtualMachinException -from frameworks.console import MyConsole -from frameworks.decorators import retry -from host_tools import File -from ssh_wrapper import Ssh, Sftp, SshException, ServerData -from tests.data import LinuxData, TestData -from tests.tools.desktop_report import DesktopReport - -console = MyConsole().console -print = console.print - - -def handle_interrupt(signum, frame): - raise KeyboardInterrupt - - -signal.signal(signal.SIGINT, handle_interrupt) - - -class DesktopTest: - def __init__(self, vm_name: str, test_data: TestData, vm_cpus: int = 4, vm_memory: int = 4096): - self.vm_cores = vm_cpus - self.vm_memory = vm_memory - self.data = test_data - self.vm_name = vm_name - self.vm_data = None - Dir.create((self.data.report_dir, self.data.tmp_dir), stdout=False) - self.report = self._create_report() - - @retry(max_attempts=2, exception_type=VirtualMachinException) - def run(self, headless: bool = True): - vm = VirtualMachine(self.vm_name) - try: - self.run_vm(vm, headless=headless) - self.vm_data = self._create_vm_data(vm.get_logged_user(), vm.network.get_ip()) - self._clean_know_hosts(self.vm_data.ip) - self.run_script_on_vm(self._get_user_password(vm)) - - except VirtualMachinException: - print(f"[bold red]|ERROR|{self.vm_name}| Failed to create a virtual machine") - self.report.write(self.data.version, self.vm_name, "FAILED_CREATE_VM") - - except KeyboardInterrupt: - print("[bold red]|WARNING| Interruption by the user") - raise - - finally: - vm.stop() - - def run_vm(self, vm: VirtualMachine, headless: bool = True) -> VirtualMachine: - if vm.power_status(): - vm.stop() - vm.snapshot.restore() - self.configurate_virtual_machine(vm) - vm.run(headless=headless) - vm.network.wait_up(status_bar=self.data.status_bar, timeout=600) - vm.wait_logged_user(status_bar=self.data.status_bar, timeout=600) - return vm - - def configurate_virtual_machine(self, vm: VirtualMachine) -> None: - vm.set_cpus(self.vm_cores) - vm.nested_virtualization(True) - vm.set_memory(self.vm_memory) - vm.audio(False) - vm.speculative_execution_control(True) - - def run_script_on_vm(self, user_password: str = None): - _server = ServerData(self.vm_data.ip, self.vm_data.user, user_password, self.vm_data.name) - with Ssh(_server) as ssh, Sftp(_server, ssh.connection) as sftp: - self._create_vm_dirs(ssh) - self._change_vm_service_dir_access(ssh) - self._upload_files(sftp) - self._start_my_service(ssh) - self._wait_execute_service(ssh) - self._download_report(sftp) - - def _upload_files(self, sftp: Sftp): - service = self._create_file(join(self.data.tmp_dir, 'service'), self.vm_data.my_service()) - script = self._create_file(join(self.data.tmp_dir, 'script.sh'), self.vm_data.script_sh()) - sftp.upload_file(self.data.token_file, self.vm_data.tg_token_file, stdout=True) - sftp.upload_file(self.data.chat_id_file, self.vm_data.tg_chat_id_file, stdout=True) - sftp.upload_file(self.data.proxy_config_path, self.vm_data.proxy_config_file, stdout=True) - sftp.upload_file(service, self.vm_data.my_service_path, stdout=True) - sftp.upload_file(script, self.vm_data.script_path, stdout=True) - sftp.upload_file(self.data.config_path, self.vm_data.custom_config_path, stdout=True) - sftp.upload_file(self.data.lic_file, self.vm_data.lic_file, stdout=True) - - @staticmethod - def _create_file(path: str, text: str) -> str: - File.write(path, '\n'.join(line.strip() for line in text.split('\n')), newline='') - return path - - def _start_my_service(self, ssh: Ssh): - ssh.exec_command(f"sudo rm /var/log/journal/*/*.journal") # clean journal - for cmd in self.vm_data.start_service_commands: - ssh.exec_command(cmd, stdout=False, stderr=False) - - def _create_vm_dirs(self, ssh: Ssh): - for cmd in [f'mkdir {self.vm_data.script_dir}', f'mkdir {self.vm_data.tg_dir}']: - ssh.exec_command(cmd, stderr=False, stdout=False) - - def _change_vm_service_dir_access(self, ssh: Ssh): - for cmd in [ - f'sudo chown {self.vm_data.user}:{self.vm_data.user} {self.vm_data.services_dir}', - f'sudo chmod u+w {self.vm_data.services_dir}' - ]: - ssh.exec_command(cmd) - - def _wait_execute_service(self, ssh: Ssh, timeout: int = None): - print(f"[bold cyan]{'-' * 90}\n|INFO|{self.vm_data.name}| Wait executing script on vm\n{'-' * 90}") - service_name = self.vm_data.my_service_name - - msg = f"[cyan]|INFO|{self.vm_data.name}|{self.vm_data.ip}| Waiting for execute {service_name}" - status = console.status(msg) - status.start() if self.data.status_bar else print(msg) - - start_time = time.time() - while ssh.exec_command(f'systemctl is-active {service_name}', stdout=False).stdout == 'active': - - status.update(f"{msg}\n{self._get_my_service_log(ssh)}") if self.data.status_bar else None - time.sleep(0.5) - - if isinstance(timeout, int) and (time.time() - start_time) >= timeout: - status.stop() if self.data.status_bar else None - raise SshException( - f'[bold red]|WARNING|{self.vm_data.name}|{self.vm_data.ip}| ' - f'The service {service_name} waiting time has expired.' - ) - - status.stop() if self.data.status_bar else ... - print( - f"[blue]{'-' * 90}\n|INFO|{self.vm_data.name}|{self.vm_data.ip}|Service {service_name} log:\n{'-' * 90}\n\n" - f"{self._get_my_service_log(ssh, 1000)}\n{'-' * 90}" - ) - - def _get_my_service_log(self, ssh: Ssh, line_num: str | int = 20) -> str: - command = f'sudo journalctl -n {line_num} -u {self.vm_data.my_service_name}' - return ssh.exec_command(command, stdout=False, stderr=False).stdout - - def _download_report(self, sftp: Sftp): - try: - remote_report_dir = f"{self.vm_data.report_dir}/{self.data.title}/{self.data.version}" - sftp.download_dir(remote_report_dir, self.report.dir) - if self.report.column_is_empty("Os"): - raise FileNotFoundError - self.report.insert_vm_name(self.vm_name) - except (FileExistsError, FileNotFoundError) as e: - self.report.write(self.data.version, self.vm_data.name, "REPORT_NOT_EXISTS") - print(f"[red]|ERROR| Can't download report from {self.vm_data.name}.\nError: {e}") - - def _create_vm_data(self, user: str, ip: str): - return LinuxData( - user=user, - ip=ip, - version=self.data.version, - old_version=self.data.update_from, - name=self.vm_name, - telegram=self.data.telegram, - custom_config=self.data.custom_config_mode - ) - - def _create_report(self): - return DesktopReport( - join(self.data.report_dir, self.vm_name, f"{self.data.version}_{self.data.title}_report.csv") - ) - - def _clean_know_hosts(self, ip: str): - with open(self.data.know_hosts, 'r') as file: - filtered_lines = [line for line in file.readlines() if not line.startswith(ip)] - with open(self.data.know_hosts, 'w') as file: - file.writelines(filtered_lines) - - def _get_user_password(self, vm: VirtualMachine) -> Optional[str]: - try: - password_file = join(dirname(vm.get_parameter('CfgFile')), 'password') - password = File.read(password_file).strip() if isfile(password_file) else None - return password if password else self.data.config.get('password', None) - except (TypeError, FileNotFoundError): - return self.data.config.get('password', None) diff --git a/tests/desktop_tests/__init__.py b/tests/desktop_tests/__init__.py new file mode 100644 index 0000000..2c3944e --- /dev/null +++ b/tests/desktop_tests/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from .tools import DesktopReport +from .desktop_tests import DesktopTest +from . import multiprocessing + +__all__ = [DesktopReport, DesktopTest, multiprocessing] diff --git a/tests/desktop_tests/desktop_tests.py b/tests/desktop_tests/desktop_tests.py new file mode 100644 index 0000000..edb2696 --- /dev/null +++ b/tests/desktop_tests/desktop_tests.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from rich import print +from .tools import TestTools, TestData + + +class DesktopTest: + def __init__(self, vm_name: str, test_data: TestData): + self.test_tools = TestTools( + vm_name=vm_name, + test_data=test_data + ) + + def run(self, headless: bool = True): + try: + self.test_tools.run_vm(headless=headless) + self.test_tools.run_test_on_vm() + + except KeyboardInterrupt: + print("[bold red]|WARNING| Interruption by the user") + raise + + finally: + self.test_tools.stop_vm() diff --git a/tests/multiprocessing.py b/tests/desktop_tests/multiprocessing.py similarity index 85% rename from tests/multiprocessing.py rename to tests/desktop_tests/multiprocessing.py index 9d11976..b9b75f4 100644 --- a/tests/multiprocessing.py +++ b/tests/desktop_tests/multiprocessing.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- import time import concurrent.futures +from rich import print -from tests.data import TestData +from .tools import TestData from tests.desktop_tests import DesktopTest def run_test(vm_name, data, headless): - DesktopTest(vm_name=vm_name, test_data=data, vm_cpus=4, vm_memory=3096).run(headless=headless) + DesktopTest(vm_name=vm_name, test_data=data).run(headless=headless) def run(data: TestData, max_processes: int = 1, vm_startup_delay: int | float = 0, headless: bool = False): diff --git a/tests/desktop_tests/tools/__init__.py b/tests/desktop_tests/tools/__init__.py new file mode 100644 index 0000000..d897672 --- /dev/null +++ b/tests/desktop_tests/tools/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from .test_data import TestData +from .test_tools import TestTools +from .desktop_report import DesktopReport + +__all__ = [TestData, TestTools, DesktopReport] diff --git a/tests/tools/desktop_report.py b/tests/desktop_tests/tools/desktop_report.py similarity index 100% rename from tests/tools/desktop_report.py rename to tests/desktop_tests/tools/desktop_report.py diff --git a/tests/desktop_tests/tools/linux_script_demon.py b/tests/desktop_tests/tools/linux_script_demon.py new file mode 100644 index 0000000..3778880 --- /dev/null +++ b/tests/desktop_tests/tools/linux_script_demon.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +from os.path import exists +from posixpath import join +from tempfile import gettempdir + +class LinuxScriptDemon: + """ + A class to manage and generate systemd service scripts for running custom bash scripts as services on a Linux system. + """ + + services_dir = join('/etc', 'systemd', 'system') + + def __init__(self, exec_script_path: str, user: str, name: str = 'my_script.service'): + """ + Initialize the LinuxScriptDemon with the path to the bash script, user, and service name. + :param exec_script_path: The path to the bash script to be executed by the service. + :param user: The user under which the service will run. + :param name: The name of the systemd service file. Defaults to 'my_script.service'. + """ + self.exec_script_path = exec_script_path + self.name = name + self.user = user + + def generate(self) -> str: + """ + Generate the content of the systemd service file. + + :return: A string representing the content of the systemd service file. + """ + return f'''\ + [Unit] + Description=CustomBashScript + + [Service] + Type=simple + ExecStart=/bin/bash {self.exec_script_path} + User={self.user} + + [Install] + WantedBy=multi-user.target\ + '''.strip() + + def start_demon_commands(self) -> list: + """ + Generate the list of commands to start the service. + + :return: A list of shell commands to start the service. + """ + return [ + f'chmod +x {self.exec_script_path}', + 'sudo systemctl daemon-reload', + f'sudo systemctl start {self.name}' + ] + + def change_service_dir_access_cmd(self) -> list: + """ + Generate the list of commands to change access permissions of the service directory. + + :return: A list of shell commands to change the service directory permissions. + """ + return [ + f'sudo chown {self.user}:{self.user} {self.services_dir}', + f'sudo chmod u+w {self.services_dir}' + ] + + def create(self, save_path: str = None) -> str: + """ + Create the systemd service file at the specified path or in a temporary directory. + :param save_path: The path to save the generated service file. If None, a temporary file is created. + :return: The path to the created service file. + """ + _path = save_path or self.services_dir if exists(self.services_dir) else join(gettempdir(), self.name) + with open(_path, mode='w', newline='') as file: + file.write('\n'.join(line.strip() for line in self.generate().split('\n'))) + return _path diff --git a/tests/desktop_tests/tools/paths/__init__.py b/tests/desktop_tests/tools/paths/__init__.py new file mode 100644 index 0000000..07dd1b2 --- /dev/null +++ b/tests/desktop_tests/tools/paths/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from .paths import Paths +from .local_paths import LocalPaths +from .remote_paths import RemotePaths + +__all__ = ['Paths', 'RemotePaths', 'LocalPaths'] diff --git a/tests/desktop_tests/tools/paths/local_paths.py b/tests/desktop_tests/tools/paths/local_paths.py new file mode 100644 index 0000000..d8f842c --- /dev/null +++ b/tests/desktop_tests/tools/paths/local_paths.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +from os import getcwd +from os.path import join, expanduser + + +class LocalPaths: + project_dir: str = getcwd() + tg_dir: str = join(expanduser('~'), '.telegram') + tmp_dir: str = join(project_dir, 'tmp') + know_hosts: str = join(expanduser('~'), '.ssh', 'known_hosts') + lic_file: str = join(project_dir, 'test_lic.lickey') + proxy_config: str = join(expanduser('~'), '.telegram', 'proxy.json') diff --git a/tests/desktop_tests/tools/paths/paths.py b/tests/desktop_tests/tools/paths/paths.py new file mode 100644 index 0000000..73aa235 --- /dev/null +++ b/tests/desktop_tests/tools/paths/paths.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +from .local_paths import LocalPaths +from .remote_paths import RemotePaths + + +class Paths: + + def __init__(self, remote_user_name: str = None): + self.local = LocalPaths() + if remote_user_name: + self.remote = RemotePaths(user_name=remote_user_name) diff --git a/tests/desktop_tests/tools/paths/remote_paths.py b/tests/desktop_tests/tools/paths/remote_paths.py new file mode 100644 index 0000000..e3d01da --- /dev/null +++ b/tests/desktop_tests/tools/paths/remote_paths.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from posixpath import join + +class RemotePaths: + def __init__(self, user_name: str): + self.user_name = user_name + self.home_dir = join("/home", user_name) + self.script_path = join(self.home_dir, 'script.sh') + self.script_dir = join(self.home_dir, 'scripts') + self.desktop_testing_path = join(self.script_dir, 'desktop_testing') + self.report_dir = join(self.desktop_testing_path, 'reports') + self.custom_config_path = join(self.script_dir, 'custom_config.json') + self.tg_dir = join(self.home_dir, '.telegram') + self.tg_token_file = join(self.tg_dir, 'token') + self.tg_chat_id_file = join(self.tg_dir, 'chat') + self.proxy_config_file = join(self.tg_dir, 'proxy.json') + self.services_dir = join('/etc', 'systemd', 'system') + self.my_service_name = 'myscript.service' + self.my_service_path = join(self.services_dir, self.my_service_name) + self.lic_file = join(self.script_dir, 'test_lic.lickey') diff --git a/tests/desktop_tests/tools/run_script.py b/tests/desktop_tests/tools/run_script.py new file mode 100644 index 0000000..549f490 --- /dev/null +++ b/tests/desktop_tests/tools/run_script.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +import os +from posixpath import join +from host_tools import File + +from .paths import Paths + + +class RunScript: + def __init__( + self, + version: str, + old_version: str, + telegram: bool, + custom_config_path: str, + desktop_testing_url: str, + branch: str, + paths: Paths + ): + self.version = version + self.old_version = old_version + self.telegram = telegram + self.custom_config = custom_config_path + self.save_path = join(os.getcwd(), 'script.sh') + self._path = paths + self.lic_file = self._path.remote.lic_file + self.desktop_testing_url = desktop_testing_url + self.branch = branch + + def generate(self) -> str: + return f'''\ + #!/bin/bash + cd {self._path.remote. script_dir} + {self.clone_desktop_testing_repo()} + cd {self._path.remote.desktop_testing_path} + python3 -m venv venv + source ./venv/bin/activate + python3 ./install_requirements.py + {self.generate_run_test_cmd()} + ''' + + def clone_desktop_testing_repo(self) -> str: + branch = f"{'-b ' if self.branch else ''}{self.branch if self.branch else ''}".strip() + return f"git clone {branch} {self.desktop_testing_url} {self._path.remote.desktop_testing_path}" + + def generate_run_test_cmd(self) -> str: + return ( + f"invoke open-test -d -v {self.version} " + f"{' -u ' + self.old_version if self.old_version else ''} " + f"{' -t' if self.telegram else ''} " + f"{(' -c ' + self.custom_config) if self.custom_config else ''} " + f"{(' -l ' + self.lic_file) if self.custom_config else ''}" + ) + + def create(self) -> str: + File.write(self.save_path, '\n'.join(line.strip() for line in self.generate().split('\n')), newline='') + return self.save_path diff --git a/tests/desktop_tests/tools/ssh_connection.py b/tests/desktop_tests/tools/ssh_connection.py new file mode 100644 index 0000000..e2d386b --- /dev/null +++ b/tests/desktop_tests/tools/ssh_connection.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +import contextlib +import time + +from tempfile import gettempdir +from ssh_wrapper import Ssh, Sftp, SshException + +from frameworks.console import MyConsole +from . import TestData +from .linux_script_demon import LinuxScriptDemon +from .paths import Paths +from .run_script import RunScript + +console = MyConsole().console +print = console.print + +class SSHConnection: + + def __init__(self, ssh: Ssh, sftp: Sftp, test_data: TestData, paths: Paths): + self.ssh = ssh + self.sftp = sftp + self.data = test_data + self._paths = paths + self.tmp_dir = gettempdir() + + def upload_test_files(self, service: LinuxScriptDemon, script: RunScript): + self.create_test_dirs() + self.upload(self.data.token_file, self._paths.remote.tg_token_file) + self.upload(self.data.chat_id_file, self._paths.remote.tg_chat_id_file) + self.upload(self._paths.local.proxy_config, self._paths.remote.proxy_config_file) + self.upload(service.create(), self._paths.remote.my_service_path) + self.upload(script.create(), self._paths.remote.script_path) + self.upload(self.data.config_path, self._paths.remote.custom_config_path) + self.upload(self._paths.local.lic_file, self._paths.remote.lic_file) + + def upload(self, local_path: str, remote_path: str): + self.sftp.upload_file(local=local_path, remote=remote_path, stdout=True) + + def create_test_dirs(self): + for cmd in [f'mkdir {self._paths.remote.script_dir}', f'mkdir {self._paths.remote.tg_dir}']: + self.exec_cmd(cmd) + + def change_vm_service_dir_access(self, user_name: str): + for cmd in [ + f'sudo chown {user_name}:{user_name} {self._paths.remote.services_dir}', + f'sudo chmod u+w {self._paths.remote.services_dir}' + ]: + self.exec_cmd(cmd) + + def start_my_service(self, start_service_cmd: list): + self.clean_log_journal() + for cmd in start_service_cmd: + self.exec_cmd(cmd) + + def clean_log_journal(self): + self.exec_cmd("sudo rm /var/log/journal/*/*.journal") + + def wait_execute_service(self, timeout: int = None, status_bar: bool = False): + service_name = self._paths.remote.my_service_name + server_info = f"{self.ssh.server.custom_name}|{self.ssh.server.ip}" + msg = f"[cyan]|INFO|{server_info}| Waiting for execution of {service_name}" + + print(f"[bold cyan]{'-' * 90}\n|INFO|{server_info}| Waiting for script execution on VM\n{'-' * 90}") + + with console.status(msg) if status_bar else contextlib.nullcontext() as status: + print(msg) if not status_bar else None + start_time = time.time() + while self.exec_cmd(f'systemctl is-active {service_name}', stderr=True).stdout == 'active': + status.update(f"{msg}\n{self._get_my_service_log(stdout=False)}") if status_bar else None + time.sleep(0.5) + + if isinstance(timeout, int) and (time.time() - start_time) >= timeout: + raise SshException( + f'[bold red]|WARNING|{server_info}| The service {service_name} waiting time has expired.' + ) + print( + f"[blue]{'-' * 90}\n|INFO|{server_info}| Service {service_name} log:\n{'-' * 90}\n\n" + f"{self._get_my_service_log(1000, stdout=False)}\n{'-' * 90}" + ) + + def _get_my_service_log(self, line_num: str | int = 20, stdout: bool = True, stderr: bool = True) -> str: + command = f'sudo journalctl -n {line_num} -u {self._paths.remote.my_service_name}' + return self.exec_cmd(command, stdout=stdout, stderr=stderr).stdout + + def download_report(self, product_title: str, version: str, report_dir: str): + try: + remote_report_dir = f"{self._paths.remote.report_dir}/{product_title}/{version}" + self.sftp.download_dir(remote_report_dir, report_dir) + return True + except (FileExistsError, FileNotFoundError) as e: + print(e) + return False + + def exec_cmd(self,cmd, stderr=False, stdout=False): + return self.ssh.exec_command(cmd, stderr=stderr, stdout=stdout) diff --git a/tests/desktop_tests/tools/test_data.py b/tests/desktop_tests/tools/test_data.py new file mode 100644 index 0000000..4cbde83 --- /dev/null +++ b/tests/desktop_tests/tools/test_data.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +from os import getcwd +from rich import print +from typing import Dict, Optional, Union, List + +from dataclasses import dataclass, field +from os.path import join, isfile +from host_tools import File + +from .paths import LocalPaths + +@dataclass +class TestData: + version: str + config_path: str + status_bar: bool = True + telegram: bool = False + custom_config_mode: Union[bool, str] = False + update_from: Optional[str] = None + + config: Dict = field(init=False) + desktop_testing_url: str = field(init=False) + branch: str = field(init=False) + vm_names: List[str] = field(init=False) + title: str = field(init=False) + report_dir: str = field(init=False) + report_path: str = field(init=False) + local_paths: LocalPaths = field(init=False) + + def __post_init__(self): + self.config = self._read_config() + self.desktop_testing_url = self.config['desktop_script'] + self.branch = self.config['branch'] + self.vm_names = self.config.get('hosts', []) + self.title = self.config.get('title', 'Undefined_title') + self.report_dir = join(getcwd(), 'reports', self.title, self.version) + self.report_path = join(self.report_dir, f"{self.version}_{self.title}_desktop_tests_report.csv") + self.local_paths = LocalPaths() + + @property + def tg_token(self) -> str: + return self._read_file(self.token_file).strip() + + @property + def token_file(self) -> str: + return self._get_file_path('token_file', 'token') + + @property + def tg_chat_id(self) -> str: + return self._read_file(self.chat_id_file).strip() + + @property + def chat_id_file(self) -> str: + return self._get_file_path('chat_id_file', 'chat') + + def _read_config(self) -> Dict: + if not isfile(self.config_path): + raise FileNotFoundError(f"[red]|ERROR| Configuration file not found: {self.config_path}") + return File.read_json(self.config_path) + + @staticmethod + def _read_file(file_path: str) -> str: + if not isfile(file_path): + raise FileNotFoundError(f"[red]|ERROR| File not found: {file_path}") + return File.read(file_path) + + def _get_file_path(self, config_key: str, default_filename: str) -> str: + filename = self.config.get(config_key, '').strip() + if filename: + file_path = join(self.local_paths.tg_dir, filename) + if isfile(file_path): + return file_path + print( + f"[red]|WARNING| {config_key.replace('_', ' ').capitalize()} " + f"from config file not exists: {file_path}" + ) + return join(self.local_paths.tg_dir, default_filename) diff --git a/tests/desktop_tests/tools/test_tools.py b/tests/desktop_tests/tools/test_tools.py new file mode 100644 index 0000000..6d343c7 --- /dev/null +++ b/tests/desktop_tests/tools/test_tools.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +import signal +from os import getcwd +from os.path import join, dirname, isfile +from typing import Optional + +from VBoxWrapper import VirtualMachinException +from host_tools import File, Dir +from ssh_wrapper import Ssh, Sftp, ServerData + +from frameworks.decorators import retry, vm_data_created +from frameworks import VboxMachine, MyConsole +from .desktop_report import DesktopReport +from .paths import Paths +from .ssh_connection import SSHConnection +from .linux_script_demon import LinuxScriptDemon +from .run_script import RunScript +from .test_data import TestData + + +console = MyConsole().console +print = console.print + +def handle_interrupt(signum, frame): + raise KeyboardInterrupt + +signal.signal(signal.SIGINT, handle_interrupt) + + +class TestTools: + vm_config_path = join(getcwd(), "vm_configs", "desktop_test_vm_config.json") + + def __init__(self, vm_name: str, test_data: TestData): + self.data = test_data + self.vm_name = vm_name + self.vm = VboxMachine(self.vm_name, config_path=self.vm_config_path) + self.password_cache = None + + self._initialize_report() + + @retry(max_attempts=2, exception_type=VirtualMachinException) + def run_vm(self, headless: bool = True): + try: + self.vm.run(headless=headless, status_bar=self.data.status_bar) + self._initialize_paths() + self._initialize_run_script() + self._initialize_linux_demon() + + except VirtualMachinException: + self._handle_vm_creation_failure() + + def stop_vm(self): + self.vm.stop() + + @vm_data_created + def run_test_on_vm(self): + self._clean_known_hosts(self.vm.data.ip) + server = self._get_server() + + with Ssh(server) as ssh, Sftp(server, ssh.connection) as sftp: + connect = SSHConnection(ssh=ssh, sftp=sftp, test_data=self.data, paths=self.paths) + connect.change_vm_service_dir_access(self.vm.data.user) + connect.upload_test_files(self.linux_demon, self.run_script) + connect.start_my_service(self.linux_demon.start_demon_commands()) + connect.wait_execute_service(status_bar=self.data.status_bar) + + if self._download_and_check_report(connect): + self.report.insert_vm_name(self.vm_name) + + def _download_and_check_report(self, connect): + if ( + connect.download_report(self.data.title, self.data.version, self.report.dir) + and not self.report.column_is_empty("Os") + ): + return True + + print(f"[red]|ERROR| Can't download report from {self.vm.data.name}.") + self.report.write(self.data.version, self.vm.data.name, "REPORT_NOT_EXISTS") + return False + + def _get_server(self) -> ServerData: + return ServerData( + ip=self.vm.data.ip, + username=self.vm.data.user, + password=self._get_password(self.vm.data.local_dir), + custom_name=self.vm.data.name + ) + + def _initialize_report(self): + report_file = join(self.data.report_dir, self.vm_name, f"{self.data.version}_{self.data.title}_report.csv") + Dir.create(dirname(report_file), stdout=False) + self.report = DesktopReport(report_file) + + @vm_data_created + def _initialize_run_script(self): + self.run_script = RunScript( + version=self.data.version, + old_version=self.data.update_from, + telegram=self.data.telegram, + custom_config_path=self.data.custom_config_mode, + desktop_testing_url=self.data.desktop_testing_url, + branch=self.data.branch, + paths=self.paths + ) + + @vm_data_created + def _initialize_linux_demon(self): + self.linux_demon = LinuxScriptDemon( + exec_script_path=self.paths.remote.script_path, + user=self.vm.data.user, + name=self.paths.remote.my_service_name + ) + + @vm_data_created + def _initialize_paths(self): + self.paths = Paths(remote_user_name=self.vm.data.user) + + def _clean_known_hosts(self, ip: str): + with open(self.paths.local.know_hosts, 'r') as file: + filtered_lines = [line for line in file if not line.startswith(ip)] + with open(self.paths.local.know_hosts, 'w') as file: + file.writelines(filtered_lines) + + def _get_password(self, vm_dir: str) -> Optional[str]: + if self.password_cache: + return self.password_cache + + try: + password_file = join(dirname(vm_dir), 'password') + password = File.read(password_file).strip() if isfile(password_file) else None + self.password_cache = password or self.data.config.get('password') + except (TypeError, FileNotFoundError): + self.password_cache = self.data.config.get('password') + + return self.password_cache + + def _handle_vm_creation_failure(self): + print(f"[bold red]|ERROR|{self.vm_name}| Failed to create a virtual machine") + self.report.write(self.data.version, self.vm_name, "FAILED_CREATE_VM") diff --git a/vm_configs/desktop_test_vm_config.json b/vm_configs/desktop_test_vm_config.json new file mode 100644 index 0000000..2defca6 --- /dev/null +++ b/vm_configs/desktop_test_vm_config.json @@ -0,0 +1,7 @@ +{ + "cpus": 4, + "memory": 4096, + "audio": false, + "nested_virtualization": true, + "speculative_execution_control": true +}