diff --git a/.gitignore b/.gitignore
index c39e69a..6e4df71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+bin/
*.pyc
.tox
.testrepository
+.coverage
diff --git a/Makefile b/Makefile
index 1ad17bf..97b3d97 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@ PYTHON := /usr/bin/env python
lint:
@flake8 --exclude hooks/charmhelpers,tests/charmhelpers \
- hooks unit_tests tests
+ hooks unit_tests tests ocf/maas
@charm proof
test:
diff --git a/README.md b/README.md
index 1021e99..c98834b 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,28 @@ To enable HA clustering support (for mysql for example):
The principle charm must have explicit support for the hacluster interface
in order for clustering to occur - otherwise nothing actually get configured.
+
+# HA/Clustering
+
+There are two mutually exclusive high availability options: using virtual
+IP(s) or DNS.
+
+To use virtual IP(s) the clustered nodes must be on the same subnet such that
+the VIP is a valid IP on the subnet for one of the node's interfaces and each
+node has an interface in said subnet. The VIP becomes a highly-available API
+endpoint.
+
+To use DNS high availability there are several prerequisites. However, DNS HA
+does not require the clustered nodes to be on the same subnet.
+Currently the DNS HA feature is only available for MAAS 2.0 or greater
+environments. MAAS 2.0 requires Juju 2.0 or greater. The MAAS 2.0 client
+requires Ubuntu 16.04 or greater. The clustered nodes must have static or
+"reserved" IP addresses registered in MAAS. The DNS hostname(s) must be
+pre-registered in MAAS before use with DNS HA.
+
+The charm will throw an exception in the following circumstances:
+If running on a version of Ubuntu less than Xenial 16.04
+
# Usage for Charm Authors
The hacluster interface supports a number of different cluster configuration
diff --git a/charm-helpers-hooks.yaml b/charm-helpers-hooks.yaml
index cd121ea..6080e94 100644
--- a/charm-helpers-hooks.yaml
+++ b/charm-helpers-hooks.yaml
@@ -8,7 +8,9 @@ include:
- contrib.hahelpers
- contrib.storage
- contrib.network.ip
- - contrib.openstack.utils
- contrib.openstack.exceptions
+ - contrib.openstack.ip
+ - contrib.openstack.utils
+ - contrib.openstack.ha.utils
- contrib.python.packages
- contrib.charmsupport
diff --git a/config.yaml b/config.yaml
index 91f843c..3e8f213 100644
--- a/config.yaml
+++ b/config.yaml
@@ -61,6 +61,18 @@ options:
type: string
default:
description: MAAS credentials (required for STONITH).
+ maas_source:
+ type: string
+ default: ppa:maas/stable
+ description: |
+ PPA for python3-maas-client:
+ .
+ - ppa:maas/stable
+ - ppa:maas/next
+ .
+ The last option should be used in conjunction with the key configuration
+ option.
+ Used when service_dns is set on the primary charm for DNS HA
cluster_count:
type: int
default: 2
diff --git a/hooks/charmhelpers/contrib/hahelpers/cluster.py b/hooks/charmhelpers/contrib/hahelpers/cluster.py
index 92325a9..90e437a 100644
--- a/hooks/charmhelpers/contrib/hahelpers/cluster.py
+++ b/hooks/charmhelpers/contrib/hahelpers/cluster.py
@@ -280,14 +280,14 @@ def get_hacluster_config(exclude_keys=None):
for initiating a relation to hacluster:
ha-bindiface, ha-mcastport, vip, os-internal-hostname,
- os-admin-hostname, os-public-hostname
+ os-admin-hostname, os-public-hostname, os-access-hostname
param: exclude_keys: list of setting key(s) to be excluded.
returns: dict: A dict containing settings keyed by setting name.
raises: HAIncompleteConfig if settings are missing or incorrect.
'''
settings = ['ha-bindiface', 'ha-mcastport', 'vip', 'os-internal-hostname',
- 'os-admin-hostname', 'os-public-hostname']
+ 'os-admin-hostname', 'os-public-hostname', 'os-access-hostname']
conf = {}
for setting in settings:
if exclude_keys and setting in exclude_keys:
@@ -324,7 +324,7 @@ def valid_hacluster_config():
# If dns-ha then one of os-*-hostname must be set
if dns:
dns_settings = ['os-internal-hostname', 'os-admin-hostname',
- 'os-public-hostname']
+ 'os-public-hostname', 'os-access-hostname']
# At this point it is unknown if one or all of the possible
# network spaces are in HA. Validate at least one is set which is
# the minimum required.
diff --git a/hooks/charmhelpers/contrib/openstack/ha/__init__.py b/hooks/charmhelpers/contrib/openstack/ha/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/hooks/charmhelpers/contrib/openstack/ha/utils.py b/hooks/charmhelpers/contrib/openstack/ha/utils.py
new file mode 100644
index 0000000..2a8a129
--- /dev/null
+++ b/hooks/charmhelpers/contrib/openstack/ha/utils.py
@@ -0,0 +1,130 @@
+# Copyright 2014-2016 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+#
+# Copyright 2016 Canonical Ltd.
+#
+# Authors:
+# Openstack Charmers <
+#
+
+"""
+Helpers for high availability.
+"""
+
+import re
+
+from charmhelpers.core.hookenv import (
+ log,
+ relation_set,
+ charm_name,
+ config,
+ status_set,
+ DEBUG,
+)
+
+from charmhelpers.core.host import (
+ lsb_release
+)
+
+from charmhelpers.contrib.openstack.ip import (
+ resolve_address,
+)
+
+
+class DNSHAException(Exception):
+ """Raised when an error occurs setting up DNS HA
+ """
+
+ pass
+
+
+def update_dns_ha_resource_params(resources, resource_params,
+ relation_id=None,
+ crm_ocf='ocf:maas:dns'):
+ """ Check for os-*-hostname settings and update resource dictionaries for
+ the HA relation.
+
+ @param resources: Pointer to dictionary of resources.
+ Usually instantiated in ha_joined().
+ @param resource_params: Pointer to dictionary of resource parameters.
+ Usually instantiated in ha_joined()
+ @param relation_id: Relation ID of the ha relation
+ @param crm_ocf: Corosync Open Cluster Framework resource agent to use for
+ DNS HA
+ """
+
+ # Validate the charm environment for DNS HA
+ assert_charm_supports_dns_ha()
+
+ settings = ['os-admin-hostname', 'os-internal-hostname',
+ 'os-public-hostname', 'os-access-hostname']
+
+ # Check which DNS settings are set and update dictionaries
+ hostname_group = []
+ for setting in settings:
+ hostname = config(setting)
+ if hostname is None:
+ log('DNS HA: Hostname setting {} is None. Ignoring.'
+ ''.format(setting),
+ DEBUG)
+ continue
+ m = re.search('os-(.+?)-hostname', setting)
+ if m:
+ networkspace = m.group(1)
+ else:
+ msg = ('Unexpected DNS hostname setting: {}. '
+ 'Cannot determine network space name'
+ ''.format(setting))
+ status_set('blocked', msg)
+ raise DNSHAException(msg)
+
+ hostname_key = 'res_{}_{}_hostname'.format(charm_name(), networkspace)
+ if hostname_key in hostname_group:
+ log('DNS HA: Resource {}: {} already exists in '
+ 'hostname group - skipping'.format(hostname_key, hostname),
+ DEBUG)
+ continue
+
+ hostname_group.append(hostname_key)
+ resources[hostname_key] = crm_ocf
+ resource_params[hostname_key] = (
+ 'params fqdn="{}" ip_address="{}" '
+ ''.format(hostname, resolve_address(endpoint_type=networkspace,
+ override=False)))
+
+ if len(hostname_group) >= 1:
+ log('DNS HA: Hostname group is set with {} as members. '
+ 'Informing the ha relation'.format(' '.join(hostname_group)),
+ DEBUG)
+ relation_set(relation_id=relation_id, groups={
+ 'grp_{}_hostnames'.format(charm_name()): ' '.join(hostname_group)})
+ else:
+ msg = 'DNS HA: Hostname group has no members.'
+ status_set('blocked', msg)
+ raise DNSHAException(msg)
+
+
+def assert_charm_supports_dns_ha():
+ """Validate prerequisites for DNS HA
+ The MAAS client is only available on Xenial or greater
+ """
+ if lsb_release().get('DISTRIB_RELEASE') < '16.04':
+ msg = ('DNS HA is only supported on 16.04 and greater '
+ 'versions of Ubuntu.')
+ status_set('blocked', msg)
+ raise DNSHAException(msg)
+ return True
diff --git a/hooks/charmhelpers/contrib/openstack/ip.py b/hooks/charmhelpers/contrib/openstack/ip.py
new file mode 100644
index 0000000..7875b99
--- /dev/null
+++ b/hooks/charmhelpers/contrib/openstack/ip.py
@@ -0,0 +1,182 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+
+from charmhelpers.core.hookenv import (
+ config,
+ unit_get,
+ service_name,
+ network_get_primary_address,
+)
+from charmhelpers.contrib.network.ip import (
+ get_address_in_network,
+ is_address_in_network,
+ is_ipv6,
+ get_ipv6_addr,
+ resolve_network_cidr,
+)
+from charmhelpers.contrib.hahelpers.cluster import is_clustered
+
+PUBLIC = 'public'
+INTERNAL = 'int'
+ADMIN = 'admin'
+
+ADDRESS_MAP = {
+ PUBLIC: {
+ 'binding': 'public',
+ 'config': 'os-public-network',
+ 'fallback': 'public-address',
+ 'override': 'os-public-hostname',
+ },
+ INTERNAL: {
+ 'binding': 'internal',
+ 'config': 'os-internal-network',
+ 'fallback': 'private-address',
+ 'override': 'os-internal-hostname',
+ },
+ ADMIN: {
+ 'binding': 'admin',
+ 'config': 'os-admin-network',
+ 'fallback': 'private-address',
+ 'override': 'os-admin-hostname',
+ }
+}
+
+
+def canonical_url(configs, endpoint_type=PUBLIC):
+ """Returns the correct HTTP URL to this host given the state of HTTPS
+ configuration, hacluster and charm configuration.
+
+ :param configs: OSTemplateRenderer config templating object to inspect
+ for a complete https context.
+ :param endpoint_type: str endpoint type to resolve.
+ :param returns: str base URL for services on the current service unit.
+ """
+ scheme = _get_scheme(configs)
+
+ address = resolve_address(endpoint_type)
+ if is_ipv6(address):
+ address = "[{}]".format(address)
+
+ return '%s://%s' % (scheme, address)
+
+
+def _get_scheme(configs):
+ """Returns the scheme to use for the url (either http or https)
+ depending upon whether https is in the configs value.
+
+ :param configs: OSTemplateRenderer config templating object to inspect
+ for a complete https context.
+ :returns: either 'http' or 'https' depending on whether https is
+ configured within the configs context.
+ """
+ scheme = 'http'
+ if configs and 'https' in configs.complete_contexts():
+ scheme = 'https'
+ return scheme
+
+
+def _get_address_override(endpoint_type=PUBLIC):
+ """Returns any address overrides that the user has defined based on the
+ endpoint type.
+
+ Note: this function allows for the service name to be inserted into the
+ address if the user specifies {service_name}.somehost.org.
+
+ :param endpoint_type: the type of endpoint to retrieve the override
+ value for.
+ :returns: any endpoint address or hostname that the user has overridden
+ or None if an override is not present.
+ """
+ override_key = ADDRESS_MAP[endpoint_type]['override']
+ addr_override = config(override_key)
+ if not addr_override:
+ return None
+ else:
+ return addr_override.format(service_name=service_name())
+
+
+def resolve_address(endpoint_type=PUBLIC, override=True):
+ """Return unit address depending on net config.
+
+ If unit is clustered with vip(s) and has net splits defined, return vip on
+ correct network. If clustered with no nets defined, return primary vip.
+
+ If not clustered, return unit address ensuring address is on configured net
+ split if one is configured, or a Juju 2.0 extra-binding has been used.
+
+ :param endpoint_type: Network endpoing type
+ :param override: Accept hostname overrides or not
+ """
+ resolved_address = None
+ if override:
+ resolved_address = _get_address_override(endpoint_type)
+ if resolved_address:
+ return resolved_address
+
+ vips = config('vip')
+ if vips:
+ vips = vips.split()
+
+ net_type = ADDRESS_MAP[endpoint_type]['config']
+ net_addr = config(net_type)
+ net_fallback = ADDRESS_MAP[endpoint_type]['fallback']
+ binding = ADDRESS_MAP[endpoint_type]['binding']
+ clustered = is_clustered()
+
+ if clustered and vips:
+ if net_addr:
+ for vip in vips:
+ if is_address_in_network(net_addr, vip):
+ resolved_address = vip
+ break
+ else:
+ # NOTE: endeavour to check vips against network space
+ # bindings
+ try:
+ bound_cidr = resolve_network_cidr(
+ network_get_primary_address(binding)
+ )
+ for vip in vips:
+ if is_address_in_network(bound_cidr, vip):
+ resolved_address = vip
+ break
+ except NotImplementedError:
+ # If no net-splits configured and no support for extra
+ # bindings/network spaces so we expect a single vip
+ resolved_address = vips[0]
+ else:
+ if config('prefer-ipv6'):
+ fallback_addr = get_ipv6_addr(exc_list=vips)[0]
+ else:
+ fallback_addr = unit_get(net_fallback)
+
+ if net_addr:
+ resolved_address = get_address_in_network(net_addr, fallback_addr)
+ else:
+ # NOTE: only try to use extra bindings if legacy network
+ # configuration is not in use
+ try:
+ resolved_address = network_get_primary_address(binding)
+ except NotImplementedError:
+ resolved_address = fallback_addr
+
+ if resolved_address is None:
+ raise ValueError("Unable to resolve a suitable IP address based on "
+ "charm state and configuration. (net_type=%s, "
+ "clustered=%s)" % (net_type, clustered))
+
+ return resolved_address
diff --git a/hooks/hooks.py b/hooks/hooks.py
index cf7f9a1..43c0f44 100755
--- a/hooks/hooks.py
+++ b/hooks/hooks.py
@@ -28,7 +28,6 @@ from charmhelpers.core.hookenv import (
from charmhelpers.core.host import (
service_stop,
service_running,
- mkdir,
)
from charmhelpers.fetch import (
@@ -55,6 +54,9 @@ from utils import (
disable_lsb_services,
disable_upstart_services,
get_ipv6_addr,
+ validate_dns_ha,
+ setup_maas_api,
+ setup_ocf_files,
set_unit_status,
)
@@ -88,12 +90,7 @@ def install():
# should be removed once the pacemaker package is fixed.
status_set('maintenance', 'Installing apt packages')
apt_install(filter_installed_packages(PACKAGES), fatal=True)
- # NOTE(adam_g) rbd OCF only included with newer versions of
- # ceph-resource-agents. Bundle /w charm until we figure out a
- # better way to install it.
- mkdir('/usr/lib/ocf/resource.d/ceph')
- if not os.path.isfile('/usr/lib/ocf/resource.d/ceph/rbd'):
- shutil.copy('ocf/ceph/rbd', '/usr/lib/ocf/resource.d/ceph/rbd')
+ setup_ocf_files()
def get_transport():
@@ -121,6 +118,9 @@ def ensure_ipv6_requirements(hanode_rid):
@hooks.hook()
def config_changed():
+
+ setup_ocf_files()
+
if config('prefer-ipv6'):
assert_charm_supports_ipv6()
@@ -221,6 +221,25 @@ def ha_relation_changed():
for ra in resources.itervalues()]:
apt_install('ceph-resource-agents')
+ if True in [ra.startswith('ocf:maas')
+ for ra in resources.values()]:
+ if validate_dns_ha():
+ log('Setting up access to MAAS API', level=INFO)
+ setup_maas_api()
+ # Update resource_parms for DNS resources to include MAAS URL and
+ # credentials
+ for resource in resource_params.keys():
+ if resource.endswith("_hostname"):
+ resource_params[resource] += (
+ ' maas_url="{}" maas_credentials="{}"'
+ ''.format(config('maas_url'),
+ config('maas_credentials')))
+ else:
+ msg = ("DNS HA is requested but maas_url "
+ "or maas_credentials are not set")
+ status_set('blocked', msg)
+ raise ValueError(msg)
+
# NOTE: this should be removed in 15.04 cycle as corosync
# configuration should be set directly on subordinate
configure_corosync()
diff --git a/hooks/utils.py b/hooks/utils.py
index 2f02e90..1d84a88 100644
--- a/hooks/utils.py
+++ b/hooks/utils.py
@@ -30,7 +30,12 @@ from charmhelpers.contrib.openstack.utils import (
clear_unit_paused,
is_unit_paused_set,
)
+from charmhelpers.contrib.openstack.ha.utils import (
+ assert_charm_supports_dns_ha
+)
from charmhelpers.core.host import (
+ mkdir,
+ rsync,
service_start,
service_stop,
service_restart,
@@ -41,6 +46,8 @@ from charmhelpers.core.host import (
)
from charmhelpers.fetch import (
apt_install,
+ add_source,
+ apt_update,
)
from charmhelpers.contrib.hahelpers.cluster import (
peer_ips,
@@ -82,6 +89,10 @@ COROSYNC_CONF_FILES = [
SUPPORTED_TRANSPORTS = ['udp', 'udpu', 'multicast', 'unicast']
+class MAASConfigIncomplete(Exception):
+ pass
+
+
def disable_upstart_services(*services):
for service in services:
with open("/etc/init/{}.override".format(service), "w") as override:
@@ -517,6 +528,50 @@ def restart_corosync():
service_start("pacemaker")
+def validate_dns_ha():
+ """Validate the DNS HA
+
+ Assert the charm will support DNS HA
+ Check MAAS related configuration options are properly set
+ """
+
+ # Will raise an exception if unable to continue
+ assert_charm_supports_dns_ha()
+
+ if config('maas_url') and config('maas_credentials'):
+ return True
+ else:
+ msg = ("DNS HA is requested but the maas_url or maas_credentials "
+ "settings are not set")
+ status_set('blocked', msg)
+ raise MAASConfigIncomplete(msg)
+
+
+def setup_maas_api():
+ """Install MAAS PPA and packages for accessing the MAAS API.
+ """
+ add_source(config('maas_source'))
+ apt_update(fatal=True)
+ apt_install('python3-maas-client', fatal=True)
+
+
+def setup_ocf_files():
+ """Setup OCF resrouce agent files
+ """
+
+ # TODO (thedac) Eventually we want to package the OCF files.
+ # Bundle with the charm until then.
+ mkdir('/usr/lib/ocf/resource.d/ceph')
+ mkdir('/usr/lib/ocf/resource.d/maas')
+ # Xenial corosync is not creating this directory
+ mkdir('/etc/corosync/uidgid.d')
+
+ rsync('ocf/ceph/rbd', '/usr/lib/ocf/resource.d/ceph/rbd')
+ rsync('ocf/maas/dns', '/usr/lib/ocf/resource.d/maas/dns')
+ rsync('ocf/maas/maas_dns.py', '/usr/lib/heartbeat/maas_dns.py')
+ rsync('ocf/maas/maasclient/', '/usr/lib/heartbeat/maasclient/')
+
+
def is_in_standby_mode(node_name):
"""Check if node is in standby mode in pacemaker
diff --git a/ocf/maas/dns b/ocf/maas/dns
new file mode 100755
index 0000000..842c668
--- /dev/null
+++ b/ocf/maas/dns
@@ -0,0 +1,267 @@
+#!/bin/sh
+#
+# Copyright 2016 Canonical Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# OCF instance parameters
+# OCF_RESKEY_logfile
+# OCF_RESKEY_errlogfile
+#
+# This RA starts $binfile with $cmdline_options as $user in $workdir and writes a $pidfile from that.
+# If you want it to, it logs:
+# - stdout to $logfile, stderr to $errlogfile or
+# - stdout and stderr to $logfile
+# - or to will be captured by lrmd if these options are omitted.
+# Monitoring is done through $pidfile or your custom $monitor_hook script.
+# The RA expects the program to keep running "daemon-like" and
+# not just quit and exit. So this is NOT (yet - feel free to
+# enhance) a way to just run a single one-shot command which just
+# does something and then exits.
+
+
+# XXX Update all comments
+
+# Initialization:
+: ${OCF_FUNCTIONS_DIR=${OCF_ROOT}/lib/heartbeat}
+. ${OCF_FUNCTIONS_DIR}/ocf-shellfuncs
+
+# OCF parameters are as below
+# OCF_RESKEY_fqdn
+# OCF_RESKEY_ip_address
+# OCF_RESKEY_ttl
+# OCF_RESKEY_maas_url
+# OCF_RESKEY_maas_credentials
+
+# Defaults
+
+
+maas_dns_usage() {
+cat </dev/null 2>&1
+ then
+ ocf_log err "user $user does not exist."
+ exit $OCF_ERR_INSTALLED
+ fi
+ for logfilename in "$logfile" "$errlogfile"
+ do
+ if [ -n "$logfilename" ]; then
+ mkdir -p `dirname $logfilename` || {
+ ocf_log err "cannot create $(dirname $logfilename)"
+ exit $OCF_ERR_INSTALLED
+ }
+ fi
+ done
+ return $OCF_SUCCESS
+}
+
+maas_dns_meta() {
+cat <
+
+
+1.0
+
+OCF RA to manage MAAS DNS entries. This will call out to maas_dns.py with the command name, fqdn, ip_address, maas_url and maas_credentials
+
+OCF RA to manage MAAS DNS entries
+
+
+
+The fully qualified domain name for the DNS entry.
+
+Fully qualified domain name
+
+
+
+
+The IP address for the DNS entry
+
+IP Address
+
+
+
+
+The URL to the MAAS host where the DNS entry will be updated.
+
+MAAS URL
+
+
+
+
+MAAS Oauth credentials for the MAAS API
+
+MAAS Credentials
+
+
+
+
+File to write STDOUT to
+
+File to write STDOUT to
+
+
+
+
+File to write STDERR to
+
+File to write STDERR to
+
+
+
+
+
+
+
+
+
+
+
+END
+exit 0
+}
+
+case "$1" in
+ meta-data|metadata|meta_data)
+ maas_dns_meta
+ ;;
+ start)
+ maas_dns_start
+ ;;
+ stop)
+ maas_dns_stop
+ ;;
+ monitor)
+ maas_dns_monitor
+ ;;
+ status)
+ maas_dns_status
+ ;;
+ validate-all)
+ maas_dns_validate
+ ;;
+ *)
+ maas_dns_usage
+ ocf_log err "$0 was called with unsupported arguments: $*"
+ exit $OCF_ERR_UNIMPLEMENTED
+ ;;
+esac
diff --git a/ocf/maas/maas_dns.py b/ocf/maas/maas_dns.py
new file mode 100755
index 0000000..1117698
--- /dev/null
+++ b/ocf/maas/maas_dns.py
@@ -0,0 +1,166 @@
+#!/usr/bin/python3
+#
+# Copyright 2016 Canonical Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import maasclient
+import argparse
+import sys
+import logging
+
+
+class MAASDNS(object):
+ def __init__(self, options):
+ self.maas = maasclient.MAASClient(options.maas_server,
+ options.maas_credentials)
+ # String representation of the fqdn
+ self.fqdn = options.fqdn
+ # Dictionary representation of MAAS dnsresource object
+ # TODO: Do this as a property
+ self.dnsresource = self.get_dnsresource()
+ # String representation of the time to live
+ self.ttl = str(options.ttl)
+ # String representation of the ip
+ self.ip = options.ip_address
+
+ def get_dnsresource(self):
+ """ Get a dnsresource object """
+ dnsresources = self.maas.get_dnsresources()
+ self.dnsresource = None
+ for dnsresource in dnsresources:
+ if dnsresource['fqdn'] == self.fqdn:
+ self.dnsresource = dnsresource
+ return self.dnsresource
+
+ def get_dnsresource_id(self):
+ """ Get a dnsresource ID """
+ return self.dnsresource['id']
+
+ def update_resource(self):
+ """ Update a dnsresource record with an IP """
+ return self.maas.update_dnsresource(self.dnsresource['id'],
+ self.dnsresource['fqdn'],
+ self.ip)
+
+ def create_dnsresource(self):
+ """ Create a DNS resource object
+ Due to https://bugs.launchpad.net/maas/+bug/1555393
+ This is currently unused
+ """
+ return self.maas.create_dnsresource(self.fqdn,
+ self.ip,
+ self.ttl)
+
+
+class MAASIP(object):
+ def __init__(self, options):
+ self.maas = maasclient.MAASClient(options.maas_server,
+ options.maas_credentials)
+ # String representation of the IP
+ self.ip = options.ip_address
+ # Dictionary representation of MAAS ipaddresss object
+ # TODO: Do this as a property
+ self.ipaddress = self.get_ipaddress()
+
+ def get_ipaddress(self):
+ """ Get an ipaddresses object """
+ ipaddresses = self.maas.get_ipaddresses()
+ self.ipaddress = None
+ for ipaddress in ipaddresses:
+ if ipaddress['ip'] == self.ip:
+ self.ipaddress = ipaddress
+ return self.ipaddress
+
+ def create_ipaddress(self, hostname=None):
+ """ Create an ipaddresses object
+ Due to https://bugs.launchpad.net/maas/+bug/1555393
+ This is currently unused
+ """
+ return self.maas.create_ipaddress(self.ip, hostname)
+
+
+def setup_logging(logfile, log_level='INFO'):
+ logFormatter = logging.Formatter(
+ fmt="%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S")
+ rootLogger = logging.getLogger()
+ rootLogger.setLevel(log_level)
+
+ consoleHandler = logging.StreamHandler()
+ consoleHandler.setFormatter(logFormatter)
+ rootLogger.addHandler(consoleHandler)
+
+ try:
+ fileLogger = logging.getLogger('file')
+ fileLogger.propagate = False
+
+ fileHandler = logging.FileHandler(logfile)
+ fileHandler.setFormatter(logFormatter)
+ rootLogger.addHandler(fileHandler)
+ fileLogger.addHandler(fileHandler)
+ except IOError:
+ logging.error('Unable to write to logfile: {}'.format(logfile))
+
+
+def dns_ha():
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--maas_server', '-s',
+ help='URL to mangage the MAAS server',
+ required=True)
+ parser.add_argument('--maas_credentials', '-c',
+ help='MAAS OAUTH credentials',
+ required=True)
+ parser.add_argument('--fqdn', '-d',
+ help='Fully Qualified Domain Name',
+ required=True)
+ parser.add_argument('--ip_address', '-i',
+ help='IP Address, target of the A record',
+ required=True)
+ parser.add_argument('--ttl', '-t',
+ help='DNS Time To Live in seconds',
+ default='')
+ parser.add_argument('--logfile', '-l',
+ help='Path to logfile',
+ default='/var/log/{}.log'
+ ''.format(sys.argv[0]
+ .split('/')[-1]
+ .split('.')[0]))
+ options = parser.parse_args()
+
+ setup_logging(options.logfile)
+ logging.info("Starting maas_dns")
+
+ dns_obj = MAASDNS(options)
+ if not dns_obj.dnsresource:
+ logging.info('DNS Resource does not exist. '
+ 'Create it with the maas cli.')
+ elif dns_obj.dnsresource.get('ip_addresses'):
+ # TODO: Handle multiple IPs returned for ip_addresses
+ for ip in dns_obj.dnsresource['ip_addresses']:
+ if ip.get('ip') != options.ip_address:
+ logging.info('Update the dnsresource with IP: {}'
+ ''.format(options.ip_address))
+ dns_obj.update_resource()
+ else:
+ logging.info('IP is the SAME {}, no update required'
+ ''.format(options.ip_address))
+ else:
+ logging.info('Update the dnsresource with IP: {}'
+ ''.format(options.ip_address))
+ dns_obj.update_resource()
+
+
+if __name__ == '__main__':
+ dns_ha()
diff --git a/ocf/maas/maasclient/__init__.py b/ocf/maas/maasclient/__init__.py
new file mode 100644
index 0000000..70d017a
--- /dev/null
+++ b/ocf/maas/maasclient/__init__.py
@@ -0,0 +1,128 @@
+# Copyright 2016 Canonical Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+
+from .apidriver import APIDriver
+
+log = logging.getLogger('vmaas.main')
+
+
+class MAASException(Exception):
+ pass
+
+
+class MAASDriverException(Exception):
+ pass
+
+
+class MAASClient(object):
+ """
+ A wrapper for the python maas client which makes using the API a bit
+ more user friendly.
+ """
+
+ def __init__(self, api_url, api_key, **kwargs):
+ self.driver = self._get_driver(api_url, api_key, **kwargs)
+
+ def _get_driver(self, api_url, api_key, **kwargs):
+ return APIDriver(api_url, api_key)
+
+ def _validate_maas(self):
+ try:
+ self.driver.validate_maas()
+ logging.info("Validated MAAS API")
+ return True
+ except Exception as e:
+ logging.error("MAAS API validation has failed. "
+ "Check maas_url and maas_credentials. Error: {}"
+ "".format(e))
+ return False
+
+ ###########################################################################
+ # DNS API - http://maas.ubuntu.com/docs2.0/api.html#dnsresource
+ ###########################################################################
+ def get_dnsresources(self):
+ """
+ Get a listing of DNS resources which are currently defined.
+
+ :returns: a list of DNS objects
+ DNS object is a dictionary of the form:
+ {'fqdn': 'keystone.maas',
+ 'resource_records': [],
+ 'address_ttl': None,
+ 'resource_uri': '/MAAS/api/2.0/dnsresources/1/',
+ 'ip_addresses': [],
+ 'id': 1}
+ """
+ resp = self.driver.get_dnsresources()
+ if resp.ok:
+ return resp.data
+ return []
+
+ def update_dnsresource(self, rid, fqdn, ip_address):
+ """
+ Updates a DNS resource with a new ip_address
+
+ :param rid: The dnsresource_id i.e.
+ /api/2.0/dnsresources/{dnsresource_id}/
+ :param fqdn: The fqdn address to update
+ :param ip_address: The ip address to update the A record to point to
+ :returns: True if the DNS object was updated, False otherwise.
+ """
+ resp = self.driver.update_dnsresource(rid, fqdn, ip_address)
+ if resp.ok:
+ return True
+ return False
+
+ def create_dnsresource(self, fqdn, ip_address, address_ttl=None):
+ """
+ Creates a new DNS resource
+
+ :param fqdn: The fqdn address to update
+ :param ip_address: The ip address to update the A record to point to
+ :param adress_ttl: DNS time to live
+ :returns: True if the DNS object was updated, False otherwise.
+ """
+ resp = self.driver.create_dnsresource(fqdn, ip_address, address_ttl)
+ if resp.ok:
+ return True
+ return False
+
+ ###########################################################################
+ # IP API - http://maas.ubuntu.com/docs2.0/api.html#ip-address
+ ###########################################################################
+ def get_ipaddresses(self):
+ """
+ Get a list of ip addresses
+
+ :returns: a list of ip address dictionaries
+ """
+ resp = self.driver.get_ipaddresses()
+ if resp.ok:
+ return resp.data
+ return []
+
+ def create_ipaddress(self, ip_address, hostname=None):
+ """
+ Creates a new IP resource
+
+ :param ip_address: The ip address to register
+ :param hostname: the hostname to register at the same time
+ :returns: True if the DNS object was updated, False otherwise.
+ """
+ resp = self.driver.create_ipaddress(ip_address, hostname)
+ if resp.ok:
+ return True
+ return False
diff --git a/ocf/maas/maasclient/apidriver.py b/ocf/maas/maasclient/apidriver.py
new file mode 100644
index 0000000..5e16f48
--- /dev/null
+++ b/ocf/maas/maasclient/apidriver.py
@@ -0,0 +1,211 @@
+# Copyright 2016 Canonical Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import yaml
+import logging
+
+from apiclient import maas_client as maas
+from .driver import MAASDriver
+from .driver import Response
+
+try:
+ from urllib2 import HTTPError
+except ImportError:
+ from urllib3.exceptions import HTTPError
+
+log = logging.getLogger('vmaas.main')
+OK = 200
+
+
+class APIDriver(MAASDriver):
+ """
+ A MAAS driver implementation which uses the MAAS API.
+ """
+
+ def __init__(self, api_url, api_key, *args, **kwargs):
+ if api_url[-1] != '/':
+ api_url += '/'
+ if api_url.find('/api/') < 0:
+ api_url = api_url + 'api/2.0/'
+ super(APIDriver, self).__init__(api_url, api_key, *args, **kwargs)
+ self._client = None
+ self._oauth = None
+
+ @property
+ def client(self):
+ """
+ MAAS client
+
+ :rtype: MAASClient
+ """
+ if self._client:
+ return self._client
+
+ self._client = maas.MAASClient(auth=self.oauth,
+ dispatcher=maas.MAASDispatcher(),
+ base_url=self.api_url)
+ return self._client
+
+ @property
+ def oauth(self):
+ """
+ MAAS OAuth information for interacting with the MAAS API.
+
+ :rtype: MAASOAuth
+ """
+ if self._oauth:
+ return self._oauth
+
+ if self.api_key:
+ api_key = self.api_key.split(':')
+ self._oauth = maas.MAASOAuth(consumer_key=api_key[0],
+ resource_token=api_key[1],
+ resource_secret=api_key[2])
+ return self._oauth
+ else:
+ return None
+
+ def validate_maas(self):
+ return self._get('/')
+
+ def _get(self, path, **kwargs):
+ """
+ Issues a GET request to the MAAS REST API, returning the data
+ from the query in the python form of the json data.
+ """
+ response = self.client.get(path, **kwargs)
+ payload = response.read()
+ log.debug("Request %s results: [%s] %s", path, response.getcode(),
+ payload)
+
+ if response.getcode() == OK:
+ return Response(True, yaml.load(payload))
+ else:
+ return Response(False, payload)
+
+ def _post(self, path, op='update', **kwargs):
+ """
+ Issues a POST request to the MAAS REST API.
+ """
+ try:
+ response = self.client.post(path, **kwargs)
+ payload = response.read()
+ log.debug("Request %s results: [%s] %s", path, response.getcode(),
+ payload)
+
+ if response.getcode() == OK:
+ return Response(True, yaml.load(payload))
+ else:
+ return Response(False, payload)
+ except HTTPError as e:
+ log.error("Error encountered: %s for %s with params %s",
+ str(e), path, str(kwargs))
+ return Response(False, None)
+ except Exception as e:
+ log.error("Post request raised exception: %s", e)
+ return Response(False, None)
+
+ def _put(self, path, **kwargs):
+ """
+ Issues a PUT request to the MAAS REST API.
+ """
+ try:
+ response = self.client.put(path, **kwargs)
+ payload = response.read()
+ log.debug("Request %s results: [%s] %s", path, response.getcode(),
+ payload)
+ if response.getcode() == OK:
+ return Response(True, payload)
+ else:
+ return Response(False, payload)
+ except HTTPError as e:
+ log.error("Error encountered: %s with details: %s for %s with "
+ "params %s", e, e.read(), path, str(kwargs))
+ return Response(False, None)
+ except Exception as e:
+ log.error("Put request raised exception: %s", e)
+ return Response(False, None)
+
+ ###########################################################################
+ # DNS API - http://maas.ubuntu.com/docs2.0/api.html#dnsresource
+ ###########################################################################
+ def get_dnsresources(self):
+ """
+ Get a listing of the MAAS dnsresources
+
+ :returns: a list of MAAS dnsresrouce objects
+ """
+ return self._get('/dnsresources/')
+
+ def update_dnsresource(self, rid, fqdn, ip_address):
+ """
+ Updates a DNS resource with a new ip_address
+
+ :param rid: The dnsresource_id i.e.
+ /api/2.0/dnsresources/{dnsresource_id}/
+ :param fqdn: The fqdn address to update
+ :param ip_address: The ip address to update the A record to point to
+ :returns: True if the DNS object was updated, False otherwise.
+ """
+ return self._put('/dnsresources/{}/'.format(rid), fqdn=fqdn,
+ ip_addresses=ip_address)
+
+ def create_dnsresource(self, fqdn, ip_address, address_ttl=None):
+ """
+ Creates a new DNS resource
+
+ :param fqdn: The fqdn address to update
+ :param ip_address: The ip address to update the A record to point to
+ :param adress_ttl: DNS time to live
+ :returns: True if the DNS object was updated, False otherwise.
+ """
+ fqdn = bytes(fqdn, encoding='utf-8')
+ ip_address = bytes(ip_address, encoding='utf-8')
+ if address_ttl:
+ return self._post('/dnsresources/',
+ fqdn=fqdn,
+ ip_addresses=ip_address,
+ address_ttl=address_ttl)
+ else:
+ return self._post('/dnsresources/',
+ fqdn=fqdn,
+ ip_addresses=ip_address)
+
+ ###########################################################################
+ # IP API - http://maas.ubuntu.com/docs2.0/api.html#ip-addresses
+ ###########################################################################
+ def get_ipaddresses(self):
+ """
+ Get a dictionary of a given ip_address
+
+ :param ip_address: The ip address to get information for
+ :returns: a dictionary for a given ip
+ """
+ return self._get('/ipaddresses/')
+
+ def create_ipaddress(self, ip_address, hostname=None):
+ """
+ Creates a new IP resource
+
+ :param ip_address: The ip address to register
+ :param hostname: the hostname to register at the same time
+ :returns: True if the DNS object was updated, False otherwise.
+ """
+ if hostname:
+ return self._post('/ipaddresses/', op='reserve',
+ ip_addresses=ip_address,
+ hostname=hostname)
+ else:
+ return self._post('/ipaddresses/', op='reserve',
+ ip_addresses=ip_address)
diff --git a/ocf/maas/maasclient/driver.py b/ocf/maas/maasclient/driver.py
new file mode 100644
index 0000000..9bcae78
--- /dev/null
+++ b/ocf/maas/maasclient/driver.py
@@ -0,0 +1,63 @@
+# Copyright 2016 Canonical Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+
+log = logging.getLogger('vmaas.main')
+
+
+class Response(object):
+ """
+ Response for the API calls to use internally
+ """
+ def __init__(self, ok=False, data=None):
+ self.ok = ok
+ self.data = data
+
+ def __nonzero__(self):
+ """Allow boolean comparison"""
+ return bool(self.ok)
+
+
+class MAASDriver(object):
+ """
+ Defines the commands and interfaces for generically working with
+ the MAAS controllers.
+ """
+
+ def __init__(self, api_url, api_key):
+ self.api_url = api_url
+ self.api_key = api_key
+
+ def _get_system_id(self, obj):
+ """
+ Returns the system_id from an object or the object itself
+ if the system_id is not found.
+ """
+ if 'system_id' in obj:
+ return obj.system_id
+ return obj
+
+ def _get_uuid(self, obj):
+ """
+ Returns the UUID for the MAAS object. If the object has the attribute
+ 'uuid', then this method will return obj.uuid, otherwise this method
+ will return the object itself.
+ """
+ if hasattr(obj, 'uuid'):
+ return obj.uuid
+ else:
+ log.warning("Attr 'uuid' not found in %s" % obj)
+
+ return obj
diff --git a/unit_tests/test_hacluster_hooks.py b/unit_tests/test_hacluster_hooks.py
index 3e3f464..6861f4d 100644
--- a/unit_tests/test_hacluster_hooks.py
+++ b/unit_tests/test_hacluster_hooks.py
@@ -93,3 +93,130 @@ class TestCorosyncConf(unittest.TestCase):
else:
commit.assert_any_call(
'crm -w -F configure %s %s %s' % (kw, name, params))
+
+ @mock.patch.object(hooks, 'setup_maas_api')
+ @mock.patch.object(hooks, 'validate_dns_ha')
+ @mock.patch('pcmk.wait_for_pcmk')
+ @mock.patch.object(hooks, 'peer_units')
+ @mock.patch('pcmk.crm_opt_exists')
+ @mock.patch.object(hooks, 'oldest_peer')
+ @mock.patch.object(hooks, 'configure_corosync')
+ @mock.patch.object(hooks, 'configure_cluster_global')
+ @mock.patch.object(hooks, 'configure_monitor_host')
+ @mock.patch.object(hooks, 'configure_stonith')
+ @mock.patch.object(hooks, 'related_units')
+ @mock.patch.object(hooks, 'get_cluster_nodes')
+ @mock.patch.object(hooks, 'relation_set')
+ @mock.patch.object(hooks, 'relation_ids')
+ @mock.patch.object(hooks, 'get_corosync_conf')
+ @mock.patch('pcmk.commit')
+ @mock.patch.object(hooks, 'config')
+ @mock.patch.object(hooks, 'parse_data')
+ def test_ha_relation_changed_dns_ha(self, parse_data, config, commit,
+ get_corosync_conf, relation_ids,
+ relation_set, get_cluster_nodes,
+ related_units, configure_stonith,
+ configure_monitor_host,
+ configure_cluster_global,
+ configure_corosync, oldest_peer,
+ crm_opt_exists, peer_units,
+ wait_for_pcmk, validate_dns_ha,
+ setup_maas_api):
+ validate_dns_ha.return_value = True
+ crm_opt_exists.return_value = False
+ oldest_peer.return_value = True
+ related_units.return_value = ['ha/0', 'ha/1', 'ha/2']
+ get_cluster_nodes.return_value = ['10.0.3.2', '10.0.3.3', '10.0.3.4']
+ relation_ids.return_value = ['ha:1']
+ get_corosync_conf.return_value = True
+ cfg = {'debug': False,
+ 'prefer-ipv6': False,
+ 'corosync_transport': 'udpu',
+ 'corosync_mcastaddr': 'corosync_mcastaddr',
+ 'cluster_count': 3,
+ 'maas_url': 'http://maas/MAAAS/',
+ 'maas_credentials': 'secret'}
+
+ config.side_effect = lambda key: cfg.get(key)
+
+ rel_get_data = {'locations': {'loc_foo': 'bar rule inf: meh eq 1'},
+ 'clones': {'cl_foo': 'res_foo meta interleave=true'},
+ 'groups': {'grp_foo': 'res_foo'},
+ 'colocations': {'co_foo': 'inf: grp_foo cl_foo'},
+ 'resources': {'res_foo_hostname': 'ocf:maas:dns'},
+ 'resource_params': {'res_foo_hostname': 'params bar'},
+ 'ms': {'ms_foo': 'res_foo meta notify=true'},
+ 'orders': {'foo_after': 'inf: res_foo ms_foo'}}
+
+ def fake_parse_data(relid, unit, key):
+ return rel_get_data.get(key, {})
+
+ parse_data.side_effect = fake_parse_data
+
+ hooks.ha_relation_changed()
+ self.assertTrue(validate_dns_ha.called)
+ self.assertTrue(setup_maas_api.called)
+ # Validate maas_credentials and maas_url are added to params
+ commit.assert_any_call(
+ 'crm -w -F configure primitive res_foo_hostname ocf:maas:dns '
+ 'params bar maas_url="http://maas/MAAAS/" '
+ 'maas_credentials="secret"')
+
+ @mock.patch.object(hooks, 'setup_maas_api')
+ @mock.patch.object(hooks, 'validate_dns_ha')
+ @mock.patch('pcmk.wait_for_pcmk')
+ @mock.patch.object(hooks, 'peer_units')
+ @mock.patch('pcmk.crm_opt_exists')
+ @mock.patch.object(hooks, 'oldest_peer')
+ @mock.patch.object(hooks, 'configure_corosync')
+ @mock.patch.object(hooks, 'configure_cluster_global')
+ @mock.patch.object(hooks, 'configure_monitor_host')
+ @mock.patch.object(hooks, 'configure_stonith')
+ @mock.patch.object(hooks, 'related_units')
+ @mock.patch.object(hooks, 'get_cluster_nodes')
+ @mock.patch.object(hooks, 'relation_set')
+ @mock.patch.object(hooks, 'relation_ids')
+ @mock.patch.object(hooks, 'get_corosync_conf')
+ @mock.patch('pcmk.commit')
+ @mock.patch.object(hooks, 'config')
+ @mock.patch.object(hooks, 'parse_data')
+ def test_ha_relation_changed_dns_ha_missing(
+ self, parse_data, config, commit, get_corosync_conf, relation_ids,
+ relation_set, get_cluster_nodes, related_units, configure_stonith,
+ configure_monitor_host, configure_cluster_global,
+ configure_corosync, oldest_peer, crm_opt_exists, peer_units,
+ wait_for_pcmk, validate_dns_ha, setup_maas_api):
+
+ validate_dns_ha.return_value = False
+ crm_opt_exists.return_value = False
+ oldest_peer.return_value = True
+ related_units.return_value = ['ha/0', 'ha/1', 'ha/2']
+ get_cluster_nodes.return_value = ['10.0.3.2', '10.0.3.3', '10.0.3.4']
+ relation_ids.return_value = ['ha:1']
+ get_corosync_conf.return_value = True
+ cfg = {'debug': False,
+ 'prefer-ipv6': False,
+ 'corosync_transport': 'udpu',
+ 'corosync_mcastaddr': 'corosync_mcastaddr',
+ 'cluster_count': 3,
+ 'maas_url': 'http://maas/MAAAS/',
+ 'maas_credentials': None}
+
+ config.side_effect = lambda key: cfg.get(key)
+
+ rel_get_data = {'locations': {'loc_foo': 'bar rule inf: meh eq 1'},
+ 'clones': {'cl_foo': 'res_foo meta interleave=true'},
+ 'groups': {'grp_foo': 'res_foo'},
+ 'colocations': {'co_foo': 'inf: grp_foo cl_foo'},
+ 'resources': {'res_foo_hostname': 'ocf:maas:dns'},
+ 'resource_params': {'res_foo_hostname': 'params bar'},
+ 'ms': {'ms_foo': 'res_foo meta notify=true'},
+ 'orders': {'foo_after': 'inf: res_foo ms_foo'}}
+
+ def fake_parse_data(relid, unit, key):
+ return rel_get_data.get(key, {})
+
+ parse_data.side_effect = fake_parse_data
+
+ with self.assertRaises(ValueError):
+ hooks.ha_relation_changed()
diff --git a/unit_tests/test_hacluster_utils.py b/unit_tests/test_hacluster_utils.py
index d9a92fa..acfd359 100644
--- a/unit_tests/test_hacluster_utils.py
+++ b/unit_tests/test_hacluster_utils.py
@@ -146,3 +146,39 @@ class UtilsTestCase(unittest.TestCase):
self.assertFalse(mock_get_host_ip.called)
self.assertTrue(mock_get_ipv6_addr.called)
+
+ @mock.patch.object(utils, 'assert_charm_supports_dns_ha')
+ @mock.patch.object(utils, 'config')
+ def test_validate_dns_ha_valid(self, config,
+ assert_charm_supports_dns_ha):
+ cfg = {'maas_url': 'http://maas/MAAAS/',
+ 'maas_credentials': 'secret'}
+ config.side_effect = lambda key: cfg.get(key)
+
+ self.assertTrue(utils.validate_dns_ha())
+ self.assertTrue(assert_charm_supports_dns_ha.called)
+
+ @mock.patch.object(utils, 'assert_charm_supports_dns_ha')
+ @mock.patch.object(utils, 'status_set')
+ @mock.patch.object(utils, 'config')
+ def test_validate_dns_ha_invalid(self, config, status_set,
+ assert_charm_supports_dns_ha):
+ cfg = {'maas_url': 'http://maas/MAAAS/',
+ 'maas_credentials': None}
+ config.side_effect = lambda key: cfg.get(key)
+
+ self.assertRaises(utils.MAASConfigIncomplete,
+ lambda: utils.validate_dns_ha())
+ self.assertTrue(assert_charm_supports_dns_ha.called)
+
+ @mock.patch.object(utils, 'apt_install')
+ @mock.patch.object(utils, 'apt_update')
+ @mock.patch.object(utils, 'add_source')
+ @mock.patch.object(utils, 'config')
+ def test_setup_maas_api(self, config, add_source, apt_update, apt_install):
+ cfg = {'maas_source': 'ppa:maas/stable'}
+ config.side_effect = lambda key: cfg.get(key)
+
+ utils.setup_maas_api()
+ add_source.assert_called_with(cfg['maas_source'])
+ self.assertTrue(apt_install.called)