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