diff --git a/Makefile b/Makefile index 23c829c..23c34ed 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,13 @@ PACKAGE=dyn -.PHONY: clean +.PHONY: clean publish dics style init ci init: pip install -r test-requirements.txt style: flake8 $(PACKAGE) + pycodestyle $(PACKAGE) ci: init style @@ -23,4 +24,4 @@ clean: docs: cd docs && make html - @echo "\033[95m\n\nBuild successful! View the docs homepage at docs/_build/html/index.html.\n\033[0m" \ No newline at end of file + @echo "\033[95m\n\nBuild successful! View the docs homepage at docs/_build/html/index.html.\n\033[0m" diff --git a/docs/conf.py b/docs/conf.py index b8fbad0..4528b22 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,8 +19,9 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('../..')) + # Import package for version info -import dyn +import dyn # noqa E402 def skip(app, what, name, obj, skip, options): @@ -35,11 +36,13 @@ def setup(app): # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. + + extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.viewcode', @@ -52,7 +55,7 @@ def setup(app): source_suffix = '.rst' # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' @@ -72,13 +75,13 @@ def setup(app): # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -86,27 +89,27 @@ def setup(app): # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output ---------------------------------------------- @@ -118,10 +121,10 @@ def setup(app): # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". @@ -132,7 +135,7 @@ def setup(app): # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = '' +# html_logo = '' # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 @@ -147,31 +150,31 @@ def setup(app): # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = False @@ -180,15 +183,15 @@ def setup(app): html_show_sphinx = False # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'sphinxdoc' @@ -196,42 +199,42 @@ def setup(app): # -- Options for LaTeX output --------------------------------------------- -latex_elements = { +latex_elements = {} # The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', +# 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', +# 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. -#'preamble': '', -} +# 'preamble': '', + # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). -latex_documents = [('index', 'sphinx.tex', u'Dyn Documentation', u'Author', +latex_documents = [('index', 'sphinx.tex', u'Dyn Documentation', u'Author', 'manual')] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- @@ -244,7 +247,7 @@ def setup(app): ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -259,16 +262,16 @@ def setup(app): ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False # -- Options for Epub output ---------------------------------------------- @@ -279,63 +282,61 @@ def setup(app): epub_publisher = u'Author' epub_copyright = u'2014, Author' + # The basename for the epub file. It defaults to the project name. -#epub_basename = u'.' +# epub_basename = u'.' -# The HTML theme for the epub output. Since the default themes are not optimized -# for small screen space, using the same theme for HTML and epub output is -# usually not wise. This defaults to 'epub', a theme designed to save visual -# space. -#epub_theme = 'epub' +# The CSS theme for the ebub. +# epub_theme = 'epub' # The language of the text. It defaults to the language option # or en if the language is not set. -#epub_language = '' +# epub_language = '' # The scheme of the identifier. Typical schemes are ISBN or URL. -#epub_scheme = '' +# epub_scheme = '' # The unique identifier of the text. This can be a ISBN number # or the project homepage. -#epub_identifier = '' +# epub_identifier = '' # A unique identification for the text. -#epub_uid = '' +# epub_uid = '' # A tuple containing the cover image and cover page html template filenames. -#epub_cover = () +# epub_cover = () # A sequence of (type, uri, title) tuples for the guide element of content.opf. -#epub_guide = () +# epub_guide = () # HTML files that should be inserted before the pages created by sphinx. # The format is a list of tuples containing the path and title. -#epub_pre_files = [] +# epub_pre_files = [] # HTML files shat should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. -#epub_post_files = [] +# epub_post_files = [] # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] # The depth of the table of contents in toc.ncx. -#epub_tocdepth = 3 +# epub_tocdepth = 3 # Allow duplicate toc entries. -#epub_tocdup = True +# epub_tocdup = True # Choose between 'default' and 'includehidden'. -#epub_tocscope = 'default' +# epub_tocscope = 'default' # Fix unsupported image types using the PIL. -#epub_fix_images = False +# epub_fix_images = False # Scale large images. -#epub_max_image_width = 0 +# epub_max_image_width = 0 # How to display URL addresses: 'footnote', 'no', or 'inline'. -#epub_show_urls = 'inline' +# epub_show_urls = 'inline' # If false, no index is generated. -#epub_use_index = True +# epub_use_index = True diff --git a/dyn/cli/__init__.py b/dyn/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py new file mode 100755 index 0000000..7d2c336 --- /dev/null +++ b/dyn/cli/dyntm.py @@ -0,0 +1,1027 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +A command line tool for interacting with the Dyn Traffic Management API. + +""" + +# TODO +# A file cache of zones, nodes, services etc. Any of the 'get_all_X'. +# DTRT with one argument specifying a zone and node. +# Better documentation, help messages, and error messages. + +# system libs +import os +import sys +import re +import copy +import argparse +import shlex +import subprocess +import functools +import getpass +import yaml +import json + +# internal libs +import dyn.tm +from dyn.tm.accounts import get_users +from dyn.tm.zones import Zone, get_all_zones +from dyn.tm.zones import SecondaryZone, get_all_secondary_zones +from dyn.tm.session import DynectSession +from dyn.tm.errors import DynectError, DynectAuthError +from dyn.tm.services.httpredirect import HTTPRedirect + +# globals! +srstyles = ['increment', 'epoch', 'day', 'minute'] +rectypes = sorted(dyn.tm.zones.RECS.keys()) + + +# parent command class +class DyntmCommand(object): + ''' + This is a help string right? + ''' + + name = "dyntm" + desc = "Interact with Dyn Traffic Management API" + subtitle = "Commands" + args = [ + {'arg': '--conf', 'type': str, 'dest': 'conf', + 'help': 'Alternate configuration file.'}, + {'arg': '--cust', 'type': str, 'dest': 'cust', + 'help': 'Customer account name for authentication.'}, + {'arg': '--user', 'type': str, 'dest': 'user', + 'help': 'User name for authentication.'}, + {'arg': '--host', 'type': str, 'dest': 'host', + 'help': 'Alternate DynECT API host.'}, + {'arg': '--port', 'type': int, 'dest': 'port', + 'help': 'Alternate DynECT API port.'}, + {'arg': '--proxy-host', 'type': str, + 'dest': 'proxy_host', 'help': 'HTTP proxy host.'}, + {'arg': '--proxy-port', 'type': str, + 'dest': 'proxy_port', 'help': 'HTTP proxy port.'}, + {'arg': '--proxy-user', 'type': str, 'dest': 'proxy_user', + 'help': 'HTTP proxy user name.'}, + {'arg': '--proxy-pass', 'type': str, + 'dest': 'proxy_pass', 'help': 'HTTP proxy password.'}, + ] + + @classmethod + def parser(cls, parser=None): + # setup parser + if not parser: + parser = argparse.ArgumentParser( + prog=cls.name, description=cls.desc) + # add arguments + for spec in [dict(s) for s in cls.args if s]: + parser.add_argument(spec.pop('arg'), **spec) + # set action function and name + parser.set_defaults(func=cls.action, command=cls.name) + # setup subcommand parsers + if len(cls.__subclasses__()) != 0: + what = parser.add_subparsers(title=cls.subtitle) + for cmd in cls.__subclasses__(): + sub = what.add_parser(cmd.name, help=cmd.desc) + cmd.parser(parser=sub) + return parser + + @classmethod + def config(cls, conf): + # maybe generate an empty configuration file + cpath = os.path.expanduser(conf) + if not os.path.exists(cpath): + creds = {"customer": "", "user": "", "password": ""} + with open(cpath, 'w') as cf: + yaml.dump(creds, cf, default_flow_style=False) + # read configuration file and return config dict + with open(cpath, 'r') as cf: + return yaml.load(cf) + + @classmethod + def session(cls, auth=False, **kwargs): + # return session singleton if it exists already + session = DynectSession.get_session() + if session and not auth: + return session + # require credentials + cust = kwargs.get('customer') + user = kwargs.get('user') + if not user or not cust: + msg = "A customer name and user name must be provided!\n" + raise ValueError(msg) + # run system command to fetch password if possible + password = None + passcmd = kwargs.get('passcmd') + if passcmd: + toks = shlex.split(passcmd) + proc = subprocess.Popen(toks, stdout=subprocess.PIPE) + if proc.wait() == 0: + output = proc.stdout.readline().decode('UTF-8') + password = output.strip() + else: + password = kwargs.get('password') + # or get password interactively if practical + if not password and sys.stdout.isatty(): + prompt = "Password for {}/{} > ".format(cust, user) + password = getpass.getpass(prompt) + # require a password + if not password: + raise ValueError("A password must be provided!") + # setup session + token = None + tpath = os.path.expanduser("~/.dyntm_{}_{}".format(cust, user)) + # maybe load cached session token + if os.path.isfile(tpath): + with open(tpath, 'r') as tf: + token = tf.readline() + # figure session fields + keys = ['host', 'port', 'proxy_host', 'proxy_port', + 'proxy_user', 'proxy_pass'] + opts = {k: v for k, v in kwargs.items() + if k in keys and v is not None} + # create session + if not token or auth: + # authenticate + session = DynectSession(cust, user, password, **opts) + session.authenticate() + else: + # maybe use cached session token + session = DynectSession(cust, user, password, + auto_auth=False, **opts) + session._token = token + # record session token for later use + if session._token and session._token != token: + with open(tpath, 'w') as tf: + tf.write(session._token) + # return the session handle + return session + + @classmethod + def action(cls, *argv, **opts): + # parse arguments + args = vars(cls.parser().parse_args()) + # get configuration + try: + conf = cls.config(args.get('conf') or "~/.dyntm.yml") + except Exception as e: + msg = "Configuration problem!\n{}\n".format(e.message or str(e)) + sys.exit(1) + # command line arguments take precedence over config + auth = ['customer', 'user', 'password', 'passcmd', 'host', 'port', + 'proxy_host', 'proxy_port', 'proxy_user', 'proxy_pass'] + plan = {k: args.get(k) or conf.get(k) for k in auth} + # get session + try: + cls.session(auth=False, **plan) + except Exception as e: + msg = "Authentication problem!\n{}\n".format(str(e)) + sys.stderr.write(msg) + sys.exit(2) + # figure out arguments for subcommand + mine = auth + ['command', 'func'] + inp = {k: v for k, v in args.items() if k not in mine} + # run the command, reauthenticate if needed + func = args.get('func') + command = args.get('command') + context = "{} {}".format(command, str(inp)) + try: + try: + func(**inp) + except DynectAuthError as err: + cls.session(auth=True, **plan) + func(**inp) + except DynectError as err: + msg = "{}\n{}\n".format(context, str(err)) + sys.stderr.write(msg) + exit(3) + except Exception as err: + msg = "{}\n{}\n".format(context, str(err)) + sys.stderr.write(msg) + exit(4) + # done! + exit(0) + + def __init__(self): + return + + +# user permissions +class CommandUserPermissions(DyntmCommand): + name = "perms" + desc = "List permissions." + args = [] + + @classmethod + def action(cls, *rest, **args): + # get active session + session = cls.session() + # print each permission available to current session + for perm in sorted(session.permissions): + sys.stdout.write("{}\n".format(str(perm))) + + +# log out +class CommandUserLogOut(DyntmCommand): + name = "logout" + desc = "Log out of the current session." + args = [] + + @classmethod + def action(cls, *rest, **args): + # get active session and log out + session = cls.session() + session.log_out() + + +# update password +class CommandUserPassword(DyntmCommand): + name = "passwd" + desc = "Update password." + args = [ + {'arg': 'password', 'type': str, + 'help': 'A new password.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # get active session + session = cls.session() + # get password or prompt for it + newpass = args['password'] or getpass() + # update password + session.update_password(newpass) + + +# list users +class CommandUserList(DyntmCommand): + name = "users" + desc = "List users." + args = [] + + @classmethod + def action(cls, *rest, **args): + # attrs = ['first_name', 'last_name', 'phone', 'organization', + # 'address', 'city', 'country', 'fax'] + for user in get_users(): + # mess = json.dumps({k: getattr(user, k, "") for k in attrs}) + # msg = "{} {} {}\n".format( + # user.user_name, user.status, user.email) + sys.stdout.write("{}\n".format(user.user_name)) + + +# list zones +class CommandZoneList(DyntmCommand): + name = "zones" + desc = "List all the zones available." + args = [] + + @classmethod + def action(cls, *rest, **args): + zones = get_all_zones() + for zone in zones: + sys.stdout.write("{}\n".format(zone.name)) + + +# list primary zones +class CommandZonePrimaryList(DyntmCommand): + name = "primary" + desc = "List all the primary zones available." + args = [] + + @classmethod + def action(cls, *rest, **args): + zones = get_all_zones() + secondary = get_all_secondary_zones() + primary = [z for z in zones + if z.name not in [s.zone for s in secondary]] + for zone in primary: + sys.stdout.write("{}\n".format(zone.name)) + + +# list secondary zones +class CommandZoneSecondaryList(DyntmCommand): + name = "secondary" + desc = "List all the secondary zones available." + args = [] + + @classmethod + def action(cls, *rest, **args): + secondary = get_all_secondary_zones() + for zone in secondary: + sys.stdout.write("{}\n".format(zone.zone)) + + +# create zone +class CommandZoneCreate(DyntmCommand): + name = "zone-new" + desc = "Make a new zone." + args = [ + {'arg': '--ttl', 'dest': 'ttl', 'type': int, + 'help': 'Integer TTL.'}, + {'arg': '--timeout', 'dest': 'timeout', 'type': int, + 'help': 'Integer timeout for transfer.'}, + {'arg': '--style', 'type': str, 'dest': 'serial_style', + 'help': 'Serial style.', 'choices': srstyles}, + {'arg': '--file', 'dest': 'file', 'type': str, + 'help': 'File from which to import zone data.'}, + {'arg': '--master', 'dest': 'master', 'type': str, + 'help': 'Master IP from which to transfer zone.'}, + {'arg': 'name', 'type': str, + 'help': 'The name of the zone.'}, + {'arg': 'contact', 'type': str, + 'help': 'Administrative contact for this zone (RNAME).'}, + ] + + @classmethod + def action(cls, *rest, **args): + # figure out zone init arguments + spec = [d['dest'] if 'dest' in d else d['arg'] for d in cls.args] + new = {k: args[k] for k in spec if args[k] is not None} + # make a new zone + zone = Zone(**new) + sys.stdout.write("{}".format(str(zone))) + + +# create secondary zone +class CommandSecondaryZoneCreate(DyntmCommand): + name = "secondary-new" + desc = "Make a new secondary zone." + args = [ + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + {'arg': 'masters', 'type': str, 'nargs': '+', + 'help': 'IPs of master nameservers of the zone.'}, + {'arg': '--contact', 'type': str, 'dest': 'contact_nickname', + 'help': 'Administrative contact for this zone (RNAME).'}, + {'arg': '--tsig-key', 'type': str, 'dest': 'tsig_key_name', + 'help': 'Name of TSIG key to use when communicating with masters.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # figure out zone init arguments + spec = [d['dest'] if 'dest' in d else d['arg'] for d in cls.args] + new = {k: args[k] for k in spec if args[k] is not None} + # make a new secondary zone + zone = SecondaryZone(**new) + sys.stdout.write("{}".format(str(zone))) + + +# delete zone +class CommandZoneDelete(DyntmCommand): + name = "zone-delete" + desc = "Make a new zone." + args = [ + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # get the zone and delete it! + zone = Zone(args['zone']) + zone.delete() + + +# freeze zone +class CommandZoneFreeze(DyntmCommand): + name = "freeze" + desc = "Freeze the given zone." + args = [ + {'arg': '--ttl', 'type': int, + 'help': 'Integer TTL.'}, + {'arg': '--timeout', 'type': int, + 'help': 'Integer timeout for transfer.'}, + {'arg': '--style', 'dest': 'serial_style', + 'help': 'Serial style.', 'choices': srstyles}, + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # get the zone and freeze it solid + zone = Zone(args['zone']) + zone.freeze() + + +# thaw zone +class CommandZoneThaw(DyntmCommand): + name = "thaw" + desc = "Thaw the given zone." + args = [ + {'arg': '--ttl', 'type': int, + 'help': 'Integer TTL.'}, + {'arg': '--timeout', 'type': int, + 'help': 'Integer timeout for transfer.'}, + {'arg': '--style', 'dest': 'serial_style', + 'help': 'Serial style.', 'choices': srstyles}, + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # get the zone and thaw it out + zone = Zone(args['zone']) + zone.thaw() + + +# list nodes +class CommandNodeList(DyntmCommand): + name = "nodes" + desc = "List nodes in the given zone." + args = [ + {'arg': 'zone', 'type': str, 'help': 'The name of the zone.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # get the zone + zone = Zone(args['zone']) + # output all of the zone's nodes + for node in zone.get_all_nodes(): + sys.stdout.write("{}\n".format(node.fqdn)) + + +# delete nodes +class CommandNodeDelete(DyntmCommand): + name = "node-delete" + desc = "Delete the given node." + args = [ + {'arg': 'zone', 'type': str, 'help': 'The name of the zone.'}, + {'arg': 'node', 'type': str, 'help': 'The name of the node.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # get the zone and node + zone = Zone(args['zone']) + node = zone.get_node(args['node']) + # delete the node + node.delete() + + +# zone changes +class CommandZoneChanges(DyntmCommand): + name = "changes" + desc = "List pending changes to a zone." + args = [ + {'arg': 'zone', 'type': str, 'help': 'The name of the zone.'}, + {'arg': 'note', 'type': str, 'nargs': '?', + 'help': 'A note associated with this change.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # get the zone + zone = Zone(args['zone']) + for change in zone.get_changes(): + fqdn = change["fqdn"] + ttl = change["ttl"] + rtype = change["rdata_type"] + rdata = change["rdata"].get("rdata_{}".format(rtype.lower()), {}) + msg = "{} {} {} {}\n".format(fqdn, rtype, ttl, json.dumps(rdata)) + sys.stdout.write(msg) + + +# zone publish +class CommandZonePublish(DyntmCommand): + name = "publish" + desc = "Publish pending changes to a zone." + args = [ + {'arg': 'zone', 'type': str, 'help': 'The name of the zone.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # get the zone + zone = Zone(args['zone']) + zone.publish(notes=args.get('note', None)) + + +# zone change reset +class CommandZoneChangeDiscard(DyntmCommand): + name = "discard" + desc = "Discard pending changes to a zone." + args = [ + {'arg': 'zone', 'type': str, 'help': 'The name of the zone.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # get the zone + zone = Zone(args['zone']) + zone.discard_changes() + +# record commands + +# record type specifications for child class generation + + +# TODO write sensible help strings +rtypes = { + # 'RTYPE' : [ {'arg':'', 'dest':'','type':str, 'help':''}, ] + 'A': [ + {'arg': 'address', 'type': str, + 'help': 'An IPv4 address.'}, + ], + 'AAAA': [ + {'arg': 'address', 'type': str, + 'help': 'An IPv6 address.'}, + ], + 'ALIAS': [ + {'arg': 'alias', 'type': str, + 'help': 'A hostname.'}, + ], + 'CAA': [ + {'arg': 'flags', 'type': str, + 'help': 'A byte?.'}, + {'arg': 'tag', 'type': str, + 'help': 'A string representing the name of the property.'}, + {'arg': 'value', 'type': str, + 'help': 'A string representing the value of the property.'}, + ], + 'CDNSKEY': [ + {'arg': 'protocol', 'type': int, + 'help': 'Numeric value for protocol.'}, + {'arg': 'public_key', 'type': str, + 'help': 'The public key for the DNSSEC signed zone.'}, + {'arg': '--algo', 'dest': 'algorithm', 'type': int, + 'help': 'Numeric code of encryption algorithm.'}, + {'arg': '--flags', 'dest': 'flags', 'type': int, + 'help': 'A hostname.'}, + ], + 'CDS': [ + {'arg': 'digest', 'type': str, + 'help': 'Hexadecimal digest string of a DNSKEY.'}, + {'arg': '--keytag', 'dest': 'keytag', 'type': int, + 'help': 'Numeric code of digest mechanism for verification.'}, + {'arg': '--algo', 'dest': 'algorithm', 'type': int, + 'help': 'Numeric code of encryption algorithm.'}, + {'arg': '--digtype', 'dest': 'digtype', 'type': int, + 'help': 'Numeric code of digest mechanism for verification.'}, + ], + 'CERT': [ + {'arg': 'format', 'type': int, + 'help': 'Numeric value of certificate type.'}, + {'arg': 'tag', 'type': int, + 'help': 'Numeric value of public key certificate.'}, + {'arg': '--algo', 'dest': 'algorithm', 'type': int, + 'help': 'Numeric code of encryption algorithm.'}, + ], + 'CNAME': [ + {'arg': 'cname', 'type': str, 'help': 'A hostname.'}, + ], + 'CSYNC': [ + {'arg': 'soa_serial', 'type': int, + 'help': 'SOA serial to bind to this record.'}, + {'arg': 'flags', 'type': str, + 'help': 'SOA serial to bind to this record.'}, + {'arg': 'rectypes', 'type': str, + 'help': 'SOA serial to bind to this record.', 'nargs': '+'}, + ], + 'DHCID': [ + {'arg': 'digest', 'type': str, + 'help': 'Base-64 encoded digest of DHCP data.'}, + ], + 'DNAME': [ + {'arg': 'cname', 'type': str, + 'help': 'A hostname.'}, + ], + 'DNSKEY': [ + {'arg': 'protocol', 'type': int, + 'help': 'Numeric value for protocol.'}, + {'arg': 'public_key', 'type': str, + 'help': 'The public key for the DNSSEC signed zone.'}, + {'arg': '--algo', 'dest': 'algorithm', 'type': int, + 'help': 'Numeric code of encryption algorithm.'}, + {'arg': '--flags', 'dest': 'flags', 'type': int, + 'help': 'A hostname.'}, + ], + 'DS': [ + {'arg': 'digest', 'type': str, + 'help': 'Hexadecimal digest string of a DNSKEY.'}, + {'arg': '--keytag', 'dest': 'keytag', 'type': int, + 'help': 'Numeric code of digest mechanism for verification.'}, + {'arg': '--algo', 'dest': 'algorithm', 'type': int, + 'help': 'Numeric code of encryption algorithm.'}, + {'arg': '--digtype', 'dest': 'digtype', 'type': int, + 'help': 'Numeric code of digest mechanism for verification.'}, + ], + 'KEY': [ + {'arg': 'algorithm', 'type': int, + 'help': 'Numeric code of encryption algorithm.'}, + {'arg': 'flags', 'type': int, + 'help': 'Flags!? RTFRFC!'}, + {'arg': 'protocol', 'type': int, + 'help': 'Numeric code of protocol.'}, + {'arg': 'public_key', 'type': str, + 'help': 'The public key.'}, + ], + 'KX': [ + {'arg': 'exchange', 'type': str, + 'help': 'Hostname of key exchange.'}, + {'arg': 'preference', 'type': int, + 'help': 'Numeric priority of this exchange.'}, + ], + 'LOC': [ + {'arg': 'altitude', 'type': str, + 'help': ''}, + {'arg': 'latitude', 'type': str, + 'help': ''}, + {'arg': 'longitude', 'type': str, + 'help': ''}, + {'arg': '--horiz_pre', 'dest': 'horiz_pre', 'type': str, + 'help': ''}, + {'arg': '--vert_pre', 'dest': 'vert_pre', 'type': str, + 'help': ''}, + {'arg': '--size', 'dest': 'size', 'type': str, + 'help': ''}, + ], + 'IPSECKEY': [ + {'arg': 'precedence', 'type': str, + 'help': ''}, + {'arg': 'gatetype', 'type': str, + 'help': ''}, + {'arg': 'algorithm', 'type': str, + 'help': ''}, + {'arg': 'gateway', 'type': str, + 'help': ''}, + {'arg': 'public_key', 'type': str, + 'help': ''}, + ], + 'MX': [ + {'arg': 'exchange', 'type': str, + 'help': ''}, + {'arg': 'prefernce', 'type': str, + 'help': ''}, + ], + 'NAPTR': [ + {'arg': 'order', 'type': str, + 'help': ''}, + {'arg': 'preference', 'type': str, + 'help': ''}, + {'arg': 'services', 'type': str, + 'help': ''}, + {'arg': 'regexp', 'type': str, + 'help': ''}, + {'arg': 'replacement', 'type': str, + 'help': ''}, + {'arg': 'flags', 'type': str, + 'help': ''}, + ], + 'PTR': [ + {'arg': 'ptrdname', 'type': str, + 'help': ''}, + ], + 'PX': [ + {'arg': 'prefernce', 'type': str, + 'help': ''}, + {'arg': 'map822', 'type': str, + 'help': ''}, + {'arg': 'map400', 'type': str, + 'help': ''}, + ], + 'NSAP': [ + {'arg': 'nsap', 'type': str, + 'help': ''}, + ], + 'RP': [ + {'arg': 'mbox', 'type': str, + 'help': ''}, + {'arg': 'txtdname', 'type': str, + 'help': ''}, + ], + 'NS': [ + {'arg': 'nsdname', 'type': str, + 'help': ''}, + ], + 'SOA': [ + # TODO + ], + 'SPF': [ + {'arg': 'txtdata', 'type': str, + 'help': 'Some text data.'}, + ], + 'SRV': [ + {'arg': 'port', 'type': str, + 'help': ''}, + {'arg': 'priority', 'type': str, + 'help': ''}, + {'arg': 'target', 'type': str, + 'help': ''}, + {'arg': 'weight', 'type': str, + 'help': ''}, + ], + 'SSHFP': [ + {'arg': 'algorithm', 'type': str, + 'help': ''}, + {'arg': 'fptype', 'type': str, + 'help': ''}, + {'arg': 'fingerprint', 'type': str, + 'help': ''}, + ], + 'TLSA': [ + {'arg': 'cert_usage', 'type': str, + 'help': ''}, + {'arg': 'selector', 'type': str, + 'help': ''}, + {'arg': 'match_type', 'type': str, + 'help': ''}, + {'arg': 'certificate', 'type': str, + 'help': ''}, + ], + 'TXT': [ + {'arg': 'txtdata', 'type': str, 'help': + 'Some text data.'}, + ], +} + + +# create record +class CommandRecordCreate(DyntmCommand): + name = "record-new" + desc = "Create record." + subtitle = "Record Types" + args = [ + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + {'arg': 'node', 'type': str, + 'help': 'Node on which to create the record.'}, + {'arg': '--publish', 'type': bool, + 'help': 'Zone should be published immediately.'}, + # MAYBE have TTL here instead + ] + + @classmethod + def action(cls, *rest, **args): + # get the zone and node + zone = Zone(args['zone']) + node = zone.get_node(args['node']) + # figure out record init arguments specific to this command + spec = [d['dest'] if 'dest' in d else d['arg'].strip('-') + for d in cls.args] + new = {k: args[k] for k in spec if args[k] is not None} + # add a new record on that node + rec = node.add_record(cls.name, **new) + # publish the zone + if args['publish']: + zone.publish() + # output the new record + sys.stdout.write("{}\n".format(rec)) + + +# setup record creation command subclass for each record type +rcreate = {} +for rtype in [k for k in sorted(rtypes.keys()) if k not in ['SOA']]: + opts = copy.deepcopy(rtypes[rtype]) + opts += [{'arg': '--ttl', 'dest': 'ttl', 'type': int, + 'help': 'TTL of the record.'}] + attr = { + 'name': rtype, + 'args': opts, + 'desc': "Create one {} record.".format(rtype), + } + rcreate[rtype] = type("CommandRecordCreate" + rtype, + (CommandRecordCreate,), attr) + + +# list records +class CommandRecordList(DyntmCommand): + name = "records" + desc = "Get an existing record." + args = [ + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # context + zone = Zone(args['zone']) + # get records + recs = functools.reduce( + lambda x, y: x + y, zone.get_all_records().values()) + # output all records + for r in sorted(recs, key=lambda x: x.fqdn): + rtype = r.rec_name.upper() + rdata = json.dumps(dyn.tm.records.DNSRecord.rdata(r)) + sys.stdout.write("{} {} {} {} {}\n".format( + r.fqdn, rtype, r._record_id, r.ttl, rdata)) + + +# get records +class CommandRecordGet(DyntmCommand): + name = "record" + desc = "List records." + args = [ + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + {'arg': 'node', 'type': str, + 'help': 'Node on which the the record appears.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # context + rtype = cls.name + zone = Zone(args['zone']) + node = zone.get_node(args['node']) + # get set of records + recs = node.get_all_records_by_type(rtype) + fields = ['_record_id'] + fields.extend([a['dest'] + if 'dest' in a else a['arg'].strip('-') + for a in cls.args]) + found = [r for r in recs + if any([re.search(str(args[f]), str(getattr(r, f, ""))) + for f in fields if args[f]])] + # output selected records + for r in sorted(found, key=lambda x: x.fqdn): + rtype = r.rec_name.upper() + rdata = json.dumps(dyn.tm.records.DNSRecord.rdata(r)) + sys.stdout.write("{} {} {} {} {}\n".format( + r.fqdn, rtype, r._record_id, r.ttl, rdata)) + + +# setup record selection command subclass for each record type +rget = {} +for rtype in sorted(rtypes.keys()): + # setup argument spec + opts = copy.deepcopy(rtypes[rtype]) # list(rtypes[rtype]) + opts += [ + {'arg': '--ttl', 'dest': 'ttl', 'type': int, + 'help': 'TTL of the record.'}, + {'arg': '--id', 'type': int, 'dest': '_record_id', + 'help': 'Awkward internal record ID'}, + ] + # tweak args to make them all optional + for opt in opts: + if not opt['arg'].startswith('--'): + opt['arg'] = "--" + opt['arg'] + attr = { + 'name': rtype, + 'args': opts, + 'desc': "List some {} records.".format(rtype), + } + rget[rtype] = type("CommandRecordGet" + rtype, (CommandRecordGet,), attr) + + +# update record +class CommandRecordUpdate(DyntmCommand): + name = "record-update" + desc = "Update a record." + args = [ + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + {'arg': 'node', 'type': str, + 'help': 'Node on which the the record appears.'}, + {'arg': '--publish', 'type': bool, + 'help': 'Zone should be published immediately.'}, + ] + subtitle = "Record Types" + + @classmethod + def action(cls, *rest, **args): + # context + zone = Zone(args['zone']) + node = zone.get_node(args['node']) + rid = args['id'] + # identify target record + recs = node.get_all_records_by_type(cls.name) + them = [r for r in recs if str(r._record_id) == str(rid)] + if len(them) == 0: + raise Exception("Record {} not found.".format(rid)) + that = them.pop() + # build update arguments + fields = [a['dest'] if 'dest' in a else a['arg'].strip("-") + for a in cls.args] + # update the record + for field in fields: + if args[field]: + setattr(that, field, args[field]) + # maybe publish the zone + if args['publish']: + zone.publish() + # success + sys.stdout.write("{}\n".format(str(that))) + + +# setup record update command subclass for each record type +rupdate = {} +for rtype in [k for k in sorted(rtypes.keys())]: + # tweak args to make them all optional + opts = copy.deepcopy(rtypes[rtype]) # list(rtypes[rtype]) + for opt in opts: + if not opt['arg'].startswith('--'): + opt['arg'] = "--" + opt['arg'] + # require record ID argument + opts += [{'arg': 'id', 'type': str, + 'help': 'The unique numeric ID of the record.'}] + opts += [{'arg': '--ttl', 'dest': 'ttl', 'type': int, + 'help': 'TTL of the record.'}] + # setup the class attributes + attr = { + 'name': rtype, + 'args': opts, + 'desc': "Update one {} record.".format(rtype), + } + # make the record update subclass + rupdate[rtype] = type("CommandRecordUpdate" + rtype, + (CommandRecordUpdate,), attr) + + +# delete record +class CommandRecordDelete(DyntmCommand): + name = "record-delete" + desc = "Delete a record." + args = [ + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + {'arg': 'node', 'type': str, + 'help': 'Node on which the the record appears.'}, + {'arg': '--publish', 'type': bool, + 'help': 'Zone should be published immediately.'}, + ] + subtitle = "Record Types" + + @classmethod + def action(cls, *rest, **args): + # context + zone = Zone(args['zone']) + node = zone.get_node(args['node']) + rid = args['id'] + # identify target record + recs = node.get_all_records_by_type(cls.name) + them = [r for r in recs if str(r._record_id) == str(rid)] + if len(them) == 0: + raise Exception("Record {} not found.".format(rid)) + that = them.pop() + # delete the record + that.delete() + # maybe publish the zone + if args['publish']: + zone.publish() + # success + sys.stdout.write("{}\n".format(str(that))) + + +# setup record delete command subclass for each record type +rdelete = {} +for rtype in [k for k in sorted(rtypes.keys())]: + # require record ID argument + opts = {'arg': 'id', 'type': str, + 'help': 'The unique numeric ID of the record.'}, + # setup the class attributes + attr = { + 'name': rtype, + 'args': opts, + 'desc': "Update one {} record.".format(rtype), + } + # make the record delete subclass + rdelete[rtype] = type("CommandRecordDelete{}".format(rtype), + (CommandRecordDelete,), attr) + + +# create redirect service +class CommandRedirectCreate(DyntmCommand): + name = "redirect-new" + desc = "Create an HTTP redirect service." + args = [ + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + {'arg': 'node', 'type': str, + 'help': 'Node on which to create the record.'}, + {'arg': 'url', 'type': str, + 'help': 'The HTTP(S) URL to which requests will be redirected.'}, + {'arg': '--permanent', 'type': bool, + 'help': 'Respond with 301 Permanent Redirect not 302.'}, + {'arg': '--keep', 'type': bool, + 'help': 'Keep the requested current hostname after redirect.'}, + ] + + @classmethod + def action(cls, *rest, **args): + # required arguments + zone, node, url = args['zone'], args['node'], args['url'] + # optional arguments + code = 302 if args.get('permanent', False) else 301 + keep = args.get('keep', False) + # make the redirect service + redir = HTTPRedirect(zone, node, code, keep, url) + sys.stdout.write(str(redir)) + + +# gslb commands TODO +# dsf commands TODO + + +# main +def main(): + DyntmCommand.action(sys.argv[1:]) + + +if __name__ == "__main__": + main() diff --git a/dyn/core.py b/dyn/core.py index 0e12036..9dbdfbd 100644 --- a/dyn/core.py +++ b/dyn/core.py @@ -8,9 +8,7 @@ import copy import locale import logging -import re import threading -import time from datetime import datetime from . import __version__ @@ -64,19 +62,6 @@ class Singleton(_Singleton('SingletonMeta', (object,), {})): pass -class _History(list): - """A *list* subclass specifically targeted at being able to store the - history of calls made via a SessionEngine - """ - - def append(self, p_object): - """Override builtin list append operators to allow for the automatic - appendation of a timestamp for cleaner record keeping - """ - now_ts = datetime.now().isoformat() - super(_History, self).append(tuple([now_ts] + list(p_object))) - - class SessionEngine(Singleton): """Base object representing a DynectSession Session""" _valid_methods = tuple() @@ -100,7 +85,6 @@ def __init__(self, host=None, port=443, ssl=True, history=False, :return: SessionEngine object """ super(SessionEngine, self).__init__() - self.__call_cache = _History() if history else None self.extra_headers = dict() self.logger = logging.getLogger(self.name) self.host = host @@ -110,12 +94,11 @@ def __init__(self, host=None, port=443, ssl=True, history=False, self.proxy_port = proxy_port self.proxy_user = proxy_user self.proxy_pass = proxy_pass - self.poll_incomplete = True self.content_type = 'application/json' self._encoding = locale.getdefaultlocale()[-1] or 'UTF-8' self._token = self._conn = self._last_response = None self._permissions = None - self._tasks = {} + self._history = [] @classmethod def new_session(cls, *args, **kwargs): @@ -162,36 +145,25 @@ def name(self): return str(self.__class__).split('.')[-1][:-2] def connect(self): - """Establishes a connection to the REST API server as defined by the + """Establishes a connection to the API server as defined by the host, port and ssl instance variables. If a proxy is specified, it is used. """ - if self._token: - self.logger.debug('Forcing logout from old session') - orig_value = self.poll_incomplete - self.poll_incomplete = False - self.execute('/REST/Session', 'DELETE') - self.poll_incomplete = orig_value - self._token = None self._conn = None - use_proxy = False headers = {} if self.proxy_host and not self.proxy_port: msg = 'Proxy missing port, please specify a port' raise ValueError(msg) + # proxy or normal connection? if self.proxy_host and self.proxy_port: - use_proxy = True - if self.proxy_user and self.proxy_pass: - auth = '{}:{}'.format(self.proxy_user, self.proxy_pass) - headers['Proxy-Authorization'] = 'Basic ' + base64.b64encode( - auth) - - if use_proxy: + creds = 'Basic {}'.format(base64.b64encode( + '{}:{}'.format(self.proxy_user, self.proxy_pass))) + headers['Proxy-Authorization'] = creds if self.ssl: - s = 'Establishing SSL connection to {}:{} with proxy {}:{}' + s = 'Establishing SSL connection to {}:{} via {}:{}' msg = s.format( self.host, self.port, @@ -202,125 +174,77 @@ def connect(self): timeout=300) self._conn.set_tunnel(self.host, self.port, headers) else: - s = ('Establishing unencrypted connection to {}:{} with proxy ' - '{}:{}') - msg = s.format( - self.host, - self.port, - self.proxy_host, - self.proxy_port) + s = ('Establishing unencrypted connection to {}:{} via {}:{}') + msg = s.format(self.host, self.port, + self.proxy_host, self.proxy_port) self.logger.info(msg) self._conn = HTTPConnection(self.proxy_host, self.proxy_port, timeout=300) self._conn.set_tunnel(self.host, self.port, headers) else: if self.ssl: - msg = 'Establishing SSL connection to {}:{}'.format(self.host, - self.port) - self.logger.info(msg) - self._conn = HTTPSConnection(self.host, self.port, - timeout=300) + msg = 'Establishing SSL connection to {}:{}' + self.logger.info(msg.format(self.host, self.port)) + self._conn = HTTPSConnection(self.host, self.port, timeout=300) else: msg = 'Establishing unencrypted connection to {}:{}'.format( self.host, self.port) self.logger.info(msg) - self._conn = HTTPConnection(self.host, self.port, - timeout=300) + self._conn = HTTPConnection(self.host, self.port, timeout=300) - def _process_response(self, response, method, final=False): + def _process_response(self, response, uri, method, args, final=False): """API Method. Process an API response for failure, incomplete, or success and throw any appropriate errors :param response: the JSON response from the request being processed :param method: the HTTP method :param final: boolean flag representing whether or not to continue - polling + polling. """ return response - def _handle_error(self, uri, method, raw_args): + def _handle_error(self, uri, method, args): """Handle the processing of a connection error with the api. Note, to be implemented as needed in subclasses. """ return None - def _retry(self, msgs, final=False): - """Retry logic around throttled or blocked tasks""" - - throttle_err = 'RATE_LIMIT_EXCEEDED' - throttled = any(throttle_err == err['ERR_CD'] for err in msgs) - - if throttled: - # We're rate limited, so wait 5 seconds and try again - return dict(retry=True, wait=5, final=final) - - blocked_err = 'Operation blocked by current task' - blocked = any(blocked_err in err['INFO'] for err in msgs) - - pat = re.compile(r'^task_id:\s+(\d+)$') - if blocked: - try: - # Get the task id - task = next(pat.match(i['INFO']).group(1) for i in msgs - if pat.match(i.get('INFO', ''))) - except: - # Task id could not be recovered - wait = 1 - else: - # Exponential backoff for individual blocked tasks - wait = self._tasks.get(task, 1) - self._tasks[task] = wait * 2 + 1 - - # Give up if final or wait > 30 seconds - return dict(retry=True, wait=wait, final=wait > 30 or final) - - # Neither blocked nor throttled? - return dict(retry=False, wait=0, final=True) - - def _handle_response(self, response, uri, method, raw_args, final): + def _handle_response(self, response, uri, method, args, final): """Handle the processing of the API's response""" + # Read response body = response.read() self.logger.debug('RESPONSE: {0}'.format(body)) - self._last_response = response - - if self.poll_incomplete: - response, body = self.poll_response(response, body) - self._last_response = response + # The response was empty? Something went wrong. if not body: - err_msg_fmt = "Received Empty Response: {!r} status: {!r} {!r}" - error_message = err_msg_fmt.format(body, response.status, uri) - self.logger.error(error_message) - raise ValueError(error_message) + err = "Received Empty Response: {!r} status: {!r} {!r}" + msg = err.format(body, response.status, uri) + self.logger.error(msg) + raise ValueError(msg) - json_err_fmt = "Decode Error on Response Body: {!r} status: {!r} {!r}" + # Parse response JSON try: - ret_val = json.loads(body.decode('UTF-8')) + data = json.loads(body.decode('UTF-8')) except ValueError: - self.logger.error(json_err_fmt.format(body, response.status, uri)) + err = "Decode Error on Response Body: {!r} status: {!r} {!r}" + self.logger.error(err.format(body, response.status, uri)) raise - if self.__call_cache is not None: - self.__call_cache.append((uri, method, clean_args(raw_args), - ret_val['status'])) + # Add a record of this request/response to the history. + now = datetime.now().isoformat() + self._history.append( + (now, uri, method, clean_args(args), data['status'])) - self._meta_update(uri, method, ret_val) + # Call this hook for client state updates. + self._meta_update(uri, method, data) - retry = {} - # Try to retry? - if ret_val['status'] == 'failure' and not final: - retry = self._retry(ret_val['msgs'], final) - - if retry.get('retry', False): - time.sleep(retry['wait']) - return self.execute(uri, method, raw_args, final=retry['final']) - else: - return self._process_response(ret_val, method) + # Return processed response. + return self._process_response(data, uri, method, args, final) def _validate_uri(self, uri): """Validate and return a cleaned up uri. Make sure the command is - prefixed by '/REST/' + prefixed by root. """ if not uri.startswith('/'): uri = '/' + uri @@ -356,8 +280,7 @@ def _prepare_arguments(self, args, method, uri): def execute(self, uri, method, args=None, final=False): """Execute a commands against the rest server - :param uri: The uri of the resource to access. /REST/ will be prepended - if it is not at the beginning of the uri + :param uri: The URI of the resource to access :param method: One of 'DELETE', 'GET', 'POST', or 'PUT' :param args: Any arguments to be sent as a part of the request :param final: boolean flag representing whether or not we have already @@ -372,14 +295,14 @@ def execute(self, uri, method, args=None, final=False): self._validate_method(method) # Prepare arguments to send to API - raw_args, args, uri = self._prepare_arguments(args, method, uri) + args, data, uri = self._prepare_arguments(args, method, uri) msg = 'uri: {}, method: {}, args: {}' self.logger.debug( - msg.format(uri, method, clean_args(json.loads(args)))) + msg.format(uri, method, clean_args(json.loads(data)))) # Send the command and deal with results - self.send_command(uri, method, args) + self.send_command(uri, method, data) # Deal with the results try: @@ -389,48 +312,18 @@ def execute(self, uri, method, args=None, final=False): raise e else: # Handle processing a connection error - resp = self._handle_error(uri, method, raw_args) + resp = self._handle_error(uri, method, args) # If we got a valid response back from our _handle_error call # Then return it, otherwise raise the original exception if resp is not None: return resp raise e - return self._handle_response(response, uri, method, raw_args, final) + return self._handle_response(response, uri, method, args, final) def _meta_update(self, uri, method, results): - """Update the HTTP session token if the uri is a login or logout - - :param uri: the uri from the call being updated - :param method: the api method - :param results: the JSON results - """ - # If we had a successful log in, update the token - if uri.startswith('/REST/Session') and method == 'POST': - if results['status'] == 'success': - self._token = results['data']['token'] - - # Otherwise, if it's a successful logout, blank the token - if uri.startswith('/REST/Session') and method == 'DELETE': - if results['status'] == 'success': - self._token = None - - def poll_response(self, response, body): - """Looks at a response from a REST command, and while indicates that - the job is incomplete, poll for response - - :param response: the JSON response containing return codes - :param body: the body of the HTTP response - """ - while response.status == 307: - time.sleep(1) - uri = response.getheader('Location') - self.logger.info('Polling {}'.format(uri)) - - self.send_command(uri, 'GET', '') - response = self._conn.getresponse() - body = response.read() - return response, body + """Hook into response handling.""" + pass def send_command(self, uri, method, args): """Responsible for packaging up the API request and sending it to the @@ -460,29 +353,6 @@ def send_command(self, uri, method, args): self._conn.send(prepare_to_send(args)) - def wait_for_job_to_complete(self, job_id, timeout=120): - """When a response comes back with a status of "incomplete" we need to - wait and poll for the status of that job until it comes back with - success or failure - - :param job_id: the id of the job to poll for a response from - :param timeout: how long (in seconds) we should wait for a valid - response before giving up on this request - """ - self.logger.debug('Polling for job_id: {}'.format(job_id)) - start = datetime.now() - uri = '/Job/{}/'.format(job_id) - api_args = {} - # response = self.execute(uri, 'GET', api_args) - response = {'status': 'incomplete'} - now = datetime.now() - self.logger.warn('Waiting for job {}'.format(job_id)) - too_long = (now - start).seconds < timeout - while response['status'] is 'incomplete' and too_long: - time.sleep(10) - response = self.execute(uri, 'GET', api_args) - return response - def __getstate__(cls): """Because HTTP/HTTPS connections are not serializeable, we need to strip the connection instance out before we ship the pickled data @@ -516,4 +386,4 @@ def history(self): *list* of 5-tuples of the form: (timestamp, uri, method, args, status) where status will be one of 'success' or 'failure' """ - return self.__call_cache + return self._history diff --git a/dyn/mm/session.py b/dyn/mm/session.py index fc8b2f5..2c8560a 100644 --- a/dyn/mm/session.py +++ b/dyn/mm/session.py @@ -61,13 +61,14 @@ def _prepare_arguments(self, args, method, uri): return {}, '{}', uri return args, urlencode(args), uri - def _handle_response(self, response, uri, method, raw_args, final): + def _handle_response(self, response, uri, method, args, final): """Handle the processing of the API's response""" body = response.read() - ret_val = json.loads(prepare_for_loads(body, self._encoding)) - return self._process_response(ret_val['response'], method, final) + data = json.loads(prepare_for_loads(body, self._encoding)) + resp = data['response'] + return self._process_response(resp, uri, method, args, final) - def _process_response(self, response, method, final=False): + def _process_response(self, response, uri, method, args, final=False): """Process an API response for failure, incomplete, or success and throw any appropriate errors diff --git a/dyn/tm/errors.py b/dyn/tm/errors.py index b434f60..d7ff165 100644 --- a/dyn/tm/errors.py +++ b/dyn/tm/errors.py @@ -4,7 +4,7 @@ completely unexpected happens TODO: add a DynectInvalidPermissionsError """ -__all__ = ['DynectAuthError', 'DynectInvalidArgumentError', +__all__ = ['DynectError', 'DynectAuthError', 'DynectInvalidArgumentError', 'DynectCreateError', 'DynectUpdateError', 'DynectGetError', 'DynectDeleteError', 'DynectQueryTimeout'] __author__ = 'jnappi' diff --git a/dyn/tm/records.py b/dyn/tm/records.py index b23ca4a..5d4af94 100644 --- a/dyn/tm/records.py +++ b/dyn/tm/records.py @@ -76,8 +76,8 @@ def _update_record(self, api_args): self._fqdn += '.' if not self._record_type.endswith('Record'): self._record_type += 'Record' - uri = '/{}/{}/{}/{}/'.format(self._record_type, self._zone, self._fqdn, - self._record_id) + uri = '/{}/{}/{}/{}/'.format( + self._record_type, self._zone, self._fqdn, self._record_id) response = DynectSession.get_session().execute(uri, 'PUT', api_args) self._build(response['data']) @@ -95,15 +95,11 @@ def _build(self, data): def rdata(self): """Return a records rdata""" - rdata = {} - for key, val in self.__dict__.items(): - if (key.startswith('_') and - not hasattr(val, '__call__') and - key != '_record_type' and - key != '_record_id' and key != '_implicitPublish'): - missing = {'ttl', 'zone', 'fqdn'} - if all([i not in key for i in missing]): - rdata[key[1:]] = val + skip = {'_record_type', '_record_id', '_implicitPublish', + '_note', '_ttl', '_zone', '_fqdn'} + rdata = {k[1:]: v for k, v in self.__dict__.items() + if not hasattr(v, '__call__') and + k.startswith('_') and k not in skip} return rdata @property diff --git a/dyn/tm/services/dsf.py b/dyn/tm/services/dsf.py index 0022d8a..7d1e51a 100644 --- a/dyn/tm/services/dsf.py +++ b/dyn/tm/services/dsf.py @@ -419,7 +419,7 @@ def _build(self, data): self._rdata_class.lower()): for k, v in rdata_v.items(): setattr(self, '_' + k, v) - except: + except Exception: pass else: setattr(self, '_' + key, val) diff --git a/dyn/tm/session.py b/dyn/tm/session.py index 74dab5b..ddaf6c1 100644 --- a/dyn/tm/session.py +++ b/dyn/tm/session.py @@ -5,11 +5,13 @@ own respective functionality. """ import warnings +import time + # API Libs from dyn.compat import force_unicode from dyn.core import SessionEngine from dyn.encrypt import AESCipher -from dyn.tm.errors import (DynectAuthError, DynectCreateError, +from dyn.tm.errors import (DynectError, DynectAuthError, DynectCreateError, DynectUpdateError, DynectGetError, DynectDeleteError, DynectQueryTimeout) @@ -52,6 +54,7 @@ def __init__(self, customer, username, password, host='api.dynect.net', self.customer = customer self.username = username self.password = self.__cipher.encrypt(password) + self.tasks = {} self.connect() if auto_auth: self.authenticate() @@ -72,7 +75,7 @@ def _encrypt(self, data): """Accessible method for subclass to encrypt with existing AESCipher""" return self.__cipher.encrypt(data) - def _handle_error(self, uri, method, raw_args): + def _handle_error(self, uri, method, args): """Handle the processing of a connection error with the api""" # Need to force a re-connect on next execute self._conn.close() @@ -81,7 +84,7 @@ def _handle_error(self, uri, method, raw_args): try: session_check = self.execute('/REST/Session/', 'GET') renew_token = 'login:' in session_check['msgs'][0]['INFO'] - except DynectGetError: + except DynectAuthError: renew_token = True if renew_token: @@ -92,9 +95,9 @@ def _handle_error(self, uri, method, raw_args): # Then try the current call again and Specify final as true so # if we fail again we can raise the actual error - return self.execute(uri, method, raw_args, final=True) + return self.execute(uri, method, args, final=True) - def _process_response(self, response, method, final=False): + def _process_response(self, response, uri, method, args, final=False): """Process an API response for failure, incomplete, or success and throw any appropriate errors @@ -103,29 +106,64 @@ def _process_response(self, response, method, final=False): :param final: boolean flag representing whether or not to continue polling """ - status = response['status'] - self.logger.debug(status) + # Establish response context. + status = response.get('status') + messages = response.get('msgs') + job = response.get('job_id') + # Check for successful response if status == 'success': return response - elif status == 'failure': - msgs = response['msgs'] - if method == 'POST' and 'login' in msgs[0]['INFO']: - raise DynectAuthError(response['msgs']) - if method == 'POST': - raise DynectCreateError(response['msgs']) - elif method == 'GET': - raise DynectGetError(response['msgs']) - elif method == 'PUT': - raise DynectUpdateError(response['msgs']) - else: - raise DynectDeleteError(response['msgs']) - else: # Status was incomplete - job_id = response['job_id'] - if not final: - response = self.wait_for_job_to_complete(job_id) - return self._process_response(response, method, True) - else: + # Task must have failed or be incomplete. Reattempt request if possible + retry = False + if any(err['ERR_CD'] == 'RATE_LIMIT_EXCEEDED' for err in messages): + # Rate limit exceeded, try again. + retry = True + elif any('Operation blocked' in err['INFO'] for err in messages): + # Waiting on other task completion, try again. + retry = True + elif any('already has a job' in err['INFO'] for err in messages): + # Request made in parallel with another task. + retry = True + elif status == 'incomplete': + # Waiting on completion of current task, poll given job + retry, method, uri = True, 'GET', '/Job/{}/'.format(job) + # Maybe retry the call + if retry: + if final: raise DynectQueryTimeout({}) + # Back off exponentially up to 30 seconds + delay = self.tasks.get(job, 1) + self.tasks[job] = delay * 2 + 1 + time.sleep(delay) + return self.execute(uri, method, args, final=delay > 30) + # Request failed, raise an appropriate error + if any('login' in msg['INFO'] for msg in messages): + raise DynectAuthError(messages) + elif method == 'POST': + raise DynectCreateError(messages) + elif method == 'GET': + raise DynectGetError(messages) + elif method == 'PUT': + raise DynectUpdateError(messages) + elif method == 'DELETE': + raise DynectDeleteError(messages) + raise DynectError(messages) + + def _meta_update(self, uri, method, results): + """Update the HTTP session token if the uri is a login or logout + :param uri: the uri from the call being updated + :param method: the api method + :param results: the JSON results + """ + # If we had a successful log in, update the token + if uri.startswith('/REST/Session') and method == 'POST': + if results['status'] == 'success': + self._token = results['data']['token'] + + # Otherwise, if it's a successful logout, blank the token + if uri.startswith('/REST/Session') and method == 'DELETE': + if results['status'] == 'success': + self._token = None def update_password(self, new_password): """Update the current users password @@ -221,7 +259,7 @@ def __init__(self, customer, username, password, host='api.dynect.net', proxy_pass=proxy_pass) self.__add_open_session() - def _handle_error(self, uri, method, raw_args): + def _handle_error(self, uri, method, args): """Handle the processing of a connection error with the api""" # Need to force a re-connect on next execute @@ -242,7 +280,7 @@ def _handle_error(self, uri, method, raw_args): # Then try the current call again and Specify final as true so # if we fail again we can raise the actual error - return self.execute(uri, method, raw_args, final=True) + return self.execute(uri, method, args, final=True) def __add_open_session(self): """Add new open session to hash of open sessions""" diff --git a/dyn/tm/zones.py b/dyn/tm/zones.py index a8b57be..e6ffc1a 100644 --- a/dyn/tm/zones.py +++ b/dyn/tm/zones.py @@ -23,9 +23,11 @@ from dyn.tm.task import Task __author__ = 'jnappi' -__all__ = ['get_all_zones', 'Zone', 'SecondaryZone', 'Node', +__all__ = ['get_all_zones', 'Zone', 'Node', + 'get_all_secondary_zones', 'SecondaryZone', 'ExternalNameserver', 'ExternalNameserverEntry'] + RECS = {'A': ARecord, 'AAAA': AAAARecord, 'ALIAS': ALIASRecord, 'CAA': CAARecord, 'CDS': CDSRecord, 'CDNSKEY': CDNSKEYRecord, 'CSYNC': CSYNCRecord, 'CERT': CERTRecord, 'CNAME': CNAMERecord, @@ -44,13 +46,9 @@ def get_all_zones(): :return: a *list* of :class:`~dyn.tm.zones.Zone`'s """ - uri = '/Zone/' - api_args = {'detail': 'Y'} - response = DynectSession.get_session().execute(uri, 'GET', api_args) - zones = [] - for zone in response['data']: - zones.append(Zone(zone['zone'], api=False, **zone)) - return zones + session = DynectSession.get_session() + response = session.execute('/Zone/', 'GET', {'detail': 'Y'}) + return [Zone(zone['zone'], api=False, **zone) for zone in response['data']] def get_all_secondary_zones(): @@ -59,13 +57,10 @@ def get_all_secondary_zones(): :return: a *list* of :class:`~dyn.tm.zones.SecondaryZone`'s """ - uri = '/Secondary/' - api_args = {'detail': 'Y'} - response = DynectSession.get_session().execute(uri, 'GET', api_args) - zones = [] - for zone in response['data']: - zones.append(SecondaryZone(zone.pop('zone'), api=False, **zone)) - return zones + session = DynectSession.get_session() + response = session.execute('/Secondary/', 'GET', {'detail': 'Y'}) + return [SecondaryZone(api=False, **zone) + for zone in response['data']] def get_apex(node_name, full_details=False): @@ -79,14 +74,9 @@ def get_apex(node_name, full_details=False): :return: a *string* containing apex zone name, if full_details is :const:`False`, a :const:`dict` containing apex zone name otherwise """ - - uri = '/Apex/{}'.format(node_name) - api_args = {} - response = DynectSession.get_session().execute(uri, 'GET', api_args) - if full_details: - return response['data'] - else: - return response['data']['zone'] + session = DynectSession.get_session() + response = session.execute('/Apex/{}'.format(node_name), 'GET', {}) + return response['data'] if full_details else response['data']['zone'] class Zone(object): @@ -397,6 +387,20 @@ def get_notes(self, offset=None, limit=None): response = DynectSession.get_session().execute(uri, 'POST', api_args) return response['data'] + def get_changes(self): + """Describes pending changes to this zone.""" + session = DynectSession.get_session() + frag = '/ZoneChanges/{}'.format(self.name) + response = session.execute(frag, 'GET') + return response['data'] + + def discard_changes(self): + """Removes pending changes to this zone.""" + session = DynectSession.get_session() + frag = '/ZoneChanges/{}'.format(self.name) + response = session.execute(frag, 'DELETE') + return True if response else False + def add_record(self, name=None, record_type='A', *args, **kwargs): """Adds an a record with the provided name and data to this :class:`Zone` diff --git a/requirements.txt b/requirements.txt index e69de29..2e29ed1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1 @@ +PyYaml diff --git a/setup.py b/setup.py index da50cb3..a33a38b 100644 --- a/setup.py +++ b/setup.py @@ -22,11 +22,16 @@ author_email='jnappi@dyn.com', url='https://github.com/dyninc/dyn-python', packages=['dyn', 'dyn/tm', 'dyn/mm', 'dyn/tm/services'], + entry_points={ + 'console_scripts': [ + 'dyntm = dyn.cli.dyntm:main' + ], + }, classifiers=[ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3', 'Topic :: Internet :: Name Service (DNS)', - 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Libraries', ], install_requires=requires, tests_require=tests_requires, diff --git a/test-requirements.txt b/test-requirements.txt index d02d1e6..a1e989e 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,3 +4,4 @@ pytest-cov pytest-pep8 pytest-xdist flake8 +pycodestyle