Add support for maas stonith

The change adds a stonith plugin for maas and method for creating
stonith resources that use the plugin.

Change-Id: I825d211d68facce94bee9c6b4b34debaa359e836
This commit is contained in:
Liam Young 2019-03-27 19:17:01 +00:00
parent 6e3cec799f
commit 3d34611e88
4 changed files with 479 additions and 2 deletions

View File

@ -55,8 +55,19 @@ def wait_for_pcmk(retries=12, sleep=10):
"".format(retries, output))
def commit(cmd):
return subprocess.call(cmd.split())
def commit(cmd, failure_is_fatal=False):
"""Run the given command.
:param cmd: Command to run
:type cmd: str
:param failure_is_fatal: Whether to raise exception if command fails.
:type failure_is_fatal: bool
:raises: subprocess.CalledProcessError
"""
if failure_is_fatal:
return subprocess.check_call(cmd.split())
else:
return subprocess.call(cmd.split())
def is_resource_present(resource):

View File

@ -586,6 +586,36 @@ def configure_cluster_global():
pcmk.commit(cmd)
def configure_maas_stonith_resource(stonith_hostname):
"""Create stonith resource for the given hostname.
:param stonith_hostname: The hostname that the stonith management system
refers to the remote node as.
:type stonith_hostname: str
"""
log('Checking for existing stonith resource', level=DEBUG)
stonith_res_name = 'st-{}'.format(stonith_hostname.split('.')[0])
if not pcmk.is_resource_present(stonith_res_name):
ctxt = {
'url': config('maas_url'),
'apikey': config('maas_credentials'),
'hostnames': stonith_hostname,
'stonith_resource_name': stonith_res_name}
if all(ctxt.values()):
cmd = (
"crm configure primitive {stonith_resource_name} "
"stonith:external/maas "
"params url='{url}' apikey='{apikey}' hostnames={hostnames} "
"op monitor interval=25 start-delay=25 "
"timeout=25").format(**ctxt)
pcmk.commit(cmd, failure_is_fatal=True)
else:
raise ValueError("Missing configuration: {}".format(ctxt))
pcmk.commit(
"crm configure property stonith-enabled=true",
failure_is_fatal=True)
def get_ip_addr_from_resource_params(params):
"""Returns the IP address in the resource params provided
@ -744,6 +774,9 @@ def setup_ocf_files():
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/')
rsync(
'ocf/maas/maas_stonith_plugin.py',
'/usr/lib/stonith/plugins/external/maas')
def write_maas_dns_address(resource_name, resource_addr):

380
ocf/maas/maas_stonith_plugin.py Executable file
View File

@ -0,0 +1,380 @@
#! /usr/bin/python3
#
# Copyright 2019 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 aiohttp
import functools
import os
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
import maas.client
DESCRIPTION = "Maas stonith plugin"
DESCRIPTION_LONG = "External Maas stonith plugin"
DEV_URL = "https://maas.io/"
PARAM_XML = """<parameters>
<parameter name="hostnames" unique="1">
<content type="string" />
<shortdesc lang="en">
Profile
</shortdesc>
<longdesc lang="en">
Space seperate list of hosts this stonith resource will manage
</longdesc>
</parameter>
<parameter name="url" unique="1">
<content type="string" />
<shortdesc lang="en">
Maas URL
</shortdesc>
<longdesc lang="en">
Maas API URL
</longdesc>
</parameter>
<parameter name="apikey" unique="1">
<content type="string" />
<shortdesc lang="en">
API Key
</shortdesc>
<longdesc lang="en">
Maas API key
</longdesc>
</parameter>
</parameters>"""
INFO = "info"
DEBUG = "debug"
CRIT = "crit"
class FindMachineException(Exception):
"""Exception raised when machine lookup fails
:param count: Number of machines matching the hostname.
:type count: int
"""
def __init__(self, count):
self.message = "Expected to find 1 machine found {}".format(count)
log(self.message, CRIT)
class MachinePowerException(Exception):
"""Exception raised when machine fails to reach power state.
:param state: Power state machine was transitioning to.
:type state: str
"""
def __init__(self, state):
self.message = "Machine timed out reaching {} state".format(state)
log(self.message, CRIT)
def get_config_names():
"""Derive the available configuration options
:returns: Config options and their values
:rtype: dict
"""
root = ET.fromstring(PARAM_XML)
config_names = []
for child in root:
if child.tag == 'parameter':
config_names.append(child.attrib['name'])
return config_names
def log(msg, level=None):
"""Log messages
:param msg: Message to log
:type msg: str
:param level: Log level (crit, err, warn, notice, info or debug)
:type auth_token: str
"""
level = level or 'debug'
subprocess.call(['ha_log.sh', level, msg])
with open('/tmp/maas.log', 'a') as f:
f.write('{} {}\n'.format(level, msg))
def get_maas_client(maas_url, auth_token):
"""Return a maas client
:param maas_url: URL of maas api
:type maas_url: str
:param auth_token: Maas API key
:type auth_token: str
:returns: Maas client
:rtype: maas.client.facade.Client
"""
log("Creating maas client", DEBUG)
return maas.client.connect(
url=maas_url,
apikey=auth_token)
def get_machine(client, hostname):
"""Return the machine corresponding to hostname.
:param client: Maas client
:type client: maas.client.facade.Client
:param hostname: Name of hostname to lookup.
:type hostname: str
:returns: Maas machine
:rtype: origin.Machine
"""
log("Creating maas client", DEBUG)
log("Getting machine with hostname {} from maas ".format(hostname), DEBUG)
machines = client.machines.list(hostnames=[hostname])
if len(machines) != 1:
raise FindMachineException(len(machines))
log("Found machine {} ({})".format(hostname, machines[0].system_id), DEBUG)
return machines[0]
def wait_for_power_state(client, hostname, state):
"""Wait for machine power to reach given state.
:param client: Maas client
:type client: maas.client.facade.Client
:param hostname: Name of hostname to lookup.
:type hostname: str
:param state: Target power state
:type state: maas.client.enum.PowerState
:raises: MachinePowerException
"""
log("Waiting for {} to reach power state {}".format(hostname, state.value),
DEBUG)
for i in range(0, 20):
machine = get_machine(client, hostname)
if machine.power_state == state:
log("{} reached {}".format(hostname, state.value), DEBUG)
break
time.sleep(0.5)
else:
raise MachinePowerException(state.value)
log("{} is in power state {}".format(hostname, machine.power_state.value),
INFO)
def power_on(maas_url, auth_token, hostname):
"""Power on given machine
:param maas_url: URL of maas api
:type maas_url: str
:param auth_token: Maas API key
:type auth_token: str
:param hostname: Name of hostname to lookup.
:type hostname: str
:returns: Success indicator
:rtype: int
"""
log("Powering on {}".format(hostname), INFO)
client = get_maas_client(maas_url, auth_token)
machine = get_machine(client, hostname)
machine.power_on()
return 0
def power_off(maas_url, auth_token, hostname):
"""Power off given machine
:param maas_url: URL of maas api
:type maas_url: str
:param auth_token: Maas API key
:type auth_token: str
:param hostname: Name of hostname to lookup.
:type hostname: str
:returns: Success indicator
:rtype: int
"""
log("Powering off {}".format(hostname), INFO)
client = get_maas_client(maas_url, auth_token)
machine = get_machine(client, hostname)
machine.power_off()
return 0
def power_reset(maas_url, auth_token, hostname):
"""Reset power on given machine
:param maas_url: URL of maas api
:type maas_url: str
:param auth_token: Maas API key
:type auth_token: str
:param hostname: Name of hostname to lookup.
:type hostname: str
:returns: Success indicator
:rtype: int
"""
log("Performing power reset on {}".format(hostname), INFO)
client = get_maas_client(maas_url, auth_token)
machine = get_machine(client, hostname)
log("{} is in power state {}".format(hostname, machine.power_state.value),
INFO)
if machine.power_state != maas.client.enum.PowerState.OFF:
log("Powering off {}".format(hostname), INFO)
machine.power_off()
else:
log("Skipping power off of {} it is already off".format(hostname),
INFO)
wait_for_power_state(client, hostname, maas.client.enum.PowerState.OFF)
log("Powering on {}".format(hostname), INFO)
machine.power_on()
return 0
def status(maas_url, auth_token):
"""Test connectivity to maas api
:param maas_url: URL of maas api
:type maas_url: str
:param auth_token: Maas API key
:type auth_token: str
:returns: Success indicator
:rtype: int
"""
log("Checking status of Maas", INFO)
try:
client = get_maas_client(maas_url, auth_token)
client.version.get()
except aiohttp.client_exceptions.ClientConnectorError:
return 1
return 0
def get_environment_config():
"""Extract config from environment variables
:returns: Dictionary of config
:rtype: dict
"""
runtime_config = {}
for k in get_config_names():
runtime_config[k] = os.environ.get(k)
return runtime_config
def show_hosts():
"""Print hosts supported by this stonith instance.
:returns: Success indicator
:rtype: int
"""
for host in get_environment_config().get('hostnames').split():
print(host)
return 0
def show_config_names():
"""Print name of config options picked up from environment variables
:returns: Success indicator
:rtype: int
"""
print(' '.join(get_config_names()))
return 0
def show_info_devid():
"""Print name of config options picked up from environment variables
:returns: Success indicator
:rtype: int
"""
print(DESCRIPTION)
return 0
def show_info_devname():
"""Print description of this stonith method
:returns: Success indicator
:rtype: int
"""
print(DESCRIPTION_LONG)
return 0
def show_info_devdescr():
"""Print description of this stonith method
:returns: Success indicator
:rtype: int
"""
print(DESCRIPTION_LONG)
return 0
def show_info_devurl():
"""Print URL for dev community
:returns: Success indicator
:rtype: int
"""
print(DEV_URL)
return 0
def show_info_xml():
"""Print XML describing config options
:returns: Success indicator
:rtype: int
"""
print(PARAM_XML)
return 0
def map_commands(args):
config = get_environment_config()
maas_url = config['url']
auth_token = config['apikey']
cmd = args[1]
try:
hostname = args[2]
except IndexError:
hostname = config.get('hostname')
commands = {
'on': functools.partial(power_on, maas_url, auth_token, hostname),
'off': functools.partial(power_off, maas_url, auth_token, hostname),
'reset': functools.partial(power_reset, maas_url, auth_token,
hostname),
'status': functools.partial(status, maas_url, auth_token),
'gethosts': show_hosts,
'getconfignames': show_config_names,
'getinfo-devid': show_info_devid,
'getinfo-devname': show_info_devname,
'getinfo-devdescr': show_info_devdescr,
'getinfo-devurl': show_info_devurl,
'getinfo-xml': show_info_xml}
try:
rc = commands[cmd]()
except (FindMachineException, MachinePowerException):
rc = 1
return rc
if __name__ == '__main__':
sys.exit(map_commands(sys.argv))

View File

@ -576,3 +576,56 @@ class UtilsTestCase(unittest.TestCase):
write_file.assert_has_calls(expect_write_calls)
render_template.assert_has_calls(expect_render_calls)
mkdir.assert_called_once_with('/etc/corosync/uidgid.d')
@mock.patch.object(utils, 'config')
@mock.patch('pcmk.commit')
@mock.patch('pcmk.is_resource_present')
def test_configure_maas_stonith_resource(self, is_resource_present,
commit, config):
cfg = {
'maas_url': 'http://maas/2.0',
'maas_credentials': 'apikey'}
is_resource_present.return_value = False
config.side_effect = lambda x: cfg.get(x)
utils.configure_maas_stonith_resource('node1')
cmd = (
"crm configure primitive st-node1 "
"stonith:external/maas "
"params url='http://maas/2.0' apikey='apikey' "
"hostnames=node1 "
"op monitor interval=25 start-delay=25 "
"timeout=25")
commit_calls = [
mock.call(cmd, failure_is_fatal=True),
mock.call(
'crm configure property stonith-enabled=true',
failure_is_fatal=True),
]
commit.assert_has_calls(commit_calls)
@mock.patch.object(utils, 'config')
@mock.patch('pcmk.commit')
@mock.patch('pcmk.is_resource_present')
def test_configure_maas_stonith_resource_duplicate(self,
is_resource_present,
commit, config):
cfg = {
'maas_url': 'http://maas/2.0',
'maas_credentials': 'apikey'}
is_resource_present.return_value = True
config.side_effect = lambda x: cfg.get(x)
utils.configure_maas_stonith_resource('node1')
self.assertFalse(commit.called)
@mock.patch.object(utils, 'config')
@mock.patch('pcmk.commit')
@mock.patch('pcmk.is_resource_present')
def test_configure_maas_stonith_resource_no_url(self,
is_resource_present,
commit, config):
cfg = {
'maas_credentials': 'apikey'}
is_resource_present.return_value = False
config.side_effect = lambda x: cfg.get(x)
with self.assertRaises(ValueError):
utils.configure_maas_stonith_resource('node1')