# 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" )