1""" 2Azure (ARM) Utilities 3 4.. versionadded:: 2019.2.0 5 6:maintainer: <devops@eitr.tech> 7:maturity: new 8:depends: 9 * `azure <https://pypi.python.org/pypi/azure>`_ >= 2.0.0rc6 10 * `azure-common <https://pypi.python.org/pypi/azure-common>`_ >= 1.1.4 11 * `azure-mgmt <https://pypi.python.org/pypi/azure-mgmt>`_ >= 0.30.0rc6 12 * `azure-mgmt-compute <https://pypi.python.org/pypi/azure-mgmt-compute>`_ >= 0.33.0 13 * `azure-mgmt-network <https://pypi.python.org/pypi/azure-mgmt-network>`_ >= 0.30.0rc6 14 * `azure-mgmt-resource <https://pypi.python.org/pypi/azure-mgmt-resource>`_ >= 0.30.0 15 * `azure-mgmt-storage <https://pypi.python.org/pypi/azure-mgmt-storage>`_ >= 0.30.0rc6 16 * `azure-mgmt-web <https://pypi.python.org/pypi/azure-mgmt-web>`_ >= 0.30.0rc6 17 * `azure-storage <https://pypi.python.org/pypi/azure-storage>`_ >= 0.32.0 18 * `msrestazure <https://pypi.python.org/pypi/msrestazure>`_ >= 0.4.21 19:platform: linux 20 21""" 22 23import importlib 24import logging 25import sys 26from operator import itemgetter 27 28import salt.config 29import salt.loader 30import salt.utils.stringutils 31import salt.version 32from salt.exceptions import SaltInvocationError, SaltSystemExit 33 34try: 35 from azure.common.credentials import ( 36 UserPassCredentials, 37 ServicePrincipalCredentials, 38 ) 39 from msrestazure.azure_cloud import ( 40 MetadataEndpointError, 41 get_cloud_from_metadata_endpoint, 42 ) 43 44 HAS_AZURE = True 45except ImportError: 46 HAS_AZURE = False 47 48__opts__ = salt.config.minion_config("/etc/salt/minion") 49__salt__ = salt.loader.minion_mods(__opts__) 50 51log = logging.getLogger(__name__) 52 53 54def __virtual__(): 55 if not HAS_AZURE: 56 return False 57 else: 58 return True 59 60 61def _determine_auth(**kwargs): 62 """ 63 Acquire Azure ARM Credentials 64 """ 65 if "profile" in kwargs: 66 azure_credentials = __salt__["config.option"](kwargs["profile"]) 67 kwargs.update(azure_credentials) 68 69 service_principal_creds_kwargs = ["client_id", "secret", "tenant"] 70 user_pass_creds_kwargs = ["username", "password"] 71 72 try: 73 if kwargs.get("cloud_environment") and kwargs.get( 74 "cloud_environment" 75 ).startswith("http"): 76 cloud_env = get_cloud_from_metadata_endpoint(kwargs["cloud_environment"]) 77 else: 78 cloud_env_module = importlib.import_module("msrestazure.azure_cloud") 79 cloud_env = getattr( 80 cloud_env_module, kwargs.get("cloud_environment", "AZURE_PUBLIC_CLOUD") 81 ) 82 except (AttributeError, ImportError, MetadataEndpointError): 83 raise sys.exit( 84 "The Azure cloud environment {} is not available.".format( 85 kwargs["cloud_environment"] 86 ) 87 ) 88 89 if set(service_principal_creds_kwargs).issubset(kwargs): 90 if not (kwargs["client_id"] and kwargs["secret"] and kwargs["tenant"]): 91 raise SaltInvocationError( 92 "The client_id, secret, and tenant parameters must all be " 93 "populated if using service principals." 94 ) 95 else: 96 credentials = ServicePrincipalCredentials( 97 kwargs["client_id"], 98 kwargs["secret"], 99 tenant=kwargs["tenant"], 100 cloud_environment=cloud_env, 101 ) 102 elif set(user_pass_creds_kwargs).issubset(kwargs): 103 if not (kwargs["username"] and kwargs["password"]): 104 raise SaltInvocationError( 105 "The username and password parameters must both be " 106 "populated if using username/password authentication." 107 ) 108 else: 109 credentials = UserPassCredentials( 110 kwargs["username"], kwargs["password"], cloud_environment=cloud_env 111 ) 112 elif "subscription_id" in kwargs: 113 try: 114 from msrestazure.azure_active_directory import MSIAuthentication 115 116 credentials = MSIAuthentication(cloud_environment=cloud_env) 117 except ImportError: 118 raise SaltSystemExit( 119 msg=( 120 "MSI authentication support not availabe (requires msrestazure >=" 121 " 0.4.14)" 122 ) 123 ) 124 125 else: 126 raise SaltInvocationError( 127 "Unable to determine credentials. " 128 "A subscription_id with username and password, " 129 "or client_id, secret, and tenant or a profile with the " 130 "required parameters populated" 131 ) 132 133 if "subscription_id" not in kwargs: 134 raise SaltInvocationError("A subscription_id must be specified") 135 136 subscription_id = salt.utils.stringutils.to_str(kwargs["subscription_id"]) 137 138 return credentials, subscription_id, cloud_env 139 140 141def get_client(client_type, **kwargs): 142 """ 143 Dynamically load the selected client and return a management client object 144 """ 145 client_map = { 146 "compute": "ComputeManagement", 147 "authorization": "AuthorizationManagement", 148 "dns": "DnsManagement", 149 "storage": "StorageManagement", 150 "managementlock": "ManagementLock", 151 "monitor": "MonitorManagement", 152 "network": "NetworkManagement", 153 "policy": "Policy", 154 "resource": "ResourceManagement", 155 "subscription": "Subscription", 156 "web": "WebSiteManagement", 157 } 158 159 if client_type not in client_map: 160 raise SaltSystemExit( 161 msg="The Azure ARM client_type {} specified can not be found.".format( 162 client_type 163 ) 164 ) 165 166 map_value = client_map[client_type] 167 168 if client_type in ["policy", "subscription"]: 169 module_name = "resource" 170 elif client_type in ["managementlock"]: 171 module_name = "resource.locks" 172 else: 173 module_name = client_type 174 175 try: 176 client_module = importlib.import_module("azure.mgmt." + module_name) 177 # pylint: disable=invalid-name 178 Client = getattr(client_module, "{}Client".format(map_value)) 179 except ImportError: 180 raise sys.exit("The azure {} client is not available.".format(client_type)) 181 182 credentials, subscription_id, cloud_env = _determine_auth(**kwargs) 183 184 if client_type == "subscription": 185 client = Client( 186 credentials=credentials, 187 base_url=cloud_env.endpoints.resource_manager, 188 ) 189 else: 190 client = Client( 191 credentials=credentials, 192 subscription_id=subscription_id, 193 base_url=cloud_env.endpoints.resource_manager, 194 ) 195 196 client.config.add_user_agent("Salt/{}".format(salt.version.__version__)) 197 198 return client 199 200 201def log_cloud_error(client, message, **kwargs): 202 """ 203 Log an azurearm cloud error exception 204 """ 205 try: 206 cloud_logger = getattr(log, kwargs.get("azurearm_log_level")) 207 except (AttributeError, TypeError): 208 cloud_logger = getattr(log, "error") 209 210 cloud_logger( 211 "An AzureARM %s CloudError has occurred: %s", client.capitalize(), message 212 ) 213 214 return 215 216 217def paged_object_to_list(paged_object): 218 """ 219 Extract all pages within a paged object as a list of dictionaries 220 """ 221 paged_return = [] 222 while True: 223 try: 224 page = next(paged_object) 225 paged_return.append(page.as_dict()) 226 except StopIteration: 227 break 228 229 return paged_return 230 231 232def create_object_model(module_name, object_name, **kwargs): 233 """ 234 Assemble an object from incoming parameters. 235 """ 236 object_kwargs = {} 237 238 try: 239 model_module = importlib.import_module( 240 "azure.mgmt.{}.models".format(module_name) 241 ) 242 # pylint: disable=invalid-name 243 Model = getattr(model_module, object_name) 244 except ImportError: 245 raise sys.exit( 246 "The {} model in the {} Azure module is not available.".format( 247 object_name, module_name 248 ) 249 ) 250 251 if "_attribute_map" in dir(Model): 252 for attr, items in Model._attribute_map.items(): 253 param = kwargs.get(attr) 254 if param is not None: 255 if items["type"][0].isupper() and isinstance(param, dict): 256 object_kwargs[attr] = create_object_model( 257 module_name, items["type"], **param 258 ) 259 elif items["type"][0] == "{" and isinstance(param, dict): 260 object_kwargs[attr] = param 261 elif items["type"][0] == "[" and isinstance(param, list): 262 obj_list = [] 263 for list_item in param: 264 if items["type"][1].isupper() and isinstance(list_item, dict): 265 obj_list.append( 266 create_object_model( 267 module_name, 268 items["type"][ 269 items["type"].index("[") 270 + 1 : items["type"].rindex("]") 271 ], 272 **list_item 273 ) 274 ) 275 elif items["type"][1] == "{" and isinstance(list_item, dict): 276 obj_list.append(list_item) 277 elif not items["type"][1].isupper() and items["type"][1] != "{": 278 obj_list.append(list_item) 279 object_kwargs[attr] = obj_list 280 else: 281 object_kwargs[attr] = param 282 283 # wrap calls to this function to catch TypeError exceptions 284 return Model(**object_kwargs) 285 286 287def compare_list_of_dicts(old, new, convert_id_to_name=None): 288 """ 289 Compare lists of dictionaries representing Azure objects. Only keys found in the "new" dictionaries are compared to 290 the "old" dictionaries, since getting Azure objects from the API returns some read-only data which should not be 291 used in the comparison. A list of parameter names can be passed in order to compare a bare object name to a full 292 Azure ID path for brevity. If string types are found in values, comparison is case insensitive. Return comment 293 should be used to trigger exit from the calling function. 294 """ 295 ret = {} 296 297 if not convert_id_to_name: 298 convert_id_to_name = [] 299 300 if not isinstance(new, list): 301 ret["comment"] = "must be provided as a list of dictionaries!" 302 return ret 303 304 if len(new) != len(old): 305 ret["changes"] = {"old": old, "new": new} 306 return ret 307 308 try: 309 local_configs, remote_configs = [ 310 sorted(config, key=itemgetter("name")) for config in (new, old) 311 ] 312 except TypeError: 313 ret["comment"] = "configurations must be provided as a list of dictionaries!" 314 return ret 315 except KeyError: 316 ret["comment"] = 'configuration dictionaries must contain the "name" key!' 317 return ret 318 319 for idx, val in enumerate(local_configs): 320 for key in val: 321 local_val = val[key] 322 if key in convert_id_to_name: 323 remote_val = ( 324 remote_configs[idx].get(key, {}).get("id", "").split("/")[-1] 325 ) 326 else: 327 remote_val = remote_configs[idx].get(key) 328 if isinstance(local_val, str): 329 local_val = local_val.lower() 330 if isinstance(remote_val, str): 331 remote_val = remote_val.lower() 332 if local_val != remote_val: 333 ret["changes"] = {"old": remote_configs, "new": local_configs} 334 return ret 335 336 return ret 337