|
| 1 | +#!/usr/bin/python |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | + |
| 4 | +# Copyright (C) 2018 Mohamed El Morabity <melmorabity@fedoraproject.com> |
| 5 | +# |
| 6 | +# This module is free software: you can redistribute it and/or modify it under the terms of the GNU |
| 7 | +# General Public License as published by the Free Software Foundation, either version 3 of the |
| 8 | +# License, or (at your option) any later version. |
| 9 | +# |
| 10 | +# This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without |
| 11 | +# even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| 12 | +# General Public License for more details. |
| 13 | +# |
| 14 | +# You should have received a copy of the GNU General Public License along with this program. If not, |
| 15 | +# see <http://www.gnu.org/licenses/>. |
| 16 | + |
| 17 | + |
| 18 | +from msrest.exceptions import ClientException |
| 19 | +from msrest.service_client import ServiceClient |
| 20 | + |
| 21 | +from msrestazure.azure_active_directory import ServicePrincipalCredentials |
| 22 | +from msrestazure.azure_configuration import AzureConfiguration |
| 23 | +from msrestazure.azure_exceptions import CloudError |
| 24 | +import msrestazure.tools |
| 25 | + |
| 26 | +from pynag import Plugins |
| 27 | +from pynag.Plugins import simple as Plugin |
| 28 | + |
| 29 | +from requests.exceptions import HTTPError |
| 30 | + |
| 31 | + |
| 32 | +def _call_arm_rest_api(client, path, api_version, method='GET', body=None, query=None, |
| 33 | + headers=None, timeout=None): |
| 34 | + """Launch an Azure REST API request.""" |
| 35 | + |
| 36 | + request = getattr(client, method.lower())( |
| 37 | + url=path, params=dict(query or {}, **{'api-version': api_version}) |
| 38 | + ) |
| 39 | + response = client.send( |
| 40 | + request=request, content=body, |
| 41 | + headers=dict(headers or {}, **{'Content-Type': 'application/json; charset=utf-8'}), |
| 42 | + timeout=timeout |
| 43 | + ) |
| 44 | + |
| 45 | + try: |
| 46 | + response.raise_for_status() |
| 47 | + except HTTPError: |
| 48 | + # msrestazure.azure_exceptions.CloudError constructor provides a nice way to extract |
| 49 | + # Azure errors from request responses |
| 50 | + raise CloudError(response) |
| 51 | + |
| 52 | + try: |
| 53 | + result = response.json() |
| 54 | + except ValueError: |
| 55 | + result = response.text |
| 56 | + |
| 57 | + return result |
| 58 | + |
| 59 | + |
| 60 | +class NagiosAzureResourceMonitor(Plugin): |
| 61 | + """Implements functionalities to grab metrics from Azure resource objects.""" |
| 62 | + |
| 63 | + DEFAULT_AZURE_SERVICE_HOST = 'management.azure.com' |
| 64 | + _AZURE_METRICS_API = '2017-05-01-preview' |
| 65 | + _AZURE_METRICS_UNIT_SYMBOLS = {'Percent': '%', 'Bytes': 'B', 'Seconds': 's'} |
| 66 | + |
| 67 | + def __init__(self, *args, **kwargs): |
| 68 | + Plugin.__init__(self, *args, **kwargs) |
| 69 | + |
| 70 | + self._set_cli_options() |
| 71 | + |
| 72 | + def _set_cli_options(self): |
| 73 | + """Define command line options.""" |
| 74 | + |
| 75 | + self.add_arg('C', 'client', 'Azure client ID') |
| 76 | + self.add_arg('S', 'secret', 'Azure client secret') |
| 77 | + self.add_arg('T', 'tenant', 'Azure tenant ID') |
| 78 | + |
| 79 | + self.add_arg('R', 'resource', 'Azure resource ID') |
| 80 | + self.add_arg('M', 'metric', 'Metric') |
| 81 | + self.add_arg('D', 'dimension', 'Metric dimension', required=None) |
| 82 | + self.add_arg('V', 'dimension-value', 'Metric dimension value', required=None) |
| 83 | + |
| 84 | + def activate(self): |
| 85 | + """Parse out all command line options and get ready to process the plugin.""" |
| 86 | + Plugin.activate(self) |
| 87 | + |
| 88 | + if not msrestazure.tools.is_valid_resource_id(self['resource']): |
| 89 | + self.parser.error('invalid resource ID') |
| 90 | + |
| 91 | + if bool(self['dimension']) != bool(self['dimension-value']): |
| 92 | + self.parser.error('--dimension and --dimension-value must be used together') |
| 93 | + |
| 94 | + # Set up Azure Resource Management URL |
| 95 | + if self['host'] is None: |
| 96 | + self['host'] = self.DEFAULT_AZURE_SERVICE_HOST |
| 97 | + |
| 98 | + # Set up timeout |
| 99 | + if self['timeout'] is not None: |
| 100 | + try: |
| 101 | + self['timeout'] = float(self['timeout']) |
| 102 | + if self['timeout'] < 0: |
| 103 | + raise ValueError |
| 104 | + except ValueError as ex: |
| 105 | + self.parser.error('Invalid timeout') |
| 106 | + |
| 107 | + # Authenticate to ARM |
| 108 | + azure_management_url = 'https://{}'.format(self['host']) |
| 109 | + try: |
| 110 | + credentials = ServicePrincipalCredentials(client_id=self['client'], |
| 111 | + secret=self['secret'], |
| 112 | + tenant=self['tenant']) |
| 113 | + self._client = ServiceClient(credentials, AzureConfiguration(azure_management_url)) |
| 114 | + except ClientException as ex: |
| 115 | + self.nagios_exit(Plugins.UNKNOWN, str(ex.inner_exception or ex)) |
| 116 | + |
| 117 | + try: |
| 118 | + self._metric_definitions = self._get_metric_definitions() |
| 119 | + except CloudError as ex: |
| 120 | + self.nagios_exit(Plugins.UNKNOWN, ex.message) |
| 121 | + |
| 122 | + metric_ids = [m['name']['value'] for m in self._metric_definitions] |
| 123 | + if self['metric'] not in metric_ids: |
| 124 | + self.parser.error( |
| 125 | + 'Unknown metric {} for specified resource. ' \ |
| 126 | + 'Supported metrics are: {}'.format(self['metric'], ', '.join(metric_ids)) |
| 127 | + ) |
| 128 | + self._metric_properties = self._get_metric_properties() |
| 129 | + |
| 130 | + dimension_ids = [d['value'] for d in self._metric_properties.get('dimensions', [])] |
| 131 | + if self._is_dimension_required() and self['dimension'] is None: |
| 132 | + self.parser.error( |
| 133 | + 'Dimension required for metric {}. ' \ |
| 134 | + 'Supported dimensions are: {}'.format(self['metric'], ', '.join(dimension_ids)) |
| 135 | + ) |
| 136 | + if self['dimension'] is not None and self['dimension'] not in dimension_ids: |
| 137 | + self.parser.error( |
| 138 | + 'Unknown dimension {} for metric {}. ' \ |
| 139 | + 'Supported dimensions are: {}'.format(self['dimension'], self['metric'], |
| 140 | + ', '.join(dimension_ids)) |
| 141 | + ) |
| 142 | + |
| 143 | + def _get_metric_definitions(self): |
| 144 | + """Get all available metric definitions for the Azure resource object.""" |
| 145 | + |
| 146 | + path = '{}/providers/Microsoft.Insights/metricDefinitions'.format(self['resource']) |
| 147 | + metrics = _call_arm_rest_api(self._client, path, self._AZURE_METRICS_API, |
| 148 | + timeout=self['timeout']) |
| 149 | + |
| 150 | + return metrics['value'] |
| 151 | + |
| 152 | + def _get_metric_properties(self): |
| 153 | + """Get metric properties.""" |
| 154 | + |
| 155 | + for metric in self._metric_definitions: |
| 156 | + if metric['name']['value'] == self['metric']: |
| 157 | + return metric |
| 158 | + |
| 159 | + return None |
| 160 | + |
| 161 | + def _is_dimension_required(self): |
| 162 | + """Check whether an additional metric is required for a given metric ID.""" |
| 163 | + |
| 164 | + return self._metric_properties['isDimensionRequired'] |
| 165 | + |
| 166 | + def _get_metric_value(self): |
| 167 | + """Get latest metric value available for the Azure resource object.""" |
| 168 | + |
| 169 | + query = {'metric': self['metric']} |
| 170 | + if self['dimension'] is not None: |
| 171 | + query['$filter'] = "{} eq '{}'".format(self['dimension'], self['dimension-value']) |
| 172 | + |
| 173 | + path = '{}/providers/Microsoft.Insights/metrics/{}'.format(self['resource'], |
| 174 | + self['metric']) |
| 175 | + |
| 176 | + try: |
| 177 | + metric_values = _call_arm_rest_api(self._client, path, |
| 178 | + self._AZURE_METRICS_API, query=query, |
| 179 | + timeout=self['timeout']) |
| 180 | + metric_values = metric_values['value'][0]['timeseries'] |
| 181 | + except CloudError as ex: |
| 182 | + self.nagios_exit(Plugins.UNKNOWN, ex.message) |
| 183 | + |
| 184 | + if not metric_values: |
| 185 | + return None |
| 186 | + |
| 187 | + aggregation_type = self._metric_properties['primaryAggregationType'].lower() |
| 188 | + # Get the latest value available |
| 189 | + for value in metric_values[0]['data'][::-1]: |
| 190 | + if aggregation_type in value: |
| 191 | + return value[aggregation_type] |
| 192 | + |
| 193 | + def check_metric(self): |
| 194 | + """Check if the metric value is within the threshold range, and exits with status code, |
| 195 | + message and perfdata. |
| 196 | + """ |
| 197 | + |
| 198 | + value = self._get_metric_value() |
| 199 | + if value is None: |
| 200 | + message = 'No value available for metric {}'.format(self['metric']) |
| 201 | + if self['dimension'] is not None: |
| 202 | + message += ' and dimension {}'.format(self['dimension']) |
| 203 | + self.nagios_exit(Plugins.UNKNOWN, message) |
| 204 | + |
| 205 | + status = Plugins.check_threshold(value, warning=self['warning'], critical=self['critical']) |
| 206 | + |
| 207 | + unit = self._AZURE_METRICS_UNIT_SYMBOLS.get(self._metric_properties['unit']) |
| 208 | + self.add_perfdata(self._metric_properties['name']['value'], value, uom=unit, |
| 209 | + warn=self['warning'], crit=self['critical']) |
| 210 | + |
| 211 | + self.nagios_exit(status, |
| 212 | + '{} {} {}'.format(self._metric_properties['name']['localizedValue'], |
| 213 | + value, |
| 214 | + self._metric_properties['unit'].lower())) |
| 215 | + |
| 216 | + |
| 217 | +if __name__ == '__main__': |
| 218 | + PLUGIN = NagiosAzureResourceMonitor() |
| 219 | + PLUGIN.activate() |
| 220 | + PLUGIN.check_metric() |
0 commit comments