diff --git a/.travis.yml b/.travis.yml index 262cb7d..c43c1f2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ language: python python: - 3.5 - 3.6 + - 3.7 install: - pip install -r requirements.txt -r requirements-dev.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8236209..a0591b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,79 @@ # Changelog ## [Unreleased][] -[Unreleased]: https://github.com/chaostoolkit-incubator/chaostoolkit-azure/compare/0.5.0...HEAD +[Unreleased]: https://github.com/chaostoolkit-incubator/chaostoolkit-azure/compare/0.8.3...HEAD + +### Added + +- Individual Azure managemnt clients for website and compute resources + +### Removed + +- Removed common init_client + +## [0.8.3][] - 2020-05-14 + +[0.8.3]: https://github.com/chaostoolkit-incubator/chaostoolkit-azure/compare/0.8.2...0.8.3 + +### Added + +- Missing init module files + +## [0.8.2][] - 2020-05-14 + +[0.8.2]: https://github.com/chaostoolkit-incubator/chaostoolkit-azure/compare/0.8.1...0.8.2 + +### Changed + +- Letting setuptools find all packages + +## [0.8.1][] - 2020-05-14 + +[0.8.1]: https://github.com/chaostoolkit-incubator/chaostoolkit-azure/compare/0.8.0...0.8.1 + +### Added + +- Expose the `auth` and `common` packages on building the top-level package + +## [0.8.0][] - 2020-05-14 + +[0.8.0]: https://github.com/chaostoolkit-incubator/chaostoolkit-azure/compare/0.7.0...0.8.0 + +### Added +- Return output from activities +- Allow user to load more than one VMSS instance for VMSS actions +- Update list of unsupported scripts for Windows VM +- Added network latency operation vor VMSS instances +- Added burn io (memory exploit) operation vor VMSS instances +- Added fill disk operation vor VMSS instances +- Interrupt an experiment execution when secrets are error-prone +- Interrupt an experiment execution when an invalid cloud is configured +- Remove an unused configuration property from the resource graph since it is deprecated +- Technical refactoring: Separate concerns from the main _init_ module +- Technical refactoring: Applied DRY principles in test module +- Technical refactoring: Resource graph client now outputs error messages +- Allow to load secrets from azure credential file + +## [0.7.0][] - 2020-04-09 + +[0.7.0]: https://github.com/chaostoolkit-incubator/chaostoolkit-azure/compare/0.6.0...0.7.0 + +### Added + +- Supporting criteria for selection of the virtual machine scale set instance to stop +- Added optional path parameter to fill_disk + +### Changed + +- Use the official configuration accessor for `subscription_id` [#91][91] + +[91]: https://github.com/chaostoolkit-incubator/chaostoolkit-azure/issues/91 + +## [0.6.0][] - 2019-11-12 + +[0.6.0]: https://github.com/chaostoolkit-incubator/chaostoolkit-azure/compare/0.5.0...0.6.0 + +### Added - Added the burn_io feature: increase the I/O operations per seconds of the hard drive for a time period (default time period is 1 minute). Works by @@ -10,6 +82,10 @@ script. - Added the network_latency feature: disturb the network of the VM, adding some latency for a time period (defaults to a 200 +/- 50ms latency for 1 minute). Only works on Linux machines for now. +- Supporting multiple Azure Cloud, such as AZURE_CHINA_CLOUD. +- Code clean up and refactoring, moving up client initiation. +- Added the ability to stress a machine instance in a virtual machine scale set +- Supporting Azure token based credentials (no refresh token support yet) ## [0.5.0][] - 2019-07-05 diff --git a/MANIFEST.in b/MANIFEST.in index caa3ec1..49e301c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,4 +4,4 @@ include requirements-dev.txt include LICENSE include CHANGELOG.md include pytest.ini -include chaosazure/machine/scripts/* \ No newline at end of file +include chaosazure/common/scripts/* \ No newline at end of file diff --git a/README.md b/README.md index f5170fd..b7aa945 100644 --- a/README.md +++ b/README.md @@ -30,19 +30,19 @@ experiment file: ```json { - "type": "action", - "name": "start-service-factory-chaos", - "provider": { - "type": "python", - "module": "chaosazure.vm.actions", - "func": "stop_machines", - "secrets": ["azure"], - "arguments": { - "parameters": { - "TimeToRunInSeconds": 45 - } - } + "type": "action", + "name": "start-service-factory-chaos", + "provider": { + "type": "python", + "module": "chaosazure.vm.actions", + "func": "stop_machines", + "secrets": ["azure"], + "arguments": { + "parameters": { + "TimeToRunInSeconds": 45 + } } + } } ``` @@ -50,83 +50,140 @@ That's it! Please explore the code to see existing probes and actions. +## Configuration +This extension uses the [Azure SDK][sdk] libraries under the hood. The Azure SDK library expects that you have a tenant and client identifier, as well as a client secret and subscription, that allows you to authenticate with the Azure resource management API. -## Configuration +Configuration values for the Chaos Toolkit Extension for Azure can come from several sources: -### Credentials -This extension uses the [Azure SDK][sdk] libraries under the hood. The Azure SDK library -expects that you have a tenant and client identifier, as well as a client secret and subscription, that allows you to -authenticate with the Azure resource management API. +- Experiment file +- Azure credential file + +The extension will first try to load the configuration from the `experiment file`. If configuration is not provided in the `experiment file`, it will try to load it from the `Azure credential file`. [creds]: https://docs.microsoft.com/en-us/azure/service-fabric/service-fabric-connect-to-secure-cluster [requests]: http://docs.python-requests.org/en/master/ [sdk]: https://github.com/Azure/azure-sdk-for-python -There are two ways of doing this: +### Credentials -* you can either pass the name of the environment variables to the experiment definition as follows (recommended): +- Secrets in the Experiment file - ```json - { - "azure": { - "client_id": { - "type": "env", - "key": "AZURE_CLIENT_ID" - }, - "client_secret": { - "type": "env", - "key": "AZURE_CLIENT_SECRET" - }, - "tenant_id": { - "type": "env", - "key": "AZURE_TENANT_ID" - } - } + ```json + { + "secrets": { + "azure": { + "client_id": "your-super-secret-client-id", + "client_secret": "your-even-more-super-secret-client-secret", + "tenant_id": "your-tenant-id" + } } - ``` - -* or you inject the secrets explicitly to the experiment definition: + } + ``` - ```json - { - "azure": { - "client_id": "your-super-secret-client-id", - "client_secret": "your-even-more-super-secret-client-secret", - "tenant_id": "your-tenant-id" - } + You can retrieve secretes as well from [environment][env_secrets] or [HashiCorp vault][vault_secrets]. + + + If you are not working with Public Global Azure, e.g. China Cloud You can set the cloud environment. + + ```json + { + "client_id": "your-super-secret-client-id", + "client_secret": "your-even-more-super-secret-client-secret", + "tenant_id": "your-tenant-id", + "azure_cloud": "AZURE_CHINA_CLOUD" + } + ``` + + Available cloud names: + + - AZURE_CHINA_CLOUD + - AZURE_GERMAN_CLOUD + - AZURE_PUBLIC_CLOUD + - AZURE_US_GOV_CLOUD + + [vault_secrets]: https://docs.chaostoolkit.org/reference/api/experiment/#vault-secrets + [env_secrets]: https://docs.chaostoolkit.org/reference/api/experiment/#environment-secrets + + +- Secrets in the Azure credential file + + You can retrieve a credentials file with your subscription ID already in place by signing in to Azure using the az login command followed by the az ad sp create-for-rbac command + + ```bash + az login + az ad sp create-for-rbac --sdk-auth > credentials.json + ``` + + credentials.json: + + ```json + { + "subscriptionId": "", + "tenantId": "", + "clientId": "", + "clientSecret": "", + "activeDirectoryEndpointUrl": "https://login.microsoftonline.com", + "resourceManagerEndpointUrl": "https://management.azure.com/", + "activeDirectoryGraphResourceId": "https://graph.windows.net/", + "sqlManagementEndpointUrl": "https://management.core.windows.net:8443/", + "galleryEndpointUrl": "https://gallery.azure.com/", + "managementEndpointUrl": "https://management.core.windows.net/" + } + ``` + + Store the path to the file in an environment variable called **AZURE_AUTH_LOCATION** and make sure that your experiment does **NOT** contain `secrets` section. + +### Subscription + +Additionally you need to provide the Azure subscription id. + +- Subscription id in the experiment file + + ```json + { + "configuration": { + "azure_subscription_id": "your-azure-subscription-id" } - ``` - - Additionally you need to provide the Azure subscription id. + } + ``` - ```json - { - "azure": { - "subscription_id": "your-azure-subscription-id" - } + Configuration may be as well retrieved from an [environment][env_configuration]. + + An old, but deprecated way of doing it was as follows, this still works + but should not be favoured over the previous approaches as it's not the + Chaos Toolkit way to pass structured configurations. + + ```json + { + "configuration": { + "azure": { + "subscription_id": "your-azure-subscription-id" + } } - ``` + } + ``` + + [env_configuration]: https://docs.chaostoolkit.org/reference/api/experiment/#environment-configurations + +- Subscription id in the Azure credential file + + Credential file described in the previous "Credential" section contains as well subscription id. If **AZURE_AUTH_LOCATION** is set and subscription id is **NOT** set in the experiment definition, extension will try to load it from the credential file. + + ### Putting it all together -Here is a full example: +Here is a full example for an experiment containing secrets and configuration: ```json { "version": "1.0.0", "title": "...", "description": "...", - "tags": [ - "azure", - "kubernetes", - "aks", - "node" - ], + "tags": ["azure", "kubernetes", "aks", "node"], "configuration": { - "azure": { - "subscription_id": "xxx" - } + "azure_subscription_id": "xxx" }, "secrets": { "azure": { @@ -157,18 +214,12 @@ Here is a full example: "type": "python", "module": "chaosazure.machine.actions", "func": "restart_machines", - "secrets": [ - "azure" - ], - "config": [ - "azure" - ] + "secrets": ["azure"], + "config": ["azure_subscription_id"] } } ], - "rollbacks": [ - - ] + "rollbacks": [] } ``` @@ -197,7 +248,7 @@ those dependencies. [venv]: http://chaostoolkit.org/reference/usage/install/#create-a-virtual-environment ```console -$ pip install -r requirements-dev.txt -r requirements.txt +$ pip install -r requirements-dev.txt -r requirements.txt ``` Then, point your environment to this directory: diff --git a/chaosazure/__init__.py b/chaosazure/__init__.py index c0abcee..3a6653a 100644 --- a/chaosazure/__init__.py +++ b/chaosazure/__init__.py @@ -1,65 +1,26 @@ # -*- coding: utf-8 -*- """Top-level package for chaostoolkit-azure.""" -import contextlib -from chaoslib.discovery import initialize_discovery_result, discover_actions, \ - discover_probes -from chaoslib.types import Discovery, DiscoveredActivities, Secrets -from logzero import logger -from msrestazure.azure_active_directory import ServicePrincipalCredentials -from typing import List - -__all__ = ["auth", "discover", "__version__"] -__version__ = '0.5.0' - -@contextlib.contextmanager -def auth(secrets: Secrets) -> ServicePrincipalCredentials: - """ - Attempt to load the Azure authentication information from a local - configuration file or the passed `configuration` mapping. The latter takes - precedence over the local configuration file. - - If you provide a secrets dictionary, the returned mapping will - be created from their content. For instance, you could have: - - Secrets mapping (in your experiment file): - ```json - { - "azure": { - "client_id": "AZURE_CLIENT_ID", - "client_secret": "AZURE_CLIENT_SECRET", - "tenant_id": "AZURE_TENANT_ID" - } - } - ``` - - The client_id, tenant_id, and client_secret content will be read - from the specified local environment variables, e.g. `AZURE_CLIENT_ID`, - `AZURE_TENANT_ID`, and `AZURE_CLIENT_SECRET` that you will have populated - before hand. - ``` - - Using this function goes as follows: - - ```python - with auth(secrets) as cred: - azure_subscription_id = configuration['azure']['subscription_id'] - resource_client = ResourceManagementClient(cred, azure_subscription_id) - compute_client = ComputeManagementClient(cred, azure_subscription_id) - ``` - """ - creds = dict( - azure_client_id=None, azure_client_secret=None, azure_tenant_id=None) +from typing import List - if secrets: - creds["azure_client_id"] = secrets.get("client_id") - creds["azure_client_secret"] = secrets.get("client_secret") - creds["azure_tenant_id"] = secrets.get("tenant_id") +from azure.mgmt.compute import ComputeManagementClient +from azure.mgmt.web import WebSiteManagementClient +from azure.mgmt.resourcegraph import ResourceGraphClient +from chaoslib.discovery import (discover_actions, discover_probes, + initialize_discovery_result) +from chaoslib.types import (Configuration, DiscoveredActivities, Discovery, + Secrets) +from logzero import logger - credentials = __get_credentials(creds) +from chaosazure.auth import auth +from chaosazure.common.config import load_configuration, load_secrets - yield credentials +__all__ = [ + "discover", "__version__", "init_compute_management_client", + "init_website_management_client", "init_resource_graph_client" +] +__version__ = '0.8.3' def discover(discover_system: bool = True) -> Discovery: @@ -74,18 +35,62 @@ def discover(discover_system: bool = True) -> Discovery: return discovery +def init_compute_management_client( + experiment_secrets: Secrets, + experiment_configuration: Configuration) -> ComputeManagementClient: + """ + Initializes Compute management client for virtual machine, + and virtual machine scale sets resources under Azure Resource manager. + """ + secrets = load_secrets(experiment_secrets) + configuration = load_configuration(experiment_configuration) + with auth(secrets) as authentication: + base_url = secrets.get('cloud').endpoints.resource_manager + client = ComputeManagementClient( + credential=authentication, + subscription_id=configuration.get('subscription_id'), + base_url=base_url) + + return client + + +def init_website_management_client( + experiment_secrets: Secrets, + experiment_configuration: Configuration) -> WebSiteManagementClient: + """ + Initializes Website management client for webapp resource under Azure + Resource manager. + """ + secrets = load_secrets(experiment_secrets) + configuration = load_configuration(experiment_configuration) + with auth(secrets) as authentication: + base_url = secrets.get('cloud').endpoints.resource_manager + client = WebSiteManagementClient( + credentials=authentication, + subscription_id=configuration.get('subscription_id'), + base_url=base_url) + + return client + + +def init_resource_graph_client( + experiment_secrets: Secrets) -> ResourceGraphClient: + """ + Initializes Resource Graph client. + """ + secrets = load_secrets(experiment_secrets) + with auth(secrets) as authentication: + base_url = secrets.get('cloud').endpoints.resource_manager + client = ResourceGraphClient( + credentials=authentication, + base_url=base_url) + + return client + + ############################################################################### # Private functions ############################################################################### -def __get_credentials(creds): - credentials = ServicePrincipalCredentials( - client_id=creds['azure_client_id'], - secret=creds['azure_client_secret'], - tenant=creds['azure_tenant_id'] - ) - return credentials - - def __load_exported_activities() -> List[DiscoveredActivities]: """ Extract metadata from actions and probes exposed by this extension. diff --git a/chaosazure/aks/actions.py b/chaosazure/aks/actions.py index ee7650d..de7979e 100644 --- a/chaosazure/aks/actions.py +++ b/chaosazure/aks/actions.py @@ -7,7 +7,7 @@ from chaosazure.aks.constants import RES_TYPE_AKS from chaosazure.machine.actions import delete_machines, stop_machines, \ restart_machines -from chaosazure.rgraph.resource_graph import fetch_resources +from chaosazure.common.resources.graph import fetch_resources __all__ = ["delete_node", "stop_node", "restart_node"] @@ -34,7 +34,7 @@ def delete_node(filter: str = None, configuration, filter)) query = node_resource_group_query(filter, configuration, secrets) - delete_machines(query, configuration, secrets) + return delete_machines(query, configuration, secrets) def stop_node(filter: str = None, @@ -56,7 +56,7 @@ def stop_node(filter: str = None, configuration, filter)) query = node_resource_group_query(filter, configuration, secrets) - stop_machines(query, configuration, secrets) + return stop_machines(query, configuration, secrets) def restart_node(filter: str = None, @@ -78,7 +78,7 @@ def restart_node(filter: str = None, configuration, filter)) query = node_resource_group_query(filter, configuration, secrets) - restart_machines(query, configuration, secrets) + return restart_machines(query, configuration, secrets) ############################################################################### diff --git a/chaosazure/auth/__init__.py b/chaosazure/auth/__init__.py new file mode 100644 index 0000000..6b6dc3a --- /dev/null +++ b/chaosazure/auth/__init__.py @@ -0,0 +1,111 @@ +import contextlib +from typing import Dict + +from chaoslib.exceptions import InterruptExecution +from msrest.exceptions import AuthenticationError +from msrestazure.azure_active_directory import AADMixin + +from chaosazure.auth.authentication import ServicePrincipalAuth, TokenAuth + +AAD_TOKEN = "aad_token" +SERVICE_PRINCIPAL = "service_principal" + + +@contextlib.contextmanager +def auth(secrets: Dict) -> AADMixin: + """ + Create Azure authentication client from a provided secrets. + + Service principle and token based auth types are supported. Token + based auth do not currently support refresh token functionality. + + Type of authentication client is determined based on passed secrets. + + For example, secrets that contains a `client_id`, `client_secret` and + `tenant_id` will create ServicePrincipalAuth client + ```python + { + "client_id": "AZURE_CLIENT_ID", + "client_secret": "AZURE_CLIENT_SECRET", + "tenant_id": "AZURE_TENANT_ID" + } + ``` + If you are not working with Public Global Azure, e.g. China Cloud + you can provide `msrestazure.azure_cloud.Cloud` object. If omitted the + Public Cloud is taken as default. Please refer to msrestazure.azure_cloud + ```python + { + "client_id": "xxxxxxx", + "client_secret": "*******", + "tenant_id": "@@@@@@@@@@@", + "cloud": "msrestazure.azure_cloud.Cloud" + } + ``` + + If the `client_secret` is not provided, then token based credentials is + assumed and an `access_token` value must be present in `secrets` object + and updated when the token expires. + ``` + + Using this function goes as follows: + + ```python + with auth(secrets) as cred: + subscription_id = configuration.get("subscription_id") + resource_client = ResourceManagementClient(cred, subscription_id) + compute_client = ComputeManagementClient(cred, subscription_id) + ``` + + Again, if you are not working with Public Azure Cloud, + and you set azure_cloud in secret, + this will pass one more parameter `base_url` to above function. + ```python + with auth(secrets) as cred: + cloud = cred.get('cloud') + client = ComputeManagementClient( + credentials=cred, subscription_id=subscription_id, + base_url=cloud.endpoints.resource_manager) + ``` + + """ + + # No input validation needed: + # 1) Either no secrets are passed at all - chaostoolkit-lib + # will handle it for us *or* + # 2) Secret arguments are partially missing or invalid - we + # rely on the ms azure library + yield __create(secrets) + + +################## +# HELPER FUNCTIONS +################## + +def __create(secrets: Dict) -> AADMixin: + _auth_type = __authentication_type(secrets) + + if _auth_type == SERVICE_PRINCIPAL: + _authentication = ServicePrincipalAuth() + + elif _auth_type == AAD_TOKEN: + _authentication = TokenAuth() + + try: + result = _authentication.create(secrets) + return result + except AuthenticationError as e: + msg = e.inner_exception.error_response.get('error_description') + raise InterruptExecution(msg) + + +def __authentication_type(secrets: dict) -> str: + if 'client_secret' in secrets and secrets['client_secret']: + return SERVICE_PRINCIPAL + + elif 'access_token' in secrets and secrets['access_token']: + return AAD_TOKEN + + else: + raise InterruptExecution( + "Authentication to Azure requires a" + " client secret or an access token") diff --git a/chaosazure/auth/authentication.py b/chaosazure/auth/authentication.py new file mode 100644 index 0000000..d9501ca --- /dev/null +++ b/chaosazure/auth/authentication.py @@ -0,0 +1,37 @@ +from abc import ABCMeta, abstractmethod +from typing import Dict + +from chaoslib import Secrets +from chaoslib.exceptions import InterruptExecution +from msrestazure.azure_active_directory import (AADMixin, AADTokenCredentials, + ServicePrincipalCredentials) + + +class Auth(metaclass=ABCMeta): + + @abstractmethod + def create(self, secrets: Secrets) -> AADMixin: + raise InterruptExecution("Not implemented") + + +class ServicePrincipalAuth(Auth): + + def create(self, secrets: Dict) -> ServicePrincipalCredentials: + result = ServicePrincipalCredentials( + client_id=secrets.get('client_id'), + secret=secrets.get('client_secret'), + tenant=secrets.get('tenant_id'), + cloud_environment=secrets.get('cloud') + ) + return result + + +class TokenAuth(Auth): + + def create(self, secrets: Dict) -> AADTokenCredentials: + result = AADTokenCredentials( + token={"accessToken": secrets['access_token']}, + client_id=secrets.get('client_id'), + cloud_environment=secrets.get('cloud')) + + return result diff --git a/chaosazure/rgraph/__init__.py b/chaosazure/common/__init__.py similarity index 100% rename from chaosazure/rgraph/__init__.py rename to chaosazure/common/__init__.py diff --git a/chaosazure/common/cleanse.py b/chaosazure/common/cleanse.py new file mode 100644 index 0000000..3b906e4 --- /dev/null +++ b/chaosazure/common/cleanse.py @@ -0,0 +1,40 @@ +def machine(resource: dict) -> dict: + """ + Free the virtual machine dictionary from unwanted keys listed below. + """ + cleanse = [ + "properties" + ] + + return __cleanse(cleanse, resource) + + +def vmss(resource: dict) -> dict: + """ + Free the VMSS dictionary from unwanted keys listed below. + """ + cleanse = [ + "properties", "instances" + ] + + return __cleanse(cleanse, resource) + + +def vmss_instance(resource: dict) -> dict: + """ + Free the VMSS instance from unwanted keys listed below. + """ + cleanse = [ + "hardware_profile", "storage_profile", "network_profile", + "os_profile", "network_profile_configuration", "resources" + ] + + return __cleanse(cleanse, resource) + + +def __cleanse(cleanse_list: [], resource: dict) -> dict: + for key in cleanse_list: + if key in resource: + del resource[key] + + return resource diff --git a/chaosazure/common/cloud.py b/chaosazure/common/cloud.py new file mode 100644 index 0000000..aa845a2 --- /dev/null +++ b/chaosazure/common/cloud.py @@ -0,0 +1,37 @@ +from chaoslib.exceptions import InterruptExecution +from logzero import logger +from msrestazure import azure_cloud + +AZURE_CHINA_CLOUD = "AZURE_CHINA_CLOUD" +AZURE_GERMAN_CLOUD = "AZURE_GERMAN_CLOUD" +AZURE_PUBLIC_CLOUD = "AZURE_PUBLIC_CLOUD" +AZURE_US_GOV_CLOUD = "AZURE_US_GOV_CLOUD" + + +def get_or_raise(value: str = "AZURE_PUBLIC_CLOUD") -> azure_cloud.Cloud: + """ Returns the proper Azure cloud object or raises + an InterruptException if not found """ + + if not value: + logger.warn("Azure cloud not provided. Using" + " AZURE_PUBLIC_CLOUD as default") + return azure_cloud.AZURE_PUBLIC_CLOUD + + cloud = value.strip().upper() + + if cloud == AZURE_PUBLIC_CLOUD: + result = azure_cloud.AZURE_PUBLIC_CLOUD + elif cloud == AZURE_CHINA_CLOUD: + result = azure_cloud.AZURE_CHINA_CLOUD + elif cloud == AZURE_US_GOV_CLOUD: + result = azure_cloud.AZURE_US_GOV_CLOUD + elif cloud == AZURE_GERMAN_CLOUD: + result = azure_cloud.AZURE_GERMAN_CLOUD + + else: + msg = "Invalid Azure cloud '{}'. Please " \ + "provide a proper cloud value".format(cloud) + logger.info(msg) + raise InterruptExecution(msg) + + return result diff --git a/chaosazure/common/compute/__init__.py b/chaosazure/common/compute/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chaosazure/common/compute/command.py b/chaosazure/common/compute/command.py new file mode 100644 index 0000000..d8c9cd8 --- /dev/null +++ b/chaosazure/common/compute/command.py @@ -0,0 +1,89 @@ +import os + +from chaoslib.exceptions import FailedActivity, InterruptExecution +from logzero import logger + +from chaosazure import init_compute_management_client +from chaosazure.machine.constants import OS_LINUX, OS_WINDOWS, RES_TYPE_VM +from chaosazure.vmss.constants import RES_TYPE_VMSS_VM + +UNSUPPORTED_WINDOWS_SCRIPTS = ['network_latency', 'burn_io'] + + +def prepare_path(machine: dict, path: str): + os_type = __get_os_type(machine) + if os_type == OS_LINUX: + result = "/root/burn" if path is None else path + else: + result = "C:/burn" if path is None else path + + return result + + +def prepare(compute: dict, script: str): + os_type = __get_os_type(compute) + if os_type == OS_LINUX: + command_id = 'RunShellScript' + script_name = "{}.sh".format(script) + else: + if script in UNSUPPORTED_WINDOWS_SCRIPTS: + raise InterruptExecution("'{}' is not supported for os '{}'" + .format(script, OS_WINDOWS)) + command_id = 'RunPowerShellScript' + script_name = "{}.ps1".format(script) + + file_path = os.path.join( + os.path.dirname(__file__), "../scripts", script_name) + with open(file_path) as file_path: + script_content = file_path.read() + return command_id, script_content + + +def run(resource_group: str, compute: dict, timeout: int, parameters: dict, + secrets, configuration): + client = init_compute_management_client(secrets, configuration) + + compute_type = compute.get('type').lower() + if compute_type == RES_TYPE_VMSS_VM.lower(): + poller = client.virtual_machine_scale_set_vms.run_command( + resource_group, compute['scale_set'], + compute['instance_id'], parameters) + + elif compute_type == RES_TYPE_VM.lower(): + poller = client.virtual_machines.run_command( + resource_group, compute['name'], parameters) + + else: + msg = "Trying to run a command for the unknown resource type '{}'" \ + .format(compute.get('type')) + raise InterruptExecution(msg) + + result = poller.result(timeout) # Blocking till executed + if result and result.value: + logger.debug(result.value[0].message) # stdout/stderr + else: + raise FailedActivity("Operation did not finish properly." + " You may consider increasing timeout setting.") + + +##################### +# HELPER FUNCTIONS +#################### +def __get_os_type(compute): + compute_type = compute['type'].lower() + + if compute_type == RES_TYPE_VMSS_VM.lower(): + os_type = compute['storage_profile']['os_disk']['os_type'] + + elif compute_type == RES_TYPE_VM.lower(): + os_type = compute['properties']['storageProfile']['osDisk']['osType'] + + else: + msg = "Trying to run a command for the unknown resource type '{}'" \ + .format(compute.get('type')) + raise InterruptExecution(msg) + + if os_type.lower() not in (OS_LINUX, OS_WINDOWS): + raise FailedActivity("Unknown OS Type: %s" % os_type) + + return os_type.lower() diff --git a/chaosazure/common/config.py b/chaosazure/common/config.py new file mode 100644 index 0000000..e74d6c3 --- /dev/null +++ b/chaosazure/common/config.py @@ -0,0 +1,133 @@ + +import io +import json +import os + +from chaoslib.types import Configuration, Secrets +from logzero import logger +from msrestazure import azure_cloud + +from chaosazure.common import cloud + + +def load_secrets(experiment_secrets: Secrets): + """Load secrets from experiments or azure credential file. + + :param experiment_secrets: Secrets provided in experiment file + :returns: a secret object + + Load secrets from multiple sources that can contain different format + such as azure credential file or experiment secrets section. + The latter takes precedence over azure credential file. + + Function returns following dictionary object: + ```python + { + # always available + "cloud": "variable contains msrest cloud object" + + # optional - available if user authenticate with service principal + "client_id": "variable contains client id", + "client_secret": "variable contains client secret", + "tenant_id": "variable contains tenant id", + + # optional - available if user authenticate with existing token + "access_token": "variable contains access token", + } + ``` + + :Loading secrets from experiment file: + + Function will try to load following secrets from the experiment file: + ```json + { + "azure": { + "client_id": "AZURE_CLIENT_ID", + "client_secret": "AZURE_CLIENT_SECRET", + "tenant_id": "AZURE_TENANT_ID", + "access_token": "AZURE_ACCESS_TOKEN" + } + } + ``` + + :Loading secrets from azure credential file: + + If experiment file contains no secrets, function will try to load secrets + from the azure credential file. Path to the file should be set under + AZURE_AUTH_LOCATION environment variable. + + Function will try to load following secrets from azure credential file: + ```json + { + "clientId": "AZURE_CLIENT_ID", + "clientSecret": "AZURE_CLIENT_SECRET", + "tenantId": "AZURE_TENANT_ID", + "resourceManagerEndpointUrl": "AZURE_RESOURCE_MANAGER_ENDPOINT", + ... + } + ``` + More info about azure credential file may be found: + https://docs.microsoft.com/en-us/azure/developer/python/azure-sdk-authenticate + + """ + + # 1: lookup for secrets in experiment file + if experiment_secrets: + return { + 'client_id': experiment_secrets.get('client_id'), + 'client_secret': experiment_secrets.get('client_secret'), + 'tenant_id': experiment_secrets.get('tenant_id'), + # load cloud object + 'cloud': cloud.get_or_raise(experiment_secrets.get('azure_cloud')), + 'access_token': experiment_secrets.get('access_token'), + } + + # 2: lookup for credentials in azure auth file + az_auth_file = _load_azure_auth_file() + if az_auth_file: + rm_endpoint = az_auth_file.get('resourceManagerEndpointUrl') + return { + 'client_id': az_auth_file.get('clientId'), + 'client_secret': az_auth_file.get('clientSecret'), + 'tenant_id': az_auth_file.get('tenantId'), + # load cloud object + 'cloud': azure_cloud.get_cloud_from_metadata_endpoint(rm_endpoint), + # access token is not supported for credential files + 'access_token': None, + } + + # no secretes + logger.warn("Unable to load Azure credentials.") + return {} + + +def load_configuration(experiment_configuration: Configuration): + subscription_id = None + # 1: lookup for configuration in experiment config file + if experiment_configuration: + subscription_id = experiment_configuration.get("azure_subscription_id") + # check legacy subscription location + if not subscription_id: + subscription_id = experiment_configuration\ + .get('azure', {}).get('subscription_id') + + if subscription_id: + return {'subscription_id': subscription_id} + + # 2: lookup for configuration in azure auth file + az_auth_file = _load_azure_auth_file() + if az_auth_file: + return {'subscription_id': az_auth_file.get('subscriptionId')} + + # no configuration + logger.warn("Unable to load subscription id.") + return {} + + +def _load_azure_auth_file(): + auth_path = os.environ.get('AZURE_AUTH_LOCATION') + credential_file = {} + if auth_path and os.path.exists(auth_path): + with io.open(auth_path, 'r', encoding='utf-8-sig') as auth_fd: + credential_file = json.load(auth_fd) + return credential_file diff --git a/chaosazure/common/resources/__init__.py b/chaosazure/common/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chaosazure/common/resources/graph.py b/chaosazure/common/resources/graph.py new file mode 100644 index 0000000..38abea3 --- /dev/null +++ b/chaosazure/common/resources/graph.py @@ -0,0 +1,73 @@ +from datetime import datetime +from typing import List + +from azure.mgmt.resourcegraph.models \ + import QueryRequest, ErrorResponseException +from chaoslib.exceptions import InterruptExecution +from chaoslib.types import Secrets, Configuration +from logzero import logger + +from chaosazure import init_resource_graph_client +from chaosazure.common.config import load_configuration + + +def fetch_resources(input_query: str, resource_type: str, + secrets: Secrets, configuration: Configuration): + # prepare query + _query = __query_from(resource_type, input_query) + _query_request = __query_request_from(_query, configuration) + + # prepare resource graph client + try: + client = init_resource_graph_client(secrets) + resources = client.resources(_query_request) + except ErrorResponseException as e: + msg = e.inner_exception.error.code + if e.inner_exception.error.details: + for d in e.inner_exception.error.details: + msg += ": " + str(d) + raise InterruptExecution(msg) + + # prepare results + results = __to_dicts(resources.data, client.api_version) + return results + + +def __query_request_from(query, experiment_configuration: Configuration): + configuration = load_configuration(experiment_configuration) + result = QueryRequest( + query=query, + subscriptions=[configuration.get('subscription_id')] + ) + return result + + +def __query_from(resource_type, query) -> str: + where = "where type=~'{}'".format(resource_type) + if not query: + result = "{}".format(where) + else: + result = "{}| {}".format(where, query) + + return "Resources | {}".format(result) + + +def __to_dicts(table, version) -> List[dict]: + results = [] + version_date = datetime.strptime(version, '%Y-%m-%d').date() + + if version_date >= datetime.strptime('2019-04-01', '%Y-%m-%d').date(): + for row in table['rows']: + result = {} + for col_index in range(len(table['columns'])): + result[table['columns'][col_index]['name']] = row[col_index] + results.append(result) + + else: + for row in table.rows: + result = {} + for col_index in range(len(table.columns)): + result[table.columns[col_index].name] = row[col_index] + results.append(result) + + return results diff --git a/chaosazure/machine/scripts/burn_io.sh b/chaosazure/common/scripts/burn_io.sh similarity index 100% rename from chaosazure/machine/scripts/burn_io.sh rename to chaosazure/common/scripts/burn_io.sh diff --git a/chaosazure/machine/scripts/cpu_stress_test.ps1 b/chaosazure/common/scripts/cpu_stress_test.ps1 similarity index 100% rename from chaosazure/machine/scripts/cpu_stress_test.ps1 rename to chaosazure/common/scripts/cpu_stress_test.ps1 diff --git a/chaosazure/machine/scripts/cpu_stress_test.sh b/chaosazure/common/scripts/cpu_stress_test.sh similarity index 100% rename from chaosazure/machine/scripts/cpu_stress_test.sh rename to chaosazure/common/scripts/cpu_stress_test.sh diff --git a/chaosazure/machine/scripts/fill_disk.ps1 b/chaosazure/common/scripts/fill_disk.ps1 similarity index 84% rename from chaosazure/machine/scripts/fill_disk.ps1 rename to chaosazure/common/scripts/fill_disk.ps1 index 84c6966..5c2da14 100644 --- a/chaosazure/machine/scripts/fill_disk.ps1 +++ b/chaosazure/common/scripts/fill_disk.ps1 @@ -8,6 +8,6 @@ Write-Host "Filling disk with $size MB of random data for $duration seconds." $Msize = $size*1024000 -fsutil file createnew C:/burn $Msize +fsutil file createnew $path $Msize Start-Sleep -s $duration -rm C:/burn \ No newline at end of file +rm $path \ No newline at end of file diff --git a/chaosazure/machine/scripts/fill_disk.sh b/chaosazure/common/scripts/fill_disk.sh similarity index 50% rename from chaosazure/machine/scripts/fill_disk.sh rename to chaosazure/common/scripts/fill_disk.sh index 6e9ed9b..950e03b 100644 --- a/chaosazure/machine/scripts/fill_disk.sh +++ b/chaosazure/common/scripts/fill_disk.sh @@ -1,5 +1,5 @@ echo "Filling Disk with $size MB of random data for $duration seconds." -nohup dd if=/dev/urandom of=/root/burn bs=1M count=$size iflag=fullblock +nohup dd if=/dev/urandom of=$path bs=1M count=$size iflag=fullblock sleep $duration -rm /root/burn \ No newline at end of file +rm $path \ No newline at end of file diff --git a/chaosazure/machine/scripts/network_latency.sh b/chaosazure/common/scripts/network_latency.sh similarity index 100% rename from chaosazure/machine/scripts/network_latency.sh rename to chaosazure/common/scripts/network_latency.sh diff --git a/chaosazure/machine/actions.py b/chaosazure/machine/actions.py index 1c85d2a..05c8837 100644 --- a/chaosazure/machine/actions.py +++ b/chaosazure/machine/actions.py @@ -1,17 +1,21 @@ # -*- coding: utf-8 -*- -import os -from azure.mgmt.compute import ComputeManagementClient -from chaosazure import auth -from chaosazure.machine.constants import RES_TYPE_VM, OS_LINUX, OS_WINDOWS -from chaosazure.rgraph.resource_graph import fetch_resources + from chaoslib.exceptions import FailedActivity from chaoslib.types import Configuration, Secrets from logzero import logger +from chaosazure import init_compute_management_client +from chaosazure.common import cleanse +from chaosazure.common.compute import command +from chaosazure.machine.constants import RES_TYPE_VM +from chaosazure.common.resources.graph import fetch_resources + __all__ = ["delete_machines", "stop_machines", "restart_machines", "start_machines", "stress_cpu", "fill_disk", "network_latency", "burn_io"] +from chaosazure.vmss.records import Records + def delete_machines(filter: str = None, configuration: Configuration = None, @@ -48,11 +52,15 @@ def delete_machines(filter: str = None, machines = __fetch_machines(filter, configuration, secrets) client = __compute_mgmt_client(secrets, configuration) + machine_records = Records() for m in machines: group = m['resourceGroup'] name = m['name'] logger.debug("Deleting machine: {}".format(name)) client.virtual_machines.delete(group, name) + machine_records.add(cleanse.machine(m)) + + return machine_records.output_as_dict('resources') def stop_machines(filter: str = None, @@ -87,11 +95,16 @@ def stop_machines(filter: str = None, machines = __fetch_machines(filter, configuration, secrets) client = __compute_mgmt_client(secrets, configuration) + + machine_records = Records() for m in machines: group = m['resourceGroup'] name = m['name'] logger.debug("Stopping machine: {}".format(name)) client.virtual_machines.power_off(group, name) + machine_records.add(cleanse.machine(m)) + + return machine_records.output_as_dict('resources') def restart_machines(filter: str = None, @@ -126,11 +139,15 @@ def restart_machines(filter: str = None, machines = __fetch_machines(filter, configuration, secrets) client = __compute_mgmt_client(secrets, configuration) + machine_records = Records() for m in machines: group = m['resourceGroup'] name = m['name'] logger.debug("Restarting machine: {}".format(name)) client.virtual_machines.restart(group, name) + machine_records.add(cleanse.machine(m)) + + return machine_records.output_as_dict('resources') def start_machines(filter: str = None, @@ -167,7 +184,16 @@ def start_machines(filter: str = None, machines = __fetch_machines(filter, configuration, secrets) client = __compute_mgmt_client(secrets, configuration) stopped_machines = __fetch_all_stopped_machines(client, machines) - __start_stopped_machines(client, stopped_machines) + + machine_records = Records() + for machine in stopped_machines: + logger.debug("Starting machine: {}".format(machine['name'])) + client.virtual_machines.start(machine['resourceGroup'], + machine['name']) + + machine_records.add(cleanse.machine(machine)) + + return machine_records.output_as_dict('resources') def stress_cpu(filter: str = None, @@ -176,7 +202,7 @@ def stress_cpu(filter: str = None, configuration: Configuration = None, secrets: Secrets = None): """ - Stress CPU up to 100% at random machines. + Stress CPU up to 100% at virtual machines. Parameters ---------- @@ -208,30 +234,17 @@ def stress_cpu(filter: str = None, Stress two machines at random from the group 'rg' """ - logger.debug( - "Start stress_cpu: configuration='{}', filter='{}'".format( - configuration, filter)) + msg = "Starting stress_cpu:" \ + " configuration='{}', filter='{}', duration='{}', timeout='{}'" \ + .format(configuration, filter, duration, timeout) + logger.debug(msg) machines = __fetch_machines(filter, configuration, secrets) - client = __compute_mgmt_client(secrets, configuration) - for m in machines: - name = m['name'] - group = m['resourceGroup'] - os_type = __get_os_type(m) - if os_type == OS_WINDOWS: - command_id = 'RunPowerShellScript' - script_name = "cpu_stress_test.ps1" - elif os_type == OS_LINUX: - command_id = 'RunShellScript' - script_name = "cpu_stress_test.sh" - else: - raise FailedActivity( - "Cannot run CPU stress test on OS: %s" % os_type) - - with open(os.path.join(os.path.dirname(__file__), - "scripts", script_name)) as file: - script_content = file.read() + machine_records = Records() + for machine in machines: + command_id, script_content = command \ + .prepare(machine, 'cpu_stress_test') parameters = { 'command_id': command_id, @@ -241,21 +254,21 @@ def stress_cpu(filter: str = None, ] } - logger.debug("Stressing CPU of machine: {}".format(name)) - poller = client.virtual_machines.run_command(group, name, parameters) - result = poller.result(duration + timeout) # Blocking till executed - if result: - logger.debug(result.value[0].message) # stdout/stderr - else: - raise FailedActivity( - "stress_cpu operation did not finish on time. " - "You may consider increasing timeout setting.") + logger.debug("Stressing CPU of machine: '{}'".format(machine['name'])) + _timeout = duration + timeout + command.run( + machine['resourceGroup'], machine, _timeout, parameters, + secrets, configuration) + machine_records.add(cleanse.machine(machine)) + + return machine_records.output_as_dict('resources') def fill_disk(filter: str = None, duration: int = 120, timeout: int = 60, size: int = 1000, + path: str = None, configuration: Configuration = None, secrets: Secrets = None): """ @@ -275,6 +288,9 @@ def fill_disk(filter: str = None, recommended to set this value to less than 30s. Defaults to 60 seconds. size : int Size of the file created on the disk. Defaults to 1GB. + path : str, optional + The absolute path to write the fill file into. + Defaults: C:/burn for Windows clients, /root/burn for Linux clients. Examples @@ -294,51 +310,36 @@ def fill_disk(filter: str = None, Fill two machines at random from the group 'rg' """ - logger.debug( - "Start fill_disk: configuration='{}', filter='{}'".format( - configuration, filter)) + msg = "Starting fill_disk: configuration='{}', filter='{}'," \ + " duration='{}', size='{}', path='{}', timeout='{}'" \ + .format(configuration, filter, duration, size, path, timeout) + logger.debug(msg) machines = __fetch_machines(filter, configuration, secrets) - client = __compute_mgmt_client(secrets, configuration) - for m in machines: - name = m['name'] - group = m['resourceGroup'] - os_type = __get_os_type(m) - if os_type == OS_WINDOWS: - command_id = 'RunPowerShellScript' - script_name = "fill_disk.ps1" - elif os_type == OS_LINUX: - command_id = 'RunShellScript' - script_name = "fill_disk.sh" - else: - raise FailedActivity( - "Cannot run disk filling test on OS: %s" % os_type) - - with open(os.path.join(os.path.dirname(__file__), - "scripts", script_name)) as file: - script_content = file.read() + machine_records = Records() + for machine in machines: + command_id, script_content = command.prepare(machine, 'fill_disk') + fill_path = command.prepare_path(machine, path) - logger.debug("Script content: {}".format(script_content)) parameters = { 'command_id': command_id, 'script': [script_content], 'parameters': [ {'name': "duration", 'value': duration}, - {'name': "size", 'value': size} + {'name': "size", 'value': size}, + {'name': "path", 'value': fill_path} ] } - logger.debug("Filling disk of machine: {}".format(name)) - poller = client.virtual_machines.run_command(group, name, parameters) - result = poller.result(duration + timeout) # Blocking till executed - logger.debug("Execution result: {}".format(poller)) - if result: - logger.debug(result.value[0].message) # stdout/stderr - else: - raise FailedActivity( - "fill_disk operation did not finish on time. " - "You may consider increasing timeout setting.") + logger.debug("Filling disk of machine: {}".format(machine['name'])) + _timeout = duration + timeout + command.run( + machine['resourceGroup'], machine, _timeout, parameters, + secrets, configuration) + machine_records.add(cleanse.machine(machine)) + + return machine_records.output_as_dict('resources') def network_latency(filter: str = None, @@ -392,22 +393,11 @@ def network_latency(filter: str = None, configuration, filter)) machines = __fetch_machines(filter, configuration, secrets) - client = __compute_mgmt_client(secrets, configuration) - for m in machines: - name = m['name'] - group = m['resourceGroup'] - os_type = __get_os_type(m) - if os_type == OS_LINUX: - command_id = 'RunShellScript' - script_name = "network_latency.sh" - else: - raise FailedActivity( - "Cannot run network latency test on OS: %s" % os_type) - - with open(os.path.join(os.path.dirname(__file__), - "scripts", script_name)) as file: - script_content = file.read() + machine_records = Records() + for machine in machines: + command_id, script_content = command \ + .prepare(machine, 'network_latency') logger.debug("Script content: {}".format(script_content)) parameters = { @@ -420,16 +410,15 @@ def network_latency(filter: str = None, ] } - logger.debug("Increasing the latency of machine: {}".format(name)) - poller = client.virtual_machines.run_command(group, name, parameters) - result = poller.result(duration + timeout) # Blocking till executed - logger.debug("Execution result: {}".format(poller)) - if result: - logger.debug(result.value[0].message) # stdout/stderr - else: - raise FailedActivity( - "network_latency operation did not finish on time. " - "You may consider increasing timeout setting.") + logger.debug("Increasing the latency of machine: {}" + .format(machine['name'])) + _timeout = duration + timeout + command.run( + machine['resourceGroup'], machine, _timeout, parameters, + secrets, configuration) + machine_records.add(cleanse.machine(machine)) + + return machine_records.output_as_dict('resources') def burn_io(filter: str = None, @@ -472,58 +461,37 @@ def burn_io(filter: str = None, the group 'rg' """ - logger.debug( - "Start burn_io: configuration='{}', filter='{}'".format( - configuration, filter)) + msg = "Starting burn_io: configuration='{}', filter='{}', duration='{}'," \ + " timeout='{}'".format(configuration, filter, duration, timeout) + logger.debug(msg) machines = __fetch_machines(filter, configuration, secrets) - client = __compute_mgmt_client(secrets, configuration) - for m in machines: - name = m['name'] - group = m['resourceGroup'] - os_type = __get_os_type(m) - if os_type == OS_LINUX: - command_id = 'RunShellScript' - script_name = "burn_io.sh" - else: - raise FailedActivity( - "Cannot run burn_io test on OS: %s" % os_type) - - with open(os.path.join(os.path.dirname(__file__), - "scripts", script_name)) as file: - script_content = file.read() + machine_records = Records() + for machine in machines: + command_id, script_content = command.prepare(machine, 'burn_io') - logger.debug("Script content: {}".format(script_content)) parameters = { 'command_id': command_id, 'script': [script_content], 'parameters': [ - {'name': "duration", 'value': duration}, + {'name': "duration", 'value': duration} ] } - logger.debug("Increasing the I/O operations per " - "second of machine: {}".format(name)) - poller = client.virtual_machines.run_command(group, name, parameters) - result = poller.result(duration + timeout) # Blocking till executed - logger.debug("Execution result: {}".format(poller)) - if result: - logger.debug(result.value[0].message) # stdout/stderr - else: - raise FailedActivity( - "burn_io operation did not finish on time. " - "You may consider increasing timeout setting.") + logger.debug("Burning IO of machine: '{}'".format(machine['name'])) + _timeout = duration + timeout + command.run( + machine['resourceGroup'], machine, _timeout, parameters, + secrets, configuration) + machine_records.add(cleanse.machine(machine)) + + return machine_records.output_as_dict('resources') ############################################################################### # Private helper functions ############################################################################### -def __start_stopped_machines(client, stopped_machines): - for machine in stopped_machines: - logger.debug("Starting machine: {}".format(machine['name'])) - client.virtual_machines.start(machine['resourceGroup'], - machine['name']) def __fetch_all_stopped_machines(client, machines) -> []: @@ -552,19 +520,5 @@ def __fetch_machines(filter, configuration, secrets) -> []: return machines -def __get_os_type(machine): - os_type = machine['properties']['storageProfile']['osDisk']['osType'] \ - .lower() - if os_type not in (OS_LINUX, OS_WINDOWS): - raise FailedActivity("Unknown OS Type: %s" % os_type) - - return os_type - - def __compute_mgmt_client(secrets, configuration): - with auth(secrets) as cred: - subscription_id = configuration['azure']['subscription_id'] - client = ComputeManagementClient( - credentials=cred, subscription_id=subscription_id) - - return client + return init_compute_management_client(secrets, configuration) diff --git a/chaosazure/machine/probes.py b/chaosazure/machine/probes.py index 3f15539..2c30033 100644 --- a/chaosazure/machine/probes.py +++ b/chaosazure/machine/probes.py @@ -3,7 +3,7 @@ from logzero import logger from chaosazure.machine.constants import RES_TYPE_VM -from chaosazure.rgraph.resource_graph import fetch_resources +from chaosazure.common.resources.graph import fetch_resources __all__ = ["describe_machines", "count_machines"] diff --git a/chaosazure/rgraph/mapper.py b/chaosazure/rgraph/mapper.py deleted file mode 100644 index 82b604a..0000000 --- a/chaosazure/rgraph/mapper.py +++ /dev/null @@ -1,22 +0,0 @@ -from datetime import datetime - - -def to_dicts(table, version): - results = [] - version_date = datetime.strptime(version, '%Y-%m-%d').date() - - if version_date >= datetime.strptime('2019-04-01', '%Y-%m-%d').date(): - for row in table['rows']: - result = {} - for col_index in range(len(table['columns'])): - result[table['columns'][col_index]['name']] = row[col_index] - results.append(result) - - else: - for row in table.rows: - result = {} - for col_index in range(len(table.columns)): - result[table.columns[col_index].name] = row[col_index] - results.append(result) - - return results diff --git a/chaosazure/rgraph/resource_graph.py b/chaosazure/rgraph/resource_graph.py deleted file mode 100644 index 6e5b500..0000000 --- a/chaosazure/rgraph/resource_graph.py +++ /dev/null @@ -1,37 +0,0 @@ -from azure.mgmt.resourcegraph import ResourceGraphClient -from azure.mgmt.resourcegraph.models import QueryRequest - -from chaosazure import auth -from chaosazure.rgraph.mapper import to_dicts - - -def fetch_resources(query, resource_type, secrets, configuration): - with auth(secrets) as cred: - query = __create_resource_graph_query( - query, resource_type, configuration) - client = ResourceGraphClient(credentials=cred) - resources = client.resources(query) - - results = to_dicts(resources.data, client.api_version) - return results - - -def __create_resource_graph_query(query, resource_type, configuration): - subscription_id = configuration['azure']['subscription_id'] - _query = __create_query(resource_type, query) - query = QueryRequest( - query=_query, - subscriptions=[subscription_id], - additional_properties=True - ) - return query - - -def __create_query(resource_type, query) -> str: - where = "where type =~ '{}'".format(resource_type) - if not query: - result = "{}".format(where) - else: - result = "{}| {}".format(where, query) - - return result diff --git a/chaosazure/vmss/actions.py b/chaosazure/vmss/actions.py index 01b905b..253e87d 100644 --- a/chaosazure/vmss/actions.py +++ b/chaosazure/vmss/actions.py @@ -1,19 +1,22 @@ -import random +from typing import Iterable, Mapping -from azure.mgmt.compute import ComputeManagementClient from chaoslib import Configuration, Secrets -from chaoslib.exceptions import FailedActivity from logzero import logger -from chaosazure import auth -from chaosazure.rgraph.resource_graph import fetch_resources -from chaosazure.vmss.constants import RES_TYPE_VMSS -from chaosazure.vmss.vmss_fetcher import fetch_vmss_instances +from chaosazure import init_compute_management_client +from chaosazure.common import cleanse +from chaosazure.common.compute import command +from chaosazure.vmss.fetcher import fetch_vmss, fetch_instances +from chaosazure.vmss.records import Records -__all__ = ["delete_vmss", "restart_vmss", "stop_vmss", "deallocate_vmss"] +__all__ = [ + "delete_vmss", "restart_vmss", "stop_vmss", "deallocate_vmss", + "burn_io", "fill_disk", "network_latency", "stress_vmss_instance_cpu" +] def delete_vmss(filter: str = None, + instance_criteria: Iterable[Mapping[str, any]] = None, configuration: Configuration = None, secrets: Secrets = None): """ @@ -32,23 +35,34 @@ def delete_vmss(filter: str = None, 'where resourceGroup=="myresourcegroup" and name="myresourcename"' """ logger.debug( - "Start delete_vmss: configuration='{}', filter='{}'".format( + "Starting delete_vmss: configuration='{}', filter='{}'".format( configuration, filter)) - vmss = choose_vmss_at_random(filter, configuration, secrets) - vmss_instance = choose_vmss_instance_at_random( - vmss, configuration, secrets) + vmss = fetch_vmss(filter, configuration, secrets) + vmss_records = Records() + for scale_set in vmss: + instances_records = Records() + instances = fetch_instances(scale_set, instance_criteria, + configuration, secrets) - logger.debug( - "Deleting instance: {}".format(vmss_instance['name'])) - client = init_client(secrets, configuration) - client.virtual_machine_scale_set_vms.delete( - vmss['resourceGroup'], - vmss['name'], - vmss_instance['instanceId']) + for instance in instances: + logger.debug( + "Deleting instance: {}".format(instance['name'])) + client = init_compute_management_client(secrets, configuration) + client.virtual_machine_scale_set_vms.delete( + scale_set['resourceGroup'], + scale_set['name'], + instance['instance_id']) + instances_records.add(cleanse.vmss_instance(instance)) + + scale_set['virtualMachines'] = instances_records.output() + vmss_records.add(cleanse.vmss(scale_set)) + + return vmss_records.output_as_dict('resources') def restart_vmss(filter: str = None, + instance_criteria: Iterable[Mapping[str, any]] = None, configuration: Configuration = None, secrets: Secrets = None): """ @@ -63,26 +77,39 @@ def restart_vmss(filter: str = None, 'where resourceGroup=="myresourcegroup" and name="myresourcename"' """ logger.debug( - "Start restart_vmss: configuration='{}', filter='{}'".format( + "Starting restart_vmss: configuration='{}', filter='{}'".format( configuration, filter)) - vmss = choose_vmss_at_random(filter, configuration, secrets) - vmss_instance = choose_vmss_instance_at_random( - vmss, configuration, secrets) - logger.debug( - "Restarting instance: {}".format(vmss_instance['name'])) - client = init_client(secrets, configuration) - client.virtual_machine_scale_set_vms.restart( - vmss['resourceGroup'], - vmss['name'], - vmss_instance['instanceId']) + vmss = fetch_vmss(filter, configuration, secrets) + vmss_records = Records() + for scale_set in vmss: + instances_records = Records() + instances = fetch_instances(scale_set, instance_criteria, + configuration, secrets) + + for instance in instances: + logger.debug( + "Restarting instance: {}".format(instance['name'])) + client = init_compute_management_client(secrets, configuration) + client.virtual_machine_scale_set_vms.restart( + scale_set['resourceGroup'], + scale_set['name'], + instance['instance_id']) + instances_records.add(cleanse.vmss_instance(instance)) + + scale_set['virtualMachines'] = instances_records.output() + vmss_records.add(cleanse.vmss(scale_set)) + + return vmss_records.output_as_dict('resources') def stop_vmss(filter: str = None, + instance_criteria: Iterable[Mapping[str, any]] = None, configuration: Configuration = None, secrets: Secrets = None): """ - Stop a virtual machine scale set instance at random. + Stops instances from the filtered scale set either at random or by + a defined instance criteria. Parameters ---------- filter : str @@ -91,25 +118,57 @@ def stop_vmss(filter: str = None, potential chaos candidates. Filtering example: 'where resourceGroup=="myresourcegroup" and name="myresourcename"' + instance_criteria : Iterable[Mapping[str, any]] + Allows specification of criteria for selection of a given virtual + machine scale set instance. If the instance_criteria is omitted, + an instance will be chosen at random. All of the criteria within each + item of the Iterable must match, i.e. AND logic is applied. + The first item with all matching criterion will be used to select the + instance. + Criteria example: + [ + {"name": "myVMSSInstance1"}, + { + "name": "myVMSSInstance2", + "instanceId": "2" + } + {"instanceId": "3"}, + ] + If the instances include two items. One with name = myVMSSInstance4 + and instanceId = 2. The other with name = myVMSSInstance2 and + instanceId = 3. The criteria {"instanceId": "3"} will be the first + match since both the name and the instanceId did not match on the + first criteria. """ logger.debug( - "Start stop_vmss: configuration='{}', filter='{}'".format( + "Starting stop_vmss: configuration='{}', filter='{}'".format( configuration, filter)) - vmss = choose_vmss_at_random(filter, configuration, secrets) - vmss_instance = choose_vmss_instance_at_random( - vmss, configuration, secrets) + vmss = fetch_vmss(filter, configuration, secrets) + vmss_records = Records() + for scale_set in vmss: + instances_records = Records() + instances = fetch_instances(scale_set, instance_criteria, + configuration, secrets) - logger.debug( - "Stopping instance: {}".format(vmss_instance['name'])) - client = init_client(secrets, configuration) - client.virtual_machine_scale_set_vms.power_off( - vmss['resourceGroup'], - vmss['name'], - vmss_instance['instanceId']) + for instance in instances: + logger.debug( + "Stopping instance: {}".format(instance['name'])) + client = init_compute_management_client(secrets, configuration) + client.virtual_machine_scale_set_vms.power_off( + scale_set['resourceGroup'], + scale_set['name'], + instance['instance_id']) + instances_records.add(cleanse.vmss_instance(instance)) + + scale_set['virtualMachines'] = instances_records.output() + vmss_records.add(cleanse.vmss(scale_set)) + + return vmss_records.output_as_dict('resources') def deallocate_vmss(filter: str = None, + instance_criteria: Iterable[Mapping[str, any]] = None, configuration: Configuration = None, secrets: Secrets = None): """ @@ -124,53 +183,255 @@ def deallocate_vmss(filter: str = None, 'where resourceGroup=="myresourcegroup" and name="myresourcename"' """ logger.debug( - "Start deallocate_vmss: configuration='{}', filter='{}'".format( + "Starting deallocate_vmss: configuration='{}', filter='{}'".format( configuration, filter)) - vmss = choose_vmss_at_random(filter, configuration, secrets) - vmss_instance = choose_vmss_instance_at_random( - vmss, configuration, secrets) + vmss = fetch_vmss(filter, configuration, secrets) + vmss_records = Records() + for scale_set in vmss: + instances_records = Records() + instances = fetch_instances(scale_set, instance_criteria, + configuration, secrets) + + for instance in instances: + logger.debug( + "Deallocating instance: {}".format(instance['name'])) + client = init_compute_management_client(secrets, configuration) + client.virtual_machine_scale_set_vms.deallocate( + scale_set['resourceGroup'], + scale_set['name'], + instance['instance_id']) + instances_records.add(cleanse.vmss_instance(instance)) + + scale_set['virtualMachines'] = instances_records.output() + vmss_records.add(cleanse.vmss(scale_set)) + + return vmss_records.output_as_dict('resources') + + +def stress_vmss_instance_cpu( + filter: str = None, + duration: int = 120, + timeout: int = 60, + instance_criteria: Iterable[Mapping[str, any]] = None, + configuration: Configuration = None, + secrets: Secrets = None): + logger.warn( + "Deprecated usage of activity 'stress_vmss_instance_cpu'." + " Please use activity 'stress_cpu' in favor since this" + " activity will be removed in a future release.") + return stress_cpu( + filter, duration, timeout, instance_criteria, configuration, secrets) + + +def stress_cpu(filter: str = None, + duration: int = 120, + timeout: int = 60, + instance_criteria: Iterable[Mapping[str, any]] = None, + configuration: Configuration = None, + secrets: Secrets = None): + """ + Stresses the CPU of a random VMSS instances in your selected VMSS. + Similar to the stress_cpu action of the machine.actions module. + + Parameters + ---------- + filter : str, optional + Filter the VMSS. If the filter is omitted all VMSS in + the subscription will be selected as potential chaos candidates. + duration : int, optional + Duration of the stress test (in seconds) that generates high CPU usage. + Defaults to 120 seconds. + timeout : int + Additional wait time (in seconds) for stress operation to be completed. + Getting and sending data from/to Azure may take some time so it's not + recommended to set this value to less than 30s. Defaults to 60 seconds. + """ + logger.debug( + "Starting stress_vmss_instance_cpu:" + " configuration='{}', filter='{}'," + " duration='{}', timeout='{}'".format( + configuration, filter, duration, timeout)) + + vmss_records = Records() + vmss = fetch_vmss(filter, configuration, secrets) + for scale_set in vmss: + instances_records = Records() + instances = fetch_instances(scale_set, instance_criteria, + configuration, secrets) + + for instance in instances: + command_id, script_content = command.prepare(instance, + 'cpu_stress_test') + parameters = { + 'command_id': command_id, + 'script': [script_content], + 'parameters': [ + {'name': "duration", 'value': duration} + ] + } + + logger.debug( + "Stressing CPU of VMSS instance: '{}'".format( + instance['instance_id'])) + _timeout = duration + timeout + command.run( + scale_set['resourceGroup'], instance, _timeout, parameters, + secrets, configuration) + instances_records.add(cleanse.vmss_instance(instance)) + + scale_set['virtualMachines'] = instances_records.output() + vmss_records.add(cleanse.vmss(scale_set)) + + return vmss_records.output_as_dict('resources') + + +def burn_io(filter: str = None, + duration: int = 60, + timeout: int = 60, + instance_criteria: Iterable[Mapping[str, any]] = None, + configuration: Configuration = None, + secrets: Secrets = None): + """ + Increases the Disk I/O operations per second of the VMSS machine. + Similar to the burn_io action of the machine.actions module. + """ logger.debug( - "Deallocating instance: {}".format(vmss_instance['name'])) - client = init_client(secrets, configuration) - client.virtual_machine_scale_set_vms.deallocate( - vmss['resourceGroup'], - vmss['name'], - vmss_instance['instanceId']) - - -############################################################################### -# Private helper functions -############################################################################### -def choose_vmss_instance_at_random(vmss_choice, configuration, secrets): - vmss_instances = fetch_vmss_instances(vmss_choice, configuration, secrets) - if not vmss_instances: - logger.warning("No virtual machine scale set instances found") - raise FailedActivity("No virtual machine scale set instances found") - else: - logger.debug( - "Found virtual machine scale set instances: {}".format( - [x['name'] for x in vmss_instances])) - choice_vmss_instance = random.choice(vmss_instances) - return choice_vmss_instance - - -def choose_vmss_at_random(filter, configuration, secrets): - vmss = fetch_resources(filter, RES_TYPE_VMSS, secrets, configuration) - if not vmss: - logger.warning("No virtual machine scale sets found") - raise FailedActivity("No virtual machine scale sets found") - else: - logger.debug( - "Found virtual machine scale sets: {}".format( - [x['name'] for x in vmss])) - return random.choice(vmss) - - -def init_client(secrets, configuration): - with auth(secrets) as cred: - subscription_id = configuration['azure']['subscription_id'] - client = ComputeManagementClient( - credentials=cred, subscription_id=subscription_id) - - return client + "Starting burn_io: configuration='{}', filter='{}', duration='{}'," + " timeout='{}'".format(configuration, filter, duration, timeout)) + + vmss = fetch_vmss(filter, configuration, secrets) + vmss_records = Records() + for scale_set in vmss: + instances_records = Records() + instances = fetch_instances(scale_set, instance_criteria, + configuration, secrets) + + for instance in instances: + command_id, script_content = command.prepare(instance, 'burn_io') + parameters = { + 'command_id': command_id, + 'script': [script_content], + 'parameters': [ + {'name': "duration", 'value': duration} + ] + } + + logger.debug( + "Burning IO of VMSS instance: '{}'".format(instance['name'])) + _timeout = duration + timeout + command.run( + scale_set['resourceGroup'], instance, _timeout, parameters, + secrets, configuration) + instances_records.add(cleanse.vmss_instance(instance)) + + scale_set['virtualMachines'] = instances_records.output() + vmss_records.add(cleanse.vmss(scale_set)) + + return vmss_records.output_as_dict('resources') + + +def fill_disk(filter: str = None, + duration: int = 120, + timeout: int = 60, + size: int = 1000, + path: str = None, + instance_criteria: Iterable[Mapping[str, any]] = None, + configuration: Configuration = None, + secrets: Secrets = None): + """ + Fill the VMSS machine disk with random data. Similar to + the fill_disk action of the machine.actions module. + """ + logger.debug( + "Starting fill_disk: configuration='{}', filter='{}'," + " duration='{}', size='{}', path='{}', timeout='{}'".format( + configuration, filter, duration, size, path, timeout)) + + vmss = fetch_vmss(filter, configuration, secrets) + vmss_records = Records() + for scale_set in vmss: + instances_records = Records() + instances = fetch_instances(scale_set, instance_criteria, + configuration, secrets) + + for instance in instances: + command_id, script_content = command.prepare(instance, + 'fill_disk') + fill_path = command.prepare_path(instance, path) + + parameters = { + 'command_id': command_id, + 'script': [script_content], + 'parameters': [ + {'name': "duration", 'value': duration}, + {'name': "size", 'value': size}, + {'name': "path", 'value': fill_path} + ] + } + + logger.debug( + "Filling disk of VMSS instance: '{}'".format( + instance['name'])) + _timeout = duration + timeout + command.run( + scale_set['resourceGroup'], instance, _timeout, parameters, + secrets, configuration) + instances_records.add(cleanse.vmss_instance(instance)) + + scale_set['virtualMachines'] = instances_records.output() + vmss_records.add(cleanse.vmss(scale_set)) + + return vmss_records.output_as_dict('resources') + + +def network_latency(filter: str = None, + duration: int = 60, + delay: int = 200, + jitter: int = 50, + timeout: int = 60, + instance_criteria: Iterable[Mapping[str, any]] = None, + configuration: Configuration = None, + secrets: Secrets = None): + """ + Increases the response time of the virtual machine. Similar to + the network_latency action of the machine.actions module. + """ + logger.debug( + "Starting network_latency: configuration='{}', filter='{}'," + " duration='{}', delay='{}', jitter='{}', timeout='{}'".format( + configuration, filter, duration, delay, jitter, timeout)) + + vmss = fetch_vmss(filter, configuration, secrets) + vmss_records = Records() + for scale_set in vmss: + instances_records = Records() + instances = fetch_instances(scale_set, instance_criteria, + configuration, secrets) + + for instance in instances: + command_id, script_content = command.prepare( + instance, 'network_latency') + parameters = { + 'command_id': command_id, + 'script': [script_content], + 'parameters': [ + {'name': "duration", 'value': duration}, + {'name': "delay", 'value': delay}, + {'name': "jitter", 'value': jitter} + ] + } + + logger.debug( + "Increasing the latency of VMSS instance: '{}'".format( + instance['name'])) + _timeout = duration + timeout + command.run( + scale_set['resourceGroup'], instance, _timeout, parameters, + secrets, configuration) + instances_records.add(cleanse.vmss_instance(instance)) + + scale_set['virtualMachines'] = instances_records.output() + vmss_records.add(cleanse.vmss(scale_set)) + + return vmss_records.output_as_dict('resources') diff --git a/chaosazure/vmss/constants.py b/chaosazure/vmss/constants.py index 945c3b5..1297f0e 100644 --- a/chaosazure/vmss/constants.py +++ b/chaosazure/vmss/constants.py @@ -1 +1,2 @@ RES_TYPE_VMSS = "Microsoft.Compute/virtualMachineScaleSets" +RES_TYPE_VMSS_VM = "Microsoft.Compute/virtualMachineScaleSets/virtualMachines" diff --git a/chaosazure/vmss/fetcher.py b/chaosazure/vmss/fetcher.py new file mode 100644 index 0000000..ce60770 --- /dev/null +++ b/chaosazure/vmss/fetcher.py @@ -0,0 +1,120 @@ +import random +from typing import Any, Dict, Iterable, Mapping, List + +from chaoslib import Configuration, Secrets +from chaoslib.exceptions import FailedActivity +from logzero import logger + +from chaosazure import init_compute_management_client +from chaosazure.common.resources.graph import fetch_resources +from chaosazure.vmss.constants import RES_TYPE_VMSS + + +def fetch_instances(scale_set, instance_criteria, + configuration, secrets) -> List[Dict[str, Any]]: + if not instance_criteria: + instance = __random_instance_from( + scale_set, configuration, secrets) + result = [instance] + + else: + result = instances_by_criteria( + scale_set, configuration, instance_criteria, secrets) + + return result + + +def instances_by_criteria( + vmss_choice: dict, + configuration: Configuration = None, + instance_criteria: Iterable[Mapping[str, any]] = None, + secrets: Secrets = None) -> List[Dict[str, Any]]: + result = [] + instances = __fetch_vmss_instances(vmss_choice, configuration, secrets) + + for instance in instances: + if __is_criteria_matched(instance, instance_criteria): + result.append(instance) + + if len(result) == 0: + raise FailedActivity( + "No VMSS instance found for criteria '{}'".format( + instance_criteria)) + + return result + + +def fetch_vmss(filter, configuration, secrets) -> List[dict]: + vmss = fetch_resources(filter, RES_TYPE_VMSS, secrets, configuration) + + if not vmss: + raise FailedActivity("No VMSS found") + + else: + logger.debug( + "Fetched VMSS: {}".format([set['name'] for set in vmss])) + + return vmss + + +############################################################################# +# Private helper functions +############################################################################# +def __fetch_vmss_instances(choice, configuration, secrets) -> List[Dict]: + vmss_instances = [] + client = init_compute_management_client(secrets, configuration) + pages = client.virtual_machine_scale_set_vms.list( + choice['resourceGroup'], choice['name']) + first_page = pages.advance_page() + vmss_instances.extend(list(first_page)) + + while True: + try: + page = pages.advance_page() + vmss_instances.extend(list(page)) + except StopIteration: + break + + results = __parse_vmss_instances_result(vmss_instances, choice) + return results + + +def __random_instance_from( + scale_set, + configuration, + secrets) -> Dict[str, Any]: + instances = __fetch_vmss_instances( + scale_set, configuration, secrets) + if not instances: + raise FailedActivity("No VMSS instances found") + else: + logger.debug( + "Found VMSS instances: {}".format( + [x['name'] for x in instances])) + + return random.choice(instances) + + +def __is_criteria_matched(instance: dict, + criteria: Iterable[Mapping[str, any]] = None): + for criterion in criteria: + mismatch = False + + for key, value in criterion.items(): + if instance[key] != value: + mismatch = True + break + + if not mismatch: + return True + + return False + + +def __parse_vmss_instances_result(instances, vmss: dict) -> List[Dict]: + results = [] + for instance in instances: + instance_as_dict = instance.as_dict() + instance_as_dict['scale_set'] = vmss['name'] + results.append(instance_as_dict) + return results diff --git a/chaosazure/vmss/probes.py b/chaosazure/vmss/probes.py new file mode 100644 index 0000000..12e0319 --- /dev/null +++ b/chaosazure/vmss/probes.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from chaoslib.types import Configuration, Secrets +from logzero import logger + +from chaosazure.common.resources.graph import fetch_resources +from chaosazure.vmss.constants import RES_TYPE_VMSS + +__all__ = ["count_instances"] + + +def count_instances(filter: str = None, + configuration: Configuration = None, + secrets: Secrets = None) -> int: + """ + Return count of VMSS instances. + + Parameters + ---------- + filter : str + Filter the VMSS instance. If the filter is omitted all machines in + the subscription will be selected for the probe. + Filtering example: + 'where resourceGroup=="myresourcegroup" and name="myresourcename"' + """ + logger.debug( + "Starting count_instances: configuration='{}', filter='{}'".format( + configuration, filter)) + + instances = fetch_resources(filter, RES_TYPE_VMSS, secrets, configuration) + return len(instances) diff --git a/chaosazure/vmss/records.py b/chaosazure/vmss/records.py new file mode 100644 index 0000000..8118ab8 --- /dev/null +++ b/chaosazure/vmss/records.py @@ -0,0 +1,21 @@ +from calendar import timegm +from datetime import datetime + + +class Records: + elements = [] + + def __init__(self): + self.elements = [] + + def add(self, element: dict): + element['performed_at'] = timegm(datetime.utcnow().utctimetuple()) + self.elements.append(element) + + def output(self): + return self.elements + + def output_as_dict(self, key: str): + return { + key: self.elements + } diff --git a/chaosazure/vmss/vmss_fetcher.py b/chaosazure/vmss/vmss_fetcher.py deleted file mode 100644 index c772f7a..0000000 --- a/chaosazure/vmss/vmss_fetcher.py +++ /dev/null @@ -1,45 +0,0 @@ -from azure.mgmt.compute import ComputeManagementClient - -from chaosazure import auth - - -def fetch_vmss_instances(choice, configuration, secrets): - vmss_instances = [] - client = init_client(secrets, configuration) - pages = client.virtual_machine_scale_set_vms.list( - choice['resourceGroup'], choice['name']) - first_page = pages.advance_page() - vmss_instances.extend(list(first_page)) - - while True: - try: - page = pages.advance_page() - vmss_instances.extend(list(page)) - except StopIteration: - break - - results = __parse_vmss_instances_result(vmss_instances) - return results - - -############################################################################### -# Private helper functions -############################################################################### -def __parse_vmss_instances_result(instances): - results = [] - for i in instances: - m = { - 'name': i.name, - 'instanceId': i.instance_id - } - results.append(m) - return results - - -def init_client(secrets, configuration): - with auth(secrets) as cred: - subscription_id = configuration['azure']['subscription_id'] - client = ComputeManagementClient( - credentials=cred, subscription_id=subscription_id) - - return client diff --git a/chaosazure/webapp/actions.py b/chaosazure/webapp/actions.py index bdac739..67cc066 100644 --- a/chaosazure/webapp/actions.py +++ b/chaosazure/webapp/actions.py @@ -1,13 +1,14 @@ import random -from azure.mgmt.web import WebSiteManagementClient + from chaoslib import Secrets, Configuration from chaoslib.exceptions import FailedActivity from logzero import logger -from chaosazure import auth -from chaosazure.rgraph.resource_graph import fetch_resources +from chaosazure import init_website_management_client +from chaosazure.common.resources.graph import fetch_resources from chaosazure.webapp.constants import RES_TYPE_WEBAPP + __all__ = ["stop_webapp", "restart_webapp", "start_webapp", "delete_webapp"] @@ -32,7 +33,7 @@ def stop_webapp(filter: str = None, choice = __fetch_webapp_at_random(filter, configuration, secrets) logger.debug("Stopping web app: {}".format(choice['name'])) - client = __init_client(secrets, configuration) + client = init_website_management_client(secrets, configuration) client.web_apps.stop(choice['resourceGroup'], choice['name']) @@ -57,7 +58,7 @@ def restart_webapp(filter: str = None, choice = __fetch_webapp_at_random(filter, configuration, secrets) logger.debug("Restarting web app: {}".format(choice['name'])) - client = __init_client(secrets, configuration) + client = init_website_management_client(secrets, configuration) client.web_apps.restart(choice['resourceGroup'], choice['name']) @@ -82,7 +83,7 @@ def start_webapp(filter: str = None, choice = __fetch_webapp_at_random(filter, configuration, secrets) logger.debug("Starting web app: {}".format(choice['name'])) - client = __init_client(secrets, configuration) + client = init_website_management_client(secrets, configuration) client.web_apps.start(choice['resourceGroup'], choice['name']) @@ -110,7 +111,7 @@ def delete_webapp(filter: str = None, choice = __fetch_webapp_at_random(filter, configuration, secrets) logger.debug("Deleting web app: {}".format(choice['name'])) - client = __init_client(secrets, configuration) + client = init_website_management_client(secrets, configuration) client.web_apps.delete(choice['resourceGroup'], choice['name']) @@ -133,13 +134,3 @@ def __fetch_webapp_at_random(filter, configuration, secrets): webapps = fetch_webapps(filter, configuration, secrets) choice = random.choice(webapps) return choice - - -def __init_client(secrets, configuration): - with auth(secrets) as cred: - subscription_id = configuration['azure']['subscription_id'] - client = WebSiteManagementClient( - credentials=cred, - subscription_id=subscription_id) - - return client diff --git a/chaosazure/webapp/probes.py b/chaosazure/webapp/probes.py index f09ffa4..d8c2c88 100644 --- a/chaosazure/webapp/probes.py +++ b/chaosazure/webapp/probes.py @@ -1,7 +1,7 @@ from chaoslib import Configuration, Secrets from logzero import logger -from chaosazure.rgraph.resource_graph import fetch_resources +from chaosazure.common.resources.graph import fetch_resources from chaosazure.webapp.constants import RES_TYPE_WEBAPP diff --git a/setup.py b/setup.py index 724bda6..a447e84 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ long_desc = strm.read() classifiers = [ - 'Development Status :: 2 - Pre-Alpha', + 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'License :: Freely Distributable', 'Operating System :: OS Independent', @@ -26,6 +26,7 @@ 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: Implementation', 'Programming Language :: Python :: Implementation :: CPython' ] @@ -33,14 +34,7 @@ author_email = 'contact@chaostoolkit.org' url = 'https://chaostoolkit.org' license = 'Apache License Version 2.0' -packages = [ - 'chaosazure', - 'chaosazure.aks', - 'chaosazure.machine', - 'chaosazure.webapp', - 'chaosazure.rgraph', - 'chaosazure.vmss' -] +packages = setuptools.find_packages(exclude=['tests']) needs_pytest = set(['pytest', 'test']).intersection(sys.argv) pytest_runner = ['pytest_runner'] if needs_pytest else [] diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py new file mode 100644 index 0000000..23755e8 --- /dev/null +++ b/tests/auth/test_init.py @@ -0,0 +1,49 @@ +from unittest.mock import patch, MagicMock + +import pytest +from chaoslib.exceptions import InterruptExecution +from msrest.exceptions import AuthenticationError +from chaosazure.auth import auth, authentication +from tests.data import secrets_provider + + +def test_violate_authentication_type(): + secrets = secrets_provider.provide_violating_secrets() + + with pytest.raises(InterruptExecution) as _: + with auth(secrets) as _: + pass + + +@patch.object(authentication.ServicePrincipalAuth, 'create') +def test_create_service_principal_auth(mocked_auth_create): + mocked_auth_create.return_value = MagicMock() + secrets = secrets_provider.provide_secrets_via_service_principal() + + with auth(secrets) as _: + pass + + mocked_auth_create.assert_called_once_with(secrets) + + +@patch.object(authentication.TokenAuth, 'create') +def test_create_token_auth(mocked_auth_create): + mocked_auth_create.return_value = MagicMock() + secrets = secrets_provider.provide_secrets_via_token() + + with auth(secrets) as _: + pass + + mocked_auth_create.assert_called_once_with(secrets) + + +@patch.object(authentication.ServicePrincipalAuth, 'create') +def test_create_service_principal_with_auth_error(mocked_auth_create): + secrets = secrets_provider.provide_secrets_via_service_principal() + inner_exception = MagicMock() + mocked_auth_create.side_effect = \ + AuthenticationError("Auth error", inner_exception) + + with pytest.raises(InterruptExecution) as _: + with auth(secrets) as _: + pass diff --git a/tests/common/fixtures/credentials.json b/tests/common/fixtures/credentials.json new file mode 100644 index 0000000..7f48854 --- /dev/null +++ b/tests/common/fixtures/credentials.json @@ -0,0 +1,12 @@ +{ + "subscriptionId": "AZURE_SUBSCRIPTION_ID", + "tenantId": "AZURE_TENANT_ID", + "clientId": "AZURE_CLIENT_ID", + "clientSecret": "AZURE_CLIENT_SECRET", + "activeDirectoryEndpointUrl": "https://login.microsoftonline.com", + "resourceManagerEndpointUrl": "https://management.azure.com/", + "activeDirectoryGraphResourceId": "https://graph.windows.net/", + "sqlManagementEndpointUrl": "https://management.core.windows.net:8443/", + "galleryEndpointUrl": "https://gallery.azure.com/", + "managementEndpointUrl": "https://management.core.windows.net/" +} \ No newline at end of file diff --git a/tests/common/test_cloud.py b/tests/common/test_cloud.py new file mode 100644 index 0000000..04d55f5 --- /dev/null +++ b/tests/common/test_cloud.py @@ -0,0 +1,67 @@ +import pytest +from chaoslib.exceptions import InterruptExecution +from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD, \ + AZURE_US_GOV_CLOUD, AZURE_GERMAN_CLOUD, AZURE_CHINA_CLOUD + +from chaosazure.common import cloud +from tests.data import secrets_provider + +CONFIG = { + "azure": { + "subscription_id": "***REMOVED***" + } +} + +FLAT_CONFIG_FROM_EN = { + "azure_subscription_id": { + "type": "env", + "key": "SUBSCRIPTION_ID" + } +} + + +def test_resolve_cloud_env_by_name_default(): + data = secrets_provider.provide_secrets_via_service_principal() + result = cloud.get_or_raise(data.get("azure_cloud")) \ + .endpoints.resource_manager + + assert result == AZURE_PUBLIC_CLOUD.endpoints.resource_manager + + +def test_get_env_by_name_china(): + data = secrets_provider.provide_secrets_china() + result = cloud.get_or_raise(data.get("azure_cloud")) \ + .endpoints.resource_manager + + assert result == AZURE_CHINA_CLOUD.endpoints.resource_manager + + +def test_get_env_by_name_public(): + data = secrets_provider.provide_secrets_public() + result = cloud.get_or_raise(data.get("azure_cloud")) \ + .endpoints.resource_manager + + assert result == AZURE_PUBLIC_CLOUD.endpoints.resource_manager + + +def test_get_env_by_name_germany(): + data = secrets_provider.provide_secrets_germany() + result = cloud.get_or_raise(data.get("azure_cloud")) \ + .endpoints.resource_manager + + assert result == AZURE_GERMAN_CLOUD.endpoints.resource_manager + + +def test_get_env_by_name_usgov(): + data = secrets_provider.provide_secrets_us_gov() + result = cloud.get_or_raise(data.get("azure_cloud")) \ + .endpoints.resource_manager + + assert result == AZURE_US_GOV_CLOUD.endpoints.resource_manager + + +def test_get_env_by_name_bad(): + with pytest.raises(InterruptExecution): + data = secrets_provider.provide_secrets_invalid_cloud() + cloud.get_or_raise(data.get("azure_cloud")) \ + .endpoints.resource_manager diff --git a/tests/common/test_config.py b/tests/common/test_config.py new file mode 100644 index 0000000..5046fe6 --- /dev/null +++ b/tests/common/test_config.py @@ -0,0 +1,100 @@ +import os + +from chaosazure.common import config + +settings_dir = os.path.join(os.path.dirname(__file__), "fixtures") + + +def test_load_secrets_from_experiment_dict(): + # arrange + experiment_secrets = { + "client_id": "AZURE_CLIENT_ID", + "client_secret": "AZURE_CLIENT_SECRET", + "tenant_id": "AZURE_TENANT_ID", + } + + # act + secrets = config.load_secrets(experiment_secrets) + + # assert + assert secrets.get('client_id') == "AZURE_CLIENT_ID" + assert secrets.get('client_secret') == "AZURE_CLIENT_SECRET" + assert secrets.get('tenant_id') == "AZURE_TENANT_ID" + assert secrets.get('cloud')\ + .endpoints.resource_manager == "https://management.azure.com/" + + +def test_load_token_from_experiment_dict(): + # arrange + experiment_secrets = { + "access_token": "ACCESS_TOKEN" + } + + # act + secrets = config.load_secrets(experiment_secrets) + + # assert + assert secrets.get('access_token') == "ACCESS_TOKEN" + assert secrets.get('cloud')\ + .endpoints.resource_manager == "https://management.azure.com/" + + +def test_load_secrets_from_credential_file(monkeypatch): + # arrange + experiment_secrets = None + monkeypatch.setenv( + "AZURE_AUTH_LOCATION", + os.path.join(settings_dir, 'credentials.json')) + + # act + secrets = config.load_secrets(experiment_secrets) + + # assert + assert secrets.get('client_id') == "AZURE_CLIENT_ID" + assert secrets.get('client_secret') == "AZURE_CLIENT_SECRET" + assert secrets.get('tenant_id') == "AZURE_TENANT_ID" + assert secrets.get('cloud')\ + .endpoints.resource_manager == "https://management.azure.com/" + + +def test_load_subscription_from_experiment_dict(): + # arrange + experiment_configuration = { + "azure_subscription_id": "AZURE_SUBSCRIPTION_ID", + "some_other_settings": "OTHER_SETTING" + } + + # act + configuration = config.load_configuration(experiment_configuration) + + # assert + assert configuration.get('subscription_id') == "AZURE_SUBSCRIPTION_ID" + + +def test_load_legacy_subscription_from_experiment_dict(): + # arrange + experiment_configuration = { + "azure": { + "subscription_id": "AZURE_SUBSCRIPTION_ID" + } + } + + # act + configuration = config.load_configuration(experiment_configuration) + + # assert + assert configuration.get('subscription_id') == "AZURE_SUBSCRIPTION_ID" + + +def test_load_subscription_from_credential_file(monkeypatch): + # arrange + experiment_configuration = None + monkeypatch.setenv( + "AZURE_AUTH_LOCATION", + os.path.join(settings_dir, 'credentials.json')) + + # act + configuration = config.load_configuration(experiment_configuration) + + # assert + assert configuration.get('subscription_id') == "AZURE_SUBSCRIPTION_ID" diff --git a/tests/data/config_provider.py b/tests/data/config_provider.py new file mode 100644 index 0000000..648ed22 --- /dev/null +++ b/tests/data/config_provider.py @@ -0,0 +1,13 @@ +def provide_default_config(): + return { + "azure_subscription_id": "***" + } + + +def provide_config_from_env(): + return { + "azure_subscription_id": { + "type": "env", + "key": "SUBSCRIPTION_ID" + } + } diff --git a/tests/data/machine_provider.py b/tests/data/machine_provider.py new file mode 100644 index 0000000..9e13377 --- /dev/null +++ b/tests/data/machine_provider.py @@ -0,0 +1,15 @@ +from chaosazure.machine.constants import RES_TYPE_VM + + +def provide_machine(os_type: str = 'Linux'): + return { + 'name': 'chaos-machine', + 'resourceGroup': 'rg', + 'type': RES_TYPE_VM, + 'properties': { + 'storageProfile': { + 'osDisk': { + 'osType': os_type + } + }} + } diff --git a/tests/data/secrets_provider.py b/tests/data/secrets_provider.py new file mode 100644 index 0000000..f59eb06 --- /dev/null +++ b/tests/data/secrets_provider.py @@ -0,0 +1,60 @@ +from chaosazure.common import cloud + + +def provide_secrets_via_service_principal(): + return { + "client_id": "***", + "client_secret": "***", + "tenant_id": "***" + } + + +def provide_secrets_china(): + result = provide_secrets_via_service_principal() + result["azure_cloud"] = cloud.AZURE_CHINA_CLOUD + return result + + +def provide_secrets_germany(): + result = provide_secrets_via_service_principal() + result["azure_cloud"] = cloud.AZURE_GERMAN_CLOUD + return result + + +def provide_secrets_germany_small_letters(): + result = provide_secrets_via_service_principal() + result["azure_cloud"] = "azure_german_cloud" + return result + + +def provide_secrets_us_gov(): + result = provide_secrets_via_service_principal() + result["azure_cloud"] = cloud.AZURE_US_GOV_CLOUD + return result + + +def provide_secrets_public(): + result = provide_secrets_via_service_principal() + result["azure_cloud"] = cloud.AZURE_PUBLIC_CLOUD + return result + + +def provide_secrets_via_token(): + return { + "access_token": "***", + "client_id": "***", + "tenant_id": "***", + } + + +def provide_secrets_invalid_cloud(): + result = provide_secrets_via_service_principal() + result["azure_cloud"] = "this_cloud_does_not_exist" + return result + + +def provide_violating_secrets(): + return { + "client_id": "***", + "tenant_id": "***", + } \ No newline at end of file diff --git a/tests/data/vmss_provider.py b/tests/data/vmss_provider.py new file mode 100644 index 0000000..242eaf6 --- /dev/null +++ b/tests/data/vmss_provider.py @@ -0,0 +1,17 @@ +from chaosazure.vmss.constants import RES_TYPE_VMSS_VM, RES_TYPE_VMSS + + +def provide_instance(): + return { + 'name': 'chaos-pool_0', + 'instance_id': '0', + 'type': RES_TYPE_VMSS_VM, + } + + +def provide_scale_set(): + return { + 'name': 'chaos-pool', + 'resourceGroup': 'rg', + 'type': RES_TYPE_VMSS, + } diff --git a/tests/machine/test_machine_actions.py b/tests/machine/test_machine_actions.py index 36dbdae..eb98ebb 100644 --- a/tests/machine/test_machine_actions.py +++ b/tests/machine/test_machine_actions.py @@ -1,14 +1,16 @@ -from zipfile import sizeCentralDir +from unittest.mock import MagicMock, patch, mock_open import pytest from azure.mgmt.compute.v2018_10_01.models import InstanceViewStatus, \ RunCommandResult from chaoslib.exceptions import FailedActivity -from unittest.mock import MagicMock, patch, mock_open +import chaosazure from chaosazure.machine.actions import restart_machines, stop_machines, \ delete_machines, start_machines, stress_cpu, fill_disk, network_latency, \ burn_io +from chaosazure.machine.constants import RES_TYPE_VM +from tests.data import machine_provider, config_provider, secrets_provider CONFIG = { "azure": { @@ -22,6 +24,13 @@ "tenant_id": "***REMOVED***" } +SECRETS_CHINA = { + "client_id": "***REMOVED***", + "client_secret": "***REMOVED***", + "tenant_id": "***REMOVED***", + "azure_cloud": "AZURE_CHINA_CLOUD" +} + MACHINE_ALPHA = { 'name': 'VirtualMachineAlpha', 'resourceGroup': 'group'} @@ -36,19 +45,6 @@ def __eq__(self, other): return self in other -def __get_resource(os_type='Windows'): - return { - 'name': 'chaos-machine', - 'resourceGroup': 'rg', - 'properties': { - 'storageProfile': { - 'osDisk': { - 'osType': os_type - } - }} - } - - @patch('chaosazure.machine.actions.__fetch_machines', autospec=True) @patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) def test_delete_one_machine(init, fetch): @@ -65,6 +61,22 @@ def test_delete_one_machine(init, fetch): assert client.virtual_machines.delete.call_count == 1 +@patch('chaosazure.machine.actions.__fetch_machines', autospec=True) +@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) +def test_delete_one_machine_china(init, fetch): + client = MagicMock() + init.return_value = client + + machines = [MACHINE_ALPHA] + fetch.return_value = machines + + f = "where resourceGroup=='myresourcegroup' | sample 1" + delete_machines(f, CONFIG, SECRETS_CHINA) + + fetch.assert_called_with(f, CONFIG, SECRETS_CHINA) + assert client.virtual_machines.delete.call_count == 1 + + @patch('chaosazure.machine.actions.__fetch_machines', autospec=True) @patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) def test_delete_two_machines(init, fetch): @@ -194,391 +206,150 @@ def test_start_machine(init, fetch_stopped, fetch_all): init.return_value = client -@patch("builtins.open", new_callable=mock_open, read_data="script") @patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_stress_cpu_on_lnx(init, fetch, open): +@patch.object(chaosazure.common.compute.command, 'prepare', autospec=True) +@patch.object(chaosazure.common.compute.command, 'run', autospec=True) +def test_stress_cpu(mocked_command_run, mocked_command_prepare, fetch): # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Linux') - resource_list = [resource] - fetch.return_value = resource_list - # run command mocks - poller = MagicMock() - client.virtual_machines.run_command.return_value = poller - result = MagicMock(spec=RunCommandResult) - poller.result.return_value = result - result.value = [InstanceViewStatus()] - - # act - stress_cpu(filter="where name=='some_linux_machine'", duration=60, - configuration=CONFIG, secrets=SECRETS) - - # assert - fetch.assert_called_with( - "where name=='some_linux_machine'", - "Microsoft.Compute/virtualMachines", SECRETS, CONFIG) - open.assert_called_with(AnyStringWith("cpu_stress_test.sh")) - client.virtual_machines.run_command.assert_called_with( - resource['resourceGroup'], - resource['name'], - { - 'command_id': 'RunShellScript', - 'script': ['script'], - 'parameters': [{ - 'name': 'duration', - 'value': 60 - }] - }) + mocked_command_prepare.return_value = 'RunShellScript', 'cpu_stress_test.sh' + machine = machine_provider.provide_machine() + machines = [machine] + fetch.return_value = machines -@patch("builtins.open", new_callable=mock_open, read_data="script") -@patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_stress_cpu_on_win(init, fetch, open): - # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Windows') - resource_list = [resource] - fetch.return_value = resource_list - # run command mocks - poller = MagicMock() - client.virtual_machines.run_command.return_value = poller - result = MagicMock(spec=RunCommandResult) - poller.result.return_value = result - result.value = [InstanceViewStatus()] + config = config_provider.provide_default_config() + secrets = secrets_provider.provide_secrets_via_service_principal() # act - stress_cpu("where name=='some_windows_machine'", 60) + stress_cpu(filter="where name=='some_linux_machine'", + duration=60, timeout=60, configuration=config, secrets=secrets) # assert fetch.assert_called_with( - "where name=='some_windows_machine'", - "Microsoft.Compute/virtualMachines", None, None) - open.assert_called_with(AnyStringWith("cpu_stress_test.ps1")) - client.virtual_machines.run_command.assert_called_with( - resource['resourceGroup'], - resource['name'], - { - 'command_id': 'RunPowerShellScript', - 'script': ['script'], - 'parameters': [{ - 'name': 'duration', - 'value': 60 - }] - }) - - -@patch("builtins.open", new_callable=mock_open, read_data="script") -@patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_stress_cpu_invalid_resource(init, fetch, open): - # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Invalid') - resource_list = [resource] - fetch.return_value = resource_list - - # act - with pytest.raises(Exception) as ex: - stress_cpu("where name=='some_machine'", 60) - assert str(ex.value) == 'Unknown OS Type: invalid' - - -@patch("builtins.open", new_callable=mock_open, read_data="script") -@patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_stress_cpu_timeout(init, fetch, open): - # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Windows') - resource_list = [resource] - fetch.return_value = resource_list - # run command mocks - poller = MagicMock() - client.virtual_machines.run_command.return_value = poller - poller.result.return_value = None - - # act & assert - with pytest.raises(FailedActivity, match=r'stress_cpu operation did not ' - r'finish on time'): - stress_cpu("where name=='some_windows_machine'", 60) - - -# def test_fill_disk(): fill_disk(filter="where resourceGroup=~'chaosworld' -# and name=='testWindows'", -# configuration=CONFIG, secrets=SECRETS) - - -@patch("builtins.open", new_callable=mock_open, read_data="script") -@patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_fill_disk_on_lnx(init, fetch, open): - # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Linux') - resource_list = [resource] - fetch.return_value = resource_list - # run command mocks - poller = MagicMock() - client.virtual_machines.run_command.return_value = poller - result = MagicMock(spec=RunCommandResult) - poller.result.return_value = result - result.value = [InstanceViewStatus()] - - # act - fill_disk(filter="where name=='some_linux_machine'", duration=60, size=100, - configuration=CONFIG, secrets=SECRETS) - - # assert - fetch.assert_called_with( - "where name=='some_linux_machine'", - "Microsoft.Compute/virtualMachines", SECRETS, CONFIG) - open.assert_called_with(AnyStringWith("fill_disk.sh")) - client.virtual_machines.run_command.assert_called_with( - resource['resourceGroup'], - resource['name'], + "where name=='some_linux_machine'", RES_TYPE_VM, secrets, config) + mocked_command_prepare.assert_called_with(machine, 'cpu_stress_test') + mocked_command_run.assert_called_with( + machine['resourceGroup'], machine, 120, { 'command_id': 'RunShellScript', - 'script': ['script'], + 'script': ['cpu_stress_test.sh'], 'parameters': [ - {'name': 'duration', 'value': 60}, - {'name': 'size', 'value': 100} + {'name': "duration", 'value': 60}, ] - }) + }, + secrets, config + ) -@patch("builtins.open", new_callable=mock_open, read_data="script") @patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_fill_disk_on_win(init, fetch, open): +@patch.object(chaosazure.common.compute.command, 'prepare', autospec=True) +@patch.object(chaosazure.common.compute.command, 'prepare_path', autospec=True) +@patch.object(chaosazure.common.compute.command, 'run', autospec=True) +def test_fill_disk(mocked_command_run, mocked_command_prepare_path, + mocked_command_prepare, fetch): # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Windows') - resource_list = [resource] - fetch.return_value = resource_list - # run command mocks - poller = MagicMock() - client.virtual_machines.run_command.return_value = poller - result = MagicMock(spec=RunCommandResult) - poller.result.return_value = result - result.value = [InstanceViewStatus()] + mocked_command_prepare.return_value = 'RunShellScript', 'fill_disk.sh' + mocked_command_prepare_path.return_value = '/root/burn/hard' + + machine = machine_provider.provide_machine() + machines = [machine] + fetch.return_value = machines + + config = config_provider.provide_default_config() + secrets = secrets_provider.provide_secrets_via_service_principal() # act - fill_disk("where name=='some_windows_machine'", 60, size=100) + fill_disk(filter="where name=='some_linux_machine'", + duration=60, timeout=60, size=1000, + path='/root/burn/hard', configuration=config, secrets=secrets) # assert fetch.assert_called_with( - "where name=='some_windows_machine'", - "Microsoft.Compute/virtualMachines", None, None) - open.assert_called_with(AnyStringWith("fill_disk.ps1")) - client.virtual_machines.run_command.assert_called_with( - resource['resourceGroup'], - resource['name'], + "where name=='some_linux_machine'", RES_TYPE_VM, secrets, config) + mocked_command_prepare.assert_called_with(machine, 'fill_disk') + mocked_command_run.assert_called_with( + machine['resourceGroup'], machine, 120, { - 'command_id': 'RunPowerShellScript', - 'script': ['script'], + 'command_id': 'RunShellScript', + 'script': ['fill_disk.sh'], 'parameters': [ - {'name': 'duration', 'value': 60}, - {'name': 'size', 'value': 100} + {'name': "duration", 'value': 60}, + {'name': "size", 'value': 1000}, + {'name': "path", 'value': '/root/burn/hard'} ] - }) + }, + secrets, config + ) -@patch("builtins.open", new_callable=mock_open, read_data="script") @patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_fill_disk_invalid_resource(init, fetch, open): +@patch.object(chaosazure.common.compute.command, 'prepare', autospec=True) +@patch.object(chaosazure.common.compute.command, 'run', autospec=True) +def test_network_latency(mocked_command_run, mocked_command_prepare, fetch): # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Invalid') - resource_list = [resource] - fetch.return_value = resource_list - - # act - with pytest.raises(Exception) as ex: - fill_disk("where name=='some_machine'", 60, 100) - assert str(ex.value) == 'Unknown OS Type: invalid' - - -@patch("builtins.open", new_callable=mock_open, read_data="script") -@patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_fill_disk_timeout(init, fetch, open): - # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Windows') - resource_list = [resource] - fetch.return_value = resource_list - # run command mocks - poller = MagicMock() - client.virtual_machines.run_command.return_value = poller - poller.result.return_value = None - - # act & assert - with pytest.raises(FailedActivity, match=r'fill_disk operation did not ' - r'finish on time'): - fill_disk("where name=='some_windows_machine'", 60, 100) + mocked_command_prepare.return_value = 'RunShellScript', 'network_latency.sh' + machine = machine_provider.provide_machine() + machines = [machine] + fetch.return_value = machines -@patch("builtins.open", new_callable=mock_open, read_data="script") -@patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_network_latency_on_lnx(init, fetch, open): - # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Linux') - resource_list = [resource] - fetch.return_value = resource_list - # run command mocks - poller = MagicMock() - client.virtual_machines.run_command.return_value = poller - result = MagicMock(spec=RunCommandResult) - poller.result.return_value = result - result.value = [InstanceViewStatus()] + config = config_provider.provide_default_config() + secrets = secrets_provider.provide_secrets_via_service_principal() # act - network_latency(filter="where name=='some_linux_machine'", duration=60, - delay=200, jitter=50, configuration=CONFIG, - secrets=SECRETS) + network_latency(filter="where name=='some_linux_machine'", + duration=60, delay=200, jitter=50, timeout=60, + configuration=config, secrets=secrets) # assert fetch.assert_called_with( - "where name=='some_linux_machine'", - "Microsoft.Compute/virtualMachines", SECRETS, CONFIG) - open.assert_called_with(AnyStringWith("network_latency.sh")) - client.virtual_machines.run_command.assert_called_with( - resource['resourceGroup'], - resource['name'], + "where name=='some_linux_machine'", RES_TYPE_VM, secrets, config) + mocked_command_prepare.assert_called_with(machine, 'network_latency') + mocked_command_run.assert_called_with( + machine['resourceGroup'], machine, 120, { 'command_id': 'RunShellScript', - 'script': ['script'], + 'script': ['network_latency.sh'], 'parameters': [ - {'name': 'duration', 'value': 60}, - {'name': 'delay', 'value': 200}, - {'name': 'jitter', 'value': 50} + {'name': "duration", 'value': 60}, + {'name': "delay", 'value': 200}, + {'name': "jitter", 'value': 50} ] - }) + }, + secrets, config + ) -@patch("builtins.open", new_callable=mock_open, read_data="script") @patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_network_latency_invalid_resource(init, fetch, open): +@patch.object(chaosazure.common.compute.command, 'prepare', autospec=True) +@patch.object(chaosazure.common.compute.command, 'run', autospec=True) +def test_burn_io(mocked_command_run, mocked_command_prepare, fetch): # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Invalid') - resource_list = [resource] - fetch.return_value = resource_list - - # act - with pytest.raises(Exception) as ex: - fill_disk("where name=='some_machine'", 60, 200, 50) - assert str(ex.value) == 'Unknown OS Type: invalid' - - -@patch("builtins.open", new_callable=mock_open, read_data="script") -@patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_network_latency_timeout(init, fetch, open): - # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Linux') - resource_list = [resource] - fetch.return_value = resource_list - # run command mocks - poller = MagicMock() - client.virtual_machines.run_command.return_value = poller - poller.result.return_value = None - - # act & assert - with pytest.raises(FailedActivity, match=r'network_latency operation ' - r'did not finish on time'): - network_latency("where name=='some_linux_machine'", 60, 200, 50) + mocked_command_prepare.return_value = 'RunShellScript', 'burn_io.sh' + machine = machine_provider.provide_machine() + machines = [machine] + fetch.return_value = machines -@patch("builtins.open", new_callable=mock_open, read_data="script") -@patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_burn_io_on_lnx(init, fetch, open): - # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Linux') - resource_list = [resource] - fetch.return_value = resource_list - # run command mocks - poller = MagicMock() - client.virtual_machines.run_command.return_value = poller - result = MagicMock(spec=RunCommandResult) - poller.result.return_value = result - result.value = [InstanceViewStatus()] + config = config_provider.provide_default_config() + secrets = secrets_provider.provide_secrets_via_service_principal() # act - burn_io(filter="where name=='some_linux_machine'", duration=60, - configuration=CONFIG, secrets=SECRETS) + burn_io(filter="where name=='some_linux_machine'", + duration=60, configuration=config, secrets=secrets) # assert fetch.assert_called_with( - "where name=='some_linux_machine'", - "Microsoft.Compute/virtualMachines", SECRETS, CONFIG) - open.assert_called_with(AnyStringWith("burn_io.sh")) - client.virtual_machines.run_command.assert_called_with( - resource['resourceGroup'], - resource['name'], + "where name=='some_linux_machine'", RES_TYPE_VM, secrets, config) + mocked_command_prepare.assert_called_with(machine, 'burn_io') + mocked_command_run.assert_called_with( + machine['resourceGroup'], machine, 120, { 'command_id': 'RunShellScript', - 'script': ['script'], + 'script': ['burn_io.sh'], 'parameters': [ {'name': 'duration', 'value': 60} ] - }) - - -@patch("builtins.open", new_callable=mock_open, read_data="script") -@patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_burn_io_invalid_resource(init, fetch, open): - # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Invalid') - resource_list = [resource] - fetch.return_value = resource_list - - # act - with pytest.raises(Exception) as ex: - burn_io("where name=='some_machine'", 60) - assert str(ex.value) == 'Unknown OS Type: invalid' - - -@patch("builtins.open", new_callable=mock_open, read_data="script") -@patch('chaosazure.machine.actions.fetch_resources', autospec=True) -@patch('chaosazure.machine.actions.__compute_mgmt_client', autospec=True) -def test_burn_io_timeout(init, fetch, open): - # arrange mocks - client = MagicMock() - init.return_value = client - resource = __get_resource(os_type='Linux') - resource_list = [resource] - fetch.return_value = resource_list - # run command mocks - poller = MagicMock() - client.virtual_machines.run_command.return_value = poller - poller.result.return_value = None - - # act & assert - with pytest.raises(FailedActivity, match=r'burn_io operation did not ' - r'finish on time'): - burn_io("where name=='some_linux_machine'", 60) + }, + secrets, config + ) diff --git a/tests/vmss/test_vmss_actions.py b/tests/vmss/test_vmss_actions.py index 5734ee6..77a633b 100644 --- a/tests/vmss/test_vmss_actions.py +++ b/tests/vmss/test_vmss_actions.py @@ -1,180 +1,76 @@ from unittest.mock import patch -import pytest -from chaoslib.exceptions import FailedActivity - +import chaosazure from chaosazure.vmss.actions import delete_vmss, restart_vmss, stop_vmss, \ - deallocate_vmss - -resource_vmss = { - 'name': 'chaos-vmss', - 'resourceGroup': 'rg'} - -resource_vmss_instance = { - 'name': 'chaos-vmss-instance', - 'instanceId': '1'} + deallocate_vmss, stress_vmss_instance_cpu, network_latency, burn_io, fill_disk +from tests.data import config_provider, secrets_provider, vmss_provider -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -@patch('chaosazure.vmss.actions.fetch_vmss_instances', autospec=True) -@patch('chaosazure.vmss.actions.init_client', autospec=True) +@patch('chaosazure.vmss.actions.fetch_vmss', autospec=True) +@patch('chaosazure.vmss.actions.fetch_instances', autospec=True) +@patch('chaosazure.vmss.actions.init_compute_management_client', autospec=True) def test_deallocate_vmss(client, fetch_instances, fetch_vmss): - vmss_list = [resource_vmss] - fetch_vmss.return_value = vmss_list + scale_set = vmss_provider.provide_scale_set() + scale_sets = [scale_set] + fetch_vmss.return_value = scale_sets - instances_list = [resource_vmss_instance] - fetch_instances.return_value = instances_list + instance = vmss_provider.provide_instance() + instances = [instance] + fetch_instances.return_value = instances client.return_value = MockComputeManagementClient() deallocate_vmss(None, None, None) -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -@patch('chaosazure.vmss.actions.fetch_vmss_instances', autospec=True) -def test_deallocate_vmss_having_no_vmss_instances(fetch_instances, fetch_vmss): - with pytest.raises(FailedActivity) as x: - vmss_list = [resource_vmss] - fetch_vmss.return_value = vmss_list - - instances_list = [] - fetch_instances.return_value = instances_list - - deallocate_vmss(None, None, None) - - assert "No virtual machine scale set instances found" in str(x.value) - - -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -def test_deallocate_vmss_having_no_vmss(fetch): - with pytest.raises(FailedActivity) as x: - resource_list = [] - fetch.return_value = resource_list - deallocate_vmss(None, None, None) - - assert "No virtual machine scale sets found" in str(x.value) - - -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -@patch('chaosazure.vmss.actions.fetch_vmss_instances', autospec=True) -@patch('chaosazure.vmss.actions.init_client', autospec=True) +@patch('chaosazure.vmss.actions.fetch_vmss', autospec=True) +@patch('chaosazure.vmss.actions.fetch_instances', autospec=True) +@patch('chaosazure.vmss.actions.init_compute_management_client', autospec=True) def test_stop_vmss(client, fetch_instances, fetch_vmss): - vmss_list = [resource_vmss] - fetch_vmss.return_value = vmss_list - - instances_list = [resource_vmss_instance] - fetch_instances.return_value = instances_list + scale_set = vmss_provider.provide_scale_set() + scale_sets = [scale_set] + instance = vmss_provider.provide_instance() + instances = [instance] + fetch_vmss.return_value = scale_sets + fetch_instances.return_value = instances client.return_value = MockComputeManagementClient() - stop_vmss(None, None, None) - - -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -@patch('chaosazure.vmss.actions.fetch_vmss_instances', autospec=True) -def test_stop_vmss_having_no_vmss_instances(fetch_instances, fetch_vmss): - with pytest.raises(FailedActivity) as x: - vmss_list = [resource_vmss] - fetch_vmss.return_value = vmss_list - - instances_list = [] - fetch_instances.return_value = instances_list - - stop_vmss(None, None, None) - - assert "No virtual machine scale set instances found" in str(x.value) - - -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -def test_stop_vmss_having_no_vmss(fetch): - with pytest.raises(FailedActivity) as x: - resource_list = [] - fetch.return_value = resource_list - stop_vmss(None, None, None) - - assert "No virtual machine scale sets found" in str(x.value) + stop_vmss(None, None, None, None) -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -@patch('chaosazure.vmss.actions.fetch_vmss_instances', autospec=True) -@patch('chaosazure.vmss.actions.init_client', autospec=True) +@patch('chaosazure.vmss.actions.fetch_vmss', autospec=True) +@patch('chaosazure.vmss.actions.fetch_instances', autospec=True) +@patch('chaosazure.vmss.actions.init_compute_management_client', autospec=True) def test_restart_vmss(client, fetch_instances, fetch_vmss): - vmss_list = [resource_vmss] - fetch_vmss.return_value = vmss_list - - instances_list = [resource_vmss_instance] - fetch_instances.return_value = instances_list + scale_set = vmss_provider.provide_scale_set() + scale_sets = [scale_set] + instance = vmss_provider.provide_instance() + instances = [instance] + fetch_vmss.return_value = scale_sets + fetch_instances.return_value = instances client.return_value = MockComputeManagementClient() restart_vmss(None, None, None) -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -@patch('chaosazure.vmss.actions.fetch_vmss_instances', autospec=True) -def test_restart_vmss_having_no_vmss_instances(fetch_instances, fetch_vmss): - with pytest.raises(FailedActivity) as x: - vmss_list = [resource_vmss] - fetch_vmss.return_value = vmss_list - - instances_list = [] - fetch_instances.return_value = instances_list - - restart_vmss(None, None, None) - - assert "No virtual machine scale set instances found" in str(x.value) - - -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -def test_restart_vmss_having_no_vmss(fetch): - with pytest.raises(FailedActivity) as x: - resource_list = [] - fetch.return_value = resource_list - restart_vmss(None, None, None) - - assert "No virtual machine scale sets found" in str(x.value) - - -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -@patch('chaosazure.vmss.actions.fetch_vmss_instances', autospec=True) -@patch('chaosazure.vmss.actions.init_client', autospec=True) +@patch('chaosazure.vmss.actions.fetch_vmss', autospec=True) +@patch('chaosazure.vmss.actions.fetch_instances', autospec=True) +@patch('chaosazure.vmss.actions.init_compute_management_client', autospec=True) def test_delete_vmss(client, fetch_instances, fetch_vmss): - vmss_list = [resource_vmss] - fetch_vmss.return_value = vmss_list - - instances_list = [resource_vmss_instance] - fetch_instances.return_value = instances_list + scale_set = vmss_provider.provide_scale_set() + scale_sets = [scale_set] + instance = vmss_provider.provide_instance() + instances = [instance] + fetch_vmss.return_value = scale_sets + fetch_instances.return_value = instances client.return_value = MockComputeManagementClient() delete_vmss(None, None, None) -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -@patch('chaosazure.vmss.actions.fetch_vmss_instances', autospec=True) -def test_delete_vmss_having_no_vmss_instances(fetch_instances, fetch_vmss): - with pytest.raises(FailedActivity) as x: - vmss_list = [resource_vmss] - fetch_vmss.return_value = vmss_list - - instances_list = [] - fetch_instances.return_value = instances_list - - delete_vmss(None, None, None) - - assert "No virtual machine scale set instances found" in str(x.value) - - -@patch('chaosazure.vmss.actions.fetch_resources', autospec=True) -def test_delete_vmss_having_no_vmss(fetch): - with pytest.raises(FailedActivity) as x: - resource_list = [] - fetch.return_value = resource_list - delete_vmss(None, None, None) - - assert "No virtual machine scale sets found" in str(x.value) - - class MockVirtualMachineScaleSetVMsOperations(object): def power_off(self, resource_group_name, scale_set_name, instance_id): pass @@ -196,3 +92,168 @@ def __init__(self): @property def virtual_machine_scale_set_vms(self): return self.operations + + +@patch('chaosazure.vmss.actions.fetch_vmss', autospec=True) +@patch('chaosazure.vmss.actions.fetch_instances', autospec=True) +@patch.object(chaosazure.common.compute.command, 'prepare', autospec=True) +@patch.object(chaosazure.common.compute.command, 'run', autospec=True) +def test_stress_cpu(mocked_command_run, mocked_command_prepare, fetch_instance, fetch_vmss): + # arrange mocks + mocked_command_prepare.return_value = 'RunShellScript', 'cpu_stress_test.sh' + + scale_set = vmss_provider.provide_scale_set() + scale_sets = [scale_set] + instance = vmss_provider.provide_instance() + instances = [instance] + fetch_vmss.return_value = scale_sets + fetch_instance.return_value = instances + + config = config_provider.provide_default_config() + secrets = secrets_provider.provide_secrets_via_service_principal() + + # act + stress_vmss_instance_cpu( + filter="where name=='some_random_instance'", + duration=60, timeout=60, configuration=config, secrets=secrets) + + # assert + fetch_vmss.assert_called_with("where name=='some_random_instance'", config, secrets) + fetch_instance.assert_called_with(scale_set, None, config, secrets) + mocked_command_prepare.assert_called_with(instance, 'cpu_stress_test') + mocked_command_run.assert_called_with( + scale_set['resourceGroup'], instance, 120, + { + 'command_id': 'RunShellScript', + 'script': ['cpu_stress_test.sh'], + 'parameters': [ + {'name': "duration", 'value': 60}, + ] + }, + secrets, config + ) + + +@patch('chaosazure.vmss.actions.fetch_vmss', autospec=True) +@patch('chaosazure.vmss.actions.fetch_instances', autospec=True) +@patch.object(chaosazure.common.compute.command, 'prepare', autospec=True) +@patch.object(chaosazure.common.compute.command, 'run', autospec=True) +def test_network_latency(mocked_command_run, mocked_command_prepare, fetch_instances, fetch_vmss): + # arrange mocks + mocked_command_prepare.return_value = 'RunShellScript', 'network_latency.sh' + + scale_set = vmss_provider.provide_scale_set() + scale_sets = [scale_set] + instance = vmss_provider.provide_instance() + instances = [instance] + fetch_vmss.return_value = scale_sets + fetch_instances.return_value = instances + + config = config_provider.provide_default_config() + secrets = secrets_provider.provide_secrets_via_service_principal() + + # act + network_latency( + filter="where name=='some_random_instance'", + duration=60, timeout=60, delay=200, jitter=50, + configuration=config, secrets=secrets) + + # assert + fetch_vmss.assert_called_with("where name=='some_random_instance'", config, secrets) + fetch_instances.assert_called_with(scale_set, None, config, secrets) + mocked_command_prepare.assert_called_with(instance, 'network_latency') + mocked_command_run.assert_called_with( + scale_set['resourceGroup'], instance, 120, + { + 'command_id': 'RunShellScript', + 'script': ['network_latency.sh'], + 'parameters': [ + {'name': "duration", 'value': 60}, + {'name': "delay", 'value': 200}, + {'name': "jitter", 'value': 50} + ] + }, + secrets, config + ) + + +@patch('chaosazure.vmss.actions.fetch_vmss', autospec=True) +@patch('chaosazure.vmss.actions.fetch_instances', autospec=True) +@patch.object(chaosazure.common.compute.command, 'prepare', autospec=True) +@patch.object(chaosazure.common.compute.command, 'run', autospec=True) +def test_burn_io(mocked_command_run, mocked_command_prepare, fetch_instances, fetch_vmss): + # arrange mocks + mocked_command_prepare.return_value = 'RunShellScript', 'burn_io.sh' + + scale_set = vmss_provider.provide_scale_set() + scale_sets = [scale_set] + instance = vmss_provider.provide_instance() + instances = [instance] + fetch_vmss.return_value = scale_sets + fetch_instances.return_value = instances + + config = config_provider.provide_default_config() + secrets = secrets_provider.provide_secrets_via_service_principal() + + # act + burn_io(filter="where name=='some_random_instance'", + duration=60, configuration=config, secrets=secrets) + + # assert + fetch_vmss.assert_called_with("where name=='some_random_instance'", config, secrets) + fetch_instances.assert_called_with(scale_set, None, config, secrets) + mocked_command_run.assert_called_with( + scale_set['resourceGroup'], instance, 120, + { + 'command_id': 'RunShellScript', + 'script': ['burn_io.sh'], + 'parameters': [ + {'name': 'duration', 'value': 60} + ] + }, + secrets, config + ) + + +@patch('chaosazure.vmss.actions.fetch_vmss', autospec=True) +@patch('chaosazure.vmss.actions.fetch_instances', autospec=True) +@patch.object(chaosazure.common.compute.command, 'prepare_path', autospec=True) +@patch.object(chaosazure.common.compute.command, 'prepare', autospec=True) +@patch.object(chaosazure.common.compute.command, 'run', autospec=True) +def test_fill_disk(mocked_command_run, mocked_command_prepare, + mocked_command_prepare_path, fetch_instances, fetch_vmss): + # arrange mocks + mocked_command_prepare.return_value = 'RunShellScript', 'fill_disk.sh' + mocked_command_prepare_path.return_value = '/root/burn/hard' + + scale_set = vmss_provider.provide_scale_set() + scale_sets = [scale_set] + instance = vmss_provider.provide_instance() + instances = [instance] + fetch_vmss.return_value = scale_sets + fetch_instances.return_value = instances + + config = config_provider.provide_default_config() + secrets = secrets_provider.provide_secrets_via_service_principal() + + # act + fill_disk(filter="where name=='some_random_instance'", + duration=60, timeout=60, size=1000, + path='/root/burn/hard', configuration=config, secrets=secrets) + + # assert + fetch_vmss.assert_called_with("where name=='some_random_instance'", config, secrets) + fetch_instances.assert_called_with(scale_set, None, config, secrets) + mocked_command_run.assert_called_with( + scale_set['resourceGroup'], instance, 120, + { + 'command_id': 'RunShellScript', + 'script': ['fill_disk.sh'], + 'parameters': [ + {'name': "duration", 'value': 60}, + {'name': "size", 'value': 1000}, + {'name': "path", 'value': '/root/burn/hard'} + ] + }, + secrets, config + ) diff --git a/tests/vmss/test_vmss_fetcher.py b/tests/vmss/test_vmss_fetcher.py new file mode 100644 index 0000000..2236215 --- /dev/null +++ b/tests/vmss/test_vmss_fetcher.py @@ -0,0 +1,156 @@ +from unittest.mock import patch + +import pytest +from chaoslib.exceptions import FailedActivity + +import chaosazure +from chaosazure.vmss.actions import delete_vmss +from chaosazure.vmss.fetcher import fetch_vmss, fetch_instances +from tests.data import vmss_provider + + +@patch('chaosazure.vmss.fetcher.fetch_resources', autospec=True) +def test_succesful_fetch_vmss(mocked_fetch_vmss): + scale_set = vmss_provider.provide_scale_set() + scale_sets = [scale_set] + mocked_fetch_vmss.return_value = scale_sets + + result = fetch_vmss(None, None, None) + + assert len(result) == 1 + assert result[0].get('name') == 'chaos-pool' + + +@patch('chaosazure.vmss.fetcher.fetch_resources', autospec=True) +def test_empty_fetch_vmss(mocked_fetch_vmss): + with pytest.raises(FailedActivity) as x: + mocked_fetch_vmss.return_value = [] + fetch_vmss(None, None, None) + + assert "No VMSS" in str(x.value) + + +@patch.object(chaosazure.vmss.fetcher, '__fetch_vmss_instances', autospec=True) +def test_succesful_fetch_instances_without_instance_criteria(mocked_fetch_instances): + instance = vmss_provider.provide_instance() + instances = [instance] + mocked_fetch_instances.return_value = instances + + scale_set = vmss_provider.provide_scale_set() + + result = fetch_instances(scale_set, None, None, None) + + assert len(result) == 1 + assert result[0].get('name') == 'chaos-pool_0' + assert result[0].get('instance_id') == '0' + + +@patch.object(chaosazure.vmss.fetcher, '__fetch_vmss_instances', autospec=True) +def test_empty_fetch_instances_without_instance_criteria(mocked_fetch_instances): + with pytest.raises(FailedActivity) as x: + mocked_fetch_instances.return_value = [] + scale_set = vmss_provider.provide_scale_set() + + fetch_instances(scale_set, None, None, None) + + assert "No VMSS instances" in str(x.value) + + +@patch.object(chaosazure.vmss.fetcher, '__fetch_vmss_instances', autospec=True) +def test_succesful_fetch_instances_with_instance_criteria_for_instance0(mocked_fetch_instances): + # arrange + instance_0 = vmss_provider.provide_instance() + instance_0['instance_id'] = '0' + instance_1 = vmss_provider.provide_instance() + instance_1['instance_id'] = '1' + instance_2 = vmss_provider.provide_instance() + instance_2['instance_id'] = '2' + instances = [instance_0, instance_1, instance_2] + mocked_fetch_instances.return_value = instances + scale_set = vmss_provider.provide_scale_set() + + # fire + result = fetch_instances(scale_set, [{'instance_id': '0'}], None, None) + + # assert + assert len(result) == 1 + assert result[0].get('name') == 'chaos-pool_0' + assert result[0].get('instance_id') == '0' + + +@patch.object(chaosazure.vmss.fetcher, '__fetch_vmss_instances', autospec=True) +def test_succesful_fetch_instances_with_instance_criteria_for_instance0_instance_2(mocked_fetch_instances): + # arrange + instance_0 = vmss_provider.provide_instance() + instance_0['instance_id'] = '0' + instance_0['name'] = 'chaos-pool_0' + instance_1 = vmss_provider.provide_instance() + instance_1['instance_id'] = '1' + instance_1['name'] = 'chaos-pool_1' + instance_2 = vmss_provider.provide_instance() + instance_2['instance_id'] = '2' + instance_2['name'] = 'chaos-pool_2' + instances = [instance_0, instance_1, instance_2] + mocked_fetch_instances.return_value = instances + scale_set = vmss_provider.provide_scale_set() + + # fire + result = fetch_instances(scale_set, [{'instance_id': '0'}, {'instance_id': '2'}], None, None) + + # assert + assert len(result) == 2 + assert result[0].get('name') == 'chaos-pool_0' + assert result[0].get('instance_id') == '0' + assert result[1].get('name') == 'chaos-pool_2' + assert result[1].get('instance_id') == '2' + + +@patch.object(chaosazure.vmss.fetcher, '__fetch_vmss_instances', autospec=True) +def test_succesful_fetch_instances_with_instance_criteria_for_all_instances(mocked_fetch_instances): + # arrange + instance_0 = vmss_provider.provide_instance() + instance_0['instance_id'] = '0' + instance_0['name'] = 'chaos-pool_0' + instance_1 = vmss_provider.provide_instance() + instance_1['instance_id'] = '1' + instance_1['name'] = 'chaos-pool_1' + instance_2 = vmss_provider.provide_instance() + instance_2['instance_id'] = '2' + instance_2['name'] = 'chaos-pool_2' + instances = [instance_0, instance_1, instance_2] + mocked_fetch_instances.return_value = instances + scale_set = vmss_provider.provide_scale_set() + + # fire + result = fetch_instances( + scale_set, [{'instance_id': '0'}, {'instance_id': '1'}, {'instance_id': '2'}], None, None) + + # assert + assert len(result) == 3 + assert result[0].get('name') == 'chaos-pool_0' + assert result[0].get('instance_id') == '0' + assert result[1].get('name') == 'chaos-pool_1' + assert result[1].get('instance_id') == '1' + assert result[2].get('name') == 'chaos-pool_2' + assert result[2].get('instance_id') == '2' + + +@patch.object(chaosazure.vmss.fetcher, '__fetch_vmss_instances', autospec=True) +def test_empty_fetch_instances_with_instance_criteria(mocked_fetch_instances): + # arrange + instance_0 = vmss_provider.provide_instance() + instance_0['instance_id'] = '0' + instance_1 = vmss_provider.provide_instance() + instance_1['instance_id'] = '1' + instance_2 = vmss_provider.provide_instance() + instance_2['instance_id'] = '2' + instances = [instance_0, instance_1, instance_2] + mocked_fetch_instances.return_value = instances + scale_set = vmss_provider.provide_scale_set() + + # fire + with pytest.raises(FailedActivity) as x: + fetch_instances( + scale_set, [{'instance_id': '99'}, {'instance_id': '100'}, {'instance_id': '101'}], None, None) + + assert "No VMSS instance" in x.value diff --git a/tests/vmss/test_vmss_probes.py b/tests/vmss/test_vmss_probes.py new file mode 100644 index 0000000..1e6e6cb --- /dev/null +++ b/tests/vmss/test_vmss_probes.py @@ -0,0 +1,17 @@ +from unittest.mock import patch + +from chaosazure.vmss.probes import count_instances + +resource = { + 'name': 'vmss_instance_0', + 'resourceGroup': 'group' +} + + +@patch('chaosazure.vmss.probes.fetch_resources', autospec=True) +def test_count_instances(fetch): + fetch.return_value = [resource] + + count = count_instances(None, None) + + assert count == 1 diff --git a/tests/webapp/test_webapp_actions.py b/tests/webapp/test_webapp_actions.py index a6dfd6f..3398e6a 100644 --- a/tests/webapp/test_webapp_actions.py +++ b/tests/webapp/test_webapp_actions.py @@ -21,7 +21,7 @@ @patch('chaosazure.webapp.actions.fetch_webapps', autospec=True) -@patch('chaosazure.webapp.actions.__init_client', autospec=True) +@patch('chaosazure.webapp.actions.init_website_management_client', autospec=True) def test_stop_webapp(init, fetch): client = MagicMock() init.return_value = client @@ -37,7 +37,7 @@ def test_stop_webapp(init, fetch): @patch('chaosazure.webapp.actions.fetch_webapps', autospec=True) -@patch('chaosazure.webapp.actions.__init_client', autospec=True) +@patch('chaosazure.webapp.actions.init_website_management_client', autospec=True) def test_restart_webapp(init, fetch): client = MagicMock() init.return_value = client @@ -53,7 +53,7 @@ def test_restart_webapp(init, fetch): @patch('chaosazure.webapp.actions.fetch_webapps', autospec=True) -@patch('chaosazure.webapp.actions.__init_client', autospec=True) +@patch('chaosazure.webapp.actions.init_website_management_client', autospec=True) def test_start_webapp(init, fetch): client = MagicMock() init.return_value = client @@ -69,7 +69,7 @@ def test_start_webapp(init, fetch): @patch('chaosazure.webapp.actions.fetch_webapps', autospec=True) -@patch('chaosazure.webapp.actions.__init_client', autospec=True) +@patch('chaosazure.webapp.actions.init_website_management_client', autospec=True) def test_delete_webapp(init, fetch): client = MagicMock() init.return_value = client