diff --git a/src/roles/ha_db_hana/tasks/fs-freeze.yml b/src/roles/ha_db_hana/tasks/fs-freeze.yml index 614ceba5..c42855bd 100644 --- a/src/roles/ha_db_hana/tasks/fs-freeze.yml +++ b/src/roles/ha_db_hana/tasks/fs-freeze.yml @@ -51,7 +51,7 @@ changed_when: cleanup_failed_resource_post.rc == 0 ignore_errors: true - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 1" when: cluster_status_pre.AUTOMATED_REGISTER | lower == "true" get_cluster_status_db: operation_step: "test_execution" @@ -67,7 +67,7 @@ - name: "Test Execution: Freeze File System on Primary Node init" when: ansible_hostname == cluster_status_pre.primary_node block: - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 2" get_cluster_status_db: operation_step: "post_failover" database_sid: "{{ db_sid | lower }}" diff --git a/src/roles/ha_db_hana/tasks/primary-crash-index.yml b/src/roles/ha_db_hana/tasks/primary-crash-index.yml index 3bca3441..d1062c17 100644 --- a/src/roles/ha_db_hana/tasks/primary-crash-index.yml +++ b/src/roles/ha_db_hana/tasks/primary-crash-index.yml @@ -51,7 +51,7 @@ - name: "Test Execution: Validate HANA DB cluster status" when: ansible_hostname == cluster_status_pre.secondary_node block: - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 1" when: cluster_status_pre.AUTOMATED_REGISTER == "true" get_cluster_status_db: operation_step: "test_execution" @@ -113,7 +113,7 @@ changed_when: cleanup_failed_resource_post.rc == 0 ignore_errors: true - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 2" get_cluster_status_db: operation_step: "post_failover" database_sid: "{{ db_sid | lower }}" diff --git a/src/roles/ha_db_hana/tasks/primary-echo-b.yml b/src/roles/ha_db_hana/tasks/primary-echo-b.yml index 7018652a..b66778d6 100644 --- a/src/roles/ha_db_hana/tasks/primary-echo-b.yml +++ b/src/roles/ha_db_hana/tasks/primary-echo-b.yml @@ -42,7 +42,7 @@ - name: "Test Execution: Switch to secondary node" when: ansible_hostname == cluster_status_pre.secondary_node block: - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 1" when: cluster_status_pre.AUTOMATED_REGISTER == "true" get_cluster_status_db: operation_step: "test_execution" @@ -100,7 +100,7 @@ changed_when: cleanup_failed_resource_post.rc == 0 ignore_errors: true - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 2" get_cluster_status_db: operation_step: "post_failover" database_sid: "{{ db_sid | lower }}" diff --git a/src/roles/ha_db_hana/tasks/primary-node-crash.yml b/src/roles/ha_db_hana/tasks/primary-node-crash.yml index a502d04d..60adaa37 100644 --- a/src/roles/ha_db_hana/tasks/primary-node-crash.yml +++ b/src/roles/ha_db_hana/tasks/primary-node-crash.yml @@ -39,7 +39,7 @@ changed_when: hana_db_stop_results == 0 failed_when: hana_db_stop_results.rc != 0 - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 1" get_cluster_status_db: operation_step: "test_execution" database_sid: "{{ db_sid | lower }}" @@ -81,7 +81,7 @@ changed_when: cleanup_failed_resource_post.rc == 0 ignore_errors: true - - name: "Test execution: Validate HANA DB cluster status" + - name: "Test execution: Validate HANA DB cluster status 2" get_cluster_status_db: operation_step: "post_failover" database_sid: "{{ db_sid | lower }}" diff --git a/src/roles/ha_db_hana/tasks/primary-node-kill.yml b/src/roles/ha_db_hana/tasks/primary-node-kill.yml index 813b0f6a..4cc6844d 100644 --- a/src/roles/ha_db_hana/tasks/primary-node-kill.yml +++ b/src/roles/ha_db_hana/tasks/primary-node-kill.yml @@ -39,7 +39,7 @@ changed_when: hana_db_kill_results == 0 failed_when: hana_db_kill_results.rc != 0 - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 1" when: cluster_status_pre.AUTOMATED_REGISTER == "true" get_cluster_status_db: operation_step: "test_execution" @@ -98,7 +98,7 @@ changed_when: cleanup_failed_resource_post.rc == 0 ignore_errors: true - - name: "Test execution: Validate HANA DB cluster status" + - name: "Test execution: Validate HANA DB cluster status 2" get_cluster_status_db: operation_step: "post_failover" database_sid: "{{ db_sid | lower }}" diff --git a/src/roles/ha_db_hana/tasks/resource-migration.yml b/src/roles/ha_db_hana/tasks/resource-migration.yml index 6efd523e..c8c58529 100644 --- a/src/roles/ha_db_hana/tasks/resource-migration.yml +++ b/src/roles/ha_db_hana/tasks/resource-migration.yml @@ -39,7 +39,7 @@ failed_when: hana_db_resource_migration.rc != 0 changed_when: hana_db_resource_migration.rc == 0 - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 1" get_cluster_status_db: operation_step: "test_execution" database_sid: "{{ db_sid | lower }}" @@ -94,7 +94,7 @@ ansible.builtin.wait_for: timeout: 100 - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 2" get_cluster_status_db: operation_step: "test_execution" database_sid: "{{ db_sid | lower }}" diff --git a/src/roles/ha_db_hana/tasks/sbd-fencing.yml b/src/roles/ha_db_hana/tasks/sbd-fencing.yml index 023f3281..3fdc88cd 100644 --- a/src/roles/ha_db_hana/tasks/sbd-fencing.yml +++ b/src/roles/ha_db_hana/tasks/sbd-fencing.yml @@ -69,7 +69,7 @@ - name: "Test Execution: SBD Inquisitor kill manual fail over" when: ansible_hostname == cluster_status_pre.primary_node block: - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 2" get_cluster_status_db: operation_step: "test_execution" database_sid: "{{ db_sid | lower }}" diff --git a/src/roles/ha_db_hana/tasks/secondary-crash-index.yml b/src/roles/ha_db_hana/tasks/secondary-crash-index.yml index 78fd724f..ae4feaef 100644 --- a/src/roles/ha_db_hana/tasks/secondary-crash-index.yml +++ b/src/roles/ha_db_hana/tasks/secondary-crash-index.yml @@ -64,7 +64,7 @@ cluster_status_test_execution.primary_node == cluster_status_pre.primary_node and cluster_status_test_execution.secondary_node == "" - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 2" get_cluster_status_db: operation_step: "post_failover" database_sid: "{{ db_sid | lower }}" diff --git a/src/roles/ha_db_hana/tasks/secondary-echo-b.yml b/src/roles/ha_db_hana/tasks/secondary-echo-b.yml index 13e0dec8..93037111 100644 --- a/src/roles/ha_db_hana/tasks/secondary-echo-b.yml +++ b/src/roles/ha_db_hana/tasks/secondary-echo-b.yml @@ -59,7 +59,7 @@ cluster_status_test_execution.primary_node == cluster_status_pre.primary_node and cluster_status_test_execution.secondary_node == "" - - name: "Test Execution: Validate HANA DB cluster status" + - name: "Test Execution: Validate HANA DB cluster status 2" get_cluster_status_db: operation_step: "post_failover" database_sid: "{{ db_sid | lower }}" diff --git a/src/roles/ha_db_hana/tasks/secondary-node-kill.yml b/src/roles/ha_db_hana/tasks/secondary-node-kill.yml index 50714898..67d5d6e2 100644 --- a/src/roles/ha_db_hana/tasks/secondary-node-kill.yml +++ b/src/roles/ha_db_hana/tasks/secondary-node-kill.yml @@ -23,7 +23,7 @@ - node_tier == "hana" - pre_validations_status == "PASSED" block: - - name: "Test Execution: Kill the secondary node." + - name: "Test Execution: Kill the secondary node" when: ansible_hostname == cluster_status_pre.primary_node block: - name: "Test Execution: Start timer" @@ -57,7 +57,7 @@ cluster_status_test_execution.primary_node == cluster_status_pre.primary_node and cluster_status_test_execution.secondary_node == "" - - name: "Test execution: Validate HANA DB cluster status" + - name: "Test execution: Validate HANA DB cluster status 2" get_cluster_status_db: operation_step: "post_failover" ansible_os_family: "{{ ansible_os_family | upper }}" diff --git a/src/roles/misc/tasks/post-validations-db.yml b/src/roles/misc/tasks/post-validations-db.yml index ce2a7d5d..9d5e9cee 100644 --- a/src/roles/misc/tasks/post-validations-db.yml +++ b/src/roles/misc/tasks/post-validations-db.yml @@ -19,12 +19,12 @@ {{ ( hostvars[cluster_status_pre.primary_node]['var_log_messages_output'].filtered_logs - | default("") + | default([]) ) + ( hostvars[cluster_status_pre.secondary_node]['var_log_messages_output'].filtered_logs - | default("") + | default([]) ) }} diff --git a/src/roles/misc/tasks/post-validations-scs.yml b/src/roles/misc/tasks/post-validations-scs.yml index ebee6f9c..2f8d4167 100644 --- a/src/roles/misc/tasks/post-validations-scs.yml +++ b/src/roles/misc/tasks/post-validations-scs.yml @@ -19,12 +19,12 @@ {{ ( hostvars[cluster_status_pre.ascs_node]['var_log_messages_output'].filtered_logs - | default("") + | default([]) ) + ( hostvars[cluster_status_pre.ers_node]['var_log_messages_output'].filtered_logs - | default("") + | default([]) ) }} diff --git a/src/roles/misc/tasks/rescue.yml b/src/roles/misc/tasks/rescue.yml index ccf1630e..8ab623b1 100644 --- a/src/roles/misc/tasks/rescue.yml +++ b/src/roles/misc/tasks/rescue.yml @@ -21,12 +21,12 @@ {{ ( hostvars[first_node]['var_log_messages_output'].filtered_logs - | default("") + | default([]) ) + ( hostvars[second_node]['var_log_messages_output'].filtered_logs - | default("") + | default([]) ) }} diff --git a/tests/roles/ha_db_hana/az_lb_test.py b/tests/roles/ha_db_hana/az_lb_test.py new file mode 100644 index 00000000..7f6d4f64 --- /dev/null +++ b/tests/roles/ha_db_hana/az_lb_test.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Test class for Azure LB configuration validation tasks. + +This test class uses pytest to run functional tests on the Azure LB configuration validation tasks +defined in roles/ha_db_hana/tasks/azure-lb.yml. It sets up a temporary test environment, +mocks necessary Python modules and commands, and verifies the execution of the tasks. +""" + +import os +import shutil +from pathlib import Path +import pytest +from tests.roles.ha_db_hana.roles_testing_base_db import RolesTestingBaseDB + + +class TestAzLBConfigValidation(RolesTestingBaseDB): + """ + Test class for Azure LB configuration validation tasks. + """ + + @pytest.fixture + def test_environment(self, ansible_inventory): + """ + Set up a temporary test environment for the Azure LB configuration validation tasks. + + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + :yield temp_dir: Path to the temporary test environment. + :ytype: str + """ + + temp_dir = self.setup_test_environment( + role_type="ha_db_hana", + ansible_inventory=ansible_inventory, + task_name="azure-lb", + task_description="The test validates the Azure load balancer configuration.", + module_names=[ + "project/library/get_azure_lb", + "project/library/log_parser", + "project/library/send_telemetry_data", + "project/library/get_package_list", + "bin/crm_resource", + ], + extra_vars_override={"node_tier": "hana"}, + ) + + os.makedirs(f"{temp_dir}/project/roles/ha_db_hana/tasks/files", exist_ok=True) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/roles/ha_db_hana/tasks/files/constants.yaml", + content=self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent / "mock_data/mock_azure_lb.txt", + ), + ) + + os.makedirs(f"{temp_dir}/project/library", exist_ok=True) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/library/uri", + content=self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent / "mock_data/azure_metadata.txt", + ), + ) + os.chmod(f"{temp_dir}/project/library/uri", 0o755) + + yield temp_dir + shutil.rmtree(temp_dir) + + def test_ha_config_validation_success(self, test_environment, ansible_inventory): + """ + Test the Azure LB configuration validation tasks using Ansible Runner. + + :param test_environment: Path to the temporary test environment. + :type test_environment: str + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + """ + result = self.run_ansible_playbook( + test_environment=test_environment, inventory_file_name="inventory_db.txt" + ) + + assert result.rc == 0, ( + f"Playbook failed with status: {result.rc}\n" + f"STDOUT: {result.stdout.read() if result.stdout else 'No output'}\n" + f"STDERR: {result.stderr.read() if result.stderr else 'No errors'}\n" + f"Events: {[e.get('event') for e in result.events if 'event' in e]}" + ) + + ok_events, failed_events = [], [] + for event in result.events: + if event.get("event") == "runner_on_ok": + ok_events.append(event) + elif event.get("event") == "runner_on_failed": + failed_events.append(event) + + assert len(ok_events) > 0 + assert len(failed_events) == 0 + + for event in ok_events: + task = event.get("event_data", {}).get("task") + task_result = event.get("event_data", {}).get("res") + if "Retrieve Subscription ID" in task: + assert task_result.get("changed") is False + if "Azure Load Balancer check" in task: + assert task_result.get("changed") is False + assert task_result["details"]["parameters"][1].get("name") == "probe_threshold" + assert task_result["details"]["parameters"][1].get("value") == "2" diff --git a/tests/roles/ha_db_hana/ha_config_test.py b/tests/roles/ha_db_hana/ha_config_test.py new file mode 100644 index 00000000..16944dd6 --- /dev/null +++ b/tests/roles/ha_db_hana/ha_config_test.py @@ -0,0 +1,134 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Test class for HANA DB HA config validation tasks + +This test class uses pytest to run functional tests on the HANA DB HA config validation tasks +defined in roles/ha_db_hana/tasks/ha-config.yml. It sets up a temporary test environment, +mocks necessary Python modules and commands, and verifies the execution of the tasks. +""" + +import os +import shutil +from pathlib import Path +import pytest +from tests.roles.ha_db_hana.roles_testing_base_db import RolesTestingBaseDB + + +class TestDbHaConfigValidation(RolesTestingBaseDB): + """ + Test class for HANA DB HA config validation tasks. + """ + + @pytest.fixture + def hana_ha_config_tasks(self): + """ + Load the HANA DB HA config validation tasks from the YAML file. + + :return: Parsed YAML content of the tasks file. + :rtype: dict + """ + return self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent.parent + / "src/roles/ha_db_hana/tasks/ha-config.yml", + ) + + @pytest.fixture + def test_environment(self, ansible_inventory): + """ + Set up a temporary test environment for the HANA DB HA config validation tasks. + + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + :yield temp_dir: Path to the temporary test environment. + :ytype: str + """ + + temp_dir = self.setup_test_environment( + role_type="ha_db_hana", + ansible_inventory=ansible_inventory, + task_name="ha-config", + task_description="The HANA DB HA config validation test validates the DB cluster " + + "configuration and other system configuration", + module_names=[ + "project/library/get_pcmk_properties_db", + "project/library/log_parser", + "project/library/send_telemetry_data", + "project/library/get_package_list", + "bin/crm_resource", + "bin/crm", + ], + extra_vars_override={"node_tier": "hana"}, + ) + + os.makedirs(f"{temp_dir}/project/roles/ha_db_hana/tasks/files", exist_ok=True) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/roles/ha_db_hana/tasks/files/constants.yaml", + content=self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent / "mock_data/cluster_config.txt", + ), + ) + + os.makedirs(f"{temp_dir}/project/library", exist_ok=True) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/library/uri", + content=self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent / "mock_data/azure_metadata.txt", + ), + ) + os.chmod(f"{temp_dir}/project/library/uri", 0o755) + + yield temp_dir + shutil.rmtree(temp_dir) + + def test_ha_config_validation_success(self, test_environment, ansible_inventory): + """ + Test the HANA DB HA config validation tasks using Ansible Runner. + + :param test_environment: Path to the temporary test environment. + :type test_environment: str + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + """ + result = self.run_ansible_playbook( + test_environment=test_environment, inventory_file_name="inventory_db.txt" + ) + + assert result.rc == 0, ( + f"Playbook failed with status: {result.rc}\n" + f"STDOUT: {result.stdout.read() if result.stdout else 'No output'}\n" + f"STDERR: {result.stderr.read() if result.stderr else 'No errors'}\n" + f"Events: {[e.get('event') for e in result.events if 'event' in e]}" + ) + + ok_events, failed_events = [], [] + for event in result.events: + if event.get("event") == "runner_on_ok": + ok_events.append(event) + elif event.get("event") == "runner_on_failed": + failed_events.append(event) + + assert len(ok_events) > 0 + assert len(failed_events) == 0 + + for event in ok_events: + task = event.get("event_data", {}).get("task") + task_result = event.get("event_data", {}).get("res") + if "Create package dictionary" in task: + assert task_result.get("changed") is False + assert task_result["details"][1].get("Corosync").get("version") == "2.4.5" + if "Virtual Machine name" in task: + assert task_result.get("changed") is False + if "HA Configuration check" in task: + assert task_result.get("changed") is False + assert ( + task_result["details"].get("parameters", {})[1].get("name") + == "migration-threshold" + ) + assert task_result["details"].get("parameters", {})[1].get("value") == "3" diff --git a/tests/roles/ha_db_hana/primary_node_ops_test.py b/tests/roles/ha_db_hana/primary_node_ops_test.py new file mode 100644 index 00000000..166a00ba --- /dev/null +++ b/tests/roles/ha_db_hana/primary_node_ops_test.py @@ -0,0 +1,224 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Test class for HANA DB primary node crash and node kill tasks + +This test class uses pytest to run functional tests on the HANA DB primary node crash, kill, echo-b, +and crash-index tasks defined in roles/ha_db_hana/tasks/primary-node-crash.yml. +It sets up a temporary test environment, mocks necessary Python modules and commands, and verifies +the execution of the tasks. +""" + +import os +import shutil +from pathlib import Path +import pytest +from tests.roles.ha_db_hana.roles_testing_base_db import RolesTestingBaseDB + + +class TestDbHDBOperations(RolesTestingBaseDB): + """ + Test class for HANA DB primary node crash, kill, echo-b, and crash-index tasks. + """ + + @pytest.fixture( + params=[ + "primary-node-crash", + "primary-node-kill", + "primary-echo-b", + "primary-crash-index", + "sbd-fencing", + "fs-freeze", + ] + ) + def task_type(self, request): + """ + Parameterized fixture to test both primary and secondary node operations. + + :param request: pytest request object containing the parameter + :type request: pytest.request + :return: Dictionary with task configuration details + :rtype: dict + """ + task_name = request.param + + if task_name == "primary-node-crash": + return { + "task_name": task_name, + "command_task": "Stop the HANA DB", + "command_type": "stop", + "validate_task": "Test execution: Validate HANA DB cluster status 2", + } + elif task_name == "primary-node-kill": + return { + "task_name": task_name, + "command_task": "Test Execution: Kill the HANA DB", + "command_type": "kill-9", + "validate_task": "Test execution: Validate HANA DB cluster status 2", + } + elif task_name == "primary-echo-b": + return { + "task_name": task_name, + "command_task": "Test Execution: Echo b", + "command_type": "echo b", + "validate_task": "Test Execution: Validate HANA DB cluster status 2", + } + elif task_name == "primary-crash-index": + return { + "task_name": task_name, + "command_task": "Test Execution: Crash the index server", + "command_type": "killall", + "validate_task": "Test Execution: Validate HANA DB cluster status", + } + elif task_name == "sbd-fencing": + return { + "task_name": task_name, + "command_task": "Test Execution: Kill the inquisitor process", + "command_type": "kill", + "validate_task": "Test Execution: Validate HANA DB cluster status 2", + } + elif task_name == "fs-freeze": + return { + "task_name": task_name, + "validate_task": "Test Execution: Validate HANA DB cluster status 2", + "command_task": "dummy (no command)", + } + + @pytest.fixture + def test_environment(self, ansible_inventory, task_type): + """ + Set up a temporary test environment for the HANA DB primary node operations tasks. + + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + :param task_type: Dictionary with task configuration details. + :type task_type: dict + :yield temp_dir: Path to the temporary test environment. + :ytype: str + """ + + task_counter_file = f"/tmp/get_cluster_status_counter_{task_type['task_name']}" + if os.path.exists(task_counter_file): + os.remove(task_counter_file) + + module_names = [ + "project/library/get_cluster_status_db", + "project/library/log_parser", + "project/library/send_telemetry_data", + "project/library/location_constraints", + "project/library/check_indexserver", + "project/library/filesystem_freeze", + "bin/crm_resource", + "bin/crm", + "bin/echo", + "bin/killall", + ] + + if task_type["task_name"] == "sbd-fencing": + module_names.extend(["bin/pgrep", "bin/kill", "bin/head"]) + + temp_dir = self.setup_test_environment( + role_type="ha_db_hana", + ansible_inventory=ansible_inventory, + task_name=task_type["task_name"], + task_description=f"The {task_type['task_name']} test validates failover scenarios", + module_names=module_names, + extra_vars_override={ + "node_tier": "hana", + "NFS_provider": "ANF", + "database_cluster_type": "ISCSI", + }, + ) + + os.makedirs(f"{temp_dir}/bin", exist_ok=True) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/bin/HDB", + content=self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent / "mock_data/HDB.txt", + ), + ) + os.chmod(f"{temp_dir}/bin/HDB", 0o755) + + playbook_content = self.file_operations( + operation="read", + file_path=f"{temp_dir}/project/roles/ha_db_hana/tasks/{task_type['task_name']}.yml", + ) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/roles/ha_db_hana/tasks/{task_type['task_name']}.yml", + content=playbook_content.replace("set -o pipefail\n", "").replace( + "/usr/sap/{{ db_sid | upper }}/HDB{{ db_instance_number }}/", "" + ), + ) + + yield temp_dir + shutil.rmtree(temp_dir) + + def test_functional_db_primary_node_success( + self, test_environment, ansible_inventory, task_type + ): + """ + Test the HANA DB primary node operations tasks using Ansible Runner. + + :param test_environment: Path to the temporary test environment. + :type test_environment: str + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + :param task_type: Dictionary with task configuration details. + :type task_type: dict + """ + result = self.run_ansible_playbook( + test_environment=test_environment, + inventory_file_name="inventory_db.txt", + task_type=task_type["task_name"], + ) + + assert result.rc == 0, ( + f"Playbook failed with status: {result.rc}\n" + f"STDOUT: {result.stdout.read() if result.stdout else 'No output'}\n" + f"STDERR: {result.stderr.read() if result.stderr else 'No errors'}\n" + f"Events: {[e.get('event') for e in result.events if 'event' in e]}" + ) + + ok_events, failed_events = [], [] + for event in result.events: + if event.get("event") == "runner_on_ok": + ok_events.append(event) + elif event.get("event") == "runner_on_failed": + failed_events.append(event) + + assert len(ok_events) > 0 + assert len(failed_events) == 0 + + post_status = {} + pre_status = {} + + for event in ok_events: + task = event.get("event_data", {}).get("task") + task_result = event.get("event_data", {}).get("res") + + if task and task_type.get("command_task") in task: + if task_type["command_type"] == "echo b" or task_type["command_type"] == "kill": + assert task_result.get("changed") is True + else: + assert task_result.get("rc") == 0 + elif ( + task + and "Test Execution: Validate HANA DB cluster status 1" in task + and task_type["task_name"] == "primary-node-crash" + ): + assert not task_result.get("secondary_node") + elif task and task_type["validate_task"] in task: + assert task_result.get("secondary_node") + assert task_result.get("primary_node") + post_status = task_result + elif task and "Pre Validation: Validate HANA DB" in task: + pre_status = task_result + elif task and "Remove any location_constraints" in task: + assert task_result.get("changed") + + assert post_status.get("primary_node") == pre_status.get("secondary_node") + assert post_status.get("secondary_node") == pre_status.get("primary_node") diff --git a/tests/roles/ha_db_hana/resource_migration_test.py b/tests/roles/ha_db_hana/resource_migration_test.py new file mode 100644 index 00000000..d6dd53b8 --- /dev/null +++ b/tests/roles/ha_db_hana/resource_migration_test.py @@ -0,0 +1,141 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Test class for HANA DB resource migration tasks. + +This test class uses pytest to run functional tests on the HANA DB resource migration tasks +defined in roles/ha_db_hana/tasks/resource-migration.yml. It sets up a temporary test environment, +mocks necessary Python modules and commands, and verifies the execution of the tasks. +""" + +import os +import shutil +from pathlib import Path +import pytest +from tests.roles.ha_db_hana.roles_testing_base_db import RolesTestingBaseDB + + +class TestDbResourceMigration(RolesTestingBaseDB): + """ + Test class for HANA DB resource migration tasks. + """ + + @pytest.fixture + def hana_migration_tasks(self): + """ + Load the RolesTestingBaseDB migration tasks from the YAML file. + + :return: Parsed YAML content of the tasks file. + :rtype: dict + """ + return self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent.parent + / "src/roles/ha_db_hana/tasks/resource-migration.yml", + ) + + @pytest.fixture + def test_environment(self, ansible_inventory): + """ + Set up a temporary test environment for the HANA DB resource migration tasks. + + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + :yield temp_dir: Path to the temporary test environment. + :ytype: str + """ + + task_counter_file = "/tmp/get_cluster_status_counter_resource-migration" + if os.path.exists(task_counter_file): + os.remove(task_counter_file) + + commands = [ + { + "name": "resource_migration_cmd", + "SUSE": "crm resource move SAPHana_HDB_HDB00 db02 force", + } + ] + + temp_dir = self.setup_test_environment( + role_type="ha_db_hana", + ansible_inventory=ansible_inventory, + task_name="resource-migration", + task_description="The Resource Migration test validates planned failover scenarios", + module_names=[ + "project/library/get_cluster_status_db", + "project/library/log_parser", + "project/library/send_telemetry_data", + "project/library/location_constraints", + "bin/crm_resource", + "bin/crm", + ], + extra_vars_override={"commands": commands, "node_tier": "hana"}, + ) + + playbook_content = self.file_operations( + operation="read", + file_path=f"{temp_dir}/project/roles/ha_db_hana/tasks/resource-migration.yml", + ) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/roles/ha_db_hana/tasks/resource-migration.yml", + content=playbook_content.replace("100", "1"), + ) + + yield temp_dir + shutil.rmtree(temp_dir) + + def test_functional_db_migration_success(self, test_environment, ansible_inventory): + """ + Test the HANA DB resource migration tasks using Ansible Runner. + + :param test_environment: Path to the temporary test environment. + :type test_environment: str + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + """ + result = self.run_ansible_playbook( + test_environment=test_environment, + inventory_file_name="inventory_db.txt", + task_type="resource-migration", + ) + + assert result.rc == 0, ( + f"Playbook failed with status: {result.rc}\n" + f"STDOUT: {result.stdout.read() if result.stdout else 'No output'}\n" + f"STDERR: {result.stderr.read() if result.stderr else 'No errors'}\n" + f"Events: {[e.get('event') for e in result.events if 'event' in e]}" + ) + + ok_events, failed_events = [], [] + for event in result.events: + if event.get("event") == "runner_on_ok": + ok_events.append(event) + elif event.get("event") == "runner_on_failed": + failed_events.append(event) + + assert len(ok_events) > 0 + assert len(failed_events) == 0 + + post_status = {} + pre_status = {} + + for event in ok_events: + task = event.get("event_data", {}).get("task") + task_result = event.get("event_data", {}).get("res") + if task and "Move the resource to the targeted node" in task: + assert task_result.get("rc") == 0 + elif task and "Test Execution: Validate HANA DB cluster status 1" in task: + assert task_result.get("secondary_node") == "" + elif task and "Test Execution: Validate HANA DB cluster status 2" in task: + assert task_result.get("secondary_node") != "" + assert task_result.get("primary_node") != "" + post_status = task_result + elif task and "Pre Validation: Validate HANA DB" in task: + pre_status = task_result + elif task and "Remove any location_constraints" in task: + assert task_result.get("changed") + + assert post_status.get("primary_node") == pre_status.get("secondary_node") + assert post_status.get("secondary_node") == pre_status.get("primary_node") diff --git a/tests/roles/ha_db_hana/roles_testing_base_db.py b/tests/roles/ha_db_hana/roles_testing_base_db.py new file mode 100644 index 00000000..7ea79006 --- /dev/null +++ b/tests/roles/ha_db_hana/roles_testing_base_db.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Base class for testing roles in Ansible playbooks. +This class provides a framework for setting up and tearing down test environments, +mocking necessary modules, and executing Ansible tasks. +""" + +from typing import Iterator +from pathlib import Path +import pytest +from tests.roles.roles_testing_base import RolesTestingBase + + +class RolesTestingBaseDB(RolesTestingBase): + """ + Base class for testing roles in Ansible playbooks. + """ + + @pytest.fixture + def ansible_inventory(self) -> Iterator[str]: + """ + Create a temporary Ansible inventory file for testing. + This inventory contains two hosts (db01 and db02) with local connections. + + :yield inventory_path: Path to the temporary inventory file. + :ytype: Iterator[str] + """ + inventory_content = self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent / "mock_data/inventory_db.txt", + ) + + inventory_path = Path(__file__).parent / "test_inventory.ini" + self.file_operations( + operation="write", + file_path=inventory_path, + content=inventory_content, + ) + + yield str(inventory_path) + + inventory_path.unlink(missing_ok=True) diff --git a/tests/roles/ha_db_hana/secondary_node_ops_test.py b/tests/roles/ha_db_hana/secondary_node_ops_test.py new file mode 100644 index 00000000..82a8924d --- /dev/null +++ b/tests/roles/ha_db_hana/secondary_node_ops_test.py @@ -0,0 +1,181 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Test class for HANA DB secondary node crash and node kill tasks + +This test class uses pytest to run functional tests on the HANA DB secondary node kill, echo-b, +and crash-index tasks defined in roles/ha_db_hana/tasks/secondary-node-kill.yml. +It sets up a temporary test environment, mocks necessary Python modules and commands, and verifies +the execution of the tasks. +""" + +import os +import shutil +from pathlib import Path +import pytest +from tests.roles.ha_db_hana.roles_testing_base_db import RolesTestingBaseDB + + +class TestDbSecondaryHDBOperations(RolesTestingBaseDB): + """ + Test class for HANA DB secondary node crash, kill, echo-b, and crash-index tasks. + """ + + @pytest.fixture(params=["secondary-node-kill", "secondary-echo-b", "secondary-crash-index"]) + def task_type(self, request): + """ + Parameterized fixture to test both secondary node operations. + + :param request: pytest request object containing the parameter + :type request: pytest.request + :return: Dictionary with task configuration details + :rtype: dict + """ + task_name = request.param + + if task_name == "secondary-node-kill": + return { + "task_name": task_name, + "command_task": "Test Execution: Kill the HANA DB", + "command_type": "kill-9", + "validate_task": "Test execution: Validate HANA DB cluster status 2", + } + elif task_name == "secondary-echo-b": + return { + "task_name": task_name, + "command_task": "Test Execution: Echo b", + "command_type": "echo b", + "validate_task": "Test Execution: Validate HANA DB cluster status 2", + } + elif task_name == "secondary-crash-index": + return { + "task_name": task_name, + "command_task": "Test Execution: Crash the index server", + "command_type": "killall", + "validate_task": "Test Execution: Validate HANA DB cluster status 2", + } + + @pytest.fixture + def test_environment(self, ansible_inventory, task_type): + """ + Set up a temporary test environment for the HANA DB secondary node operations tasks. + + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + :param task_type: Dictionary with task configuration details. + :type task_type: dict + :yield temp_dir: Path to the temporary test environment. + :ytype: str + """ + + task_counter_file = f"/tmp/get_cluster_status_counter_{task_type['task_name']}" + if os.path.exists(task_counter_file): + os.remove(task_counter_file) + + temp_dir = self.setup_test_environment( + role_type="ha_db_hana", + ansible_inventory=ansible_inventory, + task_name=task_type["task_name"], + task_description=f"The {task_type['task_name']} test validates failover scenarios", + module_names=[ + "project/library/get_cluster_status_db", + "project/library/log_parser", + "project/library/send_telemetry_data", + "project/library/location_constraints", + "project/library/check_indexserver", + "bin/crm_resource", + "bin/echo", + "bin/killall", + ], + extra_vars_override={"node_tier": "hana"}, + ) + + os.makedirs(f"{temp_dir}/bin", exist_ok=True) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/bin/HDB", + content=self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent / "mock_data/HDB.txt", + ), + ) + os.chmod(f"{temp_dir}/bin/HDB", 0o755) + + playbook_content = self.file_operations( + operation="read", + file_path=f"{temp_dir}/project/roles/ha_db_hana/tasks/{task_type['task_name']}.yml", + ) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/roles/ha_db_hana/tasks/{task_type['task_name']}.yml", + content=playbook_content.replace( + "/usr/sap/{{ db_sid | upper }}/HDB{{ db_instance_number }}/", "" + ), + ) + + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/library/get_cluster_status_db", + content=self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent + / "mock_data/secondary_get_cluster_status_db.txt", + ), + ) + + yield temp_dir + shutil.rmtree(temp_dir) + + def test_functional_db_secondary_node_success( + self, test_environment, ansible_inventory, task_type + ): + """ + Test the HANA DB secondary node operations tasks using Ansible Runner. + + :param test_environment: Path to the temporary test environment. + :type test_environment: str + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + :param task_type: Dictionary with task configuration details. + :type task_type: dict + """ + result = self.run_ansible_playbook( + test_environment=test_environment, + inventory_file_name="inventory_db.txt", + task_type=task_type["task_name"], + ) + + assert result.rc == 0, ( + f"Playbook failed with status: {result.rc}\n" + f"STDOUT: {result.stdout.read() if result.stdout else 'No output'}\n" + f"STDERR: {result.stderr.read() if result.stderr else 'No errors'}\n" + f"Events: {[e.get('event') for e in result.events if 'event' in e]}" + ) + + ok_events, failed_events = [], [] + for event in result.events: + if event.get("event") == "runner_on_ok": + ok_events.append(event) + elif event.get("event") == "runner_on_failed": + failed_events.append(event) + + assert len(ok_events) > 0 + assert len(failed_events) == 0 + + post_status = {} + pre_status = {} + + for event in ok_events: + task = event.get("event_data", {}).get("task") + task_result = event.get("event_data", {}).get("res") + + if task and task_type["validate_task"] in task: + assert task_result.get("secondary_node") + assert task_result.get("primary_node") + post_status = task_result + elif task and "Pre Validation: Validate HANA DB" in task: + pre_status = task_result + + assert post_status.get("primary_node") == pre_status.get("primary_node") + assert post_status.get("secondary_node") == pre_status.get("secondary_node") diff --git a/tests/roles/ha_scs/__init__.py b/tests/roles/ha_scs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/roles/ha_scs/ascs_migration_test.py b/tests/roles/ha_scs/ascs_migration_test.py new file mode 100644 index 00000000..4ad9d623 --- /dev/null +++ b/tests/roles/ha_scs/ascs_migration_test.py @@ -0,0 +1,134 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Test class for ASCS migration tasks. + +This test class uses pytest to run functional tests on the ASCS migration tasks +defined in roles/ha_scs/tasks/ascs-migration.yml. It sets up a temporary test environment, +mocks necessary Python modules and commands, and verifies the execution of the tasks. +""" + +import shutil +from pathlib import Path +import pytest +from tests.roles.ha_scs.roles_testing_base_scs import RolesTestingBaseSCS + + +class TestASCSMigration(RolesTestingBaseSCS): + """ + Test class for ASCS migration tasks. + """ + + @pytest.fixture + def ascs_migration_tasks(self): + """ + Load the ASCS migration tasks from the YAML file. + + :return: Parsed YAML content of the tasks file. + :rtype: dict + """ + return self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent.parent + / "src/roles/ha_scs/tasks/ascs-migration.yml", + ) + + @pytest.fixture + def test_environment(self, ansible_inventory): + """ + Set up a temporary test environment for the ASCS migration tasks. + + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + :yield temp_dir: Path to the temporary test environment. + :ytype: str + """ + + commands = [ + { + "name": "ascs_resource_migration_cmd", + "SUSE": "crm resource migrate SAP_ASCS00_ascs00 scs02", + }, + { + "name": "ascs_resource_unmigrate_cmd", + "SUSE": "crm resource clear SAP_ASCS00_ascs00", + }, + ] + + temp_dir = self.setup_test_environment( + role_type="ha_scs", + ansible_inventory=ansible_inventory, + task_name="ascs-migration", + task_description="The Resource Migration test validates planned failover scenarios", + module_names=[ + "project/library/get_cluster_status_scs", + "project/library/log_parser", + "project/library/send_telemetry_data", + "bin/crm_resource", + "bin/crm", + ], + extra_vars_override={"commands": commands, "node_tier": "scs"}, + ) + + yield temp_dir + shutil.rmtree(temp_dir) + + def test_functional_ascs_migration_success(self, test_environment, ansible_inventory): + """ + Test the ASCS migration tasks using Ansible Runner. + + :param test_environment: Path to the temporary test environment. + :type test_environment: str + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + """ + result = self.run_ansible_playbook( + test_environment=test_environment, inventory_file_name="inventory_scs.txt" + ) + + assert result.rc == 0, ( + f"Playbook failed with status: {result.rc}\n" + f"STDOUT: {result.stdout.read() if result.stdout else 'No output'}\n" + f"STDERR: {result.stderr.read() if result.stderr else 'No errors'}\n" + f"Events: {[e.get('event') for e in result.events if 'event' in e]}" + ) + + ok_events, failed_events = [], [] + for event in result.events: + if event.get("event") == "runner_on_ok": + ok_events.append(event) + elif event.get("event") == "runner_on_failed": + failed_events.append(event) + + assert len(ok_events) > 0 + assert len(failed_events) == 0 + + migrate_executed = False + validate_executed = False + unmigrate_executed = False + cleanup_executed = False + post_status = {} + pre_status = {} + + for event in ok_events: + task = event.get("event_data", {}).get("task") + if task and "Migrate ASCS resource" in task: + migrate_executed = True + elif task and "Test Execution: Validate SCS" in task: + validate_executed = True + post_status = event.get("event_data", {}).get("res") + elif task and "Cleanup resources" in task: + cleanup_executed = True + elif task and "Pre Validation: Validate SCS" in task: + pre_status = event.get("event_data", {}).get("res") + elif task and "Remove location constraints" in task: + unmigrate_executed = True + + assert post_status.get("ascs_node") == pre_status.get("ers_node") + assert post_status.get("ers_node") == pre_status.get("ascs_node") + + assert migrate_executed, "ASCS migration task was not executed" + assert validate_executed, "SCS cluster status validation task was not executed" + assert unmigrate_executed, "Remove location constraints task was not executed" + assert cleanup_executed, "Cleanup resources task was not executed" diff --git a/tests/roles/ha_scs/ascs_node_crash_test.py b/tests/roles/ha_scs/ascs_node_crash_test.py new file mode 100644 index 00000000..1090479f --- /dev/null +++ b/tests/roles/ha_scs/ascs_node_crash_test.py @@ -0,0 +1,120 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Test class for ASCS node crash tasks. + +This test class uses pytest to run functional tests on the ASCS nocde crash tasks +defined in roles/ha_scs/tasks/ascs-node-crash.yml. It sets up a temporary test environment, +mocks necessary Python modules and commands, and verifies the execution of the tasks. +""" + +import shutil +from pathlib import Path +import pytest +from tests.roles.ha_scs.roles_testing_base_scs import RolesTestingBaseSCS + + +class TestASCSNodeCrash(RolesTestingBaseSCS): + """ + Test class for ASCS node crash tasks. + """ + + @pytest.fixture + def ascs_node_crash_tasks(self): + """ + Load the ASCS node crash tasks from the YAML file. + + :return: Parsed YAML content of the tasks file. + :rtype: dict + """ + return self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent.parent + / "src/roles/ha_scs/tasks/ascs-node-crash.yml", + ) + + @pytest.fixture + def test_environment(self, ansible_inventory): + """ + Set up a temporary test environment for the ASCS node crash tasks. + + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + :yield temp_dir: Path to the temporary test environment. + :ytype: str + """ + temp_dir = self.setup_test_environment( + role_type="ha_scs", + ansible_inventory=ansible_inventory, + task_name="ascs-node-crash", + task_description="Simulate ASCS node crash", + module_names=[ + "project/library/get_cluster_status_scs", + "project/library/log_parser", + "project/library/send_telemetry_data", + "bin/crm_resource", + "bin/echo", + ], + extra_vars_override={ + "node_tier": "scs", + }, + ) + + yield temp_dir + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_functional_ascs_node_crash_success(self, test_environment, ansible_inventory): + """ + Test the ASCS node crash tasks using Ansible Runner. + + :param test_environment: Path to the temporary test environment. + :type test_environment: str + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + """ + result = self.run_ansible_playbook( + test_environment=test_environment, inventory_file_name="inventory_scs.txt" + ) + + assert result.rc == 0, ( + f"Playbook failed with status: {result.rc}\n" + f"STDOUT: {result.stdout.read() if result.stdout else 'No output'}\n" + f"STDERR: {result.stderr.read() if result.stderr else 'No errors'}\n" + f"Events: {[e.get('event') for e in result.events if 'event' in e]}" + ) + + ok_events, failed_events = [], [] + for event in result.events: + if event.get("event") == "runner_on_ok": + ok_events.append(event) + elif event.get("event") == "runner_on_failed": + failed_events.append(event) + + assert len(ok_events) > 0 + assert len(failed_events) == 0 + + node_crash_executed = False + validate_executed = False + cleanup_executed = False + post_status = {} + pre_status = {} + + for event in ok_events: + task = event.get("event_data", {}).get("task") + if task and "Echo B to /proc/sysrq-trigger" in task: + node_crash_executed = True + elif task and "Test Execution: Validate SCS" in task: + validate_executed = True + post_status = event.get("event_data", {}).get("res") + elif task and "Cleanup resources" in task: + cleanup_executed = True + elif task and "Pre Validation: Validate SCS" in task: + pre_status = event.get("event_data", {}).get("res") + + assert post_status.get("ascs_node") == pre_status.get("ers_node") + assert post_status.get("ers_node") == pre_status.get("ascs_node") + + assert node_crash_executed, "ASCS node crash task was not executed" + assert validate_executed, "SCS cluster status validation task was not executed" + assert cleanup_executed, "Cleanup resources task was not executed" diff --git a/tests/roles/ha_scs/ha_config_test.py b/tests/roles/ha_scs/ha_config_test.py new file mode 100644 index 00000000..3cc0cedc --- /dev/null +++ b/tests/roles/ha_scs/ha_config_test.py @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Test class for ASCS HA config validation tasks + +This test class uses pytest to run functional tests on the ASCS HA config validation tasks +defined in roles/ha_scs/tasks/ha-config.yml. It sets up a temporary test environment, +mocks necessary Python modules and commands, and verifies the execution of the tasks. +""" + +import os +import shutil +from pathlib import Path +import pytest +from tests.roles.ha_scs.roles_testing_base_scs import RolesTestingBaseSCS + + +class TestASCSHaConfigValidation(RolesTestingBaseSCS): + """ + Test class for ASCS HA config validation tasks. + """ + + @pytest.fixture + def ascs_migration_tasks(self): + """ + Load the ASCS HA config validation tasks from the YAML file. + + :return: Parsed YAML content of the tasks file. + :rtype: dict + """ + return self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent.parent / "src/roles/ha_scs/tasks/ha-config.yml", + ) + + @pytest.fixture + def test_environment(self, ansible_inventory): + """ + Set up a temporary test environment for the ASCS HA config validation tasks. + + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + :yield temp_dir: Path to the temporary test environment. + :ytype: str + """ + + temp_dir = self.setup_test_environment( + role_type="ha_scs", + ansible_inventory=ansible_inventory, + task_name="ha-config", + task_description="The ASCS HA config validation test validates the SCS/ERS cluster " + + "configuration and other system configuration", + module_names=[ + "project/library/get_pcmk_properties_scs", + "project/library/log_parser", + "project/library/send_telemetry_data", + "bin/crm_resource", + "bin/crm", + ], + extra_vars_override={ + "node_tier": "scs", + }, + ) + + os.makedirs(f"{temp_dir}/project/roles/ha_scs/tasks/files", exist_ok=True) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/roles/ha_scs/tasks/files/constants.yaml", + content=self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent / "mock_data/cluster_config.txt", + ), + ) + + os.makedirs(f"{temp_dir}/project/library", exist_ok=True) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/library/uri", + content=self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent / "mock_data/azure_metadata.txt", + ), + ) + os.chmod(f"{temp_dir}/project/library/uri", 0o755) + + yield temp_dir + shutil.rmtree(temp_dir) + + def test_ha_config_validation_success(self, test_environment, ansible_inventory): + """ + Test the ASCS migration tasks using Ansible Runner. + + :param test_environment: Path to the temporary test environment. + :type test_environment: str + :param ansible_inventory: Path to the Ansible inventory file. + :type ansible_inventory: str + """ + result = self.run_ansible_playbook( + test_environment=test_environment, inventory_file_name="inventory_scs.txt" + ) + + assert result.rc == 0, ( + f"Playbook failed with status: {result.rc}\n" + f"STDOUT: {result.stdout.read() if result.stdout else 'No output'}\n" + f"STDERR: {result.stderr.read() if result.stderr else 'No errors'}\n" + f"Events: {[e.get('event') for e in result.events if 'event' in e]}" + ) + + ok_events, failed_events = [], [] + for event in result.events: + if event.get("event") == "runner_on_ok": + ok_events.append(event) + elif event.get("event") == "runner_on_failed": + failed_events.append(event) + + assert len(ok_events) > 0 + assert len(failed_events) == 0 + + for event in ok_events: + task = event.get("event_data", {}).get("task") + task_result = event.get("event_data", {}).get("res") + if "Virtual Machine name" in task: + assert task_result.get("changed") is False + if "HA Configuration check" in task: + assert task_result.get("changed") is False + assert ( + task_result["details"].get("parameters", {})[1].get("name") + == "migration-threshold" + ) + assert task_result["details"].get("parameters", {})[1].get("value") == "3" diff --git a/tests/roles/ha_scs/roles_testing_base_scs.py b/tests/roles/ha_scs/roles_testing_base_scs.py new file mode 100644 index 00000000..a8d634fc --- /dev/null +++ b/tests/roles/ha_scs/roles_testing_base_scs.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Base class for testing roles in Ansible playbooks. +This class provides a framework for setting up and tearing down test environments, +mocking necessary modules, and executing Ansible tasks. +""" + +from typing import Iterator +from pathlib import Path +import pytest +from tests.roles.roles_testing_base import RolesTestingBase + + +class RolesTestingBaseSCS(RolesTestingBase): + """ + Base class for testing roles in Ansible playbooks. + """ + + @pytest.fixture + def ansible_inventory(self) -> Iterator[str]: + """ + Create a temporary Ansible inventory file for testing. + This inventory contains two hosts (scs01 and scs02) with local connections. + + :yield inventory_path: Path to the temporary inventory file. + :ytype: Iterator[str] + """ + inventory_content = self.file_operations( + operation="read", + file_path=Path(__file__).parent.parent / "mock_data/inventory_scs.txt", + ) + + inventory_path = Path(__file__).parent / "test_inventory.ini" + self.file_operations( + operation="write", + file_path=inventory_path, + content=inventory_content, + ) + + yield str(inventory_path) + + inventory_path.unlink(missing_ok=True) diff --git a/tests/roles/mock_data/HDB.txt b/tests/roles/mock_data/HDB.txt new file mode 100644 index 00000000..a0b861a7 --- /dev/null +++ b/tests/roles/mock_data/HDB.txt @@ -0,0 +1,7 @@ +#!/bin/bash +echo "HDB mock called with: $@" +if [ "$1" = "stop" ] || [ "$1" = "kill-9" ]; then + exit 0 +else + exit 1 +fi diff --git a/tests/roles/mock_data/azure_metadata.txt b/tests/roles/mock_data/azure_metadata.txt new file mode 100644 index 00000000..05548fdb --- /dev/null +++ b/tests/roles/mock_data/azure_metadata.txt @@ -0,0 +1,56 @@ +#!/usr/bin/python3 +from ansible.module_utils.basic import AnsibleModule +import json + + +def main(): + module = AnsibleModule( + argument_spec=dict( + url=dict(type="str", required=True), + use_proxy=dict(type="bool", required=False), + headers=dict(type="dict", required=False), + ) + ) + + if "169.254.169.254/metadata/instance" in module.params["url"]: + result = { + "changed": False, + "json": { + "compute": { + "name": "scs01", + "vmId": "12345678-1234-1234-1234-123456789012", + "location": "eastus", + "subscriptionId": "12345678-1234-1234-1234-123456789012", + } + }, + "status": 200, + } + elif "http://169.254.169.254:80/metadata/loadbalancer" in module.params["url"]: + result = { + "changed": False, + "json": { + "loadbalancer": { + "name": "lb01", + "id": "id", + "inboundRules": [ + { + "name": "rule1", + "protocol": "Tcp", + "idleTimeoutInMinutes": 30, + "enableFloatingIP": True, + "enableTcpReset": False, + } + ] + } + }, + "status": 200, + } + + else: + result = {"changed": False, "status": 404, "msg": "URL not mocked"} + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/roles/mock_data/check_indexserver.txt b/tests/roles/mock_data/check_indexserver.txt new file mode 100644 index 00000000..2bc840a7 --- /dev/null +++ b/tests/roles/mock_data/check_indexserver.txt @@ -0,0 +1,25 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from ansible.module_utils.basic import AnsibleModule + +def main(): + module = AnsibleModule( + argument_spec=dict( + database_sid=dict(type="str", required=True), + ansible_os_family=dict(type="str", required=True), + ) + ) + + # Always return that indexserver is enabled + result = { + "changed": False, + "indexserver_enabled": "yes", + "status": "PASSED", + "message": "The indexserver is enabled on this node", + } + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/tests/roles/mock_data/cluster_config.txt b/tests/roles/mock_data/cluster_config.txt new file mode 100644 index 00000000..b36b2afd --- /dev/null +++ b/tests/roles/mock_data/cluster_config.txt @@ -0,0 +1,15 @@ +CRM_CONFIG_DEFAULTS: + cluster-infrastructure: corosync + priority-fencing-delay: '30' + stonith-action: reboot + stonith-enabled: 'false' + concurrent-fencing: 'true' + maintenance-mode: 'false' + node-health-strategy: 'custom' + azure-events-az_globalPullState: 'IDLE' + +RSC_DEFAULTS: + migration-threshold: '3' + priority: '1' + resource-stickiness: '1' + diff --git a/tests/roles/mock_data/crm.txt b/tests/roles/mock_data/crm.txt new file mode 100644 index 00000000..cc6bb0c3 --- /dev/null +++ b/tests/roles/mock_data/crm.txt @@ -0,0 +1,14 @@ +#!/bin/bash +if [[ "$1" == "resource" && "$2" == "migrate" ]]; then + echo "Mock: Successfully migrated SCS resource $3 to $4" + exit 0 +elif [[ "$1" == "resource" && "$2" == "move" ]]; then + echo "Mock: Successfully migrated DB resource $3 to $4" + exit 0 +elif [[ "$1" == "resource" && "$2" == "clear" ]]; then + echo "Mock: Successfully removed location constraints for $3" + exit 0 +fi + +echo "Unknown command: $@" +exit 1 diff --git a/tests/roles/mock_data/crm_resource.txt b/tests/roles/mock_data/crm_resource.txt new file mode 100644 index 00000000..61202c50 --- /dev/null +++ b/tests/roles/mock_data/crm_resource.txt @@ -0,0 +1,8 @@ +#!/bin/bash +if [[ "$1" == "--cleanup" ]]; then + echo "Mock: Cleaned up resources successfully" + exit 0 +fi + +echo "Unknown command: $@" +exit 1 diff --git a/tests/roles/mock_data/echo.txt b/tests/roles/mock_data/echo.txt new file mode 100644 index 00000000..02ec9b73 --- /dev/null +++ b/tests/roles/mock_data/echo.txt @@ -0,0 +1,8 @@ +#!/bin/bash +if [[ "$1" == "-b" && "$2" == "/proc/sysrq-trigger" ]]; then + echo "Mock: Successfully crashed the node" + exit 0 +fi + +echo "Unknown command: $@" +exit 1 diff --git a/tests/roles/mock_data/filesystem_freeze.txt b/tests/roles/mock_data/filesystem_freeze.txt new file mode 100644 index 00000000..b1fb4bf5 --- /dev/null +++ b/tests/roles/mock_data/filesystem_freeze.txt @@ -0,0 +1,26 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from ansible.module_utils.basic import AnsibleModule + + +def main(): + module = AnsibleModule( + argument_spec=dict( + nfs_provider=dict(type="str", required=True), + ) + ) + + result = { + "changed": True, + "message": "The file system (/hana/shared) was successfully mounted read-only.", + "status": "PASSED", + "details": { + "rc": 0, + }, + } + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/roles/mock_data/get_azure_lb.txt b/tests/roles/mock_data/get_azure_lb.txt new file mode 100644 index 00000000..e89e86c5 --- /dev/null +++ b/tests/roles/mock_data/get_azure_lb.txt @@ -0,0 +1,43 @@ +#!/usr/bin/python3 +from ansible.module_utils.basic import AnsibleModule + +def main(): + module = AnsibleModule( + argument_spec=dict( + subscription_id=dict(type="str", required=True), + region=dict(type="str", required=True), + inbound_rules=dict(type="str", required=True), + constants=dict(type="dict", required=True), + ) + ) + + result = { + "changed": False, + "status": "PASSED", + "message": "Azure Load Balancer configuration is valid", + "details": { + "parameters":[ + { + "category": "load_balancing_rule", + "expected_value": "30", + "id": "id", + "name": "idle_timeout_in_minutes", + "status": "PASSED", + "value": '30' + }, + { + "category": "probes", + "expected_value": "2", + "id": "id", + "name": "probe_threshold", + "status": "PASSED", + "value": '2' + } + ] + } + } + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/tests/roles/mock_data/get_cluster_status_db.txt b/tests/roles/mock_data/get_cluster_status_db.txt new file mode 100644 index 00000000..b8478690 --- /dev/null +++ b/tests/roles/mock_data/get_cluster_status_db.txt @@ -0,0 +1,73 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from ansible.module_utils.basic import AnsibleModule +import os + +def main(): + module = AnsibleModule( + argument_spec=dict( + operation_step=dict(type="str", required=True), + database_sid=dict(type="str", required=True), + ansible_os_family=dict(type="str", required=False), + ) + ) + + task_type = os.environ.get("TEST_TASK_TYPE", "default") + counter_file = f"/tmp/get_cluster_status_counter_{task_type}" + + + if os.path.exists(counter_file): + with open(counter_file, "r") as f: + counter = int(f.read().strip() or "0") + else: + counter = 0 + + counter += 1 + with open(counter_file, "w") as f: + f.write(str(counter)) + + if counter == 3: + result = { + "changed": False, + "primary_node": "db02", + "secondary_node": "", + "status": "PASSED", + "pacemaker_status": "running", + "AUTOMATED_REGISTER": "true", + "replication_mode": "sync", + "primary_site_name": "db01", + "operation_mode": "active", + "stonith_action": "reboot" + } + elif counter == 1 or counter == 2: + result = { + "changed": False, + "primary_node": "db01", + "secondary_node": "db02", + "status": "PASSED", + "pacemaker_status": "running", + "AUTOMATED_REGISTER": "true", + "replication_mode": "sync", + "primary_site_name": "db01", + "operation_mode": "active", + "stonith_action": "reboot" + } + else: + result = { + "changed": False, + "primary_node": "db02", + "secondary_node": "db01", + "status": "PASSED", + "pacemaker_status": "running", + "AUTOMATED_REGISTER": "true", + "replication_mode": "sync", + "primary_site_name": "db01", + "operation_mode": "active", + "stonith_action": "reboot" + } + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/tests/roles/mock_data/get_cluster_status_scs.txt b/tests/roles/mock_data/get_cluster_status_scs.txt new file mode 100644 index 00000000..e44a4602 --- /dev/null +++ b/tests/roles/mock_data/get_cluster_status_scs.txt @@ -0,0 +1,47 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from ansible.module_utils.basic import AnsibleModule +import os + +def main(): + module = AnsibleModule( + argument_spec=dict( + sap_sid=dict(type='str', required=True), + ansible_os_family=dict(type='str', required=True) + ) + ) + + counter_file = "/tmp/get_cluster_status_counter" + + if os.path.exists(counter_file): + with open(counter_file, "r") as f: + counter = int(f.read().strip() or "0") + else: + counter = 0 + + counter += 1 + with open(counter_file, "w") as f: + f.write(str(counter)) + + if counter == 3: + result = { + "changed": False, + "ascs_node": "scs01", + "ers_node": "scs02", + "status": "PASSED", + "pacemaker_status": "running" + } + else: + result = { + "changed": False, + "ascs_node": "scs02", + "ers_node": "scs01", + "status": "PASSED", + "pacemaker_status": "running" + } + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/tests/roles/mock_data/get_package_list.txt b/tests/roles/mock_data/get_package_list.txt new file mode 100644 index 00000000..ad1b4c2a --- /dev/null +++ b/tests/roles/mock_data/get_package_list.txt @@ -0,0 +1,96 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from ansible.module_utils.basic import AnsibleModule + +def main(): + module = AnsibleModule( + argument_spec=dict( + package_facts_list=dict(type='dict', required=True), + ), + supports_check_mode=True + ) + + mock_package_details = [ + { + "Corosync Lib": { + "version": "2.4.5", + "release": "150400.3.9.1", + "architecture": "x86_64" + } + }, + { + "Corosync": { + "version": "2.4.5", + "release": "150400.3.9.1", + "architecture": "x86_64" + } + }, + { + "Fence Agents Common": { + "version": "4.8.0", + "release": "150400.4.3.1", + "architecture": "x86_64" + } + }, + { + "Fencing Agent": { + "version": "4.8.0", + "release": "150400.4.3.1", + "architecture": "x86_64" + } + }, + { + "Pacemaker CLI": { + "version": "2.0.5", + "release": "150400.3.15.1", + "architecture": "x86_64" + } + }, + { + "Pacemaker": { + "version": "2.0.5", + "release": "150400.3.15.1", + "architecture": "x86_64" + } + }, + { + "Resource Agent": { + "version": "4.8.0", + "release": "150400.4.3.1", + "architecture": "x86_64" + } + }, + { + "SAP Cluster Connector": { + "version": "3.1.2", + "release": "150400.1.4", + "architecture": "x86_64" + } + }, + { + "SAPHanaSR": { + "version": "0.162.3", + "release": "150400.3.23.1", + "architecture": "noarch" + } + }, + { + "Socat": { + "version": "1.7.3.4", + "release": "150400.4.9.1", + "architecture": "x86_64" + } + } + ] + + result = { + "changed": False, + "status": "SUCCESS", + "details": mock_package_details + } + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/tests/roles/mock_data/get_pcmk_properties_db.txt b/tests/roles/mock_data/get_pcmk_properties_db.txt new file mode 100644 index 00000000..cefe2503 --- /dev/null +++ b/tests/roles/mock_data/get_pcmk_properties_db.txt @@ -0,0 +1,46 @@ +#!/usr/bin/python3 +from ansible.module_utils.basic import AnsibleModule + +def main(): + module = AnsibleModule( + argument_spec=dict( + sid=dict(type="str"), + instance_number=dict(type="str"), + ansible_os_family=dict(type="str"), + virtual_machine_name=dict(type="str"), + fencing_mechanism=dict(type="str"), + os_version=dict(type="str"), + pcmk_constants=dict(type="dict"), + ) + ) + + result = { + "changed": False, + "status": "PASSED", + "message": "HA configuration is valid for DB nodes", + "details": { + "parameters":[ + { + "category": "rsc_defaults", + "expected_value": "1", + "id": "build-resource-stickiness", + "name": "resource-stickiness", + "status": "PASSED", + "value": '1' + }, + { + "category": "rsc_defaults", + "expected_value": "3", + "id": "rsc-options-migration-threshold", + "name": "migration-threshold", + "status": "PASSED", + "value": '3' + } + ] + } + } + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/tests/roles/mock_data/get_pcmk_properties_scs.txt b/tests/roles/mock_data/get_pcmk_properties_scs.txt new file mode 100644 index 00000000..5657dfb7 --- /dev/null +++ b/tests/roles/mock_data/get_pcmk_properties_scs.txt @@ -0,0 +1,46 @@ +#!/usr/bin/python3 +from ansible.module_utils.basic import AnsibleModule + +def main(): + module = AnsibleModule( + argument_spec=dict( + sid=dict(type='str', required=True), + ascs_instance_number=dict(type='str', required=True), + ers_instance_number=dict(type='str', required=True), + ansible_os_family=dict(type='str', required=True), + virtual_machine_name=dict(type='str', required=True), + pcmk_constants=dict(type='dict', required=True), + fencing_mechanism=dict(type='str', required=True) + ) + ) + + result = { + "changed": False, + "status": "PASSED", + "message": "HA configuration is valid for SCS nodes", + "details": { + "parameters":[ + { + "category": "rsc_defaults", + "expected_value": "1", + "id": "build-resource-stickiness", + "name": "resource-stickiness", + "status": "PASSED", + "value": '1' + }, + { + "category": "rsc_defaults", + "expected_value": "3", + "id": "rsc-options-migration-threshold", + "name": "migration-threshold", + "status": "PASSED", + "value": '3' + } + ] + } + } + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/tests/roles/mock_data/head.txt b/tests/roles/mock_data/head.txt new file mode 100644 index 00000000..a142140b --- /dev/null +++ b/tests/roles/mock_data/head.txt @@ -0,0 +1,14 @@ +#!/bin/bash + +echo "MOCK HEAD CALLED: $@" >> /tmp/head_calls.log + +if [ ! -t 0 ]; then + cat | sed -n '1p' + exit 0 +else + if [ -f "$2" ]; then + sed -n '1p' "$2" + exit 0 + fi + exit 1 +fi diff --git a/tests/roles/mock_data/inventory_db.txt b/tests/roles/mock_data/inventory_db.txt new file mode 100644 index 00000000..7edaac0a --- /dev/null +++ b/tests/roles/mock_data/inventory_db.txt @@ -0,0 +1,6 @@ +[db] +db01 ansible_host=127.0.0.1 ansible_connection=local ansible_hostname=db01 inventory_hostname=db01 virtual_host=db01 +db02 ansible_host=127.0.0.1 ansible_connection=local ansible_hostname=db02 inventory_hostname=db02 virtual_host=db02 + +[all:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/tests/roles/mock_data/inventory_scs.txt b/tests/roles/mock_data/inventory_scs.txt new file mode 100644 index 00000000..ea9f9616 --- /dev/null +++ b/tests/roles/mock_data/inventory_scs.txt @@ -0,0 +1,6 @@ +[scs] +scs01 ansible_host=127.0.0.1 ansible_connection=local ansible_hostname=scs01 inventory_hostname=scs01 virtual_host=scs01 +scs02 ansible_host=127.0.0.1 ansible_connection=local ansible_hostname=scs02 inventory_hostname=scs02 virtual_host=scs02 + +[all:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/tests/roles/mock_data/iptables.txt b/tests/roles/mock_data/iptables.txt new file mode 100644 index 00000000..5ad471e2 --- /dev/null +++ b/tests/roles/mock_data/iptables.txt @@ -0,0 +1,15 @@ +#!/bin/bash +# Mock for iptables command + +echo "MOCK IPTABLES CALLED: $@" >> /tmp/iptables_calls.log + +if [[ "$1" == "-A" ]]; then + echo "Mocking creating of firewall rule" + exit 0 +elif [[ "$1" == "-D" ]]; then + echo "Mocking deleting of firewall rule" + exit 0 +else + echo "Unsupported iptables command: $@" + exit 1 +fi diff --git a/tests/roles/mock_data/kill.txt b/tests/roles/mock_data/kill.txt new file mode 100644 index 00000000..51181763 --- /dev/null +++ b/tests/roles/mock_data/kill.txt @@ -0,0 +1,5 @@ +#!/bin/bash +# Mock for kill command + +echo "MOCK KILL CALLED: $@" >> /tmp/kill_calls.log +exit 0 diff --git a/tests/roles/mock_data/killall.txt b/tests/roles/mock_data/killall.txt new file mode 100644 index 00000000..2221ef72 --- /dev/null +++ b/tests/roles/mock_data/killall.txt @@ -0,0 +1,11 @@ +#!/bin/bash + +echo "MOCK KILLALL CALLED: $@" >> /tmp/killall_calls.log + +if [[ "$2" == "hdbindexserver" ]]; then + echo "Mocking killing of hdbindexserver" + exit 0 +else + echo "Unsupported killall command: $@" + exit 1 +fi diff --git a/tests/roles/mock_data/location_constraints.txt b/tests/roles/mock_data/location_constraints.txt new file mode 100644 index 00000000..cc44e972 --- /dev/null +++ b/tests/roles/mock_data/location_constraints.txt @@ -0,0 +1,40 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from ansible.module_utils.basic import AnsibleModule + +def main(): + module = AnsibleModule( + argument_spec=dict( + action=dict(type="str", required=True), + ansible_os_family=dict(type="str", required=True), + ), + supports_check_mode=True + ) + + action = module.params["action"] + ansible_os_family = module.params["ansible_os_family"] + + + + if action == "remove": + result = { + "changed": True, + "status": "PASSED", + "message": "Location constraints removed", + "location_constraint_removed": True, + "details": {} + } + else: + result = { + "changed": False, + "status": "INFO", + "message": "Location constraints do not exist or were already removed.", + "location_constraint_removed": False, + "details": {} + } + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/tests/roles/mock_data/log_parser.txt b/tests/roles/mock_data/log_parser.txt new file mode 100644 index 00000000..e51f851e --- /dev/null +++ b/tests/roles/mock_data/log_parser.txt @@ -0,0 +1,30 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from ansible.module_utils.basic import AnsibleModule + +def main(): + module = AnsibleModule( + argument_spec=dict( + start_time=dict(type='str', required=True), + end_time=dict(type='str', required=True), + log_file=dict(type='str', required=False, default='/var/log/messages'), + keywords=dict(type='list', required=False, default=[]), + ansible_os_family=dict(type='str', required=True) + ) + ) + + # Return a mock cluster status + module.exit_json( + changed=False, + filtered_logs=["Mock log entry 1", "Mock log entry 2"], + status="PASSED", + keywords=module.params['keywords'], + start_time=module.params['start_time'], + end_time=module.params['end_time'], + log_file=module.params['log_file'], + ) + +if __name__ == '__main__': + main() + diff --git a/tests/roles/mock_data/mock_azure_lb.txt b/tests/roles/mock_data/mock_azure_lb.txt new file mode 100644 index 00000000..bcafbc85 --- /dev/null +++ b/tests/roles/mock_data/mock_azure_lb.txt @@ -0,0 +1,10 @@ +AZURE_LOADBALANCER: + PROBES: + probe_threshold: 2 + interval_in_seconds: 5 + number_of_probes: 2 + + RULES: + idle_timeout_in_minutes: 30 + enable_floating_ip: true + enable_tcp_reset: false diff --git a/tests/roles/mock_data/pgrep.txt b/tests/roles/mock_data/pgrep.txt new file mode 100644 index 00000000..7380c7e4 --- /dev/null +++ b/tests/roles/mock_data/pgrep.txt @@ -0,0 +1,13 @@ +#!/bin/bash + +echo "MOCK PGREP CALLED: $@" >> /tmp/pgrep_calls.log + +if [ "$*" = "-f sbd: inquisitor" ] || [ "$*" = "-f 'sbd: inquisitor'" ]; then + # Return a fake PID + echo "12345" + exit 0 +else + # For any other search, log and exit with error + echo "Unknown pgrep arguments: $@" >> /tmp/pgrep_calls.log + exit 1 +fi diff --git a/tests/roles/mock_data/playbook.txt b/tests/roles/mock_data/playbook.txt new file mode 100644 index 00000000..d39bc362 --- /dev/null +++ b/tests/roles/mock_data/playbook.txt @@ -0,0 +1,17 @@ +--- +- name: Force ansible_hostname for testing + hosts: all + gather_facts: no + tasks: + - name: Set ansible_hostname based on inventory_hostname + set_fact: + ansible_hostname: "{{ inventory_hostname }}" + +- name: %s + hosts: all + gather_facts: yes + environment: + PATH: "%s/bin:$PATH" + tasks: + - name: Include tasks from the role + include_tasks: roles/%s/tasks/%s.yml diff --git a/tests/roles/mock_data/secondary_get_cluster_status_db.txt b/tests/roles/mock_data/secondary_get_cluster_status_db.txt new file mode 100644 index 00000000..5eed9c9d --- /dev/null +++ b/tests/roles/mock_data/secondary_get_cluster_status_db.txt @@ -0,0 +1,73 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from ansible.module_utils.basic import AnsibleModule +import os + +def main(): + module = AnsibleModule( + argument_spec=dict( + operation_step=dict(type="str", required=True), + database_sid=dict(type="str", required=True), + ansible_os_family=dict(type="str", required=False), + ) + ) + + task_type = os.environ.get("TEST_TASK_TYPE", "default") + counter_file = f"/tmp/get_cluster_status_counter_{task_type}" + + + if os.path.exists(counter_file): + with open(counter_file, "r") as f: + counter = int(f.read().strip() or "0") + else: + counter = 0 + + counter += 1 + with open(counter_file, "w") as f: + f.write(str(counter)) + + if counter == 3: + result = { + "changed": False, + "primary_node": "db01", + "secondary_node": "", + "status": "PASSED", + "pacemaker_status": "running", + "AUTOMATED_REGISTER": "true", + "replication_mode": "sync", + "primary_site_name": "db01", + "operation_mode": "active", + "stonith_action": "reboot" + } + elif counter == 1 or counter == 2: + result = { + "changed": False, + "primary_node": "db01", + "secondary_node": "db02", + "status": "PASSED", + "pacemaker_status": "running", + "AUTOMATED_REGISTER": "true", + "replication_mode": "sync", + "primary_site_name": "db01", + "operation_mode": "active", + "stonith_action": "reboot" + } + else: + result = { + "changed": False, + "primary_node": "db01", + "secondary_node": "db02", + "status": "PASSED", + "pacemaker_status": "running", + "AUTOMATED_REGISTER": "true", + "replication_mode": "sync", + "primary_site_name": "db01", + "operation_mode": "active", + "stonith_action": "reboot" + } + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/tests/roles/mock_data/send_telemetry_data.txt b/tests/roles/mock_data/send_telemetry_data.txt new file mode 100644 index 00000000..b7037a84 --- /dev/null +++ b/tests/roles/mock_data/send_telemetry_data.txt @@ -0,0 +1,31 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from ansible.module_utils.basic import AnsibleModule + +def main(): + module = AnsibleModule( + argument_spec=dict( + test_group_json_data=dict(type="dict", required=True), + telemetry_data_destination=dict(type="str", required=True), + laws_workspace_id=dict(type="str", required=False), + laws_shared_key=dict(type="str", required=False), + telemetry_table_name=dict(type="str", required=False), + adx_database_name=dict(type="str", required=False), + adx_cluster_fqdn=dict(type="str", required=False), + adx_client_id=dict(type="str", required=False), + workspace_directory=dict(type="str", required=True), + ) + ) + + # Return a mock cluster status + module.exit_json( + changed=False, + data_sent= False, + data_logged= False, + status="PASSED" + ) + +if __name__ == '__main__': + main() + diff --git a/tests/roles/roles_testing_base.py b/tests/roles/roles_testing_base.py new file mode 100644 index 00000000..4e063158 --- /dev/null +++ b/tests/roles/roles_testing_base.py @@ -0,0 +1,235 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Base class for testing roles in Ansible playbooks. +This class provides a framework for setting up and tearing down test environments, +mocking necessary modules, and executing Ansible tasks. +""" + +import tempfile +import shutil +import json +import os +from pathlib import Path +import ansible_runner + + +class RolesTestingBase: + """ + Base class for testing roles in Ansible playbooks. + """ + + def file_operations(self, operation, file_path, content=None): + """ + Perform file operations (read, write) on a given file. + + :param operation: The operation to perform (create, read, write, delete). + :type operation: str + :param file_path: The path to the file. + :type file_path: str + :param content: The content to write to the file (for write operation). + :type content: str + :return: The content of the file (for read operation). + :rtype: str + """ + + file_operation = "w" if operation == "write" else "r" + with open(file_path, file_operation, encoding="utf-8") as f: + if operation == "write": + f.write(content) + elif operation == "read": + return f.read() + + def mock_modules(self, temp_dir, module_names): + """ + Mock the following python or commands module to return a predefined status. + + :param module_names: List of module names to mock. + :type module_names: list + :param temp_dir: Path to the temporary directory. + :type temp_dir: str + """ + + for module in module_names: + content = self.file_operations( + operation="read", + file_path=Path(__file__).parent / f"mock_data/{module.split('/')[-1]}.txt", + ) + self.file_operations( + operation="write", + file_path=f"{temp_dir}/{module}", + content=content, + ) + os.chmod(f"{temp_dir}/{module}", 0o755) + + def _recursive_update(self, dict1, dict2): + """ + Recursively update dict1 with values from dict2. + + :param dict1: Base dictionary to update + :param dict2: Dictionary with values to update + """ + for key, val in dict2.items(): + if isinstance(val, dict) and key in dict1 and isinstance(dict1[key], dict): + self._recursive_update(dict1[key], val) + else: + dict1[key] = val + + def run_ansible_playbook(self, test_environment, inventory_file_name, task_type=None): + """ + Run an Ansible playbook using the specified inventory. + + :param test_environment: Path to the test environment. + :type test_environment: str + :param inventory_file_name: Name of the inventory file. + :type inventory_file_name: str + :param task_type: Type of task to run (optional). + :type task_type: str + :return: Result of the Ansible playbook execution. + :rtype: ansible_runner.Runner + """ + + self.file_operations( + operation="write", + file_path=f"{test_environment}/test_inventory.ini", + content=self.file_operations( + operation="read", + file_path=Path(__file__).parent / f"mock_data/{inventory_file_name}", + ), + ) + return ansible_runner.run( + private_data_dir=test_environment, + playbook="test_playbook.yml", + inventory=f"{test_environment}/test_inventory.ini", + quiet=False, + verbosity=2, + envvars={ + "PATH": f"{test_environment}/bin:" + os.environ.get("PATH", ""), + "TEST_TASK_TYPE": task_type, + }, + extravars={"ansible_become": False}, + ) + + def setup_test_environment( + self, + ansible_inventory, + role_type, + task_name, + task_description, + module_names, + additional_files=None, + extra_vars_override=None, + ): + """ + Set up a standard test environment for Ansible role testing. + + :param ansible_inventory: Path to the Ansible inventory file + :type ansible_inventory: str + :param task_name: Name of the task file to test (e.g., "ascs-migration") + :type task_name: str + :param role_type: Type of role (e.g., "db", "ers", "scs") + :type role_type: str + :param task_description: Human-readable description of the test + :type task_description: str + :param module_names: List of modules to mock + :type module_names: list + :param additional_files: Additional files to copy beyond standard ones + :type additional_files: list + :param extra_vars_override: Dictionary of extra vars to override defaults + :type extra_vars_override: dict + :return: Path to the temporary test environment + :rtype: str + """ + temp_dir = tempfile.mkdtemp() + + os.makedirs(f"{temp_dir}/env", exist_ok=True) + os.makedirs(f"{temp_dir}/project", exist_ok=True) + os.makedirs(f"{temp_dir}/project/roles/{role_type}/tasks", exist_ok=True) + os.makedirs(f"{temp_dir}/project/roles/misc/tasks", exist_ok=True) + os.makedirs(f"{temp_dir}/bin", exist_ok=True) + os.makedirs(f"{temp_dir}/project/library", exist_ok=True) + os.makedirs(f"{temp_dir}/host_vars", exist_ok=True) + + if os.path.exists("/tmp/get_cluster_status_counter"): + os.remove("/tmp/get_cluster_status_counter") + + standard_files = [ + "misc/tasks/test-case-setup.yml", + f"misc/tasks/pre-validations-{role_type.split('_')[1]}.yml", + f"misc/tasks/post-validations-{role_type.split('_')[1]}.yml", + "misc/tasks/rescue.yml", + "misc/tasks/var-log-messages.yml", + "misc/tasks/post-telemetry-data.yml", + ] + + task_file = f"{role_type}/tasks/{task_name}.yml" + file_list = standard_files + [task_file] + + if additional_files: + file_list.extend(additional_files) + + for file in file_list: + src_file = Path(__file__).parent.parent.parent / f"src/roles/{file}" + dest_file = f"{temp_dir}/project/roles/{file}" + os.makedirs(os.path.dirname(dest_file), exist_ok=True) + shutil.copy(src_file, dest_file) + + self.mock_modules(temp_dir=temp_dir, module_names=module_names) + + base_extra_vars = { + "item": { + "name": f"Test {task_description}", + "task_name": task_name, + "description": task_description, + "enabled": True, + }, + "ansible_os_family": "SUSE", + "sap_sid": "TST", + "db_sid": "TST", + "database_high_availability": "true", + "scs_high_availability": "true", + "database_cluster_type": "AFA", + "NFS_provider": "AFS", + "scs_cluster_type": "AFA", + "platform": "HANA", + "scs_instance_number": "00", + "ers_instance_number": "01", + "db_instance_number": "02", + "group_name": role_type.upper(), + "group_invocation_id": "test-run-123", + "group_start_time": "2025-03-18 11:00:00", + "telemetry_data_destination": "mock_destination", + "_workspace_directory": temp_dir, + "ansible_distribution": "SUSE", + "ansible_distribution_version": "15", + } + + if extra_vars_override: + self._recursive_update(base_extra_vars, extra_vars_override) + + self.file_operations( + operation="write", + file_path=f"{temp_dir}/env/extravars", + content=json.dumps(base_extra_vars), + ) + + playbook_content = self.file_operations( + operation="read", + file_path=Path(__file__).parent / "mock_data/playbook.txt", + ) + playbook_content = playbook_content.replace("ansible_hostname ==", "inventory_hostname ==") + + self.file_operations( + operation="write", + file_path=f"{temp_dir}/project/test_playbook.yml", + content=playbook_content + % ( + base_extra_vars["item"]["name"], + temp_dir, + role_type, + base_extra_vars["item"]["task_name"], + ), + ) + + return temp_dir