charm-hacluster/ocf/maas/maas_dns.py

392 lines
14 KiB
Python
Executable File

#!/usr/bin/python3
#
# Copyright 2016 Canonical Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import requests_oauthlib
import logging
import sys
import time
import maasclient
# Default MaaS API options
NUM_RETRIES = 5
RETRY_BASE_DELAY = 10
RETRY_CODES = [500]
# the global options that is parsed from the arguments
options = None
class RetriesException(Exception):
pass
def retry_on_request_error(retries=3, base_delay=0, codes=None):
"""Retry a function that retures a requests response.
If the response from the target function has an error code in the
:param:`codes` list then retry the function up to :param:`retries`. The
:param:`base_delay`, if not zero, will progressively back off at
`base_delay`, `base_delay * 2`, `base_delay * 3` ...
If the decorated function raises an exception, then the decorator DOESN'T
catch it, and this will bypass any retries.
In order to enable the decorator to access command line arguments, each of
the arguments can optionally be a Callable that returns the value, which
will be evaluated when the function is called.
:param retries: Number of attempts to run the decorated function.
:type retries: Option[int, Callable[..., int]]
:param base_delay: Back off time, which linearly increases by the number of
retries for each failed request.
:type base_delay: Option[int, Callable[..., int]]
:param codes: The codes to detect that force a retry that
response.status_code may contain.
:type codes: Option[List[int], Callable(..., List[int]]
:returns: decorated target function
:rtype: Callable
:raises: Exception, if the decorated function raises an exception
"""
if codes is None:
codes = [500]
def inner1(f):
def inner2(*args, **kwargs):
if callable(retries):
_retries = retries()
else:
_retries = retries
num_retries = _retries
if callable(base_delay):
_base_delay = base_delay()
else:
_base_delay = base_delay
if callable(codes):
_codes = codes()
else:
_codes = codes
multiplier = 1
while True:
response = f(*args, **kwargs)
if response.status_code not in _codes:
return response
if _retries <= 0:
raise RetriesException(
"Command {} failed after {} retries"
.format(f.__name__, num_retries))
delay = _base_delay * multiplier
multiplier += 1
logging.debug(
"Retrying '{}' {} more times (delay={})"
.format(f.__name__, _retries, delay))
_retries -= 1
if delay:
time.sleep(delay)
return inner2
return inner1
def options_retries():
"""Returns options.maas_api_retries value
It's used as a callable in the retry_on_request_error as follows:
@retry_on_request_error(retries=options_retries,
base_delay=options_base_delay,
codes=options_codes)
def some_function_that_needs_retries_that_returns_Response(...):
pass
:returns: options.maas_api_retries
:rtype: int
"""
global options
if options is not None:
return options.maas_api_retries
else:
return NUM_RETRIES
def options_base_delay():
"""Returns options.maas_base_delay
It's used as a callable in the retry_on_request_error as follows:
@retry_on_request_error(retries=options_retries,
base_delay=options_base_delay,
codes=options_codes)
def some_function_that_needs_retries_that_returns_Response(...):
pass
:returns: options.maas_base_delay
:rtype: int
"""
global options
if options is not None:
return options.maas_base_delay
else:
return RETRY_BASE_DELAY
def options_codes():
"""Returns options.maas_retry_codes
It's used as a callable in the retry_on_request_error as follows:
@retry_on_request_error(retries=options_retries,
base_delay=options_base_delay,
codes=options_codes)
def some_function_that_needs_retries_that_returns_Response(...):
pass
:returns: options.maas_retry_codes
:rtype: List[int]
"""
global options
if options is not None:
return options.maas_retry_codes
else:
return RETRY_CODES
class MAASDNS(object):
def __init__(self, options):
self.maas = maasclient.MAASClient(options.maas_server,
options.maas_credentials)
# String representation of the fqdn
self.fqdn = options.fqdn
# Dictionary representation of MAAS dnsresource object
# TODO: Do this as a property
self.dnsresource = self.get_dnsresource()
# String representation of the time to live
self.ttl = str(options.ttl)
# String representation of the ip
self.ip = options.ip_address
self.maas_server = options.maas_server
self.maas_creds = options.maas_credentials
def get_dnsresource(self):
""" Get a dnsresource object """
dnsresources = self.maas.get_dnsresources()
self.dnsresource = None
for dnsresource in dnsresources:
if dnsresource['fqdn'] == self.fqdn:
self.dnsresource = dnsresource
return self.dnsresource
def get_dnsresource_id(self):
""" Get a dnsresource ID """
return self.dnsresource['id']
@retry_on_request_error(retries=options_retries,
base_delay=options_base_delay,
codes=options_codes)
def update_resource(self):
""" Update a dnsresource record with an IP """
return self.maas.update_dnsresource(self.dnsresource['id'],
self.dnsresource['fqdn'],
self.ip)
def create_dnsresource(self):
""" Create a DNS resource object
Due to https://bugs.launchpad.net/maas/+bug/1555393
this is implemented outside of the maas lib.
"""
dns_url = '{}/api/2.0/dnsresources/?format=json'.format(
self.maas_server)
(consumer_key, access_token, token_secret) = self.maas_creds.split(':')
# The use of PLAINTEXT signature is inline with libmaas
# https://goo.gl/EJPrM7 but as noted there should be switched
# to HMAC once it works server-side.
maas_session = requests_oauthlib.OAuth1Session(
consumer_key,
signature_method='PLAINTEXT',
resource_owner_key=access_token,
resource_owner_secret=token_secret)
fqdn_list = self.fqdn.split('.')
payload = {
'fqdn': self.fqdn,
'name': fqdn_list[0],
'domain': '.'.join(fqdn_list[1:]),
'address_ttl': self.ttl,
'ip_addresses': self.ip,
}
@retry_on_request_error(retries=options_retries,
base_delay=options_base_delay,
codes=options_codes)
def inner_maas_session_post(session, dns_url, payload):
return session.post(dns_url, data=payload)
return inner_maas_session_post(maas_session, dns_url, payload)
class MAASIP(object):
def __init__(self, options):
self.maas = maasclient.MAASClient(options.maas_server,
options.maas_credentials)
# String representation of the IP
self.ip = options.ip_address
# Dictionary representation of MAAS ipaddresss object
# TODO: Do this as a property
self.ipaddress = self.get_ipaddress()
def get_ipaddress(self):
""" Get an ipaddresses object """
ipaddresses = self.maas.get_ipaddresses()
self.ipaddress = None
for ipaddress in ipaddresses:
if ipaddress['ip'] == self.ip:
self.ipaddress = ipaddress
return self.ipaddress
@retry_on_request_error(retries=options_retries,
base_delay=options_base_delay,
codes=options_codes)
def create_ipaddress(self, hostname=None):
""" Create an ipaddresses object
Due to https://bugs.launchpad.net/maas/+bug/1555393
This is currently unused
"""
return self.maas.create_ipaddress(self.ip, hostname)
def setup_logging(logfile, log_level='INFO'):
logFormatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S")
rootLogger = logging.getLogger()
rootLogger.setLevel(log_level)
consoleHandler = logging.StreamHandler()
consoleHandler.setFormatter(logFormatter)
rootLogger.addHandler(consoleHandler)
try:
fileLogger = logging.getLogger('file')
fileLogger.propagate = False
fileHandler = logging.FileHandler(logfile)
fileHandler.setFormatter(logFormatter)
rootLogger.addHandler(fileHandler)
fileLogger.addHandler(fileHandler)
except IOError:
logging.error('Unable to write to logfile: {}'.format(logfile))
def dns_ha():
parser = argparse.ArgumentParser()
parser.add_argument('--maas_server', '-s',
help='URL to mangage the MAAS server',
required=True)
parser.add_argument('--maas_credentials', '-c',
help='MAAS OAUTH credentials',
required=True)
parser.add_argument('--fqdn', '-d',
help='Fully Qualified Domain Name',
required=True)
parser.add_argument('--ip_address', '-i',
help='IP Address, target of the A record',
required=True)
parser.add_argument('--ttl', '-t',
help='DNS Time To Live in seconds',
default='')
parser.add_argument('--logfile', '-l',
help='Path to logfile',
default='/var/log/{}.log'
''.format(sys.argv[0]
.split('/')[-1]
.split('.')[0]))
parser.add_argument('--maas_api_retries', '-r',
help='The number of times to retry a MaaS API call',
type=int,
default=3)
parser.add_argument('--maas_base_delay', '-b',
help='The base delay after a failed MaaS API call',
type=int,
default=10)
def read_int_list(s):
try:
return [int(x.strip()) for x in s.split(',')]
except TypeError:
msg = "Can't convert '{}' into a list of integers".format(s)
return argparse.ArgumentTypeError(msg)
parser.add_argument('--maas_retry_codes', '-x',
help=('The codes to detect to auto-retry, as a '
'comma-separated list.'),
type=read_int_list,
default=[500])
global options
options = parser.parse_args()
setup_logging(options.logfile)
logging.info("Starting maas_dns")
dns_obj = MAASDNS(options)
if not dns_obj.dnsresource:
dns_obj.create_dnsresource()
elif dns_obj.dnsresource.get('ip_addresses'):
# TODO: Handle multiple IPs returned for ip_addresses
for ip in dns_obj.dnsresource['ip_addresses']:
if ip.get('ip') != options.ip_address:
logging.info('Update the dnsresource with IP: {}'
''.format(options.ip_address))
dns_obj.update_resource()
else:
logging.info('IP is the SAME {}, no update required'
''.format(options.ip_address))
else:
logging.info('Update the dnsresource with IP: {}'
''.format(options.ip_address))
dns_obj.update_resource()
def main():
"""Entry point for the script.
Runs dns_ha(), but wraps it with exception handling so that retries using
the MaaS API return 2 from the script, and all other errors return 1.
Otherwise the script returns 0 to indicate that it thinks it succeeded.
:returns: return code for script
:rtype: int
"""
try:
dns_ha()
except RetriesException as e:
logging.error("'{}' failed retries: {}".format(sys.argv[0], str(e)))
return 1
except Exception as e:
logging.error("'{}' failed due to: {}".format(sys.argv[0], str(e)))
import traceback
logging.error("Traceback:\n{}".format(traceback.format_exc()))
return 2
return 0
if __name__ == '__main__':
sys.exit(main())