|
| 1 | +#!/usr/bin/env python |
| 2 | + |
| 3 | +import requests |
| 4 | +from requests.auth import HTTPBasicAuth, HTTPDigestAuth |
| 5 | +from requests_oauthlib import OAuth1 |
| 6 | +from math import floor |
| 7 | +from datetime import datetime, timedelta |
| 8 | +import yaml |
| 9 | +import logging |
| 10 | +import os |
| 11 | +import sys |
| 12 | +from enum import Enum |
| 13 | + |
| 14 | +# TODO get LOG_LEVEL or DEBUG from env vars |
| 15 | +# logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) |
| 16 | +logger = logging.getLogger('pingdom.pingdomWrapper') |
| 17 | +handler = logging.StreamHandler(sys.stdout) |
| 18 | +logger.setLevel(logging.DEBUG) |
| 19 | +logger.addHandler(handler) |
| 20 | + |
| 21 | + |
| 22 | +class AuthenticationError(Exception): |
| 23 | + pass |
| 24 | + |
| 25 | + |
| 26 | +AuthType = Enum('AuthType', 'HTTPBASICAUTH HTTPDIGESTAUTH OAUTH1 OAUTH2 NONE') |
| 27 | + |
| 28 | + |
| 29 | +class Pingdom(object): |
| 30 | + def __init__( |
| 31 | + self, |
| 32 | + user=None, |
| 33 | + password=None, |
| 34 | + client_app_key=None, |
| 35 | + client_app_secret=None, |
| 36 | + user_oauth_token=None, |
| 37 | + user_oauth_token_secret=None, |
| 38 | + api_app_key=None |
| 39 | + ): |
| 40 | + '''Set up client for API communications |
| 41 | + This is where you'll need to specify all the authentication and |
| 42 | + required headers |
| 43 | + |
| 44 | + Preference will be given towards passed in variables, otherwise |
| 45 | + environment variables will be used |
| 46 | + |
| 47 | + Config file is supported but discouraged since it's a common |
| 48 | + source of credential leaks |
| 49 | + ''' |
| 50 | + # Setup Host here |
| 51 | + self.url = 'https://api.pingdom.com' |
| 52 | + # Setup Session object for all future API calls |
| 53 | + self.session = requests.Session() |
| 54 | + |
| 55 | + # Setup authentication |
| 56 | + # If interested in using a config file instead of env vars, load with |
| 57 | + # self._load_key(config_key, path) |
| 58 | + # Feel free to clear out auth methods not implemented by the API |
| 59 | + auth_type = AuthType[os.getenv('AUTHTYPE', default='NONE')] |
| 60 | + if (auth_type == AuthType.HTTPBASICAUTH or |
| 61 | + auth_type == AuthType.HTTPDIGESTAUTH): |
| 62 | + if not user: |
| 63 | + user = os.getenv('CLIENT_USER') |
| 64 | + if not password: |
| 65 | + password = os.getenv('CLIENT_PASSWORD') |
| 66 | + if auth_type == AuthType.HTTPBASICAUTH: |
| 67 | + self.session.auth = HTTPBasicAuth(user, password) |
| 68 | + else: |
| 69 | + self.session.auth = HTTPDigestAuth(user, password) |
| 70 | + if auth_type == AuthType.OAUTH1: |
| 71 | + if not client_app_key: |
| 72 | + client_app_key = os.getenv('CLIENT_APP_KEY') |
| 73 | + if not client_app_secret: |
| 74 | + client_app_secret = os.getenv('CLIENT_APP_SECRET') |
| 75 | + if not user_oauth_token: |
| 76 | + user_oauth_token = os.getenv('USER_OAUTH_TOKEN') |
| 77 | + if not user_oauth_token_secret: |
| 78 | + user_oauth_token_secret = os.getenv('USER_OAUTH_TOKEN_SECRET') |
| 79 | + self.session.auth = OAuth1( |
| 80 | + client_app_key, |
| 81 | + client_app_secret, |
| 82 | + user_oauth_token, |
| 83 | + user_oauth_token_secret |
| 84 | + ) |
| 85 | + if auth_type == AuthType.OAUTH2: |
| 86 | + # Feel free to create a PR if you want to contribute |
| 87 | + raise NotImplementedError("OAuth2 currently not supported") |
| 88 | + |
| 89 | + # Some APIs require an API key in a header in addition to or instead |
| 90 | + # of standard authentication methods |
| 91 | + if not api_app_key: |
| 92 | + api_app_key = os.getenv('API_APP_KEY') |
| 93 | + self.session.headers.update({'App-Key': api_app_key}) |
| 94 | + |
| 95 | + # Setup any additional headers required by the API |
| 96 | + # This sometimes includes additional account info |
| 97 | + account_owner = os.getenv('PINGDOM_ACCOUNT_OWNER') |
| 98 | + if account_owner: |
| 99 | + self.session.headers.update({'account-email': account_owner}) |
| 100 | + |
| 101 | + logger.info('Authenticating...') |
| 102 | + if self._authenticate(): |
| 103 | + logger.info('Authentication Successful!') |
| 104 | + else: |
| 105 | + logger.info('Authentication Failed!') |
| 106 | + raise AuthenticationError('Authentication Failed!') |
| 107 | + |
| 108 | + def _load_key(self, config_key, path): |
| 109 | + '''Example function for loading config values from a yml file |
| 110 | + ''' |
| 111 | + with open(path) as stream: |
| 112 | + yaml_data = yaml.safe_load(stream) |
| 113 | + return yaml_data[config_key] |
| 114 | + |
| 115 | + def _authenticate(self): |
| 116 | + '''Authenticate by making simple request |
| 117 | + Some APIs will offer a simple auth validation endpoint, some |
| 118 | + won't. |
| 119 | + I like to make the simplest authenticated request when |
| 120 | + instantiating the client just to make sure the auth works |
| 121 | + ''' |
| 122 | + resp_json = self._make_request('/api/2.1/servertime', 'GET') |
| 123 | + try: |
| 124 | + pass |
| 125 | + except AuthenticationError as e: |
| 126 | + raise e |
| 127 | + print(resp_json) |
| 128 | + if resp_json: |
| 129 | + return True |
| 130 | + else: |
| 131 | + return False |
| 132 | + |
| 133 | + def _make_request(self, endpoint, method, query_params=None, body=None): |
| 134 | + '''Handles all requests to Pingdom API |
| 135 | + ''' |
| 136 | + url = self.url + endpoint |
| 137 | + req = requests.Request(method, url, params=query_params, json=body) |
| 138 | + prepped = self.session.prepare_request(req) |
| 139 | + |
| 140 | + self._pprint_request(prepped) |
| 141 | + |
| 142 | + r = self.session.send(prepped) |
| 143 | + |
| 144 | + self._pprint_response(r) |
| 145 | + |
| 146 | + # Handle all response codes as elegantly as needed in a single spot |
| 147 | + if r.status_code == requests.codes.ok: |
| 148 | + try: |
| 149 | + resp_json = r.json() |
| 150 | + logger.debug('Response: {}'.format(resp_json)) |
| 151 | + return resp_json |
| 152 | + except ValueError: |
| 153 | + return r.text |
| 154 | + |
| 155 | + elif r.status_code == 401: |
| 156 | + logger.info("Authentication Unsuccessful!") |
| 157 | + try: |
| 158 | + resp_json = r.json() |
| 159 | + logger.debug('Details: ' + str(resp_json)) |
| 160 | + raise AuthenticationError(resp_json) |
| 161 | + except ValueError: |
| 162 | + raise |
| 163 | + |
| 164 | + # Raises HTTP error if status_code is 4XX or 5XX |
| 165 | + elif r.status_code >= 400: |
| 166 | + logger.error('Received a ' + str(r.status_code) + ' error!') |
| 167 | + try: |
| 168 | + logger.debug('Details: ' + str(r.json())) |
| 169 | + except ValueError: |
| 170 | + pass |
| 171 | + r.raise_for_status() |
| 172 | + |
| 173 | + def _pprint_request(self, prepped): |
| 174 | + ''' |
| 175 | + method endpoint HTTP/version |
| 176 | + Host: host |
| 177 | + header_key: header_value |
| 178 | + |
| 179 | + body |
| 180 | + ''' |
| 181 | + method = prepped.method |
| 182 | + url = prepped.path_url |
| 183 | + headers = '\n'.join('{}: {}'.format(k, v) for k, v in |
| 184 | + prepped.headers.items()) |
| 185 | + # Print body if present or empty string if not |
| 186 | + body = prepped.body or "" |
| 187 | + logger.debug( |
| 188 | + '{}\n{} {} HTTP/1.1\n{}\n\n{}'.format( |
| 189 | + '-----------REQUEST-----------', |
| 190 | + method, |
| 191 | + url, |
| 192 | + headers, |
| 193 | + body |
| 194 | + ) |
| 195 | + ) |
| 196 | + |
| 197 | + def _pprint_response(self, r): |
| 198 | + ''' |
| 199 | + HTTP/version status_code status_text |
| 200 | + header_key: header_value |
| 201 | + |
| 202 | + body |
| 203 | + ''' |
| 204 | + # Not using requests_toolbelt.dump because I want to be able to |
| 205 | + # print the request before submitting and response after |
| 206 | + # ref: https://stackoverflow.com/a/35392830/8418673 |
| 207 | + |
| 208 | + httpv0, httpv1 = list(str(r.raw.version)) |
| 209 | + httpv = 'HTTP/{}.{}'.format(httpv0, httpv1) |
| 210 | + status_code = r.status_code |
| 211 | + status_text = r.reason |
| 212 | + headers = '\n'.join('{}: {}'.format(k, v) for k, v in |
| 213 | + r.headers.items()) |
| 214 | + body = r.text or "" |
| 215 | + |
| 216 | + logger.debug( |
| 217 | + '{}\n{} {} {}\n{}\n\n{}'.format( |
| 218 | + '-----------RESPONSE-----------', |
| 219 | + httpv, |
| 220 | + status_code, |
| 221 | + status_text, |
| 222 | + headers, |
| 223 | + body |
| 224 | + ) |
| 225 | + ) |
| 226 | + |
| 227 | + def list_checks( |
| 228 | + self, |
| 229 | + tags=[], |
| 230 | + offset=0, |
| 231 | + limit=20 |
| 232 | + ): |
| 233 | + '''Get list of checks in Pingdom |
| 234 | + |
| 235 | + :tags: list of tags to filter checks on - checks will match ANY tag |
| 236 | + ''' |
| 237 | + endpoint = '/api/2.1/checks' |
| 238 | + params = {} |
| 239 | + if tags: |
| 240 | + params['tags'] = ','.join(tags) |
| 241 | + else: |
| 242 | + tags = None |
| 243 | + params['offset'] = offset |
| 244 | + params['limit'] = limit |
| 245 | + params['include_tags'] = True |
| 246 | + |
| 247 | + return self._make_request(endpoint, 'GET', query_params=params) |
| 248 | + |
| 249 | + def get_check_details(self, check_id): |
| 250 | + """TODO: Docstring for get_check_details. |
| 251 | + """ |
| 252 | + endpoint = '/api/2.1/checks/{}'.format(check_id) |
| 253 | + return self._make_request(endpoint, 'GET') |
| 254 | + |
| 255 | + def pause_unpause_mult_checks(self, check_list, pause=True): |
| 256 | + """Pauses or Unpauses multiple checks |
| 257 | + |
| 258 | + :check_list: list of check ids to update |
| 259 | + :pause: True to pause checks, False to unpause checks |
| 260 | + :returns: TODO |
| 261 | + |
| 262 | + """ |
| 263 | + endpoint = '/api/2.1/checks' |
| 264 | + check_ids = ','.join(check_list) |
| 265 | + params = {} |
| 266 | + params['checkids'] = check_ids |
| 267 | + params['paused'] = pause |
| 268 | + return self._make_request(endpoint, 'PUT', query_params=params) |
| 269 | + |
| 270 | + def create_new_check( |
| 271 | + self, |
| 272 | + name, |
| 273 | + host, |
| 274 | + **kwargs |
| 275 | + ): |
| 276 | + '''Create new check in Pingdom |
| 277 | + ''' |
| 278 | + endpoint = '/api/2.1/checks' |
| 279 | + params = {} |
| 280 | + params['name'] = name |
| 281 | + params['host'] = host |
| 282 | + # Allows an arbitrary number of keyword arguments to this method |
| 283 | + # to be converted into query_params |
| 284 | + for key, value in kwargs.items(): |
| 285 | + params[key] = value |
| 286 | + return self._make_request(endpoint, 'POST', query_params=params) |
| 287 | + |
| 288 | + def create_new_maintenance_window( |
| 289 | + self, |
| 290 | + description, |
| 291 | + from_datetime, |
| 292 | + to_datetime, |
| 293 | + uptime_ids=None, |
| 294 | + tms_ids=None |
| 295 | + ): |
| 296 | + '''Create new maintenance window in Pingdom |
| 297 | + ''' |
| 298 | + endpoint = '/api/2.1/maintenance' |
| 299 | + params = {} |
| 300 | + params['description'] = description |
| 301 | + |
| 302 | + if from_datetime is type(datetime): |
| 303 | + # floor needed because datetime.timestamp() returns |
| 304 | + # >>> datetime.datetime.now().timestamp() |
| 305 | + # 1550686321.920955 - <epoch seconds>.<epoch microseconds> |
| 306 | + params['from'] = floor(from_datetime.timestamp()) |
| 307 | + else: |
| 308 | + params['from'] = from_datetime |
| 309 | + params['to'] = floor(to_datetime.timestamp()) |
| 310 | + if uptime_ids: |
| 311 | + # Convert easy to use list to comma-delimited for the API |
| 312 | + params['uptimeids'] = ','.join(map(str, uptime_ids)) |
| 313 | + if tms_ids: |
| 314 | + params['tmsids'] = ','.join(map(str, tms_ids)) |
| 315 | + return self._make_request(endpoint, 'POST', query_params=params) |
| 316 | + |
| 317 | + |
| 318 | +if __name__ == "__main__": |
| 319 | + account = Pingdom('test@example.com', 'password') |
| 320 | + from_datetime = datetime.now() |
| 321 | + to_datetime = datetime.now() + timedelta(1) |
| 322 | + account.list_checks() |
| 323 | + account.create_new_maintenance_window('name', from_datetime, to_datetime, |
| 324 | + uptime_ids='123,345') |
0 commit comments