Skip to content

Commit

Permalink
Merge branch 'release/v0.6.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
eternalharvest committed Jun 2, 2017
2 parents 35da366 + 4cbe9bc commit 1db780d
Show file tree
Hide file tree
Showing 10 changed files with 328 additions and 44 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.tox/
local/

*.egg-info/
*.pyc
20 changes: 20 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -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}
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion r53update/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__all__ = ['main']

from r53update import main
from .r53update import main
94 changes: 59 additions & 35 deletions r53update/r53update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -106,8 +104,8 @@ def __call__(self):
try:
self._init()
self._run()
except Exception, e:
print >>sys.stderr, "[31m%s[0m" % e
except Exception as e:
print("[31m%s[0m" % e, file=sys.stderr)
sys.exit(1)

##
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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, "[31m%s[0m" % e
except Exception as e:
print("[31m%s[0m" % e, file=sys.stderr)
sys.exit(1)

if __name__ == '__main__':
Expand Down
Empty file added r53update/test/__init__.py
Empty file.
154 changes: 154 additions & 0 deletions r53update/test/test_ctx.py
Original file line number Diff line number Diff line change
@@ -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()

Loading

0 comments on commit 1db780d

Please sign in to comment.