| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| """ BVT tests for custom extension |
| """ |
| # Import Local Modules |
| from marvin.cloudstackTestCase import cloudstackTestCase |
| from marvin.cloudstackAPI import listManagementServers |
| from marvin.lib.base import (Extension, |
| Pod, |
| Cluster, |
| Host, |
| Template, |
| ServiceOffering, |
| NetworkOffering, |
| Network, |
| VirtualMachine, |
| ExtensionCustomAction) |
| from marvin.lib.common import (get_zone) |
| from marvin.lib.utils import (random_gen) |
| from marvin.sshClient import SshClient |
| from nose.plugins.attrib import attr |
| # Import System modules |
| import json |
| import logging |
| from pathlib import Path |
| import random |
| import string |
| import time |
| |
| _multiprocess_shared_ = True |
| |
| CUSTOM_EXTENSION_CONTENT = """#!/bin/bash |
| |
| parse_json() { |
| local json_string=$1 |
| echo "$json_string" | jq '.' > /dev/null || { echo '{"error":"Invalid JSON input"}'; exit 1; } |
| } |
| |
| get_vm_name() { |
| local input_json=$1 |
| local name |
| name=$(jq -r '.virtualmachinename' <<< "$input_json") |
| if [[ -z "$name" || "$name" == "null" ]]; then |
| echo '{"status":"error","message":"virtualmachinename missing in JSON"}' |
| exit 1 |
| fi |
| echo "$name" |
| } |
| |
| get_vm_file() { |
| local name=$1 |
| local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| echo "$script_dir/$name" |
| } |
| |
| validate_vm_file_exists() { |
| local file=$1 |
| if [[ ! -f "$file" ]]; then |
| echo '{"status":"error","message":"Instance file not found"}' |
| exit 1 |
| fi |
| } |
| |
| update_vm_status() { |
| local file=$1 |
| local status=$2 |
| local updated_json |
| updated_json=$(jq --arg status "$status" '.status = $status' "$file") |
| echo "$updated_json" > "$file" |
| } |
| |
| prepare() { |
| echo "" |
| } |
| |
| create() { |
| parse_json "$1" |
| local vm_name updated_json file |
| vm_name=$(get_vm_name "$1") |
| file=$(get_vm_file "$vm_name") |
| |
| updated_json=$(jq '. + {status: "Running"}' <<< "$1") |
| echo "$updated_json" > "$file" |
| |
| jq -n --arg file "$file" \ |
| '{status: "success", message: "Instance created", file: $file}' |
| } |
| |
| delete() { |
| parse_json "$1" |
| local vm_name file |
| vm_name=$(get_vm_name "$1") |
| file=$(get_vm_file "$vm_name") |
| |
| if [[ -f "$file" ]]; then |
| rm -f "$file" |
| fi |
| jq -n --arg file "$file" \ |
| '{status: "success", message: "Instance deleted", file: $file}' |
| } |
| |
| start() { |
| parse_json "$1" |
| local vm_name file |
| vm_name=$(get_vm_name "$1") |
| file=$(get_vm_file "$vm_name") |
| validate_vm_file_exists "$file" |
| |
| update_vm_status "$file" "Running" |
| echo '{"status": "success", "message": "Instance started"}' |
| } |
| |
| stop() { |
| parse_json "$1" |
| local vm_name file |
| vm_name=$(get_vm_name "$1") |
| file=$(get_vm_file "$vm_name") |
| |
| if [[ -f "$file" ]]; then |
| update_vm_status "$file" "Stopped" |
| fi |
| echo '{"status": "success", "message": "Instance stopped"}' |
| } |
| |
| reboot() { |
| parse_json "$1" |
| local vm_name file |
| vm_name=$(get_vm_name "$1") |
| file=$(get_vm_file "$vm_name") |
| validate_vm_file_exists "$file" |
| |
| update_vm_status "$file" "Running" |
| echo '{"status": "success", "message": "Instance rebooted"}' |
| } |
| |
| status() { |
| parse_json "$1" |
| local vm_name file vm_status |
| vm_name=$(get_vm_name "$1") |
| file=$(get_vm_file "$vm_name") |
| validate_vm_file_exists "$file" |
| |
| vm_status=$(jq -r '.status' "$file") |
| [[ -z "$vm_status" || "$vm_status" == "null" ]] && vm_status="unknown" |
| |
| case "${vm_status,,}" in |
| "running"|"poweron") |
| power_state="PowerOn" |
| ;; |
| "stopped"|"shutdown"|"poweroff") |
| power_state="PowerOff" |
| ;; |
| *) |
| power_state="$vm_status" |
| ;; |
| esac |
| |
| jq -n --arg ps "$power_state" '{status: "success", power_state: $ps}' |
| } |
| |
| testaction() { |
| parse_json "$1" |
| local vm_name param_val |
| vm_name=$(get_vm_name "$1") |
| param_val=$(jq -r '.parameters.Name' <<< "$1") |
| |
| echo "$param_val for $vm_name" |
| } |
| |
| action=$1 |
| parameters_file="$2" |
| wait_time="$3" |
| |
| if [[ -z "$action" || -z "$parameters_file" ]]; then |
| echo '{"error":"Missing required arguments"}' |
| exit 1 |
| fi |
| |
| if [[ ! -r "$parameters_file" ]]; then |
| echo '{"error":"File not found or unreadable"}' |
| exit 1 |
| fi |
| |
| parameters=$(<"$parameters_file") |
| |
| case $action in |
| prepare) prepare "$parameters" ;; |
| create) create "$parameters" ;; |
| delete) delete "$parameters" ;; |
| start) start "$parameters" ;; |
| stop) stop "$parameters" ;; |
| reboot) reboot "$parameters" ;; |
| status) status "$parameters" ;; |
| testaction) testaction "$parameters" ;; |
| *) echo '{"error":"Invalid action"}'; exit 1 ;; |
| esac |
| |
| exit 0 |
| |
| """ |
| |
| class TestExtensions(cloudstackTestCase): |
| |
| @classmethod |
| def setUpClass(cls): |
| testClient = super(TestExtensions, cls).getClsTestClient() |
| cls.apiclient = testClient.getApiClient() |
| cls.services = testClient.getParsedTestDataConfig() |
| |
| # Get Zone |
| cls.zone = get_zone(cls.apiclient, testClient.getZoneForTests()) |
| |
| cls.mgtSvrDetails = cls.config.__dict__["mgtSvr"][0].__dict__ |
| |
| cls._cleanup = [] |
| cls.logger = logging.getLogger('TestExtensions') |
| cls.logger.setLevel(logging.DEBUG) |
| |
| cls.compute_offering = ServiceOffering.create( |
| cls.apiclient, |
| cls.services["service_offerings"]["tiny"]) |
| cls._cleanup.append(cls.compute_offering) |
| cls.network_offering = NetworkOffering.create( |
| cls.apiclient, |
| cls.services["l2-network_offering"], |
| ) |
| cls._cleanup.append(cls.network_offering) |
| cls.network_offering.update(cls.apiclient, state='Enabled') |
| cls.services["network"]["networkoffering"] = cls.network_offering.id |
| cls.l2_network = Network.create( |
| cls.apiclient, |
| cls.services["l2-network"], |
| zoneid=cls.zone.id, |
| networkofferingid=cls.network_offering.id |
| ) |
| cls._cleanup.append(cls.l2_network) |
| |
| cls.resource_name_suffix = random_gen() |
| cls.create_cluster() |
| cls.extension = Extension.create( |
| cls.apiclient, |
| name=f"ext-{cls.resource_name_suffix}", |
| type='Orchestrator' |
| ) |
| cls._cleanup.append(cls.extension) |
| cls.update_extension_path(cls.extension.path) |
| cls.extension.register(cls.apiclient, cls.cluster.id, 'Cluster') |
| details = { |
| 'url': f"host-{cls.resource_name_suffix}", |
| 'zoneid': cls.cluster.zoneid, |
| 'podid': cls.cluster.podid, |
| 'username': 'External', |
| 'password': 'External' |
| } |
| cls.host = Host.create( |
| cls.apiclient, |
| cls.cluster, |
| details, |
| hypervisor=cls.cluster.hypervisortype |
| ) |
| cls._cleanup.append(cls.host) |
| template_name = f"template-{cls.resource_name_suffix}" |
| template_data = { |
| "name": template_name, |
| "displaytext": template_name, |
| "format": cls.host.hypervisor, |
| "hypervisor": cls.host.hypervisor, |
| "ostype": "Other Linux (64-bit)", |
| "url": template_name, |
| "requireshvm": "True", |
| "ispublic": "True", |
| "isextractable": "True", |
| "extensionid": cls.extension.id |
| } |
| cls.template = Template.register( |
| cls.apiclient, |
| template_data, |
| zoneid=cls.zone.id, |
| hypervisor=cls.cluster.hypervisortype |
| ) |
| cls._cleanup.append(cls.template) |
| logging.info("Waiting for 3 seconds for template to be ready") |
| time.sleep(3) |
| cls.services["virtual_machine"]["zoneid"] = cls.zone.id |
| cls.services["virtual_machine"]["template"] = cls.template.id |
| |
| @classmethod |
| def tearDownClass(cls): |
| super(TestExtensions, cls).tearDownClass() |
| |
| @classmethod |
| def create_cluster(cls): |
| pod_list = Pod.list(cls.apiclient) |
| if len(pod_list) <= 0: |
| cls.fail("No Pods found") |
| pod_id = pod_list[0].id |
| cluster_services = { |
| 'clustername': 'cluster-' + cls.resource_name_suffix, |
| 'clustertype': 'CloudManaged' |
| } |
| cls.cluster = Cluster.create( |
| cls.apiclient, |
| cluster_services, |
| cls.zone.id, |
| pod_id, |
| 'External' |
| ) |
| cls._cleanup.append(cls.cluster) |
| |
| @classmethod |
| def getManagementServerIps(cls): |
| if cls.mgtSvrDetails["mgtSvrIp"] in ('localhost', '127.0.0.1'): |
| return None |
| cmd = listManagementServers.listManagementServersCmd() |
| servers = cls.apiclient.listManagementServers(cmd) |
| active_server_ips = [] |
| active_server_ips.append(cls.mgtSvrDetails["mgtSvrIp"]) |
| for idx, server in enumerate(servers): |
| if server.state == 'Up' and server.ipaddress != cls.mgtSvrDetails["mgtSvrIp"]: |
| active_server_ips.append(server.ipaddress) |
| return active_server_ips |
| |
| @classmethod |
| def update_path_locally(cls, path): |
| try: |
| file = Path(path) |
| file.write_text(CUSTOM_EXTENSION_CONTENT) |
| file.chmod(file.stat().st_mode | 0o111) # Make executable |
| except Exception as e: |
| cls.fail(f"Failed to update path on localhost: {str(e)}") |
| |
| @classmethod |
| def update_extension_path(cls, path): |
| logging.info(f"Updating extension path {path}") |
| server_ips = cls.getManagementServerIps() |
| if server_ips is None: |
| if cls.mgtSvrDetails["mgtSvrIp"] in ('localhost', '127.0.0.1'): |
| cls.update_path_locally(path) |
| return |
| cls.fail(f"Extension path update cannot be done on {cls.mgtSvrDetails['mgtSvrIp']}") |
| logging.info("Updating extension path on all management server") |
| command = ( |
| f"cat << 'EOF' > {path}\n{CUSTOM_EXTENSION_CONTENT}\nEOF\n" |
| f"chmod +x {path}" |
| ) |
| for idx, server_ip in enumerate(server_ips): |
| logging.info(f"Updating extension path on management server #{idx} with IP {server_ip}") |
| sshClient = SshClient( |
| server_ip, |
| 22, |
| cls.mgtSvrDetails["user"], |
| cls.mgtSvrDetails["passwd"] |
| ) |
| sshClient.execute(command) |
| |
| def setUp(self): |
| self.cleanup = [] |
| |
| def tearDown(self): |
| super(TestExtensions, self).tearDown() |
| |
| def get_vm_content_path(self, extension_path, vm_name): |
| directory = extension_path.rsplit('/', 1)[0] if '/' in extension_path else '.' |
| path = f"{directory}/{vm_name}" |
| return path |
| |
| def get_vm_content_locally(self, path): |
| try: |
| file = Path(path) |
| if not file.exists(): |
| return None |
| return file.read_text().strip() |
| except Exception: |
| return None |
| |
| def get_vm_content(self, extension_path, vm_name): |
| path = self.get_vm_content_path(extension_path, vm_name) |
| server_ips = self.getManagementServerIps() |
| if not server_ips: |
| if self.mgtSvrDetails["mgtSvrIp"] in ('localhost', '127.0.0.1'): |
| return self.get_vm_content_locally(path) |
| return None |
| logging.info("Trying to get VM content from all management server") |
| command = f"cat {path}" |
| for idx, server_ip in enumerate(server_ips): |
| logging.info(f"Trying to get VM content from management server #{idx} with IP {server_ip}") |
| sshClient = SshClient( |
| server_ip, |
| 22, |
| self.mgtSvrDetails["user"], |
| self.mgtSvrDetails["passwd"] |
| ) |
| results = sshClient.execute(command) |
| if isinstance(results, list) and len(results) > 0: |
| return '\n'.join(line.strip() for line in results) |
| return None |
| |
| def check_vm_content_values(self, content): |
| if content is None: |
| self.fail("VM content is empty") |
| |
| try: |
| data = json.loads(content) |
| except json.JSONDecodeError as e: |
| self.fail(f"Invalid JSON for the VM: {e}") |
| |
| if not data: |
| self.fail("Empty JSON for the VM") |
| |
| required_keys = ['externaldetails', 'cloudstack.vm.details', 'virtualmachineid', 'virtualmachinename', 'status'] |
| if not all(key in data for key in required_keys): |
| self.fail("Missing one or more required keys.") |
| |
| memory = self.compute_offering.memory * 1024 * 1024 |
| vm_details = data['cloudstack.vm.details'] |
| |
| self.assertEqual( |
| memory, |
| vm_details['minRam'], |
| "VM memory mismatch" |
| ) |
| self.assertEqual( |
| self.compute_offering.cpunumber, |
| vm_details['cpus'], |
| "VM CPU count mismatch" |
| ) |
| self.assertEqual( |
| 'Running', |
| data['status'], |
| "VM status mismatch" |
| ) |
| |
| def check_vm_status_from_content(self, extension_path, vm_name, status): |
| content = self.get_vm_content(extension_path, vm_name) |
| if content is None: |
| self.fail("VM content is empty") |
| |
| try: |
| data = json.loads(content) |
| except json.JSONDecodeError as e: |
| self.fail(f"Invalid JSON for the VM: {e}") |
| |
| if not data: |
| self.fail("Empty JSON for the VM") |
| |
| self.assertEqual( |
| status, |
| data['status'], |
| "VM status mismatch" |
| ) |
| |
| def check_vm_content_exist_locally(self, path): |
| try: |
| file = Path(path) |
| return file.exists() |
| except Exception: |
| return None |
| |
| def check_vm_content_exist(self, extension_path, vm_name): |
| path = self.get_vm_content_path(extension_path, vm_name) |
| server_ips = self.getManagementServerIps() |
| if not server_ips: |
| if self.mgtSvrDetails["mgtSvrIp"] in ('localhost', '127.0.0.1'): |
| return self.check_vm_content_exist_locally(path) |
| return None |
| logging.info("Trying to check VM content exists from all management server") |
| command = f"test -e '{path}' && echo EXISTS || echo MISSING" |
| for idx, server_ip in enumerate(server_ips): |
| logging.info(f"Trying to check VM content exists from management server #{idx} with IP {server_ip}") |
| sshClient = SshClient( |
| server_ip, |
| 22, |
| self.mgtSvrDetails["user"], |
| self.mgtSvrDetails["passwd"] |
| ) |
| results = sshClient.execute(command) |
| if results is not None and results[0].strip() == "EXISTS": |
| return True |
| return False |
| |
| def popItemFromCleanup(self, item_id): |
| for idx, x in enumerate(self.cleanup): |
| if x.id == item_id: |
| self.cleanup.pop(idx) |
| break |
| |
| @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], required_hardware="false") |
| def test_01_extension_vm_lifecycle(self): |
| self.virtual_machine = VirtualMachine.create( |
| self.apiclient, |
| self.services["virtual_machine"], |
| templateid=self.template.id, |
| serviceofferingid=self.compute_offering.id, |
| networkids=[self.l2_network.id] |
| ) |
| self.cleanup.append(self.virtual_machine) |
| self.assertEqual( |
| self.virtual_machine.state, |
| 'Running', |
| "VM not in Running state" |
| ) |
| content = self.get_vm_content(self.extension.path, self.virtual_machine.instancename) |
| self.check_vm_content_values(content) |
| self.virtual_machine.stop(self.apiclient) |
| self.check_vm_status_from_content(self.extension.path, self.virtual_machine.instancename, 'Stopped') |
| self.virtual_machine.start(self.apiclient) |
| self.check_vm_status_from_content(self.extension.path, self.virtual_machine.instancename, 'Running') |
| self.virtual_machine.reboot(self.apiclient) |
| self.check_vm_status_from_content(self.extension.path, self.virtual_machine.instancename, 'Running') |
| self.virtual_machine.delete(self.apiclient, expunge=True) |
| self.popItemFromCleanup(self.virtual_machine.id) |
| self.assertFalse( |
| self.check_vm_content_exist(self.extension.path, self.virtual_machine.instancename), |
| "VM content exist event after expunge" |
| ) |
| |
| @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], required_hardware="false") |
| def test_02_run_custom_action(self): |
| name = 'testaction' |
| details = [{}] |
| details[0]['abc'] = 'xyz' |
| parameters = [{}] |
| parameter0 = { |
| 'name': 'Name', |
| 'type': 'STRING', |
| 'validationformat': 'NONE', |
| 'required': True |
| } |
| parameters[0] = parameter0 |
| self.custom_action = ExtensionCustomAction.create( |
| self.apiclient, |
| extensionid=self.extension.id, |
| name=name, |
| enabled=True, |
| details=details, |
| parameters=parameters, |
| successmessage='Successfully completed {{actionName}}' |
| ) |
| self.cleanup.append(self.custom_action) |
| self.virtual_machine = VirtualMachine.create( |
| self.apiclient, |
| self.services["virtual_machine"], |
| templateid=self.template.id, |
| serviceofferingid=self.compute_offering.id, |
| networkids=[self.l2_network.id] |
| ) |
| self.cleanup.append(self.virtual_machine) |
| param_val=random_gen() |
| run_parameters = [{}] |
| run_parameters[0] = { |
| 'Name': param_val |
| } |
| run_response = self.custom_action.run( |
| self.apiclient, |
| resourceid=self.virtual_machine.id, |
| parameters=run_parameters |
| ) |
| self.assertTrue( |
| run_response.success, |
| "Action run status not success" |
| ) |
| self.assertEquals( |
| f"Successfully completed {name}", |
| run_response.result.message, |
| "Action run status not success" |
| ) |
| data = run_response.result.details |
| self.assertEquals( |
| f"{param_val} for {self.virtual_machine.instancename}", |
| run_response.result.details, |
| "Action response details not match" |
| ) |
| |
| @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], required_hardware="false") |
| def test_03_run_invalid_custom_action(self): |
| name = 'invalidaction' |
| self.custom_action = ExtensionCustomAction.create( |
| self.apiclient, |
| extensionid=self.extension.id, |
| name=name, |
| enabled=True, |
| errormessage='Failed {{actionName}}' |
| ) |
| self.cleanup.append(self.custom_action) |
| self.virtual_machine = VirtualMachine.create( |
| self.apiclient, |
| self.services["virtual_machine"], |
| templateid=self.template.id, |
| serviceofferingid=self.compute_offering.id, |
| networkids=[self.l2_network.id] |
| ) |
| self.cleanup.append(self.virtual_machine) |
| run_response = self.custom_action.run( |
| self.apiclient, |
| resourceid=self.virtual_machine.id |
| ) |
| self.assertFalse( |
| run_response.success, |
| "Action run status not failure" |
| ) |
| self.assertEquals( |
| f"Failed {name}", |
| run_response.result.message, |
| "Action run status not success" |
| ) |