Skip to content

Commit

Permalink
Improve error handling and output
Browse files Browse the repository at this point in the history
- Move 'click.command' to the script rather than the module.
- Use exceptions to handle errors and pass them to the script, rather than calling 'sys.exit' from the module.
- Use 'warnings.warn' to handle warnings and catch these in the script.
- Implement colourised print messages along with the '--nocolour' flag to disable them.
- Move message printing to the script rather than logging function, along with logic for '--quiet'.
- Set flake8 'max-line-length = 100'.
- Bump version number to 0.2 to reflect significant changes.
  • Loading branch information
Nathan Parsons committed Sep 23, 2018
1 parent 726fb3e commit 51475a8
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 65 deletions.
2 changes: 2 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[flake8]
max-line-length=100
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,6 @@ venv.bak/

# mypy
.mypy_cache/

# Ignore personal configuration (contains secrets!)
config.yaml
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ The script can be run manually by calling `godaddy-ddns` with the following opti
- `--config`: Path to the configuration file (default: `/etc/godaddy-ddns/config.yaml`).
- `--force`: Update the IP address regardless of the value in the cache.
- `--quiet`: Don't print messages to `stdout`.
- `--nocolour`: Don't use colour when printing to `stdout`.

### Systemd

Expand Down
61 changes: 60 additions & 1 deletion bin/godaddy-ddns
Original file line number Diff line number Diff line change
@@ -1,6 +1,65 @@
#!/usr/bin/env python3

import sys
import warnings

import click

import godaddy_ddns


godaddy_ddns.update_ip()
@click.command()
@click.option(
'--config', '-c',
default="/etc/godaddy-ddns/config.yaml",
help="Path to configuration file (.yaml).",
type=click.File('r')
)
@click.option('--force', '-f', is_flag=True, help="Update the IP regardless of the cached IP.")
@click.option('--quiet', '-q', is_flag=True, help="Don't print to stdout.")
@click.option('--nocolour', is_flag=True, help="Don't colourise messages to stdout.")
def main(config, force, quiet, nocolour):
# Define an echo function to account for 'quiet' and 'nocolour'
def echo(msg):
if quiet:
pass
elif nocolour:
print(msg)
else:
godaddy_ddns.print_colourised(msg)

# Notify if forced
if force:
echo("Info: Beginning forced update.")

# Perform the update
try:
# Catch and record warnings to print later
with warnings.catch_warnings(record=True) as caught_warnings:
updated, myip, domains = godaddy_ddns.update_ip(config, force)
except (godaddy_ddns.ConfigError,
godaddy_ddns.BadResponse,
PermissionError,
ConnectionError) as e:
# Echo the message and exit with failure
echo(str(e))
sys.exit(1)
except:
echo("Error: An unexpected exception occurred!")
raise # raise the exception for debugging/issue reporting

# Print any warnings
for warning in caught_warnings:
echo(str(warning.message))

# Report the status of the update
if updated:
forced = "forcefully " if force else ""
for domain in domains:
echo("Success: IP address {}updated to {} for {}.".format(forced, myip, domain))
else:
echo("Success: IP address is already up-to-date ({})".format(myip))


if __name__ == "__main__":
main()
189 changes: 126 additions & 63 deletions godaddy_ddns/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import datetime
import errno
import os
import re
import sys
import warnings

import click
import pif
Expand All @@ -12,73 +10,75 @@
from godaddypy.client import BadResponse


@click.command()
@click.option(
'--config',
default="/etc/godaddy-ddns/config.yaml",
help="Path to configuration file (.yaml).",
type=click.File('r')
)
@click.option('--force', is_flag=True, help="Update the IP regardless of the cached IP.")
@click.option('--quiet', is_flag=True, help="Don't print to stdout.")
def update_ip(config, force, quiet):
class ConfigError(Exception):
pass


def update_ip(config_file, force):
"""Update the IP address for the configured domains/subdomains
Parameters:
- config_file: Open file or file-like object configuration file
- force: boolean flag for forcing updates (True => force update)
Returns:
- updated: bool indicating whether the IP address was updated
- myip: str containing the current IP address
- domains: list of updated domains (eg. ["[sub1,sub2].[example.com]"])
"""
# Load the configuration file
try:
conf = yaml.load(config)
except yaml.MarkedYAMLError as e:
# If the error is marked, give the details
print("Error: Malformed configuration file.")
print(e)
sys.exit(errno.EINVAL)
except yaml.YAMLError:
# Otherwise just state that it's malformed
print("Error: Malformed configuration file")
sys.exit(errno.EINVAL)
config = yaml.load(config_file)
except (yaml.MarkedYAMLError, yaml.YAMLError) as e:
raise ConfigError("Error: {}".format(e))

# Check the supplied log path
if conf.get("log_path", False):
log_path = config.get("log_path")
if log_path:
# Make sure that the log path exists and is writable
try:
touch(conf["log_path"])
touch(log_path)
except PermissionError:
print("Error: Insufficient permissions to write log to '{}'.".format(conf['log_path']))
sys.exit(errno.EACCES)
msg = "Error: Insufficient permissions to write log to '{}'.".format(log_path)
raise PermissionError(msg) # Currently no log, so just raise an exception

# Define the logging function
def write_log(msg):
now = datetime.datetime.now().isoformat(' ', timespec='seconds')
with open(conf["log_path"], 'a') as f:
with open(log_path, 'a') as f:
f.write("[{now}]: {msg}\n".format(now=now, msg=msg))
if not quiet:
click.echo(msg)
else:
# No log file specified, so disable logging
def write_log(msg):
if not quiet:
click.echo(msg) # Just print the message, don't log it
pass

# Check the supplied cache path
if conf.get("cache_path", False):
cache_path = config.get("cache_path")
if cache_path:
# Make sure that the log path exists and is writable
try:
touch(conf["cache_path"]) # Create the file if necessary
touch(cache_path) # Create the file if necessary
except PermissionError:
write_log("Error: Insufficient permissions to write to cache ({}).".format(conf['cache_path']))
sys.exit(errno.EACCES)
msg = "Error: Insufficient permissions to write to cache ({}).".format(cache_path)
write_log(msg)
raise PermissionError(msg)

# Define the caching functions
def write_cache(ip_addr):
now = datetime.datetime.now().isoformat(' ', timespec='seconds')
with open(conf["cache_path"], 'w') as f:
with open(cache_path, 'w') as f:
f.write("[{}]: {}".format(now, ip_addr))

def read_cache():
with open(conf["cache_path"], "r") as f:
with open(cache_path, "r") as f:
cached = f.readline()
return (cached[1:20], cached[23:]) # date_time, ip_addr
else:
# No cache file specified, so disable caching and warn the user!
write_log("Warning: No cache file specified, so the IP address will always be submitted as if new - this could be considered abusive!")
msg = ("Warning: No cache file specified, so the IP address will always be submitted "
"as if new - this could be considered abusive!")
write_log(msg)
warnings.warn(msg)

# Define the caching functions
def write_cache(ip_addr):
Expand All @@ -88,11 +88,12 @@ def read_cache():
return (None, None)

# Get IPv4 address
myip = pif.get_public_ip("v4.ident.me")
myip = pif.get_public_ip("v4.ident.me") # Enforce IPv4 (for now)

if not myip:
write_log("Error: Failed to determine IPv4 address")
sys.exit(errno.CONNREFUSED)
msg = "Error: Failed to determine IPv4 address"
write_log(msg)
raise ConnectionError(msg)

# Check whether the current IP is equal to the cached IP address
date_time, cached_ip = read_cache()
Expand All @@ -101,63 +102,125 @@ def read_cache():
elif myip == cached_ip:
# Already up-to-date, so log it and exit
write_log("Success: IP address is already up-to-date ({})".format(myip))
sys.exit(0)
return (False, myip, None)
else:
write_log("Info: New IP address detected ({})".format(myip))

# Get API details
account = Account(api_key=conf.get("api_key"), api_secret=conf.get("api_secret"))
client = Client(account, api_base_url=conf.get("api_base_url", "https://api.godaddy.com"))
api_key = config.get("api_key")
api_secret = config.get("api_secret")

# Check that they have values
missing_cred = []
if not api_key:
missing_cred.append("'api_key'")
if not api_secret:
missing_cred.append("'api_secret'")

if missing_cred:
msg = "Error: Missing credentials - {} must be specified".format(" and ".join(missing_cred))
write_log(msg)
raise ConfigError(msg)

# Initialise the connection classes
account = Account(api_key=config.get("api_key"), api_secret=config.get("api_secret"))
client = Client(account, api_base_url=config.get("api_base_url", "https://api.godaddy.com"))

# Check that we have a connection and get the set of available domains
try:
available_domains = set(client.get_domains())
except BadResponse as e:
write_log("Error: Bad response from GoDaddy ({})".format(e._message))
sys.exit(errno.CONNREFUSED)
msg = "Error: Bad response from GoDaddy ({})".format(e._message)
write_log(msg)
raise BadResponse(msg)

# Make the API requests to update the IP address
failed_domains = {} # Stores a set of failed domains - failures will be tolerated but logged
for target in conf.get("targets", []):
failed_domains = set() # Stores a set of failed domains - failures will be tolerated but logged
succeeded_domains = []
forced = "forcefully " if force else ""

for target in config.get("targets", []):
try:
target_domain = target["domain"]
except KeyError:
write_log("Error: Missing 'domain' in confuration file")
sys.exit(errno.EINVAL)
msg = "Error: Missing 'domain' for target in configuration file"
write_log(msg)
raise ConfigError(msg)

if type(target_domain) == str:
target_domain = {target_domain}
if isinstance(target_domain, str):
target_domain = {target_domain} # set of one element
else:
target_domain = set(target_domain)
target_domain = set(target_domain) # set of supplied targets

unknown_domains = target_domain - available_domains
failed_domains.update(unknown_domains)

domains = list(target_domain & available_domains) # Remove unknown domains
if not domains:
continue # No known domains, so don't bother contacting GoDaddy

subdomains = target.get("alias", "@") # Default to no subdomain (GoDaddy uses "@" for this)

try:
update_succeeded = client.update_ip(myip, domains=domains, subdomains=subdomains)
except BadResponse as e:
write_log("Error: Bad response from GoDaddy ({})".format(e._message))
sys.exit(errno.CONNREFUSED)
msg = "Error: Bad response from GoDaddy ({})".format(e._message)
write_log(msg)
raise BadResponse(msg)

if update_succeeded:
write_log("Success: Updated IP for {subs}.{doms}".format(subs=subdomains, doms=domains))
succeeded_domains.append("{subs}.{doms}".format(subs=subdomains, doms=domains))
write_log("Success: IP address {}updated to {} for {}.".format(forced,
myip,
succeeded_domains[-1]))
else:
write_log("Error: Unknown failure for (domain(s): {doms}, alias(es): {subs})".format(doms=target_domain, subs=subdomains))
sys.exit(errno.CONNREFUSED)
msg = "Error: Unknown failure for (domain(s): {doms}, alias(es): {subs})".format(
doms=target_domain, subs=subdomains)
write_log(msg)
raise BadResponse(msg)

if failed_domains:
write_log("Warning: The following domains were not found {}".format(failed_domains))
msg = "Warning: The following domains were not found {}".format(failed_domains)
write_log(msg)
warnings.warn(msg)

# Write the new IP address to the cache and exit
# Write the new IP address to the cache and return
write_cache(myip)
sys.exit(0)
return (True, myip, succeeded_domains)


def print_colourised(msg):
"""Print messages automatically colourised based on initial keywords
Currently supports: "Success", "Info", "Warning", "Error", None
"""
if msg.startswith("Success"):
style = {
"fg": "green",
"bold": True,
}
elif msg.startswith("Info"):
style = {
"fg": "blue",
}
elif msg.startswith("Warning"):
style = {
"fg": "yellow",
}
elif msg.startswith("Error"):
style = {
"fg": "red",
"bold": True,
}
else:
style = {} # Don't apply a special style

click.echo(click.style(msg, **style))


# Define 'touch' function
def touch(path):
"""Touch a path, creating it if necessary
"""
# Ensure the path exists
os.makedirs(os.path.dirname(path), exist_ok=True)
# Create the file if necessary
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@


setup(name='godaddy-ddns',
version='0.1',
version='0.2',
description='DDNS-like update service for GoDaddy',
url='http://github.com/N-Parsons/godaddy-ddns',
author='Nathan Parsons',
Expand Down

0 comments on commit 51475a8

Please sign in to comment.