diff --git a/test-requirements.txt b/test-requirements.txt index 357662170..89a2bb2c5 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -19,3 +19,4 @@ azure-identity azure-mgmt-compute>=22.1.0 azure-mgmt-resource>=15.0.0 msrestazure +pytz diff --git a/tests_e2e/orchestrator/docker/Dockerfile b/tests_e2e/orchestrator/docker/Dockerfile index f248f8007..2d709c791 100644 --- a/tests_e2e/orchestrator/docker/Dockerfile +++ b/tests_e2e/orchestrator/docker/Dockerfile @@ -80,7 +80,7 @@ RUN \ # \ # Install additional test dependencies \ # \ - python3 -m pip install distro msrestazure && \ + python3 -m pip install distro msrestazure pytz && \ python3 -m pip install azure-mgmt-compute --upgrade && \ \ # \ diff --git a/tests_e2e/orchestrator/lib/agent_test_suite.py b/tests_e2e/orchestrator/lib/agent_test_suite.py index d09361a96..2e84c59a3 100644 --- a/tests_e2e/orchestrator/lib/agent_test_suite.py +++ b/tests_e2e/orchestrator/lib/agent_test_suite.py @@ -203,12 +203,26 @@ def context(self): # # Test suites within the same runbook may be executed concurrently, and setup needs to be done only once. - # We use this lock to allow only 1 thread to do the setup. Setup completion is marked using the 'completed' + # We use these locks to allow only 1 thread to do the setup. Setup completion is marked using the 'completed' # file: the thread doing the setup creates the file and threads that find that the file already exists # simply skip setup. # + _working_directory_lock = RLock() _setup_lock = RLock() + def _create_working_directory(self) -> None: + """ + Creates the working directory for the test suite. + """ + self._working_directory_lock.acquire() + + try: + if not self.context.working_directory.exists(): + log.info("Creating working directory: %s", self.context.working_directory) + self.context.working_directory.mkdir(parents=True) + finally: + self._working_directory_lock.release() + def _setup(self) -> None: """ Prepares the test suite for execution (currently, it just builds the agent package) @@ -228,9 +242,6 @@ def _setup(self) -> None: return self.context.lisa_log.info("Building test agent") - log.info("Creating working directory: %s", self.context.working_directory) - self.context.working_directory.mkdir(parents=True) - self._build_agent_package() log.info("Completed setup, creating %s", completed) @@ -407,6 +418,8 @@ def _execute(self, environment: Environment, variables: Dict[str, Any]): test_suite_success = True try: + self._create_working_directory() + if not self.context.skip_setup: self._setup() diff --git a/tests_e2e/orchestrator/runbook.yml b/tests_e2e/orchestrator/runbook.yml index d24e68f29..eb4ee2e99 100644 --- a/tests_e2e/orchestrator/runbook.yml +++ b/tests_e2e/orchestrator/runbook.yml @@ -49,7 +49,7 @@ variable: # # The test suites to execute - name: test_suites - value: "agent_bvt, no_outbound_connections" + value: "agent_bvt, no_outbound_connections, extensions_disabled" - name: cloud value: "AzureCloud" is_case_visible: true diff --git a/tests_e2e/orchestrator/scripts/agent-service b/tests_e2e/orchestrator/scripts/agent-service new file mode 100755 index 000000000..d740ef8f4 --- /dev/null +++ b/tests_e2e/orchestrator/scripts/agent-service @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +# Microsoft Azure Linux Agent +# +# Copyright 2018 Microsoft Corporation +# +# Licensed 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. +# + +set -euo pipefail + +# +# The service name is walinuxagent in Ubuntu/debian and waagent elsewhere +# + +usage() ( + echo "Usage: agent-service command" + exit 1 +) + +if [ "$#" -lt 1 ]; then + usage +fi +cmd=$1 +shift + +if [ "$#" -ne 0 ] || [ -z ${cmd+x} ] ; then + usage +fi + +if command -v systemctl &> /dev/null; then + service-status() { systemctl --no-pager -l status $1; } + service-stop() { systemctl stop $1; } + service-restart() { systemctl restart $1; } + service-start() { systemctl start $1; } +else + service-status() { service $1 status; } + service-stop() { service $1 stop; } + service-restart() { service $1 restart; } + service-start() { service $1 start; } +fi + +python=$(get-agent-python) +distro=$($python -c 'from azurelinuxagent.common.version import get_distro; print(get_distro()[0])') +distro=$(echo $distro | tr '[:upper:]' '[:lower:]') + +if [[ $distro == *"ubuntu"* || $distro == *"debian"* ]]; then + service_name="walinuxagent" +else + service_name="waagent" +fi + +echo "Service name: $service_name" + +if [[ "$cmd" == "restart" ]]; then + echo "Restarting service..." + service-restart $service_name + echo "Service status..." + service-status $service_name +fi + +if [[ "$cmd" == "start" ]]; then + echo "Starting service..." + service-start $service_name +fi + +if [[ "$cmd" == "stop" ]]; then + echo "Stopping service..." + service-stop $service_name +fi + +if [[ "$cmd" == "status" ]]; then + echo "Service status..." + service-status $service_name +fi diff --git a/tests_e2e/orchestrator/scripts/update-waagent-conf b/tests_e2e/orchestrator/scripts/update-waagent-conf new file mode 100755 index 000000000..13cfd4540 --- /dev/null +++ b/tests_e2e/orchestrator/scripts/update-waagent-conf @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +# Microsoft Azure Linux Agent +# +# Copyright 2018 Microsoft Corporation +# +# Licensed 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. +# + +# +# Updates waagent.conf with the specified setting and value and restarts the Agent. +# + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: update-waagent-conf " + exit 1 +fi + +name=$1 +value=$2 + +PYTHON=$(get-agent-python) +waagent_conf=$($PYTHON -c 'from azurelinuxagent.common.osutil import get_osutil; print(get_osutil().agent_conf_file_path)') +echo "Setting $name=$value in $waagent_conf" +sed -i -E "/^$name=/d" "$waagent_conf" +sed -i -E "\$a $name=$value" "$waagent_conf" +updated=$(grep "$name" "$waagent_conf") +echo "Updated value: $updated" +agent-service restart \ No newline at end of file diff --git a/tests_e2e/pipeline/pipeline.yml b/tests_e2e/pipeline/pipeline.yml index 255947859..99d48d99c 100644 --- a/tests_e2e/pipeline/pipeline.yml +++ b/tests_e2e/pipeline/pipeline.yml @@ -9,7 +9,7 @@ parameters: - name: test_suites displayName: Test Suites type: string - default: agent_bvt, no_outbound_connections + default: agent_bvt, no_outbound_connections, extensions_disabled # NOTES: # * 'image', 'location' and 'vm_size' override any values in the test suites/images definition diff --git a/tests_e2e/test_suites/extensions_disabled.yml b/tests_e2e/test_suites/extensions_disabled.yml new file mode 100644 index 000000000..3fbff2ebd --- /dev/null +++ b/tests_e2e/test_suites/extensions_disabled.yml @@ -0,0 +1,9 @@ +# +# The test suite disables extension processing and verifies that extensions +# are not processed, but the agent continues reporting status. +# +name: "ExtensionsDisabled" +tests: + - "extensions_disabled.py" +images: "random(endorsed)" +owns_vm: true diff --git a/tests_e2e/tests/bvts/extension_operations.py b/tests_e2e/tests/bvts/extension_operations.py index e8a45ee44..081572874 100755 --- a/tests_e2e/tests/bvts/extension_operations.py +++ b/tests_e2e/tests/bvts/extension_operations.py @@ -35,7 +35,7 @@ from tests_e2e.tests.lib.identifiers import VmExtensionIds, VmExtensionIdentifier from tests_e2e.tests.lib.logging import log from tests_e2e.tests.lib.ssh_client import SshClient -from tests_e2e.tests.lib.vm_extension import VmExtension +from tests_e2e.tests.lib.virtual_machine_extension_client import VirtualMachineExtensionClient class ExtensionOperationsBvt(AgentTest): @@ -47,7 +47,7 @@ def run(self): is_arm64: bool = ssh_client.get_architecture() == "aarch64" - custom_script_2_0 = VmExtension( + custom_script_2_0 = VirtualMachineExtensionClient( self._context.vm, VmExtensionIds.CustomScript, resource_name="CustomScript") @@ -65,7 +65,7 @@ def run(self): ) custom_script_2_0.assert_instance_view(expected_version="2.0", expected_message=message) - custom_script_2_1 = VmExtension( + custom_script_2_1 = VirtualMachineExtensionClient( self._context.vm, VmExtensionIdentifier(VmExtensionIds.CustomScript.publisher, VmExtensionIds.CustomScript.type, "2.1"), resource_name="CustomScript") @@ -73,7 +73,7 @@ def run(self): if is_arm64: log.info("Installing %s", custom_script_2_1) else: - log.info("Updating %s to %s", custom_script_2_0, custom_script_2_1) + log.info("Updating %s", custom_script_2_0) message = f"Hello {uuid.uuid4()}!" custom_script_2_1.enable( diff --git a/tests_e2e/tests/bvts/run_command.py b/tests_e2e/tests/bvts/run_command.py index 5dc548583..494458eab 100755 --- a/tests_e2e/tests/bvts/run_command.py +++ b/tests_e2e/tests/bvts/run_command.py @@ -35,12 +35,12 @@ from tests_e2e.tests.lib.identifiers import VmExtensionIds from tests_e2e.tests.lib.logging import log from tests_e2e.tests.lib.ssh_client import SshClient -from tests_e2e.tests.lib.vm_extension import VmExtension +from tests_e2e.tests.lib.virtual_machine_extension_client import VirtualMachineExtensionClient class RunCommandBvt(AgentTest): class TestCase: - def __init__(self, extension: VmExtension, get_settings: Callable[[str], Dict[str, str]]): + def __init__(self, extension: VirtualMachineExtensionClient, get_settings: Callable[[str], Dict[str, str]]): self.extension = extension self.get_settings = get_settings @@ -49,7 +49,7 @@ def run(self): test_cases = [ RunCommandBvt.TestCase( - VmExtension(self._context.vm, VmExtensionIds.RunCommand, resource_name="RunCommand"), + VirtualMachineExtensionClient(self._context.vm, VmExtensionIds.RunCommand, resource_name="RunCommand"), lambda s: { "script": base64.standard_b64encode(bytearray(s, 'utf-8')).decode('utf-8') }) @@ -60,7 +60,7 @@ def run(self): else: test_cases.append( RunCommandBvt.TestCase( - VmExtension(self._context.vm, VmExtensionIds.RunCommandHandler, resource_name="RunCommandHandler"), + VirtualMachineExtensionClient(self._context.vm, VmExtensionIds.RunCommandHandler, resource_name="RunCommandHandler"), lambda s: { "source": { "script": s diff --git a/tests_e2e/tests/bvts/vm_access.py b/tests_e2e/tests/bvts/vm_access.py index 1db378048..7983d4147 100755 --- a/tests_e2e/tests/bvts/vm_access.py +++ b/tests_e2e/tests/bvts/vm_access.py @@ -33,7 +33,7 @@ from tests_e2e.tests.lib.logging import log from tests_e2e.tests.lib.ssh_client import SshClient -from tests_e2e.tests.lib.vm_extension import VmExtension +from tests_e2e.tests.lib.virtual_machine_extension_client import VirtualMachineExtensionClient class VmAccessBvt(AgentTest): @@ -58,7 +58,7 @@ def run(self): public_key = f.read() # Invoke the extension - vm_access = VmExtension(self._context.vm, VmExtensionIds.VmAccess, resource_name="VmAccess") + vm_access = VirtualMachineExtensionClient(self._context.vm, VmExtensionIds.VmAccess, resource_name="VmAccess") vm_access.enable( protected_settings={ 'username': username, diff --git a/tests_e2e/tests/extensions_disabled.py b/tests_e2e/tests/extensions_disabled.py new file mode 100755 index 000000000..98f74dc36 --- /dev/null +++ b/tests_e2e/tests/extensions_disabled.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +# Microsoft Azure Linux Agent +# +# Copyright 2018 Microsoft Corporation +# +# Licensed 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. +# + +# +# This test disables extension processing on waagent.conf and verifies that extensions are not processed, but the +# agent continues reporting status. +# + +import datetime +import pytz + +from assertpy import assert_that, fail + +from azure.mgmt.compute.models import VirtualMachineInstanceView + +from tests_e2e.tests.lib.agent_test import AgentTest +from tests_e2e.tests.lib.identifiers import VmExtensionIds +from tests_e2e.tests.lib.logging import log +from tests_e2e.tests.lib.ssh_client import SshClient +from tests_e2e.tests.lib.virtual_machine_client import VirtualMachineClient +from tests_e2e.tests.lib.virtual_machine_extension_client import VirtualMachineExtensionClient + + +class ExtensionsDisabled(AgentTest): + def run(self): + ssh_client: SshClient = self._context.create_ssh_client() + + # Disable extension processing on the test VM + log.info("Disabling extension processing on the test VM [%s]", self._context.vm.name) + output = ssh_client.run_command("update-waagent-conf Extensions.Enabled n", use_sudo=True) + log.info("Disable completed:\n%s", output) + + # From now on, extensions will time out; set the timeout to the minimum allowed(15 minutes) + log.info("Setting the extension timeout to 15 minutes") + vm: VirtualMachineClient = VirtualMachineClient(self._context.vm) + + vm.update({"extensionsTimeBudget": "PT15M"}) + + disabled_timestamp: datetime.datetime = datetime.datetime.utcnow() - datetime.timedelta(minutes=60) + + # + # Validate that the agent is not processing extensions by attempting to run CustomScript + # + log.info("Executing CustomScript; it should time out after 15 min or so.") + custom_script = VirtualMachineExtensionClient(self._context.vm, VmExtensionIds.CustomScript, resource_name="CustomScript") + try: + custom_script.enable(settings={'commandToExecute': "date"}, force_update=True, timeout=20 * 60) + fail("CustomScript should have timed out") + except Exception as error: + assert_that("VMExtensionProvisioningTimeout" in str(error)) \ + .described_as(f"Expected a VMExtensionProvisioningTimeout: {error}") \ + .is_true() + log.info("CustomScript timed out as expected") + + # + # Validate that the agent continued reporting status even if it is not processing extensions + # + instance_view: VirtualMachineInstanceView = vm.get_instance_view() + log.info("Instance view of VM Agent:\n%s", instance_view.vm_agent.serialize()) + assert_that(instance_view.vm_agent.statuses).described_as("The VM agent should have exactly 1 status").is_length(1) + assert_that(instance_view.vm_agent.statuses[0].display_status).described_as("The VM Agent should be ready").is_equal_to('Ready') + # The time in the status is time zone aware and 'disabled_timestamp' is not; we need to make the latter time zone aware before comparing them + assert_that(instance_view.vm_agent.statuses[0].time)\ + .described_as("The VM Agent should be have reported status even after extensions were disabled")\ + .is_greater_than(pytz.utc.localize(disabled_timestamp)) + log.info("The VM Agent reported status after extensions were disabled, as expected.") + + +if __name__ == "__main__": + ExtensionsDisabled.run_from_command_line() diff --git a/tests_e2e/tests/lib/azure_client.py b/tests_e2e/tests/lib/azure_client.py new file mode 100644 index 000000000..3e01762e8 --- /dev/null +++ b/tests_e2e/tests/lib/azure_client.py @@ -0,0 +1,44 @@ +# Microsoft Azure Linux Agent +# +# Copyright 2018 Microsoft Corporation +# +# Licensed 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. +# + +from typing import Any, Callable + +from azure.core.polling import LROPoller + +from tests_e2e.tests.lib.logging import log +from tests_e2e.tests.lib.retry import execute_with_retry + + +class AzureClient: + """ + Utilities for classes using the Azure SDK. + """ + _DEFAULT_TIMEOUT = 10 * 60 # (in seconds) + + @staticmethod + def _execute_async_operation(operation: Callable[[], LROPoller], operation_name: str, timeout: int) -> Any: + """ + Starts an async operation and waits its completion. Returns the operation's result. + """ + log.info("Starting [%s]", operation_name) + poller: LROPoller = execute_with_retry(operation) + log.info("Waiting for [%s]", operation_name) + poller.wait(timeout=timeout) + if not poller.done(): + raise TimeoutError(f"[{operation_name}] did not complete within {timeout} seconds") + log.info("[%s] completed", operation_name) + return poller.result() diff --git a/tests_e2e/tests/lib/virtual_machine.py b/tests_e2e/tests/lib/virtual_machine.py deleted file mode 100644 index 79b86a6f3..000000000 --- a/tests_e2e/tests/lib/virtual_machine.py +++ /dev/null @@ -1,155 +0,0 @@ -# Microsoft Azure Linux Agent -# -# Copyright 2018 Microsoft Corporation -# -# Licensed 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. -# - -# -# This module includes facilities to execute some operations on virtual machines and scale sets (list extensions, restart, etc). -# - -from abc import ABC, abstractmethod -from builtins import TimeoutError -from typing import Any, List - -from azure.core.polling import LROPoller -from azure.identity import DefaultAzureCredential -from azure.mgmt.compute import ComputeManagementClient -from azure.mgmt.compute.models import VirtualMachineExtension, VirtualMachineScaleSetExtension, VirtualMachineInstanceView, VirtualMachineScaleSetInstanceView -from azure.mgmt.resource import ResourceManagementClient -from msrestazure.azure_cloud import Cloud - -from tests_e2e.tests.lib.azure_clouds import AZURE_CLOUDS -from tests_e2e.tests.lib.identifiers import VmIdentifier -from tests_e2e.tests.lib.logging import log -from tests_e2e.tests.lib.retry import execute_with_retry - - -class VirtualMachineBaseClass(ABC): - """ - Abstract base class for VirtualMachine and VmScaleSet. - - Defines the interface common to both classes and provides the implementation of some methods in that interface. - """ - def __init__(self, vm: VmIdentifier): - super().__init__() - self._identifier: VmIdentifier = vm - cloud: Cloud = AZURE_CLOUDS[vm.cloud] - credential: DefaultAzureCredential = DefaultAzureCredential(authority=cloud.endpoints.active_directory) - self._compute_client = ComputeManagementClient( - credential=credential, - subscription_id=vm.subscription, - base_url=cloud.endpoints.resource_manager, - credential_scopes=[cloud.endpoints.resource_manager + "/.default"]) - self._resource_client = ResourceManagementClient( - credential=credential, - subscription_id=vm.subscription, - base_url=cloud.endpoints.resource_manager, - credential_scopes=[cloud.endpoints.resource_manager + "/.default"]) - - @abstractmethod - def get_instance_view(self) -> Any: # Returns VirtualMachineInstanceView or VirtualMachineScaleSetInstanceView - """ - Retrieves the instance view of the virtual machine or scale set - """ - - @abstractmethod - def get_extensions(self) -> Any: # Returns List[VirtualMachineExtension] or List[VirtualMachineScaleSetExtension] - """ - Retrieves the extensions installed on the virtual machine or scale set - """ - - def restart(self, timeout=5 * 60) -> None: - """ - Restarts the virtual machine or scale set - """ - log.info("Initiating restart of %s", self._identifier) - - poller: LROPoller = execute_with_retry(self._begin_restart) - - poller.wait(timeout=timeout) - - if not poller.done(): - raise TimeoutError(f"Failed to restart {self._identifier.name} after {timeout} seconds") - - log.info("Restarted %s", self._identifier.name) - - @abstractmethod - def _begin_restart(self) -> LROPoller: - """ - Derived classes must provide the implementation for this method using their corresponding begin_restart() implementation - """ - - def __str__(self): - return f"{self._identifier}" - - -class VirtualMachine(VirtualMachineBaseClass): - def get_instance_view(self) -> VirtualMachineInstanceView: - log.info("Retrieving instance view for %s", self._identifier) - return execute_with_retry(lambda: self._compute_client.virtual_machines.get( - resource_group_name=self._identifier.resource_group, - vm_name=self._identifier.name, - expand="instanceView" - ).instance_view) - - def get_extensions(self) -> List[VirtualMachineExtension]: - log.info("Retrieving extensions for %s", self._identifier) - return execute_with_retry(lambda: self._compute_client.virtual_machine_extensions.list( - resource_group_name=self._identifier.resource_group, - vm_name=self._identifier.name)) - - def _begin_restart(self) -> LROPoller: - return self._compute_client.virtual_machines.begin_restart( - resource_group_name=self._identifier.resource_group, - vm_name=self._identifier.name) - - -class VmScaleSet(VirtualMachineBaseClass): - def get_instance_view(self) -> VirtualMachineScaleSetInstanceView: - log.info("Retrieving instance view for %s", self._identifier) - - # TODO: Revisit this implementation. Currently this method returns the instance view of the first VM instance available. - # For the instance view of the complete VMSS, use the compute_client.virtual_machine_scale_sets function - # https://docs.microsoft.com/en-us/python/api/azure-mgmt-compute/azure.mgmt.compute.v2019_12_01.operations.virtualmachinescalesetsoperations?view=azure-python - for vm in execute_with_retry(lambda: self._compute_client.virtual_machine_scale_set_vms.list(self._identifier.resource_group, self._identifier.name)): - try: - return execute_with_retry(lambda: self._compute_client.virtual_machine_scale_set_vms.get_instance_view( - resource_group_name=self._identifier.resource_group, - vm_scale_set_name=self._identifier.name, - instance_id=vm.instance_id)) - except Exception as e: - log.warning("Unable to retrieve instance view for scale set instance %s. Trying out other instances.\nError: %s", vm, e) - - raise Exception(f"Unable to retrieve instance view of any instances for scale set {self._identifier}") - - - @property - def vm_func(self): - return self._compute_client.virtual_machine_scale_set_vms - - @property - def extension_func(self): - return self._compute_client.virtual_machine_scale_set_extensions - - def get_extensions(self) -> List[VirtualMachineScaleSetExtension]: - log.info("Retrieving extensions for %s", self._identifier) - return execute_with_retry(lambda: self._compute_client.virtual_machine_scale_set_extensions.list( - resource_group_name=self._identifier.resource_group, - vm_scale_set_name=self._identifier.name)) - - def _begin_restart(self) -> LROPoller: - return self._compute_client.virtual_machine_scale_sets.begin_restart( - resource_group_name=self._identifier.resource_group, - vm_scale_set_name=self._identifier.name) diff --git a/tests_e2e/tests/lib/virtual_machine_client.py b/tests_e2e/tests/lib/virtual_machine_client.py new file mode 100644 index 000000000..f7e67a823 --- /dev/null +++ b/tests_e2e/tests/lib/virtual_machine_client.py @@ -0,0 +1,122 @@ +# Microsoft Azure Linux Agent +# +# Copyright 2018 Microsoft Corporation +# +# Licensed 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. +# + +# +# This module includes facilities to execute operations on virtual machines (list extensions, restart, etc). +# + +from typing import Any, Dict, List + +from azure.identity import DefaultAzureCredential +from azure.mgmt.compute import ComputeManagementClient +from azure.mgmt.compute.models import VirtualMachineExtension, VirtualMachineInstanceView, VirtualMachine +from azure.mgmt.resource import ResourceManagementClient +from msrestazure.azure_cloud import Cloud + +from tests_e2e.tests.lib.azure_clouds import AZURE_CLOUDS +from tests_e2e.tests.lib.azure_client import AzureClient +from tests_e2e.tests.lib.identifiers import VmIdentifier +from tests_e2e.tests.lib.logging import log +from tests_e2e.tests.lib.retry import execute_with_retry + + +class VirtualMachineClient(AzureClient): + """ + Provides operations on virtual machine (get instance view, update, restart, etc). + """ + def __init__(self, vm: VmIdentifier): + super().__init__() + self._identifier: VmIdentifier = vm + cloud: Cloud = AZURE_CLOUDS[vm.cloud] + credential: DefaultAzureCredential = DefaultAzureCredential(authority=cloud.endpoints.active_directory) + self._compute_client = ComputeManagementClient( + credential=credential, + subscription_id=vm.subscription, + base_url=cloud.endpoints.resource_manager, + credential_scopes=[cloud.endpoints.resource_manager + "/.default"]) + self._resource_client = ResourceManagementClient( + credential=credential, + subscription_id=vm.subscription, + base_url=cloud.endpoints.resource_manager, + credential_scopes=[cloud.endpoints.resource_manager + "/.default"]) + + def get_description(self) -> VirtualMachine: + """ + Retrieves the description of the virtual machine. + """ + log.info("Retrieving description for %s", self._identifier) + return execute_with_retry( + lambda: self._compute_client.virtual_machines.get( + resource_group_name=self._identifier.resource_group, + vm_name=self._identifier.name)) + + def get_instance_view(self) -> VirtualMachineInstanceView: + """ + Retrieves the instance view of the virtual machine + """ + log.info("Retrieving instance view for %s", self._identifier) + return execute_with_retry(lambda: self._compute_client.virtual_machines.get( + resource_group_name=self._identifier.resource_group, + vm_name=self._identifier.name, + expand="instanceView" + ).instance_view) + + def get_extensions(self) -> List[VirtualMachineExtension]: + """ + Retrieves the extensions installed on the virtual machine + """ + log.info("Retrieving extensions for %s", self._identifier) + return execute_with_retry( + lambda: self._compute_client.virtual_machine_extensions.list( + resource_group_name=self._identifier.resource_group, + vm_name=self._identifier.name)) + + def update(self, properties: Dict[str, Any], timeout: int = AzureClient._DEFAULT_TIMEOUT) -> None: + """ + Updates a set of properties on the virtual machine + """ + # location is a required by begin_create_or_update, always add it + properties_copy = properties.copy() + properties_copy["location"] = self._identifier.location + + log.info("Updating %s with properties: %s", self._identifier, properties_copy) + + self._execute_async_operation( + lambda: self._compute_client.virtual_machines.begin_create_or_update( + self._identifier.resource_group, + self._identifier.name, + properties_copy), + operation_name=f"Update {self._identifier}", + timeout=timeout) + + def restart(self, timeout: int = AzureClient._DEFAULT_TIMEOUT) -> None: + """ + Restarts the virtual machine or scale set + """ + self._execute_async_operation( + lambda: self._compute_client.virtual_machines.begin_restart( + resource_group_name=self._identifier.resource_group, + vm_name=self._identifier.name), + operation_name=f"Restart {self._identifier}", + timeout=timeout) + + def __str__(self): + return f"{self._identifier}" + + + + diff --git a/tests_e2e/tests/lib/vm_extension.py b/tests_e2e/tests/lib/virtual_machine_extension_client.py similarity index 59% rename from tests_e2e/tests/lib/vm_extension.py rename to tests_e2e/tests/lib/virtual_machine_extension_client.py index bf7a41a44..d94226e6e 100644 --- a/tests_e2e/tests/lib/vm_extension.py +++ b/tests_e2e/tests/lib/virtual_machine_extension_client.py @@ -16,37 +16,29 @@ # # -# This module includes facilities to execute VM extension operations (enable, remove, etc) on single virtual machines (using -# class VmExtension) or virtual machine scale sets (using class VmssExtension). +# This module includes facilities to execute VM extension operations (enable, remove, etc). # import uuid -from abc import ABC, abstractmethod from assertpy import assert_that, soft_assertions -from typing import Any, Callable, Dict, Type +from typing import Any, Callable, Dict -from azure.core.polling import LROPoller from azure.mgmt.compute import ComputeManagementClient -from azure.mgmt.compute.models import VirtualMachineExtension, VirtualMachineScaleSetExtension, VirtualMachineExtensionInstanceView +from azure.mgmt.compute.models import VirtualMachineExtension, VirtualMachineExtensionInstanceView from azure.identity import DefaultAzureCredential from msrestazure.azure_cloud import Cloud from tests_e2e.tests.lib.azure_clouds import AZURE_CLOUDS +from tests_e2e.tests.lib.azure_client import AzureClient from tests_e2e.tests.lib.identifiers import VmIdentifier, VmExtensionIdentifier from tests_e2e.tests.lib.logging import log from tests_e2e.tests.lib.retry import execute_with_retry -_TIMEOUT = 5 * 60 # Timeout for extension operations (in seconds) - - -class _VmExtensionBaseClass(ABC): +class VirtualMachineExtensionClient(AzureClient): """ - Abstract base class for VmExtension and VmssExtension. - - Implements the operations that are common to virtual machines and scale sets. Derived classes must provide the specific types and methods for the - virtual machine or scale set. + Client for operations virtual machine extensions. """ def __init__(self, vm: VmIdentifier, extension: VmExtensionIdentifier, resource_name: str): super().__init__() @@ -61,18 +53,32 @@ def __init__(self, vm: VmIdentifier, extension: VmExtensionIdentifier, resource_ base_url=cloud.endpoints.resource_manager, credential_scopes=[cloud.endpoints.resource_manager + "/.default"]) + def get_instance_view(self) -> VirtualMachineExtensionInstanceView: + """ + Retrieves the instance view of the extension + """ + log.info("Retrieving instance view for %s...", self._identifier) + + return execute_with_retry(lambda: self._compute_client.virtual_machine_extensions.get( + resource_group_name=self._vm.resource_group, + vm_name=self._vm.name, + vm_extension_name=self._resource_name, + expand="instanceView" + ).instance_view) + def enable( self, settings: Dict[str, Any] = None, protected_settings: Dict[str, Any] = None, auto_upgrade_minor_version: bool = True, force_update: bool = False, - force_update_tag: str = None + force_update_tag: str = None, + timeout: int = AzureClient._DEFAULT_TIMEOUT ) -> None: """ Performs an enable operation on the extension. - NOTE: 'force_update' is not a parameter of the actual ARM API. It is provided for convenience: If set to True, + NOTE: 'force_update' is not a parameter of the actual ARM API. It is provided here for convenience: If set to True, the 'force_update_tag' can be left unspecified and this method will generate a random tag. """ if force_update_tag is not None and not force_update: @@ -81,7 +87,7 @@ def enable( if force_update and force_update_tag is None: force_update_tag = str(uuid.uuid4()) - extension_parameters = self._ExtensionType( + extension_parameters = VirtualMachineExtension( publisher=self._identifier.publisher, location=self._vm.location, type_properties_type=self._identifier.type, @@ -99,28 +105,28 @@ def enable( # Now set the actual protected settings before invoking the extension extension_parameters.protected_settings = protected_settings - result: VirtualMachineExtension = execute_with_retry( - lambda: self._begin_create_or_update( + result: VirtualMachineExtension = self._execute_async_operation( + lambda: self._compute_client.virtual_machine_extensions.begin_create_or_update( self._vm.resource_group, self._vm.name, self._resource_name, - extension_parameters - ).result(timeout=_TIMEOUT)) + extension_parameters), + operation_name=f"Enable {self._identifier}", + timeout=timeout) - log.info("Enable completed. Provisioning state: %s", result.provisioning_state) + log.info("Provisioning state: %s", result.provisioning_state) - def get_instance_view(self) -> VirtualMachineExtensionInstanceView: # TODO: Check type for scale sets + def delete(self, timeout: int = AzureClient._DEFAULT_TIMEOUT) -> None: """ - Retrieves the instance view of the extension + Performs a delete operation on the extension """ - log.info("Retrieving instance view for %s...", self._identifier) - - return execute_with_retry(lambda: self._get( - resource_group_name=self._vm.resource_group, - vm_name=self._vm.name, - vm_extension_name=self._resource_name, - expand="instanceView" - ).instance_view) + self._execute_async_operation( + lambda: self._compute_client.virtual_machine_extensions.begin_delete( + self._vm.resource_group, + self._vm.name, + self._resource_name), + operation_name=f"Delete {self._identifier}", + timeout=timeout) def assert_instance_view( self, @@ -157,89 +163,9 @@ def assert_instance_view( log.info("The instance view matches the expected values") - @abstractmethod - def delete(self) -> None: - """ - Performs a delete operation on the extension - """ - - @property - @abstractmethod - def _ExtensionType(self) -> Type: - """ - Type of the extension object for the virtual machine or scale set (i.e. VirtualMachineExtension or VirtualMachineScaleSetExtension) - """ - - @property - @abstractmethod - def _begin_create_or_update(self) -> Callable[[str, str, str, Any], LROPoller[Any]]: # "Any" can be VirtualMachineExtension or VirtualMachineScaleSetExtension - """ - The begin_create_or_update method for the virtual machine or scale set extension - """ - - @property - @abstractmethod - def _get(self) -> Any: # VirtualMachineExtension or VirtualMachineScaleSetExtension - """ - The get method for the virtual machine or scale set extension - """ - def __str__(self): return f"{self._identifier}" -class VmExtension(_VmExtensionBaseClass): - """ - Extension operations on a single virtual machine. - """ - @property - def _ExtensionType(self) -> Type: - return VirtualMachineExtension - - @property - def _begin_create_or_update(self) -> Callable[[str, str, str, VirtualMachineExtension], LROPoller[VirtualMachineExtension]]: - return self._compute_client.virtual_machine_extensions.begin_create_or_update - @property - def _get(self) -> VirtualMachineExtension: - return self._compute_client.virtual_machine_extensions.get - - def delete(self) -> None: - log.info("Deleting %s", self._identifier) - - execute_with_retry(lambda: self._compute_client.virtual_machine_extensions.begin_delete( - self._vm.resource_group, - self._vm.name, - self._resource_name - ).wait(timeout=_TIMEOUT)) - - -class VmssExtension(_VmExtensionBaseClass): - """ - Extension operations on virtual machine scale sets. - """ - @property - def _ExtensionType(self) -> Type: - return VirtualMachineScaleSetExtension - - @property - def _begin_create_or_update(self) -> Callable[[str, str, str, VirtualMachineScaleSetExtension], LROPoller[VirtualMachineScaleSetExtension]]: - return self._compute_client.virtual_machine_scale_set_extensions.begin_create_or_update - - @property - def _get(self) -> VirtualMachineScaleSetExtension: - return self._compute_client.virtual_machine_scale_set_extensions.get - - def delete(self) -> None: # TODO: Implement this method - raise NotImplementedError() - - def delete_from_instance(self, instance_id: str) -> None: - log.info("Deleting %s from scale set instance %s", self._identifier, instance_id) - - execute_with_retry(lambda: self._compute_client.virtual_machine_scale_set_vm_extensions.begin_delete( - resource_group_name=self._vm.resource_group, - vm_scale_set_name=self._vm.name, - vm_extension_name=self._resource_name, - instance_id=instance_id - ).wait(timeout=_TIMEOUT))