diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..930244a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.tox/ +local/ + +*.egg-info/ +*.pyc diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c97184a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +language: python +sudo: false + +matrix: + include: + - python: '2.7' + env: TOXENV=py27 + - python: '3.3' + env: TOXENV=py33 + - python: '3.4' + env: TOXENV=py34 + - python: '3.5' + env: TOXENV=py35 + - python: '3.6' + env: TOXENV=py36 + +install: + - pip install tox +script: + - tox -e ${TOXENV} diff --git a/README.md b/README.md index e3910ca..e52f3e1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # R53Update command line utility +[![Github release](https://img.shields.io/github/release/tuntunkun/r53update.svg)](https://github.com/tuntunkun/r53update/releases) +[![Python version](https://img.shields.io/badge/python-2.7%2C%203.3%2C%203.4%2C%203.5%2C%203.6-green.svg)](#) +[![Requirements Status](https://requires.io/github/tuntunkun/r53update/requirements.svg)](https://requires.io/github/tuntunkun/r53update/requirements) +[![Build Status](https://travis-ci.org/tuntunkun/r53update.svg?branch=develop)](https://travis-ci.org/tuntunkun/r53update) +[![Hex.pm](https://img.shields.io/hexpm/l/plug.svg)](https://raw.githubusercontent.com/tuntunkun/r53update/master/LICENSE) R53Update is a command line utility for Amazon Route 53 which is one of the AWS (Amazon Web Services). This tools is useful to anyone who wants to operate server with dynamic IP. You can operate not only the server which is hosted on Amazon EC2 but also on-premise servers. -[![GitHub version](https://badge.fury.io/gh/tuntunkun%2Fr53update.svg)]() [![Hex.pm](https://img.shields.io/hexpm/l/plug.svg)]() - ## Requirements diff --git a/r53update/__init__.py b/r53update/__init__.py index ed7ca21..03fcae0 100644 --- a/r53update/__init__.py +++ b/r53update/__init__.py @@ -1,3 +1,3 @@ __all__ = ['main'] -from r53update import main +from .r53update import main diff --git a/r53update/r53update.py b/r53update/r53update.py index 40e44d2..8d56aae 100755 --- a/r53update/r53update.py +++ b/r53update/r53update.py @@ -16,20 +16,18 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from boto.route53.connection import Route53Connection -from boto.route53.record import ResourceRecordSets -from botocore.session import Session +from __future__ import print_function +from boto3.session import Session from pkg_resources import get_distribution +from six.moves.urllib import request import sys import argparse -import urllib2 import dns.resolver import dns.exception import logging import logging.handlers import netifaces -import exceptions try: import argcomplete @@ -106,8 +104,8 @@ def __call__(self): try: self._init() self._run() - except Exception, e: - print >>sys.stderr, "%s" % e + except Exception as e: + print("%s" % e, file=sys.stderr) sys.exit(1) ## @@ -117,18 +115,14 @@ class R53UpdateApp(App): # Context class Context(object): def __init__(self, profile=None): - self.session = Session() - self.session.profile = profile + self.session = Session(profile_name=profile) - def getR53Connection(self): + def getR53Client(self): credential = self.session.get_credentials() if not credential: raise RuntimeError("failed to get aws credential") - return Route53Connection( - credential.access_key, - credential.secret_key - ) + return self.session.client('route53') ## # Argument Completer @@ -172,7 +166,7 @@ def __init__(self, app, url): self._url = url def resolveGlobalIP(self): - return urllib2.urlopen(self._url).read().rstrip() + return request.urlopen(self._url).read().rstrip() class DNS_GlobalIP_DetectionMethod(GlobalIP_DetectionMethod): def __init__(self, app, hostname, resolvername): @@ -183,7 +177,7 @@ def __init__(self, app, hostname, resolvername): def resolveGlobalIP(self, ns=False): resolver = dns.resolver.Resolver() resolver.nameservers = self._app._opts.dns if ns else self.resolveGlobalIP(True) - return map(lambda x: x.to_text(), resolver.query(self._resolvername if ns else self._hostname, 'A')) + return [str(x) for x in resolver.query(self._resolvername if ns else self._hostname, 'A')] class NETIFACES_GlobalIP_DetectionMethod(GlobalIP_DetectionMethod): def __init__(self, app): @@ -195,7 +189,7 @@ def resolveGlobalIP(self): except Exception as e: raise Exception("%s: no inet address found" % self._app._opts.iface) - return map(lambda x: x['addr'], inet) + return [x['addr'] for x in inet] def _pre_init(self): super(R53UpdateApp, self)._pre_init() @@ -262,6 +256,9 @@ def _post_init(self, opts): raise Exception("interface name '%s' not found" % opts.iface) self._opts.method = 'localhost' + if not opts.zone.endswith('.'): + opts.zone += '.' + def __get_global_ip(self): self.logger.debug('resolving global ip adreess with \'%s\'', self._opts.method) gips = self._gipmethods[self._opts.method].resolveGlobalIP() @@ -274,7 +271,7 @@ def __get_records_from_host(self, fqdn): try: response = resolver.query(fqdn, 'A') - results = map(lambda x: x.to_text(), response) + results = [x.to_text() for x in response] except dns.resolver.NXDOMAIN: pass except dns.resolver.Timeout: @@ -289,22 +286,41 @@ def __get_records_from_host(self, fqdn): def __update_r53_record(self, zone_name, host_name, gips): fqdn = '%s.%s' % (host_name, zone_name) - conn = self.ctx.getR53Connection() - zone = conn.get_zone(zone_name) - - if zone is None: - raise Exception("zone '%s' not found" % zone_name) - self.logger.debug('R53 zoneid: %s' % zone.id) + r53= self.ctx.getR53Client() - changes = ResourceRecordSets(conn, zone.id, '') - change = changes.add_change('UPSERT', fqdn, 'A', self._opts.ttl) + zones = r53.list_hosted_zones().get('HostedZones', []) + zone_id = None - for gip in gips: - change.add_value(gip) - - changes.commit() + for zone in zones: + if zone['Name'] == zone_name: + zone_id = zone['Id'] + break + + if zone_id is None: + raise Exception("zone '%s' not found" % zone_name) + self.logger.debug('R53 zoneid: %s' % zone_id) + + r53.change_resource_record_sets( + HostedZoneId = zone_id, + ChangeBatch = { + 'Comment': 'auto update with r53update version v%s' % self.version, + 'Changes': [{ + 'Action': 'UPSERT', + 'ResourceRecordSet': { + 'Name': fqdn, + 'Type': 'A', + 'TTL': self._opts.ttl, + 'ResourceRecords': [ + { + 'Value': ip + } for ip in gips + ] + } + }] + } + ) - self.logger.info('update A records of \'%s\' with \'%s\'' % (fqdn, gip)) + self.logger.info('update A records of "%s" with %s' % (fqdn, gips)) def _run(self): fqdn = '%s.%s' % (self._opts.host, self._opts.zone) @@ -329,17 +345,25 @@ def _run(self): else: self.logger.debug('route53 zone info is up to date') + @property + def version(self): + pass + + @version.getter + def version(self): + return get_distribution('r53update').version + def show_version(self): - print >>sys.stderr, "Copyrights (c)2014 Takuya Sawada All rights reserved." - print >>sys.stderr, "Route53Update Dynamic DNS Updater v%s" % get_distribution("r53update").version + print("Copyrights (c)2014 Takuya Sawada All rights reserved.", file=sys.stderr) + print("Route53Update Dynamic DNS Updater v%s" % self.version, file=sys.stderr) def main(): try: R53UpdateApp(sys.argv)() - except Exception, e: - print >>sys.stderr, "%s" % e + except Exception as e: + print("%s" % e, file=sys.stderr) sys.exit(1) if __name__ == '__main__': diff --git a/r53update/test/__init__.py b/r53update/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/r53update/test/test_ctx.py b/r53update/test/test_ctx.py new file mode 100644 index 0000000..20a3309 --- /dev/null +++ b/r53update/test/test_ctx.py @@ -0,0 +1,154 @@ +#!/bin/env python +# -*- coding: utf-8 -*- +# +# R53Update Dynamic DNS Updater +# (C)2014 Takuya Sawada All rights reserved. +# +# 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. +# +from ..r53update import R53UpdateApp +from botocore import exceptions + +import unittest +import string +import random +import mock +import os +import io + +class TestAppContext(unittest.TestCase): + def setUp(self): + # keep original function for reference from mock function + self.__open__ = open + self.__isfile__ = os.path.isfile + + try: + import __builtin__ + self.builtins = '__builtin__' + except: + self.builtins = 'builtins' + + # key-pair test data + self.profile = { + 'env': (self.randomstr(20), self.randomstr(40)), + 'default': (self.randomstr(20), self.randomstr(40)), + 'test': (self.randomstr(20), self.randomstr(40)) + } + + def randomstr(self, length): + return ''.join([random.choice(string.digits + string.ascii_uppercase) for i in range(12)]) + + ## + # boto core checks if config file exists before load it. + # in order to use mock file instead of actual config file, + # we must use this mock function which always return True + # if specified path is the config file. + # + # Ref) https://github.com/boto/botocore/blob/develop/botocore/configloader.py + # + def mock_isfile(self, path): + if path == os.path.expanduser('~/.aws/config'): + return True + else: + return self.__isfile__(path) + + def mock_file(self, path, *args, **kwargs): + if path == os.path.expanduser('~/.aws/config'): + return io.StringIO( + u"[default]\n" + u"aws_access_key_id = %s\n" + u"aws_secret_access_key = %s\n" + u"region = ap-northeast-1\n" + u"output = json\n" + u"\n" + u"[profile test]\n" + u"aws_access_key_id = %s\n" + u"aws_secret_access_key = %s\n" + u"region = ap-northeast-2\n" + u"output = json\n" + + % (self.profile['default'] + self.profile['test']) + ) + else: + return self.__open__(path, *args, **kwargs) + + # check credential with environment variable + def test_env(self): + AWS_ACCESS_KEY, AWS_SECRET_KEY = self.profile['env'] + + ENV = { + 'AWS_ACCESS_KEY_ID': AWS_ACCESS_KEY, + 'AWS_SECRET_ACCESS_KEY': AWS_SECRET_KEY + } + + with mock.patch.dict('os.environ', ENV): + ctx = R53UpdateApp.Context() + + creds = ctx.session.get_credentials() + self.assertNotEqual(creds, None) + + self.assertEqual(creds.access_key, AWS_ACCESS_KEY) + self.assertEqual(creds.secret_key, AWS_SECRET_KEY) + + self.assertNotEqual(ctx.getR53Client(), None) + + # check credential with profile [default] (implicit) + def test_profile_default_implicit(self): + with mock.patch('os.path.isfile', side_effect=self.mock_isfile): + with mock.patch('%s.open' % self.builtins, side_effect=self.mock_file): + AWS_ACCESS_KEY, AWS_SECRET_KEY = self.profile['default'] + ctx = R53UpdateApp.Context() + + creds = ctx.session.get_credentials() + self.assertNotEqual(creds, None) + + self.assertEqual(creds.access_key, AWS_ACCESS_KEY) + self.assertEqual(creds.secret_key, AWS_SECRET_KEY) + + self.assertNotEqual(ctx.getR53Client(), None) + + # check credential with profile [default] (explicit) + def test_profile_default_explicit(self): + with mock.patch('os.path.isfile', side_effect=self.mock_isfile): + with mock.patch('%s.open' % self.builtins, side_effect=self.mock_file): + AWS_ACCESS_KEY, AWS_SECRET_KEY = self.profile['default'] + ctx = R53UpdateApp.Context('default') + + creds = ctx.session.get_credentials() + self.assertEqual(creds.access_key, AWS_ACCESS_KEY) + self.assertEqual(creds.secret_key, AWS_SECRET_KEY) + + self.assertNotEqual(ctx.getR53Client(), None) + + # check credential with profile [test] + def test_profile_test(self): + with mock.patch('os.path.isfile', side_effect=self.mock_isfile): + with mock.patch('%s.open' % self.builtins, side_effect=self.mock_file): + AWS_ACCESS_KEY, AWS_SECRET_KEY = self.profile['test'] + ctx = R53UpdateApp.Context('test') + + creds = ctx.session.get_credentials() + self.assertEqual(creds.access_key, AWS_ACCESS_KEY) + self.assertEqual(creds.secret_key, AWS_SECRET_KEY) + + self.assertNotEqual(ctx.getR53Client(), None) + + # check not exist profile cause `ProfileNotFound` exception + def test_profile_not_found(self): + with (mock.patch('os.environ.get', return_value=None)): + with mock.patch('os.path.isfile', side_effect=self.mock_isfile): + with mock.patch('%s.open' % self.builtins, side_effect=self.mock_file): + with self.assertRaises(exceptions.ProfileNotFound): + ctx = R53UpdateApp.Context('notexist') + ctx.getR53Client() + diff --git a/r53update/test/test_method.py b/r53update/test/test_method.py new file mode 100644 index 0000000..97d6488 --- /dev/null +++ b/r53update/test/test_method.py @@ -0,0 +1,68 @@ +#!/bin/env python +# -*- coding: utf-8 -*- +# +# R53Update Dynamic DNS Updater +# (C)2014 Takuya Sawada All rights reserved. +# +# 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. +# +from ..r53update import R53UpdateApp +import dns.resolver +import netifaces +import unittest +import random +import mock +import io + +class TestMethod(unittest.TestCase): + def randomIP(self): + return u'%d.%d.%d.%d' % tuple(int(random.random()*255) for _ in range(4)) + + def test_HTTP_Method(self): + IPADDR = self.randomIP() + SERVER = u'http://ifconfig.me/' + + with mock.patch('six.moves.urllib.request.urlopen', return_value=io.StringIO(IPADDR)) as urlopen: + method = R53UpdateApp.HTTP_GlobalIP_DetectionMethod(None, SERVER) + self.assertEqual(method.resolveGlobalIP(), IPADDR) + urlopen.assert_called_once_with(SERVER) + + def test_DNS_Method(self): + RETVAL = [ self.randomIP() for _ in range(4) ] + + app = mock.MagicMock() + app._opts.dns = ['8.8.8.8', '8.8.4.4'] + + with mock.patch.object(dns.resolver.Resolver, 'query', return_value=RETVAL) as query: + method = R53UpdateApp.DNS_GlobalIP_DetectionMethod(app, 'myip.opendns.com', 'resolver1.opendns.com') + self.assertListEqual(method.resolveGlobalIP(), RETVAL) + query.has_calls(['resolver1.opendns.com', 'myip.opendns.com']) + + def test_NETIFACES_Method(self): + RETVAL = { + netifaces.AF_INET: [ + { + 'addr': self.randomIP(), + 'netmask': self.randomIP(), + 'broadcast': self.randomIP() + } for _ in range(4) + ] + } + + app = mock.MagicMock() + app._opts.iface = 'eth0' + + with mock.patch('netifaces.ifaddresses', return_value=RETVAL) as ifaddresses: + method = R53UpdateApp.NETIFACES_GlobalIP_DetectionMethod(app) + self.assertListEqual(method.resolveGlobalIP(), [x['addr'] for x in RETVAL[netifaces.AF_INET]]) + ifaddresses.assert_called_once_with(app._opts.iface) diff --git a/setup.py b/setup.py index 76b6934..ec3f9c7 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ setup( name = 'r53update', - version='0.5.2', + version='0.6.0', description='R53Update Dynamic DNS Updater', author='Takuya Sawada', author_email='takuya@tuntunkun.com', @@ -29,11 +29,10 @@ license='Apache License 2.0', packages = ['r53update'], install_requires = [ - 'argparse==1.3.0', - 'boto==2.36.0', - 'awscli==1.7.15', - 'dnspython==1.12.0', - 'netifaces==0.10.4' + 'argparse2==0.5.0a1', + 'boto3==1.4.4', + 'dnspython==1.15.0', + 'netifaces==0.10.6' ], classifiers = [ 'Development Status :: 4 - Beta', @@ -41,6 +40,7 @@ 'Operating System :: POSIX', 'Programming Language :: Python', 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 3', 'Topic :: System :: Networking', 'Topic :: Utilities' ], diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..9a5661c --- /dev/null +++ b/tox.ini @@ -0,0 +1,10 @@ +[tox] +envlist = py27,py33,py34,py35,py36 +recreate = True + +[testenv] +deps = + nose + mock +commands = + nosetests