From 15911d869fe4034d658446ac28f1abae9afa7470 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 7 Jan 2018 11:39:08 -0500 Subject: [PATCH 01/31] Start of dyntm command line tool. --- dyn/cli/dyntm.py | 163 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100755 dyn/cli/dyntm.py diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py new file mode 100755 index 0000000..9f42ee9 --- /dev/null +++ b/dyn/cli/dyntm.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +A command line tool for interacting with the Dyn Traffic Management API. + +""" + +# system libs +import os, sys +import argparse, getpass +import yaml, json + +# internal libs +import dyn.tm +from dyn.tm import * +from dyn.tm.zones import * +from dyn.tm.session import * + +# globals! +serstyle = ['increment', 'epoch', 'day', 'minute'] + +# parent command class +class DyntmCommand(object): + name = "general" + desc = "An abstract command for dyntm." + args = { } + def __init__(self): + return + def parser(self): + ap = argparse.ArgumentParser(prog=self.name, description=self.desc) + for key, opt in self.args.iteritems(): + ap.add_argument(key, **opt) + ap.set_defaults(func=self.action, command=self.name) + return ap + def action(self, *rest, **args): + pass + +# command classes! + +## user commands +class CommandListPermissions(DyntmCommand): + name = "perms" + desc = "List permissions." + def action(self, *rest, **args): + session = DynectSession.get_session() + for perm in sorted(session.permissions): + print perm + + +class CommandUpdatePassword(DyntmCommand): + name = "passwd" + desc = "Update password." + args = { + 'password' : {'type':str, 'help':'A new password.'}, + } + def action(self, *rest, **args): + newpass = args['password'] or getpass() + session = DynectSession.get_session() + session.update_password(newpass) + + +## zone commands +class CommandListZones(DyntmCommand): + name = "zones" + desc = "List all the zones available." + def action(self, *rest, **args): + zones = get_all_zones() + for zone in zones: + print zone.fqdn + + +class CommandCreateZone(DyntmCommand): + name = "zone-create" + desc = "Make a new zone." + args = { + 'name' : {'type':str, 'help':'The name of the zone.', 'metavar':'ZONE-NAME'}, + 'contact' : {'type':str, 'help':'Administrative contact for this zone (RNAME).'}, + '--ttl' : {'type':int, 'help':'Integer TTL.'}, + '--timeout' : {'type':int, 'help':'Integer timeout for transfer.' }, + '--style' : {'dest':'serial_style', 'help':'Serial style.','choices': serstyle }, + '--file' : {'type':file, 'help':'File from which to import zone data.'}, + '--master' : {'type':str, 'help':'Master IP from which to transfer zone.' }, + } + def action(self, *rest, **args): + new = { k : v for k, v in args.iteritems() if v is not None } + zone = Zone(**new) + print zone + + +class CommandDeleteZone(DyntmCommand): + name = "zone-delete" + desc = "Make a new zone." + args = { + 'name' : {'type':str, 'help':'The name of the zone.', 'metavar':'ZONE-NAME'}, + } + def action(self, *rest, **args): + zone = Zone(args['name']) + zone.delete() + + +# main +def dyntm(argv=sys.argv): + # some context + cpath = os.path.expanduser("~/.dyntm.yml") + # setup subcommands + cmds = {c.name : c() for c in DyntmCommand.__subclasses__() } + # setup argument parser + ap = argparse.ArgumentParser(description='Interact with Dyn Traffic Management API') + ap.add_argument('--conf', type=str, dest='conf', help='Alternate configuration file.') + ap.add_argument('--cust', type=str, dest='cust', help='Customer account name for authentication.') + ap.add_argument('--user', type=str, dest='user', help='User name for authentication.') + ap.add_argument('--host', type=str, dest='host', help='Alternate DynECT API host.') + ap.add_argument('--port', type=int, dest='port', help='Alternate DynECT API port.') + ap.add_argument('--proxy-host', type=str, dest='proxy_host', help='HTTP proxy host.') + ap.add_argument('--proxy-port', type=str, dest='proxy_port', help='HTTP proxy port.') + ap.add_argument('--proxy-user', type=str, dest='proxy_user', help='HTTP proxy user name.') + ap.add_argument('--proxy-pass', type=str, dest='proxy_pass', help='HTTP proxy password.') + # setup parsers for commands + sub = ap.add_subparsers(title="command") + for cmd in cmds.values(): + sub._name_parser_map[cmd.name] = cmd.parser() + # parse arguments + args, rest = ap.parse_known_args(args=argv) + # read configuration file + conf = {} + try: + with open(args.conf or cpath, 'r') as cf: + conf = yaml.load(cf) + except IOError as e: + sys.stderr.write(str(e)) + exit(1) + # require credentials + cust = args.cust or conf.get('cust') + user = args.user or conf.get('user') + if not user or not cust: + sys.stderr.write("A customer name and user name must be provided!") + exit(2) + # require password + pswd = conf.get('pass') or getpass("Password for {}/{}".format(cust, user)) + if not pswd: + sys.stderr.write("A password must be provided!") + exit(2) + # maybe more session options + keys = ['host', 'port', 'proxy_host', 'proxy_port', 'proxy_user', 'proxy_pass', 'proxy_pass'] + opts = { k : v for d in [conf, vars(args)] for k, v in d.iteritems() if k in keys and v is not None } + # setup session + try: + session = DynectSession(cust, user, pswd, **opts) + except DynectAuthError as auth: + print auth.message + exit(3) + # dispatch to command + try: + inp = { k : v for k, v in vars(args).iteritems() if k not in ['command', 'func'] } + args.func(**inp) + except Exception as err: + print err.message + exit(4) + # done! + exit(0) + +# call it if invoked +dyntm(sys.argv[1:]) From a54fe4b8443cafc63fe4a9a234fdfa26085aa042 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Fri, 12 Jan 2018 09:21:19 -0500 Subject: [PATCH 02/31] A few more commands. --- dyn/cli/dyntm.py | 171 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 145 insertions(+), 26 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 9f42ee9..69d73d2 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -5,31 +5,41 @@ """ +# TODO +## Persistent session tokens via file cache. Requires changes to dyn.tm.session? +### Publishing changes after multiple invocations of the script. +## A file cache of zones, nodes, services etc. Any of the 'get_all_X'. +### DTRT with one argument specifying a zone and node. +## Cleaned up error messages. + # system libs import os, sys import argparse, getpass import yaml, json +import itertools # internal libs import dyn.tm from dyn.tm import * +from dyn.tm.accounts import * from dyn.tm.zones import * from dyn.tm.session import * # globals! -serstyle = ['increment', 'epoch', 'day', 'minute'] +srstyles = ['increment', 'epoch', 'day', 'minute'] +rectypes = sorted(dyn.tm.zones.RECS.keys()) # parent command class class DyntmCommand(object): name = "general" desc = "An abstract command for dyntm." - args = { } + args = [] def __init__(self): return def parser(self): ap = argparse.ArgumentParser(prog=self.name, description=self.desc) - for key, opt in self.args.iteritems(): - ap.add_argument(key, **opt) + for spec in [dict(s) for s in self.args if s]: + ap.add_argument(spec.pop('arg'), **spec) ap.set_defaults(func=self.action, command=self.name) return ap def action(self, *rest, **args): @@ -38,7 +48,7 @@ def action(self, *rest, **args): # command classes! ## user commands -class CommandListPermissions(DyntmCommand): +class CommandUserPermissions(DyntmCommand): name = "perms" desc = "List permissions." def action(self, *rest, **args): @@ -47,20 +57,33 @@ def action(self, *rest, **args): print perm -class CommandUpdatePassword(DyntmCommand): +class CommandUserPassword(DyntmCommand): name = "passwd" desc = "Update password." - args = { - 'password' : {'type':str, 'help':'A new password.'}, - } + args = [ + {'arg': 'password', 'type':str, 'help':'A new password.'}, + ] def action(self, *rest, **args): newpass = args['password'] or getpass() session = DynectSession.get_session() session.update_password(newpass) +class CommandUserList(DyntmCommand): + name = "users" + desc = "List users." + def action(self, *rest, **args): + # TODO verbose output + # attrs = ['user_name', 'first_name', 'last_name', 'organization', + # 'email', 'phone', 'address', 'city', 'country', 'fax', 'status'] + # for user in get_users(): + # print ",".join([getattr(user, attr, "") for attr in attrs]) + for user in get_users(): + print user.user_name + + ## zone commands -class CommandListZones(DyntmCommand): +class CommandZoneList(DyntmCommand): name = "zones" desc = "List all the zones available." def action(self, *rest, **args): @@ -69,35 +92,130 @@ def action(self, *rest, **args): print zone.fqdn -class CommandCreateZone(DyntmCommand): - name = "zone-create" +class CommandZoneCreate(DyntmCommand): + name = "zone-new" desc = "Make a new zone." - args = { - 'name' : {'type':str, 'help':'The name of the zone.', 'metavar':'ZONE-NAME'}, - 'contact' : {'type':str, 'help':'Administrative contact for this zone (RNAME).'}, - '--ttl' : {'type':int, 'help':'Integer TTL.'}, - '--timeout' : {'type':int, 'help':'Integer timeout for transfer.' }, - '--style' : {'dest':'serial_style', 'help':'Serial style.','choices': serstyle }, - '--file' : {'type':file, 'help':'File from which to import zone data.'}, - '--master' : {'type':str, 'help':'Master IP from which to transfer zone.' }, - } + args = [ + {'arg':'name', 'type':str,'help':'The name of the zone.'}, + {'arg':'contact', 'type':str, 'help':'Administrative contact for this zone (RNAME).'}, + {'arg':'--ttl', 'type':int, 'help':'Integer TTL.'}, + {'arg':'--timeout', 'type':int, 'help':'Integer timeout for transfer.'}, + {'arg':'--style', 'type':str, 'dest':'serial_style', 'help':'Serial style.','choices': srstyles}, + {'arg':'--file', 'type':file, 'help':'File from which to import zone data.'}, + {'arg':'--master', 'type':str, 'help':'Master IP from which to transfer zone.'}, + ] def action(self, *rest, **args): new = { k : v for k, v in args.iteritems() if v is not None } zone = Zone(**new) print zone -class CommandDeleteZone(DyntmCommand): +class CommandZoneDelete(DyntmCommand): name = "zone-delete" desc = "Make a new zone." - args = { - 'name' : {'type':str, 'help':'The name of the zone.', 'metavar':'ZONE-NAME'}, - } + args = [ + {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, + ] def action(self, *rest, **args): - zone = Zone(args['name']) + zone = Zone(args['zone']) zone.delete() +class CommandZoneFreeze(DyntmCommand): + name = "freeze" + desc = "Freeze the given zone." + args = [ + {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, + {'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}, + ] + def action(self, *rest, **args): + zone = Zone(args['zone']) + zone.freeze() + + +class CommandZoneThaw(DyntmCommand): + name = "thaw" + desc = "Thaw the given zone." + args = [ + {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, + {'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}, + ] + def action(self, *rest, **args): + zone = Zone(args['zone']) + zone.thaw() + + +class CommandNodeList(DyntmCommand): + name = "nodes" + desc = "List nodes in the given zone." + args = [ + {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, + ] + def action(self, *rest, **args): + zone = Zone(args['name']) + for node in zone.get_all_nodes(): + print node.fqdn + + +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.'}, + ] + def action(self, *rest, **args): + zone = Zone(args['name']) + node = zone.get_node(args['node']) + node.delete() + + +## record commands +class CommandRecordList(DyntmCommand): + name = "records" + desc = "List records on the given zone." + args = [ + {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, + {'arg':'--node', 'type':str, 'help':'Limit list to records appearing on the given node.'}, + ] + def action(self, *rest, **args): + zone = Zone(args['zone']) + if args.get('node', None) is not None: + name = None if args['node'] == zone.name else args['node'] + node = zone.get_node(name) + recs = reduce(lambda r, n: r + n, node.get_all_records().values()) + else: + recs = reduce(lambda r, n: r + n, zone.get_all_records().values()) + for record in recs: + print "{} {} {}".format(record.fqdn, record.rec_name.upper(), record.rdata()) + + +class CommandRecordCreate(DyntmCommand): + name = "record-new" + desc = "Create record." + args = [ + {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, + {'arg':'node', 'type':str, 'help':'Node on which to create the record.'}, + {'arg':'rtype', 'type':str, 'help':'Record type.', 'metavar': 'rtype', 'choices': rectypes}, + {'arg':'rdata', 'type':str, 'help':'Record data.', 'nargs':'+'}, + ] + def action(self, *rest, **args): + zone = Zone(args['zone']) + node = zone.get_node(args['node']) + print args['rdata'] + rec = node.add_record(args['rtype'], *args['rdata']) + print rec + zone.publish() + + +## redir commands TODO +## gslb commands TODO +## dsf commands TODO + # main def dyntm(argv=sys.argv): # some context @@ -145,6 +263,7 @@ def dyntm(argv=sys.argv): opts = { k : v for d in [conf, vars(args)] for k, v in d.iteritems() if k in keys and v is not None } # setup session try: + # TODO cache session token! update SessionEngine.connect maybe? session = DynectSession(cust, user, pswd, **opts) except DynectAuthError as auth: print auth.message From 2d07360ba0f91adea7f4fe49cbe565c30bdf5e01 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sat, 13 Jan 2018 22:41:37 -0500 Subject: [PATCH 03/31] Roll logic into parent command. Recursively generate subcommands. --- dyn/cli/dyntm.py | 227 ++++++++++++++++++++++++++++------------------- 1 file changed, 137 insertions(+), 90 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 69d73d2..f3aab23 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -31,19 +31,80 @@ # parent command class class DyntmCommand(object): - name = "general" - desc = "An abstract command for dyntm." - args = [] - def __init__(self): - return - def parser(self): - ap = argparse.ArgumentParser(prog=self.name, description=self.desc) - for spec in [dict(s) for s in self.args if s]: + name = "dyntm" + desc = "Interact with Dyn Traffic Management API" + 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): + # setup parser + ap = argparse.ArgumentParser(prog=cls.name, description=cls.desc) + for spec in [dict(s) for s in cls.args if s]: ap.add_argument(spec.pop('arg'), **spec) - ap.set_defaults(func=self.action, command=self.name) + ap.set_defaults(func=cls.action, command=cls.name) + # setup subcommand parsers + if len(cls.__subclasses__()) != 0: + sub = ap.add_subparsers(title="Commands") + for cmd in cls.__subclasses__(): + sub._name_parser_map[cmd.name] = cmd.parser() return ap - def action(self, *rest, **args): - pass + + @classmethod + def action(cls, *argv, **opts): + # parse arguments + args = cls.parser().parse_args() # (args=argv) TODO list unhashable? + # read configuration file + cpath = os.path.expanduser("~/.dyntm.yml") + conf = {} + try: + with open(args.conf or cpath, 'r') as cf: + conf = yaml.load(cf) + except IOError as e: + sys.stderr.write(str(e)) + exit(1) + # require credentials + cust = args.cust or conf.get('cust') + user = args.user or conf.get('user') + if not user or not cust: + sys.stderr.write("A customer name and user name must be provided!") + exit(2) + # require password + pswd = conf.get('pass') or getpass("Password for {}/{}".format(cust, user)) + if not pswd: + sys.stderr.write("A password must be provided!") + exit(2) + # maybe more session options + keys = ['host', 'port', 'proxy_host', 'proxy_port', 'proxy_user', 'proxy_pass', 'proxy_pass'] + opts = { k : v for d in [conf, vars(args)] for k, v in d.iteritems() if k in keys and v is not None } + # setup session + try: + # TODO cache session token! update SessionEngine.connect maybe? + session = DynectSession(cust, user, pswd, **opts) + except DynectAuthError as auth: + print auth.message + exit(3) + # dispatch to command + if args.command != cls.name: + try: + inp = { k : v for k, v in vars(args).iteritems() if k not in ['command', 'func'] } + args.func(**inp) + except Exception as err: + print err.message + exit(4) + # done! + exit(0) + def __init__(self): + return # command classes! @@ -51,7 +112,8 @@ def action(self, *rest, **args): class CommandUserPermissions(DyntmCommand): name = "perms" desc = "List permissions." - def action(self, *rest, **args): + @classmethod + def action(cls, *rest, **args): session = DynectSession.get_session() for perm in sorted(session.permissions): print perm @@ -63,7 +125,9 @@ class CommandUserPassword(DyntmCommand): args = [ {'arg': 'password', 'type':str, 'help':'A new password.'}, ] - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): newpass = args['password'] or getpass() session = DynectSession.get_session() session.update_password(newpass) @@ -72,7 +136,9 @@ def action(self, *rest, **args): class CommandUserList(DyntmCommand): name = "users" desc = "List users." - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): # TODO verbose output # attrs = ['user_name', 'first_name', 'last_name', 'organization', # 'email', 'phone', 'address', 'city', 'country', 'fax', 'status'] @@ -86,7 +152,9 @@ def action(self, *rest, **args): class CommandZoneList(DyntmCommand): name = "zones" desc = "List all the zones available." - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): zones = get_all_zones() for zone in zones: print zone.fqdn @@ -104,7 +172,9 @@ class CommandZoneCreate(DyntmCommand): {'arg':'--file', 'type':file, 'help':'File from which to import zone data.'}, {'arg':'--master', 'type':str, 'help':'Master IP from which to transfer zone.'}, ] - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): new = { k : v for k, v in args.iteritems() if v is not None } zone = Zone(**new) print zone @@ -116,7 +186,9 @@ class CommandZoneDelete(DyntmCommand): args = [ {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, ] - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): zone = Zone(args['zone']) zone.delete() @@ -130,7 +202,9 @@ class CommandZoneFreeze(DyntmCommand): {'arg':'--timeout', 'type':int, 'help':'Integer timeout for transfer.'}, {'arg':'--style', 'dest':'serial_style', 'help':'Serial style.','choices': srstyles}, ] - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): zone = Zone(args['zone']) zone.freeze() @@ -144,7 +218,9 @@ class CommandZoneThaw(DyntmCommand): {'arg':'--timeout', 'type':int, 'help':'Integer timeout for transfer.' }, {'arg':'--style', 'dest':'serial_style', 'help':'Serial style.','choices': srstyles}, ] - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): zone = Zone(args['zone']) zone.thaw() @@ -155,7 +231,9 @@ class CommandNodeList(DyntmCommand): args = [ {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, ] - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): zone = Zone(args['name']) for node in zone.get_all_nodes(): print node.fqdn @@ -168,7 +246,9 @@ class CommandNodeDelete(DyntmCommand): {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, {'arg':'node', 'type':str, 'help':'The name of the node.'}, ] - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): zone = Zone(args['name']) node = zone.get_node(args['node']) node.delete() @@ -182,7 +262,9 @@ class CommandRecordList(DyntmCommand): {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, {'arg':'--node', 'type':str, 'help':'Limit list to records appearing on the given node.'}, ] - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): zone = Zone(args['zone']) if args.get('node', None) is not None: name = None if args['node'] == zone.name else args['node'] @@ -190,93 +272,58 @@ def action(self, *rest, **args): recs = reduce(lambda r, n: r + n, node.get_all_records().values()) else: recs = reduce(lambda r, n: r + n, zone.get_all_records().values()) - for record in recs: - print "{} {} {}".format(record.fqdn, record.rec_name.upper(), record.rdata()) + for record in recs: + print "{} {} {}".format(record.fqdn, record.rec_name.upper(), record.rdata()) class CommandRecordCreate(DyntmCommand): name = "record-new" desc = "Create record." args = [ + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL for the new record.'}, {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, {'arg':'node', 'type':str, 'help':'Node on which to create the record.'}, - {'arg':'rtype', 'type':str, 'help':'Record type.', 'metavar': 'rtype', 'choices': rectypes}, - {'arg':'rdata', 'type':str, 'help':'Record data.', 'nargs':'+'}, ] - def action(self, *rest, **args): + + @classmethod + def action(cls, *rest, **args): + # figure out record init arguments specific to this command + keys = [ d['arg'] if d.has_key('dest') else d['arg'] for d in cls.args ] + new = { key : args[key] for key in keys } + # get zone and node zone = Zone(args['zone']) node = zone.get_node(args['node']) - print args['rdata'] - rec = node.add_record(args['rtype'], *args['rdata']) - print rec + # add a new record on that node + rec = node.add_record(cls.name, ttl=args['ttl'], **new) + # publish the zone TODO zone.publish() - + +class CommandRecordCreateA(CommandRecordCreate): + name = "A" + desc = "Create an A record." + args = [ + {'arg':'address', 'type':str, 'help':'An IPv4 address.'}, + ] + + +class CommandRecordCreateTXT(CommandRecordCreate): + name = "TXT" + desc = "Create a TXT record." + args = [ + {'arg':'txtdata', 'type':str, 'help':'Some text data.'}, + ] + + + + ## redir commands TODO ## gslb commands TODO ## dsf commands TODO # main def dyntm(argv=sys.argv): - # some context - cpath = os.path.expanduser("~/.dyntm.yml") - # setup subcommands - cmds = {c.name : c() for c in DyntmCommand.__subclasses__() } - # setup argument parser - ap = argparse.ArgumentParser(description='Interact with Dyn Traffic Management API') - ap.add_argument('--conf', type=str, dest='conf', help='Alternate configuration file.') - ap.add_argument('--cust', type=str, dest='cust', help='Customer account name for authentication.') - ap.add_argument('--user', type=str, dest='user', help='User name for authentication.') - ap.add_argument('--host', type=str, dest='host', help='Alternate DynECT API host.') - ap.add_argument('--port', type=int, dest='port', help='Alternate DynECT API port.') - ap.add_argument('--proxy-host', type=str, dest='proxy_host', help='HTTP proxy host.') - ap.add_argument('--proxy-port', type=str, dest='proxy_port', help='HTTP proxy port.') - ap.add_argument('--proxy-user', type=str, dest='proxy_user', help='HTTP proxy user name.') - ap.add_argument('--proxy-pass', type=str, dest='proxy_pass', help='HTTP proxy password.') - # setup parsers for commands - sub = ap.add_subparsers(title="command") - for cmd in cmds.values(): - sub._name_parser_map[cmd.name] = cmd.parser() - # parse arguments - args, rest = ap.parse_known_args(args=argv) - # read configuration file - conf = {} - try: - with open(args.conf or cpath, 'r') as cf: - conf = yaml.load(cf) - except IOError as e: - sys.stderr.write(str(e)) - exit(1) - # require credentials - cust = args.cust or conf.get('cust') - user = args.user or conf.get('user') - if not user or not cust: - sys.stderr.write("A customer name and user name must be provided!") - exit(2) - # require password - pswd = conf.get('pass') or getpass("Password for {}/{}".format(cust, user)) - if not pswd: - sys.stderr.write("A password must be provided!") - exit(2) - # maybe more session options - keys = ['host', 'port', 'proxy_host', 'proxy_port', 'proxy_user', 'proxy_pass', 'proxy_pass'] - opts = { k : v for d in [conf, vars(args)] for k, v in d.iteritems() if k in keys and v is not None } - # setup session - try: - # TODO cache session token! update SessionEngine.connect maybe? - session = DynectSession(cust, user, pswd, **opts) - except DynectAuthError as auth: - print auth.message - exit(3) - # dispatch to command - try: - inp = { k : v for k, v in vars(args).iteritems() if k not in ['command', 'func'] } - args.func(**inp) - except Exception as err: - print err.message - exit(4) - # done! - exit(0) + DyntmCommand.action(argv) # call it if invoked dyntm(sys.argv[1:]) From e6a9dc023270df838b75f1407a34eaf611191157 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 14 Jan 2018 18:20:52 -0500 Subject: [PATCH 04/31] Comments and some record classes. --- dyn/cli/dyntm.py | 112 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 87 insertions(+), 25 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index f3aab23..493d37d 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -99,7 +99,8 @@ def action(cls, *argv, **opts): inp = { k : v for k, v in vars(args).iteritems() if k not in ['command', 'func'] } args.func(**inp) except Exception as err: - print err.message + print err + # print err.message exit(4) # done! exit(0) @@ -112,9 +113,12 @@ def __init__(self): class CommandUserPermissions(DyntmCommand): name = "perms" desc = "List permissions." + @classmethod def action(cls, *rest, **args): + # get active session session = DynectSession.get_session() + # print each permission available to current session for perm in sorted(session.permissions): print perm @@ -128,8 +132,11 @@ class CommandUserPassword(DyntmCommand): @classmethod def action(cls, *rest, **args): - newpass = args['password'] or getpass() + # get active session session = DynectSession.get_session() + # get password or prompt for it + newpass = args['password'] or getpass() + # update password session.update_password(newpass) @@ -164,18 +171,21 @@ 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', 'dest':'style', 'type':str, 'dest':'serial_style', 'help':'Serial style.','choices': srstyles}, + {'arg':'--file', 'dest':'file', 'type':file, '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).'}, - {'arg':'--ttl', 'type':int, 'help':'Integer TTL.'}, - {'arg':'--timeout', 'type':int, 'help':'Integer timeout for transfer.'}, - {'arg':'--style', 'type':str, 'dest':'serial_style', 'help':'Serial style.','choices': srstyles}, - {'arg':'--file', 'type':file, 'help':'File from which to import zone data.'}, - {'arg':'--master', 'type':str, 'help':'Master IP from which to transfer zone.'}, ] @classmethod def action(cls, *rest, **args): - new = { k : v for k, v in args.iteritems() if v is not None } + # figure out zone init arguments + spec = [ d['dest'] if d.has_key('dest') 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) print zone @@ -189,6 +199,7 @@ class CommandZoneDelete(DyntmCommand): @classmethod def action(cls, *rest, **args): + # get the zone and delete it! zone = Zone(args['zone']) zone.delete() @@ -197,14 +208,15 @@ class CommandZoneFreeze(DyntmCommand): name = "freeze" desc = "Freeze the given zone." args = [ - {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, {'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() @@ -213,14 +225,15 @@ class CommandZoneThaw(DyntmCommand): name = "thaw" desc = "Thaw the given zone." args = [ - {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, {'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() @@ -234,7 +247,9 @@ class CommandNodeList(DyntmCommand): @classmethod def action(cls, *rest, **args): - zone = Zone(args['name']) + # get the zone + zone = Zone(args['zone']) + # print all of the zone's nodes for node in zone.get_all_nodes(): print node.fqdn @@ -249,8 +264,10 @@ class CommandNodeDelete(DyntmCommand): @classmethod def action(cls, *rest, **args): - zone = Zone(args['name']) + # get the zone and node + zone = Zone(args['zone']) node = zone.get_node(args['node']) + # delete the node node.delete() @@ -259,21 +276,21 @@ class CommandRecordList(DyntmCommand): name = "records" desc = "List records on the given zone." args = [ - {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, {'arg':'--node', 'type':str, 'help':'Limit list to records appearing on the given node.'}, + {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, ] @classmethod def action(cls, *rest, **args): + # get the zone zone = Zone(args['zone']) - if args.get('node', None) is not None: - name = None if args['node'] == zone.name else args['node'] - node = zone.get_node(name) - recs = reduce(lambda r, n: r + n, node.get_all_records().values()) - else: - recs = reduce(lambda r, n: r + n, zone.get_all_records().values()) - for record in recs: - print "{} {} {}".format(record.fqdn, record.rec_name.upper(), record.rdata()) + # maybe limit list to a given node + thing = zone.get_node(args['node']) if args['node'] else zone + # combine awkward rtype lists + records = reduce(lambda r, n: r + n, thing.get_all_records().values()) + # print selected records + for record in records: + print "{} {} {}".format(record.fqdn, record.rec_name.upper(), record.rdata()) class CommandRecordCreate(DyntmCommand): @@ -288,9 +305,9 @@ class CommandRecordCreate(DyntmCommand): @classmethod def action(cls, *rest, **args): # figure out record init arguments specific to this command - keys = [ d['arg'] if d.has_key('dest') else d['arg'] for d in cls.args ] - new = { key : args[key] for key in keys } - # get zone and node + spec = [ d['dest'] if d.has_key('dest') else d['arg'] for d in cls.args ] + new = { k : args[k] for k in spec if args[k] is not None } + # get the zone and node zone = Zone(args['zone']) node = zone.get_node(args['node']) # add a new record on that node @@ -298,7 +315,7 @@ def action(cls, *rest, **args): # publish the zone TODO zone.publish() - +### TODO define these record classes dynamically class CommandRecordCreateA(CommandRecordCreate): name = "A" desc = "Create an A record." @@ -307,6 +324,14 @@ class CommandRecordCreateA(CommandRecordCreate): ] +class CommandRecordCreateAAAA(CommandRecordCreate): + name = "AAAA" + desc = "Create an AAAA record." + args = [ + {'arg':'address', 'type':str, 'help':'An IPv6 address.'}, + ] + + class CommandRecordCreateTXT(CommandRecordCreate): name = "TXT" desc = "Create a TXT record." @@ -314,6 +339,43 @@ class CommandRecordCreateTXT(CommandRecordCreate): {'arg':'txtdata', 'type':str, 'help':'Some text data.'}, ] + +class CommandRecordCreateCNAME(CommandRecordCreate): + name = "CNAME" + desc = "Create a CNAME record." + args = [ + {'arg':'cname', 'type':str, 'help':'A hostname.'}, + ] + + +class CommandRecordCreateCNAME(CommandRecordCreate): + name = "ALIAS" + desc = "Create an ALIAS record." + args = [ + {'arg':'alias', 'type':str, 'help':'A hostname.'}, + ] + +class CommandRecordCreateDNSKEY(CommandRecordCreate): + name = "DNSKEY" + desc = "Create a DNSKEY record." + args = [ + {'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':'A hostname.'}, + {'arg':'--flags', 'dest':'algorithm', 'type':int, 'help':'A hostname.'}, + ] + + +class CommandRecordCreateCDNSKEY(CommandRecordCreate): + name = "CDNSKEY" + desc = "Create a CDNSKEY record." + args = [ + {'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':'A hostname.'}, + {'arg':'--flags', 'dest':'algorithm', 'type':int, 'help':'A hostname.'}, + ] + From 05b010cd0b67a0f6bb63d2901c93e867f52aa643 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Tue, 16 Jan 2018 09:06:22 -0500 Subject: [PATCH 05/31] Generate record type command classes. --- dyn/cli/dyntm.py | 256 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 190 insertions(+), 66 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 493d37d..b267b34 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -33,6 +33,7 @@ class DyntmCommand(object): 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.'}, @@ -54,7 +55,7 @@ def parser(cls): ap.set_defaults(func=cls.action, command=cls.name) # setup subcommand parsers if len(cls.__subclasses__()) != 0: - sub = ap.add_subparsers(title="Commands") + sub = ap.add_subparsers(title=cls.subtitle) for cmd in cls.__subclasses__(): sub._name_parser_map[cmd.name] = cmd.parser() return ap @@ -99,8 +100,8 @@ def action(cls, *argv, **opts): inp = { k : v for k, v in vars(args).iteritems() if k not in ['command', 'func'] } args.func(**inp) except Exception as err: + # TODO catch specific errors for meaningful exit codes print err - # print err.message exit(4) # done! exit(0) @@ -289,17 +290,192 @@ def action(cls, *rest, **args): # combine awkward rtype lists records = reduce(lambda r, n: r + n, thing.get_all_records().values()) # print selected records - for record in records: + for record in sorted(records, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)) : print "{} {} {}".format(record.fqdn, record.rec_name.upper(), record.rdata()) +# 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.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'AAAA' : [ + {'arg':'address', 'type':str, 'help':'An IPv6 address.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'ALIAS' : [ + {'arg':'alias', 'type':str, 'help':'A hostname.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'CNAME' : [ + {'arg':'cname', 'type':str, 'help':'A hostname.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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':'+'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'DHCID' : [ + {'arg':'digest', 'type':str, 'help':'Base-64 encoded digest of DHCP data.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'DNAME' : [ + {'arg':'cname', 'type':str, 'help':'A hostname.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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..'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'KX' : [ + {'arg':'exchange', 'type':str, 'help':'Hostname of key exchange.'}, + {'arg':'preference', 'type':int, 'help':'Numeric priority of this exchange.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'MX' : [ + {'arg':'exchange', 'type':str, 'help':''}, + {'arg':'prefernce', 'type':str, 'help':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + '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':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'PTR' : [ + {'arg':'ptrdname', 'type':str, 'help':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'PX' : [ + {'arg':'prefernce', 'type':str, 'help':''}, + {'arg':'map822', 'type':str, 'help':''}, + {'arg':'map400', 'type':str, 'help':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'NSAP' : [ + {'arg':'nsap', 'type':str, 'help':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'RP' : [ + {'arg':'mbox', 'type':str, 'help':''}, + {'arg':'txtdname', 'type':str, 'help':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'NS' : [ + {'arg':'nsdname', 'type':str, 'help':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'SOA' : [ + # TODO + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'SPF' : [ + {'arg':'txtdata', 'type':str, 'help':'Some text data.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'SRV' : [ + {'arg':'port', 'type':str, 'help':''}, + {'arg':'priority', 'type':str, 'help':''}, + {'arg':'target', 'type':str, 'help':''}, + {'arg':'weight', 'type':str, 'help':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'SSHFP' : [ + {'arg':'algorithm', 'type':str, 'help':''}, + {'arg':'fptype', 'type':str, 'help':''}, + {'arg':'fingerprint', 'type':str, 'help':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'TLSA' : [ + {'arg':'cert_usage', 'type':str, 'help':''}, + {'arg':'selector', 'type':str, 'help':''}, + {'arg':'match_type', 'type':str, 'help':''}, + {'arg':'certificate', 'type':str, 'help':''}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], + 'TXT' : [ + {'arg':'txtdata', 'type':str, 'help':'Some text data.'}, + {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, + ], +} + class CommandRecordCreate(DyntmCommand): name = "record-new" desc = "Create record." + subtitle = "Record Types" args = [ - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL for the new record.'}, {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, {'arg':'node', 'type':str, 'help':'Node on which to create the record.'}, + # could have TTL here but that requires the option to appear before the record type ] @classmethod @@ -311,72 +487,20 @@ def action(cls, *rest, **args): zone = Zone(args['zone']) node = zone.get_node(args['node']) # add a new record on that node - rec = node.add_record(cls.name, ttl=args['ttl'], **new) + rec = node.add_record(cls.name, **new) # publish the zone TODO zone.publish() -### TODO define these record classes dynamically -class CommandRecordCreateA(CommandRecordCreate): - name = "A" - desc = "Create an A record." - args = [ - {'arg':'address', 'type':str, 'help':'An IPv4 address.'}, - ] - - -class CommandRecordCreateAAAA(CommandRecordCreate): - name = "AAAA" - desc = "Create an AAAA record." - args = [ - {'arg':'address', 'type':str, 'help':'An IPv6 address.'}, - ] - - -class CommandRecordCreateTXT(CommandRecordCreate): - name = "TXT" - desc = "Create a TXT record." - args = [ - {'arg':'txtdata', 'type':str, 'help':'Some text data.'}, - ] - - -class CommandRecordCreateCNAME(CommandRecordCreate): - name = "CNAME" - desc = "Create a CNAME record." - args = [ - {'arg':'cname', 'type':str, 'help':'A hostname.'}, - ] - - -class CommandRecordCreateCNAME(CommandRecordCreate): - name = "ALIAS" - desc = "Create an ALIAS record." - args = [ - {'arg':'alias', 'type':str, 'help':'A hostname.'}, - ] - -class CommandRecordCreateDNSKEY(CommandRecordCreate): - name = "DNSKEY" - desc = "Create a DNSKEY record." - args = [ - {'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':'A hostname.'}, - {'arg':'--flags', 'dest':'algorithm', 'type':int, 'help':'A hostname.'}, - ] - - -class CommandRecordCreateCDNSKEY(CommandRecordCreate): - name = "CDNSKEY" - desc = "Create a CDNSKEY record." - args = [ - {'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':'A hostname.'}, - {'arg':'--flags', 'dest':'algorithm', 'type':int, 'help':'A hostname.'}, - ] - +# 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']] : + attr = { + 'name':rtype, + 'args':rtypes[rtype], + 'desc':"Create one {} record.".format(rtype), + } + rcreate[rtype] = type("CommandRecordCreate" + rtype, (CommandRecordCreate,), attr) ## redir commands TODO From 42fe34e720c90a4faed3e2c2ed24978744f008a5 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sat, 20 Jan 2018 10:01:23 -0500 Subject: [PATCH 06/31] Working record list, update, delete. --- dyn/cli/dyntm.py | 223 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 190 insertions(+), 33 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index b267b34..3b20ef8 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -13,10 +13,15 @@ ## Cleaned up error messages. # system libs -import os, sys -import argparse, getpass -import yaml, json +import os +import sys +import re +import copy import itertools +import argparse +import getpass +import yaml +import json # internal libs import dyn.tm @@ -31,6 +36,10 @@ # parent command class class DyntmCommand(object): + ''' + This is a help string right? + ''' + name = "dyntm" desc = "Interact with Dyn Traffic Management API" subtitle = "Commands" @@ -101,7 +110,7 @@ def action(cls, *argv, **opts): args.func(**inp) except Exception as err: # TODO catch specific errors for meaningful exit codes - print err + print err.message exit(4) # done! exit(0) @@ -110,7 +119,7 @@ def __init__(self): # command classes! -## user commands +### user permissions class CommandUserPermissions(DyntmCommand): name = "perms" desc = "List permissions." @@ -124,6 +133,7 @@ def action(cls, *rest, **args): print perm +### update password class CommandUserPassword(DyntmCommand): name = "passwd" desc = "Update password." @@ -141,6 +151,7 @@ def action(cls, *rest, **args): session.update_password(newpass) +### list users class CommandUserList(DyntmCommand): name = "users" desc = "List users." @@ -156,7 +167,7 @@ def action(cls, *rest, **args): print user.user_name -## zone commands +### list zones class CommandZoneList(DyntmCommand): name = "zones" desc = "List all the zones available." @@ -167,7 +178,7 @@ def action(cls, *rest, **args): for zone in zones: print zone.fqdn - +### create zone class CommandZoneCreate(DyntmCommand): name = "zone-new" desc = "Make a new zone." @@ -191,6 +202,7 @@ def action(cls, *rest, **args): print zone +### delete zone class CommandZoneDelete(DyntmCommand): name = "zone-delete" desc = "Make a new zone." @@ -205,6 +217,7 @@ def action(cls, *rest, **args): zone.delete() +### freeze zone class CommandZoneFreeze(DyntmCommand): name = "freeze" desc = "Freeze the given zone." @@ -221,7 +234,7 @@ def action(cls, *rest, **args): zone = Zone(args['zone']) zone.freeze() - +### thaw zone class CommandZoneThaw(DyntmCommand): name = "thaw" desc = "Thaw the given zone." @@ -239,6 +252,7 @@ def action(cls, *rest, **args): zone.thaw() +### list nodes class CommandNodeList(DyntmCommand): name = "nodes" desc = "List nodes in the given zone." @@ -255,6 +269,7 @@ def action(cls, *rest, **args): print node.fqdn +### delete nodes class CommandNodeDelete(DyntmCommand): name = "node-delete" desc = "Delete the given node." @@ -273,26 +288,6 @@ def action(cls, *rest, **args): ## record commands -class CommandRecordList(DyntmCommand): - name = "records" - desc = "List records on the given zone." - args = [ - {'arg':'--node', 'type':str, 'help':'Limit list to records appearing on the given node.'}, - {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, - ] - - @classmethod - def action(cls, *rest, **args): - # get the zone - zone = Zone(args['zone']) - # maybe limit list to a given node - thing = zone.get_node(args['node']) if args['node'] else zone - # combine awkward rtype lists - records = reduce(lambda r, n: r + n, thing.get_all_records().values()) - # print selected records - for record in sorted(records, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)) : - print "{} {} {}".format(record.fqdn, record.rec_name.upper(), record.rdata()) - # record type specifications for child class generation # TODO write sensible help strings @@ -468,6 +463,8 @@ def action(cls, *rest, **args): ], } + +### create record class CommandRecordCreate(DyntmCommand): name = "record-new" desc = "Create record." @@ -480,29 +477,189 @@ class CommandRecordCreate(DyntmCommand): @classmethod def action(cls, *rest, **args): - # figure out record init arguments specific to this command - spec = [ d['dest'] if d.has_key('dest') else d['arg'] for d in cls.args ] - new = { k : args[k] for k in spec if args[k] is not None } # 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 d.has_key('dest') else d['arg'] 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 TODO + # publish the zone zone.publish() # 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]) attr = { 'name':rtype, - 'args':rtypes[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 = reduce(lambda x, y: x + y, zone.get_all_records().values()) + # print all records + for r in sorted(recs, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)) : + print "{} {} {} {}".format(r._record_id, r.fqdn, r.rec_name.upper(), r.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'] + [a['dest'] if a.has_key('dest') 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]])] + # print selected records + for r in sorted(found, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)) : + print "{} {} {} {}".format(r._record_id, r.fqdn, r.rec_name.upper(), r.rdata()) + + +rget = {} +for rtype in sorted(rtypes.keys()): + # tweak args to make them all optional + opts = copy.deepcopy(rtypes[rtype]) # list(rtypes[rtype]) + opts += [ {'arg':'--id', 'type':int, 'dest':'_record_id', 'help':'Awkward internal record ID'} ] + 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.'}, + ] + 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 a.has_key('dest') else a['arg'].strip("-") for a in cls.args] + # update the record + for field in fields: + if args[field]: + setattr(that, field, args[field]) + # publish the zone + zone.publish() + # success + print 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.'}, + # 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.'}, + ] + 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() + # publish the zone + zone.publish() + # success + print 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" + rtype, (CommandRecordDelete,), attr) + + + ## redir commands TODO ## gslb commands TODO ## dsf commands TODO From 8e742f9c85d525cbaf966523333b99529850c3bc Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sat, 20 Jan 2018 10:15:36 -0500 Subject: [PATCH 07/31] Dry up TTL option. More sensible order for record list/get output. --- dyn/cli/dyntm.py | 41 +++++++---------------------------------- 1 file changed, 7 insertions(+), 34 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 3b20ef8..442fadb 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -295,85 +295,70 @@ def action(cls, *rest, **args): # 'RTYPE' : [ {'arg':'', 'dest':'','type':str, 'help':''}, ] 'A' : [ {'arg':'address', 'type':str, 'help':'An IPv4 address.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'AAAA' : [ {'arg':'address', 'type':str, 'help':'An IPv6 address.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'ALIAS' : [ {'arg':'alias', 'type':str, 'help':'A hostname.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], '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.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], '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.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], '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.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], '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.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'CNAME' : [ {'arg':'cname', 'type':str, 'help':'A hostname.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], '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':'+'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'DHCID' : [ {'arg':'digest', 'type':str, 'help':'Base-64 encoded digest of DHCP data.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'DNAME' : [ {'arg':'cname', 'type':str, 'help':'A hostname.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], '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.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], '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.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], '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..'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'KX' : [ {'arg':'exchange', 'type':str, 'help':'Hostname of key exchange.'}, {'arg':'preference', 'type':int, 'help':'Numeric priority of this exchange.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'LOC' : [ {'arg':'altitude', 'type':str, 'help':''}, @@ -382,7 +367,6 @@ def action(cls, *rest, **args): {'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':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'IPSECKEY' : [ {'arg':'precedence', 'type':str, 'help':''}, @@ -390,12 +374,10 @@ def action(cls, *rest, **args): {'arg':'algorithm', 'type':str, 'help':''}, {'arg':'gateway', 'type':str, 'help':''}, {'arg':'public_key', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'MX' : [ {'arg':'exchange', 'type':str, 'help':''}, {'arg':'prefernce', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'NAPTR' : [ {'arg':'order', 'type':str, 'help':''}, @@ -404,62 +386,50 @@ def action(cls, *rest, **args): {'arg':'regexp', 'type':str, 'help':''}, {'arg':'replacement', 'type':str, 'help':''}, {'arg':'flags', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'PTR' : [ {'arg':'ptrdname', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'PX' : [ {'arg':'prefernce', 'type':str, 'help':''}, {'arg':'map822', 'type':str, 'help':''}, {'arg':'map400', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'NSAP' : [ {'arg':'nsap', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'RP' : [ {'arg':'mbox', 'type':str, 'help':''}, {'arg':'txtdname', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'NS' : [ {'arg':'nsdname', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'SOA' : [ # TODO - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'SPF' : [ {'arg':'txtdata', 'type':str, 'help':'Some text data.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'SRV' : [ {'arg':'port', 'type':str, 'help':''}, {'arg':'priority', 'type':str, 'help':''}, {'arg':'target', 'type':str, 'help':''}, {'arg':'weight', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'SSHFP' : [ {'arg':'algorithm', 'type':str, 'help':''}, {'arg':'fptype', 'type':str, 'help':''}, {'arg':'fingerprint', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'TLSA' : [ {'arg':'cert_usage', 'type':str, 'help':''}, {'arg':'selector', 'type':str, 'help':''}, {'arg':'match_type', 'type':str, 'help':''}, {'arg':'certificate', 'type':str, 'help':''}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], 'TXT' : [ {'arg':'txtdata', 'type':str, 'help':'Some text data.'}, - {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'}, ], } @@ -493,6 +463,7 @@ def action(cls, *rest, **args): 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, @@ -516,8 +487,8 @@ def action(cls, *rest, **args): # get records recs = reduce(lambda x, y: x + y, zone.get_all_records().values()) # print all records - for r in sorted(recs, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)) : - print "{} {} {} {}".format(r._record_id, r.fqdn, r.rec_name.upper(), r.rdata()) + for r in sorted(recs, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)): + print "{} {} {} {}".format(r.fqdn, r.rec_name.upper(), r._record_id, r.rdata()) ### get records @@ -541,13 +512,14 @@ def action(cls, *rest, **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]])] # print selected records for r in sorted(found, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)) : - print "{} {} {} {}".format(r._record_id, r.fqdn, r.rec_name.upper(), r.rdata()) + print "{} {} {} {}".format(r.fqdn, r.rec_name.upper(), r._record_id, r.rdata()) rget = {} for rtype in sorted(rtypes.keys()): # tweak args to make them all optional opts = copy.deepcopy(rtypes[rtype]) # list(rtypes[rtype]) + opts += [ {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'} ] opts += [ {'arg':'--id', 'type':int, 'dest':'_record_id', 'help':'Awkward internal record ID'} ] for opt in opts: if not opt['arg'].startswith('--'): @@ -603,7 +575,8 @@ def action(cls, *rest, **args): 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':'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, From 4e72cf02b3218f4d4b08e72538896f59026855a5 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sat, 20 Jan 2018 22:16:12 -0500 Subject: [PATCH 08/31] Refactor DNSRecord rdata method. Print rdata as JSON in record list commands. --- dyn/cli/dyntm.py | 12 ++++++++---- dyn/tm/records.py | 12 +++--------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 442fadb..ff3c4e1 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -451,7 +451,7 @@ def action(cls, *rest, **args): zone = Zone(args['zone']) node = zone.get_node(args['node']) # figure out record init arguments specific to this command - spec = [ d['dest'] if d.has_key('dest') else d['arg'] for d in cls.args ] + spec = [ d['dest'] if d.has_key('dest') 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) @@ -488,7 +488,9 @@ def action(cls, *rest, **args): recs = reduce(lambda x, y: x + y, zone.get_all_records().values()) # print all records for r in sorted(recs, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)): - print "{} {} {} {}".format(r.fqdn, r.rec_name.upper(), r._record_id, r.rdata()) + rtype = r.rec_name.upper() + rdata = json.dumps(dyn.tm.records.DNSRecord.rdata(r)) + print "{} {} {} {} {}".format(r.fqdn, rtype, r._record_id, r.ttl, rdata) ### get records @@ -508,11 +510,13 @@ def action(cls, *rest, **args): node = zone.get_node(args['node']) # get set of records recs = node.get_all_records_by_type(rtype) - fields = ['_record_id'] + [a['dest'] if a.has_key('dest') else a['arg'].strip("-") for a in cls.args] + fields = ['_record_id'] + [a['dest'] if a.has_key('dest') 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]])] # print selected records for r in sorted(found, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)) : - print "{} {} {} {}".format(r.fqdn, r.rec_name.upper(), r._record_id, r.rdata()) + rtype = r.rec_name.upper() + rdata = json.dumps(dyn.tm.records.DNSRecord.rdata(r)) + print "{} {} {} {} {}".format(r.fqdn, rtype, r._record_id, r.ttl, rdata) rget = {} diff --git a/dyn/tm/records.py b/dyn/tm/records.py index b23ca4a..220628d 100644 --- a/dyn/tm/records.py +++ b/dyn/tm/records.py @@ -95,15 +95,9 @@ 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 From 9959d8583a71fd9b4d6a6fecdee5d84bea356769 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 21 Jan 2018 17:37:35 -0500 Subject: [PATCH 09/31] Implement session token reuse. --- dyn/cli/dyntm.py | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index ff3c4e1..6122658 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -6,11 +6,10 @@ """ # TODO -## Persistent session tokens via file cache. Requires changes to dyn.tm.session? -### Publishing changes after multiple invocations of the script. +## Publishing changes after multiple invocations of the script. ## A file cache of zones, nodes, services etc. Any of the 'get_all_X'. -### DTRT with one argument specifying a zone and node. -## Cleaned up error messages. +## DTRT with one argument specifying a zone and node. +## Cleaned up help and error messages. # system libs import os @@ -93,16 +92,28 @@ def action(cls, *argv, **opts): if not pswd: sys.stderr.write("A password must be provided!") exit(2) - # maybe more session options - keys = ['host', 'port', 'proxy_host', 'proxy_port', 'proxy_user', 'proxy_pass', 'proxy_pass'] - opts = { k : v for d in [conf, vars(args)] for k, v in d.iteritems() if k in keys and v is not None } # setup session + token = None + tpath = os.path.expanduser("~/.dyntm-token") try: - # TODO cache session token! update SessionEngine.connect maybe? - session = DynectSession(cust, user, pswd, **opts) + # maybe load cached session token + if os.path.isfile(tpath): + with open(tpath, 'r') as tf: + token = tf.readline() + # create session + keys = ['host', 'port', 'proxy_host', 'proxy_port', 'proxy_user', 'proxy_pass', 'proxy_pass'] + opts = { k : v for d in [conf, vars(args)] for k, v in d.iteritems() if k in keys and v is not None } + # authenticate only if token needed + if token: + session = DynectSession(cust, user, pswd, auto_auth=False, **opts) + session._token = token + else: + session = DynectSession(cust, user, pswd, **opts) except DynectAuthError as auth: print auth.message exit(3) + except IOError as e: + sys.stderr.write("Could not read from token file {}.\n{}".format(tpath, str(e))) # dispatch to command if args.command != cls.name: try: @@ -112,6 +123,13 @@ def action(cls, *argv, **opts): # TODO catch specific errors for meaningful exit codes print err.message exit(4) + # record token for later use + try: + if session._token != token: + with open(tpath, 'w') as tf: + tf.write(session._token) + except IOError as e: + sys.stderr.write("Could not write to token file {}.\n{}".format(tpath, str(e))) # done! exit(0) def __init__(self): From 0c62c7c3e4a09d869cb4d47716ff9a90d11880c1 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Mon, 22 Jan 2018 20:29:27 -0500 Subject: [PATCH 10/31] Tiny cleanup. --- dyn/cli/dyntm.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 6122658..7a2b573 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -71,7 +71,8 @@ def parser(cls): @classmethod def action(cls, *argv, **opts): # parse arguments - args = cls.parser().parse_args() # (args=argv) TODO list unhashable? + ap = cls.parser() + args = ap.parse_args() # (args=argv) TODO list unhashable? # read configuration file cpath = os.path.expanduser("~/.dyntm.yml") conf = {} @@ -94,7 +95,7 @@ def action(cls, *argv, **opts): exit(2) # setup session token = None - tpath = os.path.expanduser("~/.dyntm-token") + tpath = os.path.expanduser("~/.dyntm-{}-{}".format(cust, user)) try: # maybe load cached session token if os.path.isfile(tpath): @@ -112,8 +113,8 @@ def action(cls, *argv, **opts): except DynectAuthError as auth: print auth.message exit(3) - except IOError as e: - sys.stderr.write("Could not read from token file {}.\n{}".format(tpath, str(e))) + except IOError as err: + sys.stderr.write("Could not read from token file {}.\n{}".format(tpath, str(err))) # dispatch to command if args.command != cls.name: try: @@ -128,8 +129,8 @@ def action(cls, *argv, **opts): if session._token != token: with open(tpath, 'w') as tf: tf.write(session._token) - except IOError as e: - sys.stderr.write("Could not write to token file {}.\n{}".format(tpath, str(e))) + except IOError as err: + sys.stderr.write("Could not write to token file {}.\n{}".format(tpath, str(err))) # done! exit(0) def __init__(self): @@ -196,6 +197,7 @@ def action(cls, *rest, **args): for zone in zones: print zone.fqdn + ### create zone class CommandZoneCreate(DyntmCommand): name = "zone-new" @@ -537,12 +539,16 @@ def action(cls, *rest, **args): print "{} {} {} {} {}".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()): - # tweak args to make them all optional + # setup argument spec opts = copy.deepcopy(rtypes[rtype]) # list(rtypes[rtype]) - opts += [ {'arg':'--ttl', 'dest':'ttl', 'type':int, 'help':'TTL of the record.'} ] - opts += [ {'arg':'--id', 'type':int, 'dest':'_record_id', 'help':'Awkward internal record ID'} ] + 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'] @@ -659,9 +665,7 @@ def action(cls, *rest, **args): ## gslb commands TODO ## dsf commands TODO -# main -def dyntm(argv=sys.argv): - DyntmCommand.action(argv) -# call it if invoked -dyntm(sys.argv[1:]) +# main +if __name__ == "__main__": + DyntmCommand.action(sys.argv[1:]) From 44e40a76e54971529b731fdd18efa2aa88e50026 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 28 Jan 2018 19:14:31 -0500 Subject: [PATCH 11/31] Push _meta_update down. Saner auth failure error. Comments. --- dyn/cli/dyntm.py | 4 ++-- dyn/core.py | 34 ++++++++++++++-------------------- dyn/tm/session.py | 20 ++++++++++++++++++-- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 7a2b573..82f9f06 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -101,10 +101,10 @@ def action(cls, *argv, **opts): if os.path.isfile(tpath): with open(tpath, 'r') as tf: token = tf.readline() - # create session + # figure session fields keys = ['host', 'port', 'proxy_host', 'proxy_port', 'proxy_user', 'proxy_pass', 'proxy_pass'] opts = { k : v for d in [conf, vars(args)] for k, v in d.iteritems() if k in keys and v is not None } - # authenticate only if token needed + # create session. authenticate only if token is unavailable if token: session = DynectSession(cust, user, pswd, auto_auth=False, **opts) session._token = token diff --git a/dyn/core.py b/dyn/core.py index 0e12036..bc33f2d 100644 --- a/dyn/core.py +++ b/dyn/core.py @@ -280,20 +280,24 @@ def _retry(self, msgs, final=False): def _handle_response(self, response, uri, method, raw_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 + # Poll for task completion if needed + 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) + # Parse response JSON json_err_fmt = "Decode Error on Response Body: {!r} status: {!r} {!r}" try: ret_val = json.loads(body.decode('UTF-8')) @@ -301,26 +305,28 @@ def _handle_response(self, response, uri, method, raw_args, final): self.logger.error(json_err_fmt.format(body, response.status, uri)) raise + # Add a record of this request/response to the history. if self.__call_cache is not None: self.__call_cache.append((uri, method, clean_args(raw_args), ret_val['status'])) + # Call this hook for client state updates. self._meta_update(uri, method, ret_val) + # Maybe retry failed calls? 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(ret_val, method) 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 @@ -399,21 +405,9 @@ def execute(self, uri, method, args=None, final=False): return self._handle_response(response, uri, method, raw_args, final) def _meta_update(self, uri, method, results): - """Update the HTTP session token if the uri is a login or logout + """Hook into response handling.""" + pass - :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 diff --git a/dyn/tm/session.py b/dyn/tm/session.py index 74dab5b..24006d1 100644 --- a/dyn/tm/session.py +++ b/dyn/tm/session.py @@ -81,7 +81,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: @@ -109,7 +109,7 @@ def _process_response(self, response, method, final=False): return response elif status == 'failure': msgs = response['msgs'] - if method == 'POST' and 'login' in msgs[0]['INFO']: + if 'login' in msgs[0]['INFO']: raise DynectAuthError(response['msgs']) if method == 'POST': raise DynectCreateError(response['msgs']) @@ -127,6 +127,22 @@ def _process_response(self, response, method, final=False): else: raise DynectQueryTimeout({}) + 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 From 6073deb45b99f10e97829138956699ba5c8235c3 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 4 Feb 2018 19:00:33 -0500 Subject: [PATCH 12/31] Session refactor. Clean up retries, polling, and history. --- dyn/cli/dyntm.py | 1 + dyn/core.py | 178 ++++++++-------------------------------------- dyn/mm/session.py | 4 +- dyn/tm/session.py | 67 ++++++++++------- 4 files changed, 75 insertions(+), 175 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 82f9f06..7f71d1f 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -310,6 +310,7 @@ def action(cls, *rest, **args): ## record commands # record type specifications for child class generation +# HEY! Maintaining an 80 character column limit is pointless busy work especially in this indentation oriented language. # TODO write sensible help strings rtypes = { # 'RTYPE' : [ {'arg':'', 'dest':'','type':str, 'help':''}, ] diff --git a/dyn/core.py b/dyn/core.py index bc33f2d..3471820 100644 --- a/dyn/core.py +++ b/dyn/core.py @@ -10,7 +10,6 @@ import logging import re import threading -import time from datetime import datetime from . import __version__ @@ -64,19 +63,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 +86,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 +95,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,17 +146,10 @@ 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 = {} @@ -181,15 +158,11 @@ def connect(self): 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: + headers['Proxy-Authorization'] = 'Basic ' + base64.b64encode(auth) if self.ssl: s = 'Establishing SSL connection to {}:{} with proxy {}:{}' msg = s.format( @@ -202,21 +175,18 @@ def connect(self): timeout=300) self._conn.set_tunnel(self.host, self.port, headers) else: - s = ('Establishing unencrypted connection to {}:{} with proxy ' - '{}:{}') + s = ('Establishing unencrypted connection to {}:{} with proxy {}:{}') 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 = 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) + msg = 'Establishing SSL connection to {}:{}'.format(self.host,self.port) self.logger.info(msg) self._conn = HTTPSConnection(self.host, self.port, timeout=300) @@ -228,101 +198,53 @@ def connect(self): 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)) - # Poll for task completion if needed - 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) # Parse response JSON - json_err_fmt = "Decode Error on Response Body: {!r} status: {!r} {!r}" 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 # Add a record of this request/response to the history. - if self.__call_cache is not None: - self.__call_cache.append((uri, method, clean_args(raw_args), - ret_val['status'])) + now = datetime.now().isoformat() + self._history.append((now, uri, method, clean_args(args),data['status'])) # Call this hook for client state updates. - self._meta_update(uri, method, ret_val) - - # Maybe retry failed calls? - 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']) + self._meta_update(uri, method, data) # Return processed response. - return self._process_response(ret_val, method) + 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 @@ -362,8 +284,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 @@ -378,14 +299,13 @@ 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)))) + self.logger.debug(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: @@ -395,37 +315,19 @@ 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): """Hook into response handling.""" pass - - 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 - def send_command(self, uri, method, args): """Responsible for packaging up the API request and sending it to the server over the established connection @@ -454,28 +356,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 @@ -510,4 +390,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..e96ec78 100644 --- a/dyn/mm/session.py +++ b/dyn/mm/session.py @@ -65,9 +65,9 @@ def _handle_response(self, response, uri, method, raw_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) + return self._process_response(ret_val['response'], 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/session.py b/dyn/tm/session.py index 24006d1..671885e 100644 --- a/dyn/tm/session.py +++ b/dyn/tm/session.py @@ -5,6 +5,8 @@ own respective functionality. """ import warnings +import time + # API Libs from dyn.compat import force_unicode from dyn.core import SessionEngine @@ -72,7 +74,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() @@ -92,9 +94,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 +105,46 @@ def _process_response(self, response, method, final=False): :param final: boolean flag representing whether or not to continue polling """ + # Establish response context. status = response['status'] - self.logger.debug(status) + messages = response['msgs'] + job = response['job_id'] + # Check for successful response if status == 'success': return response - elif status == 'failure': - msgs = response['msgs'] - if '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 + wait, last = (None, None) + if any(err['ERR_CD'] == 'RATE_LIMIT_EXCEEDED' for err in messages): + # Rate limit exceeded, try again. + wait, last = (5, True) + self.logger.warn('Rate limit exceeded!') + if any('Operation blocked' in err['INFO'] for err in messages): + # Waiting on other task completion, try again. + wait, last = (10, False) + self.logger.warn('Blocked by other task.') + if status == 'incomplete': + # Waiting on completion of current task, poll given job + wait, last = (10, False) + method, uri = 'GET', '/Job/{}/'.format(job) + self.logger.warn('Waiting for job {}.'.format(job)) + # Wait for a few seconds and re-attempt + if wait: + if final: raise DynectQueryTimeout({}) + time.sleep(wait) + return self.execute(uri, method, args, final=last) + # 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 @@ -237,7 +256,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 @@ -258,7 +277,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""" From 78c4423235d58111f5ab20c3c13db05d4ab14508 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Mon, 5 Feb 2018 22:17:44 -0500 Subject: [PATCH 13/31] Actual zone changeset support. Refactoring! --- dyn/cli/dyntm.py | 100 +++++++++++++++++++++++++++++++++++++---------- dyn/tm/errors.py | 2 +- dyn/tm/zones.py | 43 ++++++++++---------- 3 files changed, 101 insertions(+), 44 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 7f71d1f..2b7297f 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -6,7 +6,6 @@ """ # TODO -## Publishing changes after multiple invocations of the script. ## A file cache of zones, nodes, services etc. Any of the 'get_all_X'. ## DTRT with one argument specifying a zone and node. ## Cleaned up help and error messages. @@ -28,6 +27,7 @@ from dyn.tm.accounts import * from dyn.tm.zones import * from dyn.tm.session import * +from dyn.tm.errors import * # globals! srstyles = ['increment', 'epoch', 'day', 'minute'] @@ -111,26 +111,36 @@ def action(cls, *argv, **opts): else: session = DynectSession(cust, user, pswd, **opts) except DynectAuthError as auth: + # authentication failed print auth.message exit(3) except IOError as err: sys.stderr.write("Could not read from token file {}.\n{}".format(tpath, str(err))) - # dispatch to command - if args.command != cls.name: - try: - inp = { k : v for k, v in vars(args).iteritems() if k not in ['command', 'func'] } - args.func(**inp) - except Exception as err: - # TODO catch specific errors for meaningful exit codes - print err.message - exit(4) - # record token for later use + # figure out arguments + inp = { k : v for k, v in vars(args).iteritems() if k not in ['command', 'func'] } + # try the command again, reauthenticate if needed try: - if session._token != token: + auth = True + while auth: + try: + # call the command + args.func(**inp) + auth = False + except DynectAuthError as err: + # session is invalid + session.authenticate() + auth = True + except DynectError as err: + # something went wrong + print err.message + exit(4) + # record session token for later use + if session._token and session._token != token: + try: with open(tpath, 'w') as tf: tf.write(session._token) - except IOError as err: - sys.stderr.write("Could not write to token file {}.\n{}".format(tpath, str(err))) + except IOError as err: + sys.stderr.write("Could not write to token file {}.\n{}".format(tpath, str(err))) # done! exit(0) def __init__(self): @@ -151,6 +161,17 @@ def action(cls, *rest, **args): for perm in sorted(session.permissions): print perm +### log out +class CommandUserLogOut(DyntmCommand): + name = "logout" + desc = "Log out of the current session." + + @classmethod + def action(cls, *rest, **args): + # get active session and log out + session = DynectSession.get_session() + session.log_out() + ### update password class CommandUserPassword(DyntmCommand): @@ -254,6 +275,7 @@ def action(cls, *rest, **args): zone = Zone(args['zone']) zone.freeze() + ### thaw zone class CommandZoneThaw(DyntmCommand): name = "thaw" @@ -307,10 +329,40 @@ def action(cls, *rest, **args): 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']) + print zone.changes() + + +### 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']) + print zone.publish(notes=args.get('note', None)) + ## record commands # record type specifications for child class generation -# HEY! Maintaining an 80 character column limit is pointless busy work especially in this indentation oriented language. + # TODO write sensible help strings rtypes = { # 'RTYPE' : [ {'arg':'', 'dest':'','type':str, 'help':''}, ] @@ -463,6 +515,7 @@ class CommandRecordCreate(DyntmCommand): 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.'}, # could have TTL here but that requires the option to appear before the record type ] @@ -477,7 +530,10 @@ def action(cls, *rest, **args): # add a new record on that node rec = node.add_record(cls.name, **new) # publish the zone - zone.publish() + if args['publish']: + zone.publish() + # print the new record + print rec # setup record creation command subclass for each record type @@ -568,6 +624,7 @@ class CommandRecordUpdate(DyntmCommand): 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" @@ -589,8 +646,9 @@ def action(cls, *rest, **args): for field in fields: if args[field]: setattr(that, field, args[field]) - # publish the zone - zone.publish() + # maybe publish the zone + if args['publish']: + zone.publish() # success print that @@ -623,6 +681,7 @@ class CommandRecordDelete(DyntmCommand): 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" @@ -640,8 +699,9 @@ def action(cls, *rest, **args): that = them.pop() # delete the record that.delete() - # publish the zone - zone.publish() + # maybe publish the zone + if args['publish']: + zone.publish() # success print that 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/zones.py b/dyn/tm/zones.py index a8b57be..bf57b7f 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,9 @@ 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(zone['zone'], api=False, **zone) for zone in response['data']] def get_apex(node_name, full_details=False): @@ -79,14 +73,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 +386,14 @@ def get_notes(self, offset=None, limit=None): response = DynectSession.get_session().execute(uri, 'POST', api_args) return response['data'] + + def changes(self): + """Describes pending changes to this zone.""" + session = DynectSession.get_session() + response = session.execute('/ZoneChanges/{}'.format(self.name), 'GET') + return response['data'] + + 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` From 47a32dbc14ce6f8cf249688b7182016f138a032e Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Mon, 5 Feb 2018 22:38:48 -0500 Subject: [PATCH 14/31] Support discarding pending changes too. --- dyn/cli/dyntm.py | 23 +++++++++++++++++++++-- dyn/tm/zones.py | 8 ++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 2b7297f..024bff7 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -342,7 +342,12 @@ class CommandZoneChanges(DyntmCommand): def action(cls, *rest, **args): # get the zone zone = Zone(args['zone']) - print zone.changes() + for change in zone.get_changes(): + fqdn = change["fqdn"] + ttl = change["ttl"] + rtype = change["rdata_type"] + rdata = change["rdata"].get("rdata_{}".format(rtype.lower()),{}) + print "{} {} {} {}".format(fqdn, rtype, ttl, json.dumps(rdata)) ### zone publish @@ -359,6 +364,21 @@ def action(cls, *rest, **args): zone = Zone(args['zone']) print 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 @@ -721,7 +741,6 @@ def action(cls, *rest, **args): rdelete[rtype] = type("CommandRecordDelete" + rtype, (CommandRecordDelete,), attr) - ## redir commands TODO ## gslb commands TODO ## dsf commands TODO diff --git a/dyn/tm/zones.py b/dyn/tm/zones.py index bf57b7f..ee4c241 100644 --- a/dyn/tm/zones.py +++ b/dyn/tm/zones.py @@ -386,13 +386,17 @@ def get_notes(self, offset=None, limit=None): response = DynectSession.get_session().execute(uri, 'POST', api_args) return response['data'] - - def changes(self): + def get_changes(self): """Describes pending changes to this zone.""" session = DynectSession.get_session() response = session.execute('/ZoneChanges/{}'.format(self.name), 'GET') return response['data'] + def discard_changes(self): + """Removes pending changes to this zone.""" + session = DynectSession.get_session() + response = session.execute('/ZoneChanges/{}'.format(self.name), 'DELETE') + return True def add_record(self, name=None, record_type='A', *args, **kwargs): """Adds an a record with the provided name and data to this From c0a4c505ff075e57aa3212edde9df26c8f630fd8 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Tue, 6 Feb 2018 22:02:00 -0500 Subject: [PATCH 15/31] *** empty log message *** --- dyn/cli/dyntm.py | 45 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 024bff7..5a48d83 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -17,6 +17,8 @@ import copy import itertools import argparse +import shlex +import subprocess import getpass import yaml import json @@ -73,8 +75,21 @@ def action(cls, *argv, **opts): # parse arguments ap = cls.parser() args = ap.parse_args() # (args=argv) TODO list unhashable? - # read configuration file + # maybe generate configuration file cpath = os.path.expanduser("~/.dyntm.yml") + if not os.path.exists(cpath): + creds = { + "customer": args.cust or raw_input("Dyn account name > "), + "user": args.user or raw_input("Dyn user name > "), + # "pass" : args.pass or getpass.getpass("Password > "), + } + try: + with open(args.conf or cpath, 'w') as cf: + yaml.dump(creds, cf, default_flow_style=False) + except IOError as e: + sys.stderr.write(str(e)) + exit(1) + # read configuration file conf = {} try: with open(args.conf or cpath, 'r') as cf: @@ -83,13 +98,26 @@ def action(cls, *argv, **opts): sys.stderr.write(str(e)) exit(1) # require credentials - cust = args.cust or conf.get('cust') + cust = args.cust or conf.get('customer') user = args.user or conf.get('user') if not user or not cust: - sys.stderr.write("A customer name and user name must be provided!") + sys.stderr.write("A customer name and user name must be provided!\n") exit(2) + # get password from config + pswd = conf.get('password') + # or get password from the output of some command. not for babies™ + if conf.get('passcmd'): + try: + toks = shlex.split(conf.get('passcmd')) + proc = subprocess.Popen(toks, stdout=subprocess.PIPE) + pswd = proc.stdout.readline() if proc.wait() == 0 else None + except OSError as e: + sys.stderr.write("Something wrong with 'passcmd' config!\n{}\n".format(e)) + exit(5) + # or get password interactively if practical + if not pswd and sys.stdout.isatty(): + pswd = getpass.getpass("Password for {}/{} > ".format(cust, user)) # require password - pswd = conf.get('pass') or getpass("Password for {}/{}".format(cust, user)) if not pswd: sys.stderr.write("A password must be provided!") exit(2) @@ -116,7 +144,7 @@ def action(cls, *argv, **opts): exit(3) except IOError as err: sys.stderr.write("Could not read from token file {}.\n{}".format(tpath, str(err))) - # figure out arguments + # figure out command arguments inp = { k : v for k, v in vars(args).iteritems() if k not in ['command', 'func'] } # try the command again, reauthenticate if needed try: @@ -216,7 +244,7 @@ class CommandZoneList(DyntmCommand): def action(cls, *rest, **args): zones = get_all_zones() for zone in zones: - print zone.fqdn + print zone.name ### create zone @@ -747,5 +775,8 @@ def action(cls, *rest, **args): # main -if __name__ == "__main__": +def main(): DyntmCommand.action(sys.argv[1:]) + +if __name__ == "__main__": + main() From f955351458d05c4caf63c9c24331aa9037a25458 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Tue, 6 Feb 2018 22:03:12 -0500 Subject: [PATCH 16/31] Setup command line entry point for package. --- setup.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/setup.py b/setup.py index da50cb3..d1e865f 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,11 @@ 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', From 2d686937aa22b9764d5ede6bef04777766ac187a Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Fri, 9 Feb 2018 11:22:41 -0500 Subject: [PATCH 17/31] Fix passcmd choking on newlines. --- dyn/cli/dyntm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 5a48d83..8f3e6a0 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -110,7 +110,7 @@ def action(cls, *argv, **opts): try: toks = shlex.split(conf.get('passcmd')) proc = subprocess.Popen(toks, stdout=subprocess.PIPE) - pswd = proc.stdout.readline() if proc.wait() == 0 else None + pswd = proc.stdout.readline().strip() if proc.wait() == 0 else None except OSError as e: sys.stderr.write("Something wrong with 'passcmd' config!\n{}\n".format(e)) exit(5) From 2d73d22415d0c92937535bfa02e6b69ae06b04b8 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Fri, 9 Feb 2018 22:28:25 -0500 Subject: [PATCH 18/31] Meet stylistic requirements. --- dyn/cli/dyntm.py | 613 +++++++++++++++++++++++++---------------- dyn/core.py | 36 ++- dyn/mm/session.py | 7 +- dyn/tm/records.py | 12 +- dyn/tm/services/dsf.py | 2 +- dyn/tm/zones.py | 9 +- setup.py | 2 +- 7 files changed, 419 insertions(+), 262 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 8f3e6a0..50847f6 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -6,16 +6,15 @@ """ # TODO -## A file cache of zones, nodes, services etc. Any of the 'get_all_X'. -## DTRT with one argument specifying a zone and node. -## Cleaned up help and error messages. +# A file cache of zones, nodes, services etc. Any of the 'get_all_X'. +# DTRT with one argument specifying a zone and node. +# Cleaned up help and error messages. # system libs import os import sys import re import copy -import itertools import argparse import shlex import subprocess @@ -36,6 +35,8 @@ rectypes = sorted(dyn.tm.zones.RECS.keys()) # parent command class + + class DyntmCommand(object): ''' This is a help string right? @@ -45,15 +46,24 @@ class DyntmCommand(object): 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.'}, + {'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 @@ -74,7 +84,7 @@ def parser(cls): def action(cls, *argv, **opts): # parse arguments ap = cls.parser() - args = ap.parse_args() # (args=argv) TODO list unhashable? + args = ap.parse_args() # (args=argv) TODO list unhashable? # maybe generate configuration file cpath = os.path.expanduser("~/.dyntm.yml") if not os.path.exists(cpath): @@ -101,18 +111,23 @@ def action(cls, *argv, **opts): cust = args.cust or conf.get('customer') user = args.user or conf.get('user') if not user or not cust: - sys.stderr.write("A customer name and user name must be provided!\n") + sys.stderr.write( + "A customer name and user name must be provided!\n") exit(2) # get password from config pswd = conf.get('password') # or get password from the output of some command. not for babies™ - if conf.get('passcmd'): + pcmd = conf.get('passcmd') + if pcmd: try: + pswd = None toks = shlex.split(conf.get('passcmd')) proc = subprocess.Popen(toks, stdout=subprocess.PIPE) - pswd = proc.stdout.readline().strip() if proc.wait() == 0 else None + if proc.wait() == 0: + pswd = proc.stdout.readline().strip() except OSError as e: - sys.stderr.write("Something wrong with 'passcmd' config!\n{}\n".format(e)) + sys.stderr.write( + "Password command '{}' failed!\n{}\n".format(pcmd, e)) exit(5) # or get password interactively if practical if not pswd and sys.stdout.isatty(): @@ -130,11 +145,14 @@ def action(cls, *argv, **opts): with open(tpath, 'r') as tf: token = tf.readline() # figure session fields - keys = ['host', 'port', 'proxy_host', 'proxy_port', 'proxy_user', 'proxy_pass', 'proxy_pass'] - opts = { k : v for d in [conf, vars(args)] for k, v in d.iteritems() if k in keys and v is not None } + keys = ['host', 'port', 'proxy_host', 'proxy_port', + 'proxy_user', 'proxy_pass', 'proxy_pass'] + opts = {k: v for d in [conf, vars(args)] for k, v in d.iteritems( + ) if k in keys and v is not None} # create session. authenticate only if token is unavailable if token: - session = DynectSession(cust, user, pswd, auto_auth=False, **opts) + session = DynectSession( + cust, user, pswd, auto_auth=False, **opts) session._token = token else: session = DynectSession(cust, user, pswd, **opts) @@ -143,9 +161,11 @@ def action(cls, *argv, **opts): print auth.message exit(3) except IOError as err: - sys.stderr.write("Could not read from token file {}.\n{}".format(tpath, str(err))) + msg = "Could not read from token file {}.\n{}" + sys.stderr.write(msg.format(tpath, str(err))) # figure out command arguments - inp = { k : v for k, v in vars(args).iteritems() if k not in ['command', 'func'] } + mine = ['command', 'func'] + inp = {k: v for k, v in vars(args).iteritems() if k not in mine} # try the command again, reauthenticate if needed try: auth = True @@ -168,15 +188,19 @@ def action(cls, *argv, **opts): with open(tpath, 'w') as tf: tf.write(session._token) except IOError as err: - sys.stderr.write("Could not write to token file {}.\n{}".format(tpath, str(err))) + msg = "Could not write to token file {}.\n{}" + sys.stderr.write(msg.format(tpath, str(err))) # done! exit(0) + def __init__(self): return # command classes! -### user permissions +# user permissions + + class CommandUserPermissions(DyntmCommand): name = "perms" desc = "List permissions." @@ -189,7 +213,9 @@ def action(cls, *rest, **args): for perm in sorted(session.permissions): print perm -### log out +# log out + + class CommandUserLogOut(DyntmCommand): name = "logout" desc = "Log out of the current session." @@ -201,12 +227,13 @@ def action(cls, *rest, **args): session.log_out() -### update password +# update password class CommandUserPassword(DyntmCommand): name = "passwd" desc = "Update password." args = [ - {'arg': 'password', 'type':str, 'help':'A new password.'}, + {'arg': 'password', 'type': str, + 'help': 'A new password.'}, ] @classmethod @@ -219,7 +246,7 @@ def action(cls, *rest, **args): session.update_password(newpass) -### list users +# list users class CommandUserList(DyntmCommand): name = "users" desc = "List users." @@ -227,15 +254,16 @@ class CommandUserList(DyntmCommand): @classmethod def action(cls, *rest, **args): # TODO verbose output - # attrs = ['user_name', 'first_name', 'last_name', 'organization', - # 'email', 'phone', 'address', 'city', 'country', 'fax', 'status'] + # attrs = ['user_name', 'email', 'phone', 'organization', + # 'first_name', 'last_name', + # 'address', 'city', 'country', 'fax', 'status'] # for user in get_users(): # print ",".join([getattr(user, attr, "") for attr in attrs]) for user in get_users(): print user.user_name -### list zones +# list zones class CommandZoneList(DyntmCommand): name = "zones" desc = "List all the zones available." @@ -247,36 +275,44 @@ def action(cls, *rest, **args): print zone.name -### create 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', 'dest':'style', 'type':str, 'dest':'serial_style', 'help':'Serial style.','choices': srstyles}, - {'arg':'--file', 'dest':'file', 'type':file, '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).'}, + {'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': file, + '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 d.has_key('dest') else d['arg'] for d in cls.args ] - new = { k : args[k] for k in spec if args[k] is not None } + 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) print zone -### delete 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.'}, + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, ] @classmethod @@ -286,15 +322,19 @@ def action(cls, *rest, **args): zone.delete() -### freeze zone +# 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.'}, + {'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 @@ -304,15 +344,19 @@ def action(cls, *rest, **args): zone.freeze() -### thaw zone +# 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.'}, + {'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 @@ -322,12 +366,12 @@ def action(cls, *rest, **args): zone.thaw() -### list nodes +# 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.'}, + {'arg': 'zone', 'type': str, 'help': 'The name of the zone.'}, ] @classmethod @@ -339,13 +383,13 @@ def action(cls, *rest, **args): print node.fqdn -### delete nodes +# 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.'}, + {'arg': 'zone', 'type': str, 'help': 'The name of the zone.'}, + {'arg': 'node', 'type': str, 'help': 'The name of the node.'}, ] @classmethod @@ -357,13 +401,14 @@ def action(cls, *rest, **args): node.delete() -### zone changes +# 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.'}, + {'arg': 'zone', 'type': str, 'help': 'The name of the zone.'}, + {'arg': 'note', 'type': str, 'nargs': '?', + 'help': 'A note associated with this change.'}, ] @classmethod @@ -374,16 +419,16 @@ def action(cls, *rest, **args): fqdn = change["fqdn"] ttl = change["ttl"] rtype = change["rdata_type"] - rdata = change["rdata"].get("rdata_{}".format(rtype.lower()),{}) + rdata = change["rdata"].get("rdata_{}".format(rtype.lower()), {}) print "{} {} {} {}".format(fqdn, rtype, ttl, json.dumps(rdata)) -### zone publish +# 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.'}, + {'arg': 'zone', 'type': str, 'help': 'The name of the zone.'}, ] @classmethod @@ -393,12 +438,12 @@ def action(cls, *rest, **args): print zone.publish(notes=args.get('note', None)) -### zone change reset +# 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.'}, + {'arg': 'zone', 'type': str, 'help': 'The name of the zone.'}, ] @classmethod @@ -407,164 +452,244 @@ def action(cls, *rest, **args): zone = Zone(args['zone']) zone.discard_changes() -## record commands +# 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.'}, + 'A': [ + {'arg': 'address', 'type': str, + 'help': 'An IPv4 address.'}, ], - 'AAAA' : [ - {'arg':'address', 'type':str, 'help':'An IPv6 address.'}, + 'AAAA': [ + {'arg': 'address', 'type': str, + 'help': 'An IPv6 address.'}, ], - 'ALIAS' : [ - {'arg':'alias', 'type':str, 'help':'A hostname.'}, + '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.'}, + '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.'}, + '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.'}, + '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.'}, + '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.'}, + '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':'+'}, + '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.'}, + 'DHCID': [ + {'arg': 'digest', 'type': str, + 'help': 'Base-64 encoded digest of DHCP data.'}, ], - 'DNAME' : [ - {'arg':'cname', 'type':str, 'help':'A hostname.'}, + '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.'}, + '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.'}, + '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..'}, + '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.'}, + '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':''}, + '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':''}, + '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':''}, + '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':''}, + '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':''}, + 'PTR': [ + {'arg': 'ptrdname', 'type': str, + 'help': ''}, ], - 'PX' : [ - {'arg':'prefernce', 'type':str, 'help':''}, - {'arg':'map822', 'type':str, 'help':''}, - {'arg':'map400', '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':''}, + 'NSAP': [ + {'arg': 'nsap', 'type': str, + 'help': ''}, ], - 'RP' : [ - {'arg':'mbox', 'type':str, 'help':''}, - {'arg':'txtdname', 'type':str, 'help':''}, + 'RP': [ + {'arg': 'mbox', 'type': str, + 'help': ''}, + {'arg': 'txtdname', 'type': str, + 'help': ''}, ], - 'NS' : [ - {'arg':'nsdname', 'type':str, 'help':''}, + 'NS': [ + {'arg': 'nsdname', 'type': str, + 'help': ''}, ], - 'SOA' : [ + 'SOA': [ # TODO ], - 'SPF' : [ - {'arg':'txtdata', 'type':str, 'help':'Some text data.'}, + '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':''}, + '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':''}, + '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':''}, + '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.'}, + 'TXT': [ + {'arg': 'txtdata', 'type': str, 'help': + 'Some text data.'}, ], } -### create record +# 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.'}, - # could have TTL here but that requires the option to appear before the record type + {'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 @@ -573,8 +698,9 @@ def action(cls, *rest, **args): zone = Zone(args['zone']) node = zone.get_node(args['node']) # figure out record init arguments specific to this command - spec = [ d['dest'] if d.has_key('dest') else d['arg'].strip('-') for d in cls.args ] - new = { k : args[k] for k in spec if args[k] is not None } + 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 @@ -586,23 +712,26 @@ def action(cls, *rest, **args): # 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']] : +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.'} ] + opts += [{'arg': '--ttl', 'dest': 'ttl', 'type': int, + 'help': 'TTL of the record.'}] attr = { - 'name':rtype, - 'args':opts, - 'desc':"Create one {} record.".format(rtype), + 'name': rtype, + 'args': opts, + 'desc': "Create one {} record.".format(rtype), } - rcreate[rtype] = type("CommandRecordCreate" + rtype, (CommandRecordCreate,), attr) + rcreate[rtype] = type("CommandRecordCreate" + rtype, + (CommandRecordCreate,), attr) -### list records +# list records class CommandRecordList(DyntmCommand): name = "records" desc = "Get an existing record." args = [ - {'arg':'zone', 'type':str, 'help':'The name of the zone.'}, + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, ] @classmethod @@ -615,16 +744,19 @@ def action(cls, *rest, **args): for r in sorted(recs, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)): rtype = r.rec_name.upper() rdata = json.dumps(dyn.tm.records.DNSRecord.rdata(r)) - print "{} {} {} {} {}".format(r.fqdn, rtype, r._record_id, r.ttl, rdata) + print "{} {} {} {} {}".format( + r.fqdn, rtype, r._record_id, r.ttl, rdata) -### get records +# 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.'}, + {'arg': 'zone', 'type': str, + 'help': 'The name of the zone.'}, + {'arg': 'node', 'type': str, + 'help': 'Node on which the the record appears.'}, ] @classmethod @@ -635,44 +767,55 @@ def action(cls, *rest, **args): node = zone.get_node(args['node']) # get set of records recs = node.get_all_records_by_type(rtype) - fields = ['_record_id'] + [a['dest'] if a.has_key('dest') 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]])] + 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]])] # print selected records - for r in sorted(found, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)) : + for r in sorted(found, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)): rtype = r.rec_name.upper() rdata = json.dumps(dyn.tm.records.DNSRecord.rdata(r)) - print "{} {} {} {} {}".format(r.fqdn, rtype, r._record_id, r.ttl, rdata) + print "{} {} {} {} {}".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 = 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'}, + {'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), + 'name': rtype, + 'args': opts, + 'desc': "List some {} records.".format(rtype), } rget[rtype] = type("CommandRecordGet" + rtype, (CommandRecordGet,), attr) -### update record +# 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.'}, + {'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" @@ -689,7 +832,8 @@ def action(cls, *rest, **args): raise Exception("Record {} not found.".format(rid)) that = them.pop() # build update arguments - fields = [a['dest'] if a.has_key('dest') else a['arg'].strip("-") for a in cls.args] + 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]: @@ -703,33 +847,39 @@ def action(cls, *rest, **args): # setup record update command subclass for each record type rupdate = {} -for rtype in [k for k in sorted(rtypes.keys())] : +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]) + 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.'} ] + 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), + 'name': rtype, + 'args': opts, + 'desc': "Update one {} record.".format(rtype), } # make the record update subclass - rupdate[rtype] = type("CommandRecordUpdate" + rtype, (CommandRecordUpdate,), attr) + rupdate[rtype] = type("CommandRecordUpdate" + rtype, + (CommandRecordUpdate,), attr) -### delete record +# 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.'}, + {'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" @@ -756,27 +906,30 @@ def action(cls, *rest, **args): # setup record delete command subclass for each record type rdelete = {} -for rtype in [k for k in sorted(rtypes.keys())] : +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.'}, + 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), + 'name': rtype, + 'args': opts, + 'desc': "Update one {} record.".format(rtype), } # make the record delete subclass - rdelete[rtype] = type("CommandRecordDelete" + rtype, (CommandRecordDelete,), attr) + rdelete[rtype] = type("CommandRecordDelete{}".format(rtype), + (CommandRecordDelete,), attr) -## redir commands TODO -## gslb commands TODO -## dsf commands TODO +# redir commands TODO +# 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 3471820..b69ad54 100644 --- a/dyn/core.py +++ b/dyn/core.py @@ -161,10 +161,11 @@ def connect(self): # proxy or normal connection? if self.proxy_host and self.proxy_port: if self.proxy_user and self.proxy_pass: - auth = '{}:{}'.format(self.proxy_user, self.proxy_pass) - headers['Proxy-Authorization'] = 'Basic ' + base64.b64encode(auth) + 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, @@ -175,28 +176,24 @@ 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 = 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, uri, method, args, final=False): """API Method. Process an API response for failure, incomplete, or @@ -238,7 +235,8 @@ def _handle_response(self, response, uri, method, args, final): # 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._history.append( + (now, uri, method, clean_args(args), data['status'])) # Call this hook for client state updates. self._meta_update(uri, method, data) @@ -302,7 +300,8 @@ def execute(self, uri, method, args=None, final=False): args, data, uri = self._prepare_arguments(args, method, uri) msg = 'uri: {}, method: {}, args: {}' - self.logger.debug(msg.format(uri, method, clean_args(json.loads(data)))) + self.logger.debug( + msg.format(uri, method, clean_args(json.loads(data)))) # Send the command and deal with results self.send_command(uri, method, data) @@ -356,7 +355,6 @@ def send_command(self, uri, method, args): self._conn.send(prepare_to_send(args)) - def __getstate__(cls): """Because HTTP/HTTPS connections are not serializeable, we need to strip the connection instance out before we ship the pickled data diff --git a/dyn/mm/session.py b/dyn/mm/session.py index e96ec78..2c8560a 100644 --- a/dyn/mm/session.py +++ b/dyn/mm/session.py @@ -61,11 +61,12 @@ 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'], uri, method, args, 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, uri, method, args, final=False): """Process an API response for failure, incomplete, or success and diff --git a/dyn/tm/records.py b/dyn/tm/records.py index 220628d..def9ec3 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,9 +95,11 @@ def _build(self, data): def rdata(self): """Return a records rdata""" - 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 } + 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/zones.py b/dyn/tm/zones.py index ee4c241..5968441 100644 --- a/dyn/tm/zones.py +++ b/dyn/tm/zones.py @@ -59,7 +59,8 @@ def get_all_secondary_zones(): """ session = DynectSession.get_session() response = session.execute('/Secondary/', 'GET', {'detail': 'Y'}) - return [SecondaryZone(zone['zone'], api=False, **zone) for zone in response['data']] + return [SecondaryZone(zone['zone'], api=False, **zone) + for zone in response['data']] def get_apex(node_name, full_details=False): @@ -389,13 +390,15 @@ def get_notes(self, offset=None, limit=None): def get_changes(self): """Describes pending changes to this zone.""" session = DynectSession.get_session() - response = session.execute('/ZoneChanges/{}'.format(self.name), 'GET') + 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() - response = session.execute('/ZoneChanges/{}'.format(self.name), 'DELETE') + frag = '/ZoneChanges/{}'.format(self.name) + response = session.execute(frag, 'DELETE') return True def add_record(self, name=None, record_type='A', *args, **kwargs): diff --git a/setup.py b/setup.py index d1e865f..a33a38b 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ '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, From a673ca77176da6ea0653156dc673dc12f5675a29 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 11 Feb 2018 14:09:47 -0500 Subject: [PATCH 19/31] Refactor main dyntm command action. --- dyn/cli/dyntm.py | 207 ++++++++++++++++++++++++----------------------- 1 file changed, 104 insertions(+), 103 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 50847f6..2e3b1b6 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -8,7 +8,7 @@ # TODO # A file cache of zones, nodes, services etc. Any of the 'get_all_X'. # DTRT with one argument specifying a zone and node. -# Cleaned up help and error messages. +# Better documentation, help messages, and error messages. # system libs import os @@ -34,9 +34,8 @@ srstyles = ['increment', 'epoch', 'day', 'minute'] rectypes = sorted(dyn.tm.zones.RECS.keys()) -# parent command class - +# parent command class class DyntmCommand(object): ''' This is a help string right? @@ -81,115 +80,117 @@ def parser(cls): return ap @classmethod - def action(cls, *argv, **opts): - # parse arguments - ap = cls.parser() - args = ap.parse_args() # (args=argv) TODO list unhashable? + def config(cls, conf): # maybe generate configuration file - cpath = os.path.expanduser("~/.dyntm.yml") + cpath = os.path.expanduser(conf) if not os.path.exists(cpath): - creds = { - "customer": args.cust or raw_input("Dyn account name > "), - "user": args.user or raw_input("Dyn user name > "), - # "pass" : args.pass or getpass.getpass("Password > "), - } - try: - with open(args.conf or cpath, 'w') as cf: - yaml.dump(creds, cf, default_flow_style=False) - except IOError as e: - sys.stderr.write(str(e)) - exit(1) - # read configuration file - conf = {} - try: - with open(args.conf or cpath, 'r') as cf: - conf = yaml.load(cf) - except IOError as e: - sys.stderr.write(str(e)) - exit(1) + 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 = args.cust or conf.get('customer') - user = args.user or conf.get('user') + cust = kwargs.get('customer') + user = kwargs.get('user') if not user or not cust: - sys.stderr.write( - "A customer name and user name must be provided!\n") - exit(2) - # get password from config - pswd = conf.get('password') - # or get password from the output of some command. not for babies™ - pcmd = conf.get('passcmd') - if pcmd: - try: - pswd = None - toks = shlex.split(conf.get('passcmd')) - proc = subprocess.Popen(toks, stdout=subprocess.PIPE) - if proc.wait() == 0: - pswd = proc.stdout.readline().strip() - except OSError as e: - sys.stderr.write( - "Password command '{}' failed!\n{}\n".format(pcmd, e)) - exit(5) + 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() + password = output.strip() + else: + password = kwargs.get('password') # or get password interactively if practical - if not pswd and sys.stdout.isatty(): - pswd = getpass.getpass("Password for {}/{} > ".format(cust, user)) - # require password - if not pswd: - sys.stderr.write("A password must be provided!") - exit(2) + 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.iteritems() + 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: - # 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', 'proxy_pass'] - opts = {k: v for d in [conf, vars(args)] for k, v in d.iteritems( - ) if k in keys and v is not None} - # create session. authenticate only if token is unavailable - if token: - session = DynectSession( - cust, user, pswd, auto_auth=False, **opts) - session._token = token - else: - session = DynectSession(cust, user, pswd, **opts) - except DynectAuthError as auth: - # authentication failed - print auth.message - exit(3) - except IOError as err: - msg = "Could not read from token file {}.\n{}" - sys.stderr.write(msg.format(tpath, str(err))) - # figure out command arguments - mine = ['command', 'func'] - inp = {k: v for k, v in vars(args).iteritems() if k not in mine} - # try the command again, reauthenticate if needed + 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.stderr.write(msg) + 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: - auth = True - while auth: - try: - # call the command - args.func(**inp) - auth = False - except DynectAuthError as err: - # session is invalid - session.authenticate() - auth = True + cls.session(auth=False, **plan) + except Exception as e: + msg = "Authentication problem!\n{}\n".format(e.message) + sys.stderr.write(msg) + sys.exit(2) + # figure out arguments for subcommand + mine = auth + ['command', 'func'] + inp = {k: v for k, v in args.iteritems() if k not in mine} + # run the command, reauthenticate if needed + func = args['func'] + try: + try: + func(**inp) + except DynectAuthError as err: + cls.session(auth=True, **plan) + func(**inp) except DynectError as err: - # something went wrong - print err.message + msg = "Dynect SDK error:\n{}\n".format(err.message or str(err)) + sys.stderr.write(msg) + exit(3) + except Exception as err: + msg = "General error:\n{}\n".format(err.message or str(err)) + sys.stderr.write(msg) exit(4) - # record session token for later use - if session._token and session._token != token: - try: - with open(tpath, 'w') as tf: - tf.write(session._token) - except IOError as err: - msg = "Could not write to token file {}.\n{}" - sys.stderr.write(msg.format(tpath, str(err))) # done! exit(0) @@ -208,7 +209,7 @@ class CommandUserPermissions(DyntmCommand): @classmethod def action(cls, *rest, **args): # get active session - session = DynectSession.get_session() + session = cls.session() # print each permission available to current session for perm in sorted(session.permissions): print perm @@ -223,7 +224,7 @@ class CommandUserLogOut(DyntmCommand): @classmethod def action(cls, *rest, **args): # get active session and log out - session = DynectSession.get_session() + session = cls.session() session.log_out() @@ -239,7 +240,7 @@ class CommandUserPassword(DyntmCommand): @classmethod def action(cls, *rest, **args): # get active session - session = DynectSession.get_session() + session = cls.session() # get password or prompt for it newpass = args['password'] or getpass() # update password From 39f346a3a5c77305d3c6e44b44f9b9585b200f65 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 11 Feb 2018 14:10:50 -0500 Subject: [PATCH 20/31] Shut up pycodestyle errors in sphix script. --- docs/conf.py | 127 ++++++++++++++++++++++++++------------------------- 1 file changed, 64 insertions(+), 63 deletions(-) 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 From 7d0e20a211a39f86008864c5935808c04c974a7a Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 11 Feb 2018 17:29:01 -0500 Subject: [PATCH 21/31] Backoff exponentially on retry. Retry simultaneous tasks too. --- dyn/cli/dyntm.py | 10 ++++++---- dyn/tm/session.py | 39 +++++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 2e3b1b6..cc74a22 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -81,7 +81,7 @@ def parser(cls): @classmethod def config(cls, conf): - # maybe generate configuration file + # maybe generate an empty configuration file cpath = os.path.expanduser(conf) if not os.path.exists(cpath): creds = {"customer": "", "user": "", "password": ""} @@ -176,7 +176,9 @@ def action(cls, *argv, **opts): mine = auth + ['command', 'func'] inp = {k: v for k, v in args.iteritems() if k not in mine} # run the command, reauthenticate if needed - func = args['func'] + func = args.get('func') + command = args.get('command') + context = "{} {}".format(command, str(inp)) try: try: func(**inp) @@ -184,11 +186,11 @@ def action(cls, *argv, **opts): cls.session(auth=True, **plan) func(**inp) except DynectError as err: - msg = "Dynect SDK error:\n{}\n".format(err.message or str(err)) + msg = "{}\n{}\n".format(context, err.message or str(err)) sys.stderr.write(msg) exit(3) except Exception as err: - msg = "General error:\n{}\n".format(err.message or str(err)) + msg = "{}\n{}\n".format(context, err.message or str(err)) sys.stderr.write(msg) exit(4) # done! diff --git a/dyn/tm/session.py b/dyn/tm/session.py index 671885e..ddaf6c1 100644 --- a/dyn/tm/session.py +++ b/dyn/tm/session.py @@ -11,7 +11,7 @@ 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) @@ -54,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() @@ -106,33 +107,35 @@ def _process_response(self, response, uri, method, args, final=False): polling """ # Establish response context. - status = response['status'] - messages = response['msgs'] - job = response['job_id'] + status = response.get('status') + messages = response.get('msgs') + job = response.get('job_id') # Check for successful response if status == 'success': return response # Task must have failed or be incomplete. Reattempt request if possible - wait, last = (None, None) + retry = False if any(err['ERR_CD'] == 'RATE_LIMIT_EXCEEDED' for err in messages): # Rate limit exceeded, try again. - wait, last = (5, True) - self.logger.warn('Rate limit exceeded!') - if any('Operation blocked' in err['INFO'] for err in messages): + retry = True + elif any('Operation blocked' in err['INFO'] for err in messages): # Waiting on other task completion, try again. - wait, last = (10, False) - self.logger.warn('Blocked by other task.') - if status == 'incomplete': + 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 - wait, last = (10, False) - method, uri = 'GET', '/Job/{}/'.format(job) - self.logger.warn('Waiting for job {}.'.format(job)) - # Wait for a few seconds and re-attempt - if wait: + retry, method, uri = True, 'GET', '/Job/{}/'.format(job) + # Maybe retry the call + if retry: if final: raise DynectQueryTimeout({}) - time.sleep(wait) - return self.execute(uri, method, args, final=last) + # 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) From 23e1129366016394b2c2cabe7187c558c56f430c Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 11 Feb 2018 17:52:02 -0500 Subject: [PATCH 22/31] Fix get_all_secondary_zones. Add secondary/primary zone list commands. --- dyn/cli/dyntm.py | 25 +++++++++++++++++++++++++ dyn/tm/zones.py | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index cc74a22..6460ac0 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -278,6 +278,31 @@ def action(cls, *rest, **args): print zone.name +class CommandZonePrimaryList(DyntmCommand): + name = "primary" + desc = "List all the primary zones available." + + @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: + print zone.name + + +class CommandZoneSecondaryList(DyntmCommand): + name = "secondary" + desc = "List all the secondary zones available." + + @classmethod + def action(cls, *rest, **args): + secondary = get_all_secondary_zones() + for zone in secondary: + print zone.zone + + # create zone class CommandZoneCreate(DyntmCommand): name = "zone-new" diff --git a/dyn/tm/zones.py b/dyn/tm/zones.py index 5968441..58a9733 100644 --- a/dyn/tm/zones.py +++ b/dyn/tm/zones.py @@ -59,7 +59,7 @@ def get_all_secondary_zones(): """ session = DynectSession.get_session() response = session.execute('/Secondary/', 'GET', {'detail': 'Y'}) - return [SecondaryZone(zone['zone'], api=False, **zone) + return [SecondaryZone(api=False, **zone) for zone in response['data']] From d1460d0f77c24a402dd85ac0d3579af881729c44 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 11 Feb 2018 19:23:38 -0500 Subject: [PATCH 23/31] Make flake8 happy. --- dyn/cli/dyntm.py | 9 ++++----- dyn/core.py | 2 -- dyn/tm/records.py | 4 ++-- dyn/tm/zones.py | 2 +- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 6460ac0..ad27dff 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -24,11 +24,10 @@ # internal libs import dyn.tm -from dyn.tm import * -from dyn.tm.accounts import * -from dyn.tm.zones import * -from dyn.tm.session import * -from dyn.tm.errors import * +from dyn.tm.accounts import get_users +from dyn.tm.zones import Zone, get_all_zones, get_all_secondary_zones +from dyn.tm.session import DynectSession +from dyn.tm.errors import DynectError, DynectAuthError # globals! srstyles = ['increment', 'epoch', 'day', 'minute'] diff --git a/dyn/core.py b/dyn/core.py index b69ad54..9dbdfbd 100644 --- a/dyn/core.py +++ b/dyn/core.py @@ -8,7 +8,6 @@ import copy import locale import logging -import re import threading from datetime import datetime @@ -151,7 +150,6 @@ def connect(self): is used. """ self._conn = None - use_proxy = False headers = {} if self.proxy_host and not self.proxy_port: diff --git a/dyn/tm/records.py b/dyn/tm/records.py index def9ec3..5d4af94 100644 --- a/dyn/tm/records.py +++ b/dyn/tm/records.py @@ -98,8 +98,8 @@ def rdata(self): 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} + if not hasattr(v, '__call__') and + k.startswith('_') and k not in skip} return rdata @property diff --git a/dyn/tm/zones.py b/dyn/tm/zones.py index 58a9733..e6ffc1a 100644 --- a/dyn/tm/zones.py +++ b/dyn/tm/zones.py @@ -399,7 +399,7 @@ def discard_changes(self): session = DynectSession.get_session() frag = '/ZoneChanges/{}'.format(self.name) response = session.execute(frag, 'DELETE') - return True + 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 From e83d7d3f6cfefab0a05f73c539fe4153b63f51d2 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 11 Feb 2018 20:38:33 -0500 Subject: [PATCH 24/31] Python 3 compatibility mostly? --- dyn/cli/__init__.py | 0 dyn/cli/dyntm.py | 63 ++++++++++++++++++++++++--------------------- requirements.txt | 1 + 3 files changed, 34 insertions(+), 30 deletions(-) create mode 100644 dyn/cli/__init__.py 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 index ad27dff..9f6cac0 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -18,6 +18,7 @@ import argparse import shlex import subprocess +import functools import getpass import yaml import json @@ -109,7 +110,7 @@ def session(cls, auth=False, **kwargs): toks = shlex.split(passcmd) proc = subprocess.Popen(toks, stdout=subprocess.PIPE) if proc.wait() == 0: - output = proc.stdout.readline() + output = str(proc.stdout.readline()) password = output.strip() else: password = kwargs.get('password') @@ -130,7 +131,7 @@ def session(cls, auth=False, **kwargs): # figure session fields keys = ['host', 'port', 'proxy_host', 'proxy_port', 'proxy_user', 'proxy_pass'] - opts = {k: v for k, v in kwargs.iteritems() + 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: @@ -168,12 +169,12 @@ def action(cls, *argv, **opts): try: cls.session(auth=False, **plan) except Exception as e: - msg = "Authentication problem!\n{}\n".format(e.message) + 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.iteritems() if k not in mine} + 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') @@ -185,11 +186,11 @@ def action(cls, *argv, **opts): cls.session(auth=True, **plan) func(**inp) except DynectError as err: - msg = "{}\n{}\n".format(context, err.message or str(err)) + msg = "{}\n{}\n".format(context, str(err)) sys.stderr.write(msg) exit(3) except Exception as err: - msg = "{}\n{}\n".format(context, err.message or str(err)) + msg = "{}\n{}\n".format(context, str(err)) sys.stderr.write(msg) exit(4) # done! @@ -213,7 +214,7 @@ def action(cls, *rest, **args): session = cls.session() # print each permission available to current session for perm in sorted(session.permissions): - print perm + sys.stdout.write(perm) # log out @@ -262,7 +263,7 @@ def action(cls, *rest, **args): # for user in get_users(): # print ",".join([getattr(user, attr, "") for attr in attrs]) for user in get_users(): - print user.user_name + sys.stdout.write("{}\n".format(user.user_name)) # list zones @@ -274,7 +275,7 @@ class CommandZoneList(DyntmCommand): def action(cls, *rest, **args): zones = get_all_zones() for zone in zones: - print zone.name + sys.stdout.write("{}\n".format(zone.name)) class CommandZonePrimaryList(DyntmCommand): @@ -288,7 +289,7 @@ def action(cls, *rest, **args): primary = [z for z in zones if z.name not in [s.zone for s in secondary]] for zone in primary: - print zone.name + sys.stdout.write("{}\n".format(zone.name)) class CommandZoneSecondaryList(DyntmCommand): @@ -299,7 +300,7 @@ class CommandZoneSecondaryList(DyntmCommand): def action(cls, *rest, **args): secondary = get_all_secondary_zones() for zone in secondary: - print zone.zone + sys.stdout.write("{}\n".format(zone.zone)) # create zone @@ -313,7 +314,7 @@ class CommandZoneCreate(DyntmCommand): 'help': 'Integer timeout for transfer.'}, {'arg': '--style', 'type': str, 'dest': 'serial_style', 'help': 'Serial style.', 'choices': srstyles}, - {'arg': '--file', 'dest': 'file', 'type': file, + {'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.'}, @@ -330,7 +331,7 @@ def action(cls, *rest, **args): new = {k: args[k] for k in spec if args[k] is not None} # make a new zone zone = Zone(**new) - print zone + sys.stdout.write(zone) # delete zone @@ -405,9 +406,9 @@ class CommandNodeList(DyntmCommand): def action(cls, *rest, **args): # get the zone zone = Zone(args['zone']) - # print all of the zone's nodes + # output all of the zone's nodes for node in zone.get_all_nodes(): - print node.fqdn + sys.stdout.write("{}\n".format(node.fqdn)) # delete nodes @@ -447,7 +448,8 @@ def action(cls, *rest, **args): ttl = change["ttl"] rtype = change["rdata_type"] rdata = change["rdata"].get("rdata_{}".format(rtype.lower()), {}) - print "{} {} {} {}".format(fqdn, rtype, ttl, json.dumps(rdata)) + msg = "{} {} {} {}".format(fqdn, rtype, ttl, json.dumps(rdata)) + sys.stdout.write(msg) # zone publish @@ -462,7 +464,7 @@ class CommandZonePublish(DyntmCommand): def action(cls, *rest, **args): # get the zone zone = Zone(args['zone']) - print zone.publish(notes=args.get('note', None)) + sys.stdout.write(zone.publish(notes=args.get('note', None))) # zone change reset @@ -733,8 +735,8 @@ def action(cls, *rest, **args): # publish the zone if args['publish']: zone.publish() - # print the new record - print rec + # output the new record + sys.stdout.write(rec) # setup record creation command subclass for each record type @@ -766,13 +768,14 @@ def action(cls, *rest, **args): # context zone = Zone(args['zone']) # get records - recs = reduce(lambda x, y: x + y, zone.get_all_records().values()) - # print all records - for r in sorted(recs, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)): + 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)) - print "{} {} {} {} {}".format( - r.fqdn, rtype, r._record_id, r.ttl, rdata) + sys.stdout.write("{} {} {} {} {}\n".format( + r.fqdn, rtype, r._record_id, r.ttl, rdata)) # get records @@ -801,12 +804,12 @@ def action(cls, *rest, **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]])] - # print selected records - for r in sorted(found, cmp=lambda x, y: cmp(y.fqdn, x.fqdn)): + # 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)) - print "{} {} {} {} {}".format( - r.fqdn, rtype, r._record_id, r.ttl, rdata) + sys.stdout.write("{} {} {} {} {}\n".format( + r.fqdn, rtype, r._record_id, r.ttl, rdata)) # setup record selection command subclass for each record type @@ -869,7 +872,7 @@ def action(cls, *rest, **args): if args['publish']: zone.publish() # success - print that + sys.stdout.write("{}\n".format(str(that))) # setup record update command subclass for each record type @@ -928,7 +931,7 @@ def action(cls, *rest, **args): if args['publish']: zone.publish() # success - print that + sys.stdout.write("{}\n".format(str(that))) # setup record delete command subclass for each record type diff --git a/requirements.txt b/requirements.txt index e69de29..2e29ed1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1 @@ +PyYaml From 04e8ca95a3a91f71e52ddbb7753a3fdc97e9cf34 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 11 Feb 2018 20:45:24 -0500 Subject: [PATCH 25/31] Add pycodestyle check to CI target. --- Makefile | 5 +++-- test-requirements.txt | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 23c829c..c4feb25 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,13 @@ PACKAGE=dyn -.PHONY: clean +.PHONY: clean publsh 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/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 From 1d566150537cc2b40a60336b11de6b1bc9e79648 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 11 Feb 2018 20:54:43 -0500 Subject: [PATCH 26/31] sed -i s/publsh/publish/ Makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c4feb25..23c34ed 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ PACKAGE=dyn -.PHONY: clean publsh dics style init ci +.PHONY: clean publish dics style init ci init: pip install -r test-requirements.txt From 6e2bc9c370820216ba824f09f14c28ceacbde35b Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Sun, 11 Feb 2018 21:00:29 -0500 Subject: [PATCH 27/31] Fix output of some commands. --- dyn/cli/dyntm.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 9f6cac0..cc832b3 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -214,7 +214,7 @@ def action(cls, *rest, **args): session = cls.session() # print each permission available to current session for perm in sorted(session.permissions): - sys.stdout.write(perm) + sys.stdout.write("{}\n".format(str(perm))) # log out @@ -331,7 +331,7 @@ def action(cls, *rest, **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(zone) + sys.stdout.write("{}".format(str(zone))) # delete zone @@ -448,7 +448,7 @@ def action(cls, *rest, **args): ttl = change["ttl"] rtype = change["rdata_type"] rdata = change["rdata"].get("rdata_{}".format(rtype.lower()), {}) - msg = "{} {} {} {}".format(fqdn, rtype, ttl, json.dumps(rdata)) + msg = "{} {} {} {}\n".format(fqdn, rtype, ttl, json.dumps(rdata)) sys.stdout.write(msg) @@ -464,7 +464,7 @@ class CommandZonePublish(DyntmCommand): def action(cls, *rest, **args): # get the zone zone = Zone(args['zone']) - sys.stdout.write(zone.publish(notes=args.get('note', None))) + zone.publish(notes=args.get('note', None)) # zone change reset @@ -736,7 +736,7 @@ def action(cls, *rest, **args): if args['publish']: zone.publish() # output the new record - sys.stdout.write(rec) + sys.stdout.write("{}\n".format(rec)) # setup record creation command subclass for each record type From 3752c6c63ed9a4eee19a4991c9bdea730d06fcca Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Mon, 12 Feb 2018 08:37:59 -0500 Subject: [PATCH 28/31] Fix misencoded password from passcmd under Python 3. --- dyn/cli/dyntm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index cc832b3..d250a59 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -110,7 +110,7 @@ def session(cls, auth=False, **kwargs): toks = shlex.split(passcmd) proc = subprocess.Popen(toks, stdout=subprocess.PIPE) if proc.wait() == 0: - output = str(proc.stdout.readline()) + output = proc.stdout.readline().decode('UTF-8') password = output.strip() else: password = kwargs.get('password') @@ -123,7 +123,7 @@ def session(cls, auth=False, **kwargs): raise ValueError("A password must be provided!") # setup session token = None - tpath = os.path.expanduser("~/.dyntm-{}-{}".format(cust, user)) + tpath = os.path.expanduser("~/.dyntm_{}_{}".format(cust, user)) # maybe load cached session token if os.path.isfile(tpath): with open(tpath, 'r') as tf: From 4cc95cc7a5e7f9797a1430babc2c13d8257279f9 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Mon, 12 Feb 2018 20:25:22 -0500 Subject: [PATCH 29/31] Add dyntm command to create secondary zones. --- dyn/cli/dyntm.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index d250a59..b1c9ace 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -26,7 +26,8 @@ # internal libs import dyn.tm from dyn.tm.accounts import get_users -from dyn.tm.zones import Zone, get_all_zones, get_all_secondary_zones +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 @@ -278,6 +279,7 @@ def action(cls, *rest, **args): sys.stdout.write("{}\n".format(zone.name)) +# list primary zones class CommandZonePrimaryList(DyntmCommand): name = "primary" desc = "List all the primary zones available." @@ -292,6 +294,7 @@ def action(cls, *rest, **args): sys.stdout.write("{}\n".format(zone.name)) +# list secondary zones class CommandZoneSecondaryList(DyntmCommand): name = "secondary" desc = "List all the secondary zones available." @@ -334,6 +337,31 @@ def action(cls, *rest, **args): 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" From 351e32a022be5edaa122ef74b8208946fc861c61 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Wed, 14 Feb 2018 21:32:38 -0500 Subject: [PATCH 30/31] Add redirect creation command. --- dyn/cli/dyntm.py | 51 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index b1c9ace..40d0279 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -30,6 +30,7 @@ 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'] @@ -160,7 +161,6 @@ def action(cls, *argv, **opts): 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.stderr.write(msg) sys.exit(1) # command line arguments take precedence over config auth = ['customer', 'user', 'password', 'passcmd', 'host', 'port', @@ -200,11 +200,8 @@ def action(cls, *argv, **opts): def __init__(self): return -# command classes! # user permissions - - class CommandUserPermissions(DyntmCommand): name = "perms" desc = "List permissions." @@ -217,9 +214,8 @@ def action(cls, *rest, **args): for perm in sorted(session.permissions): sys.stdout.write("{}\n".format(str(perm))) -# log out - +# log out class CommandUserLogOut(DyntmCommand): name = "logout" desc = "Log out of the current session." @@ -257,13 +253,12 @@ class CommandUserList(DyntmCommand): @classmethod def action(cls, *rest, **args): - # TODO verbose output - # attrs = ['user_name', 'email', 'phone', 'organization', - # 'first_name', 'last_name', - # 'address', 'city', 'country', 'fax', 'status'] - # for user in get_users(): - # print ",".join([getattr(user, attr, "") for attr in attrs]) + # 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)) @@ -612,7 +607,7 @@ def action(cls, *rest, **args): {'arg': 'protocol', 'type': int, 'help': 'Numeric code of protocol.'}, {'arg': 'public_key', 'type': str, - 'help': 'The public key..'}, + 'help': 'The public key.'}, ], 'KX': [ {'arg': 'exchange', 'type': str, @@ -979,7 +974,35 @@ def action(cls, *rest, **args): (CommandRecordDelete,), attr) -# redir commands TODO +# 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 From a416e293dd430894ee2036ddcdd48beced63a141 Mon Sep 17 00:00:00 2001 From: Mike Lalumiere Date: Thu, 15 Feb 2018 09:15:56 -0500 Subject: [PATCH 31/31] Use only official interface of argparse. Fix subcommand option confusion. --- dyn/cli/dyntm.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/dyn/cli/dyntm.py b/dyn/cli/dyntm.py index 40d0279..7d2c336 100755 --- a/dyn/cli/dyntm.py +++ b/dyn/cli/dyntm.py @@ -68,18 +68,23 @@ class DyntmCommand(object): ] @classmethod - def parser(cls): + def parser(cls, parser=None): # setup parser - ap = argparse.ArgumentParser(prog=cls.name, description=cls.desc) + 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]: - ap.add_argument(spec.pop('arg'), **spec) - ap.set_defaults(func=cls.action, command=cls.name) + 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: - sub = ap.add_subparsers(title=cls.subtitle) + what = parser.add_subparsers(title=cls.subtitle) for cmd in cls.__subclasses__(): - sub._name_parser_map[cmd.name] = cmd.parser() - return ap + sub = what.add_parser(cmd.name, help=cmd.desc) + cmd.parser(parser=sub) + return parser @classmethod def config(cls, conf): @@ -205,6 +210,7 @@ def __init__(self): class CommandUserPermissions(DyntmCommand): name = "perms" desc = "List permissions." + args = [] @classmethod def action(cls, *rest, **args): @@ -219,6 +225,7 @@ def action(cls, *rest, **args): class CommandUserLogOut(DyntmCommand): name = "logout" desc = "Log out of the current session." + args = [] @classmethod def action(cls, *rest, **args): @@ -250,6 +257,7 @@ def action(cls, *rest, **args): class CommandUserList(DyntmCommand): name = "users" desc = "List users." + args = [] @classmethod def action(cls, *rest, **args): @@ -266,6 +274,7 @@ def action(cls, *rest, **args): class CommandZoneList(DyntmCommand): name = "zones" desc = "List all the zones available." + args = [] @classmethod def action(cls, *rest, **args): @@ -278,6 +287,7 @@ def action(cls, *rest, **args): class CommandZonePrimaryList(DyntmCommand): name = "primary" desc = "List all the primary zones available." + args = [] @classmethod def action(cls, *rest, **args): @@ -293,6 +303,7 @@ def action(cls, *rest, **args): class CommandZoneSecondaryList(DyntmCommand): name = "secondary" desc = "List all the secondary zones available." + args = [] @classmethod def action(cls, *rest, **args):