diff --git a/doc/writing-python-agents.md b/doc/writing-python-agents.md new file mode 100644 index 000000000..aeb4acbd6 --- /dev/null +++ b/doc/writing-python-agents.md @@ -0,0 +1,90 @@ +# Resource Agent guide for Python + +## Introduction + +A simple library for authoring resource agents in Python is +provided in the `ocf.py` library. + +Agents written in Python should be ideally compatible both with Python +2.7+ and Python 3.3+. + +The library provides various helper constants and functions, a logging +implementation as well as a run loop and metadata generation facility. + +## Constants + +The following OCF constants are provided: + +* `OCF_SUCCESS` +* `OCF_ERR_GENERIC` +* `OCF_ERR_ARGS` +* `OCF_ERR_UNIMPLEMENTED` +* `OCF_ERR_PERM` +* `OCF_ERR_INSTALLED` +* `OCF_ERR_CONFIGURED` +* `OCF_NOT_RUNNING` +* `OCF_RUNNING_MASTER` +* `OCF_FAILED_MASTER` +* `OCF_RESOURCE_INSTANCE` +* `HA_DEBUG` +* `HA_DATEFMT` +* `HA_LOGFACILITY` +* `HA_LOGFILE` +* `HA_DEBUGLOG` +* `OCF_ACTION` -- Set to `$__OCF_ACTION` if set, or to the first command line argument. + +## Logger + +The `logger` variable holds a Python standard log object with its +formatter set to follow the OCF standard logging format. + +Example: + +``` python + +from ocf import logger + +logger.error("Something went terribly wrong.") + +``` + +## Helper functions + +* `ocf_exit_reason`: Prints the exit error string to stderr. +* `have_binary`: Returns True if the given binary is available. +* `is_true`: Converts an OCF truth value to a Python boolean. +* `get_parameter`: Looks up the matching `OCF_RESKEY_` environment variable. +* `Metadata`: Class which helps to generate the XML metadata. +* `run`: OCF run loop implementation. + +## Run loop and metadata example + +``` python +OCF_FUNCTIONS_DIR="%s/lib/heartbeat" % os.environ.get("OCF_ROOT") +sys.path.append(OCF_FUNCTIONS_DIR) +import ocf + +def start_action(argument): + print("The start action receives the argument as a parameter: {}".format(argument)) + + +def main(): + metadata = ocf.Metadata("example-agent", + shortdesc="This is an example agent", + longdesc="An example of how to " + + "write an agent in Python using the ocf " + + "Python library.") + metadata.add_parameter("argument", + shortdesc="Example argument", + longdesc="This argument is just an example.", + content_type="string", + default="foobar") + metadata.add_action("start", timeout=60) + ocf.run(metadata, + handlers={ + "start": start_action + }) + +if __name__ == "__main__": + main() +``` diff --git a/heartbeat/ocf.py b/heartbeat/ocf.py index 36e7ccccd..451a41dec 100644 --- a/heartbeat/ocf.py +++ b/heartbeat/ocf.py @@ -1,137 +1,397 @@ # # Copyright (c) 2016 Red Hat, Inc, Oyvind Albrigtsen # All Rights Reserved. # # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library 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 this library; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # import sys, os, logging, syslog argv=sys.argv env=os.environ # # Common variables for the OCF Resource Agents supplied by # heartbeat. # OCF_SUCCESS=0 OCF_ERR_GENERIC=1 OCF_ERR_ARGS=2 OCF_ERR_UNIMPLEMENTED=3 OCF_ERR_PERM=4 OCF_ERR_INSTALLED=5 OCF_ERR_CONFIGURED=6 OCF_NOT_RUNNING=7 # Non-standard values. # # OCF does not include the concept of master/slave resources so we # need to extend it so we can discover a resource's complete state. # # OCF_RUNNING_MASTER: # The resource is in "master" mode and fully operational # OCF_FAILED_MASTER: # The resource is in "master" mode but in a failed state # # The extra two values should only be used during a probe. # # Probes are used to discover resources that were started outside of # the CRM and/or left behind if the LRM fails. # # They can be identified in RA scripts by checking for: # [ "${__OCF_ACTION}" = "monitor" -a "${OCF_RESKEY_CRM_meta_interval}" = "0" ] # # Failed "slaves" should continue to use: OCF_ERR_GENERIC # Fully operational "slaves" should continue to use: OCF_SUCCESS # OCF_RUNNING_MASTER=8 OCF_FAILED_MASTER=9 ## Own logger handler that uses old-style syslog handler as otherwise ## everything is sourced from /dev/syslog class SyslogLibHandler(logging.StreamHandler): """ A handler class that correctly push messages into syslog """ def emit(self, record): syslog_level = { logging.CRITICAL:syslog.LOG_CRIT, logging.ERROR:syslog.LOG_ERR, logging.WARNING:syslog.LOG_WARNING, logging.INFO:syslog.LOG_INFO, logging.DEBUG:syslog.LOG_DEBUG, logging.NOTSET:syslog.LOG_DEBUG, }[record.levelno] msg = self.format(record) # syslog.syslog can not have 0x00 character inside or exception # is thrown syslog.syslog(syslog_level, msg.replace("\x00","\n")) return OCF_RESOURCE_INSTANCE = env.get("OCF_RESOURCE_INSTANCE") +OCF_ACTION = env.get("__OCF_ACTION") +if OCF_ACTION is None and len(argv) == 2: + OCF_ACTION = argv[1] + HA_DEBUG = env.get("HA_debug", 0) HA_DATEFMT = env.get("HA_DATEFMT", "%b %d %T ") HA_LOGFACILITY = env.get("HA_LOGFACILITY") HA_LOGFILE = env.get("HA_LOGFILE") HA_DEBUGLOG = env.get("HA_DEBUGLOG") logging.basicConfig() log = logging.getLogger(os.path.basename(argv[0])) log.setLevel(logging.DEBUG) ## add logging to stderr if sys.stdout.isatty(): seh = logging.StreamHandler(stream=sys.stderr) if HA_DEBUG == 0: seh.setLevel(logging.WARNING) sehformatter = logging.Formatter('%(filename)s(%(OCF_RESOURCE_INSTANCE)s)[%(process)s]:\t%(asctime)s%(levelname)s: %(message)s', datefmt=HA_DATEFMT) seh.setFormatter(sehformatter) log.addHandler(seh) ## add logging to syslog if HA_LOGFACILITY: slh = SyslogLibHandler() if HA_DEBUG == 0: slh.setLevel(logging.WARNING) slhformatter = logging.Formatter('%(levelname)s: %(message)s') slh.setFormatter(slhformatter) log.addHandler(slh) ## add logging to file if HA_LOGFILE: lfh = logging.FileHandler(HA_LOGFILE) if HA_DEBUG == 0: lfh.setLevel(logging.WARNING) lfhformatter = logging.Formatter('%(filename)s(%(OCF_RESOURCE_INSTANCE)s)[%(process)s]:\t%(asctime)s%(levelname)s: %(message)s', datefmt=HA_DATEFMT) lfh.setFormatter(lfhformatter) log.addHandler(lfh) ## add debug logging to file if HA_DEBUGLOG and HA_LOGFILE != HA_DEBUGLOG: dfh = logging.FileHandler(HA_DEBUGLOG) if HA_DEBUG == 0: dfh.setLevel(logging.WARNING) dfhformatter = logging.Formatter('%(filename)s(%(OCF_RESOURCE_INSTANCE)s)[%(process)s]:\t%(asctime)s%(levelname)s: %(message)s', datefmt=HA_DATEFMT) dfh.setFormatter(dfhformatter) log.addHandler(dfh) logger = logging.LoggerAdapter(log, {'OCF_RESOURCE_INSTANCE': OCF_RESOURCE_INSTANCE}) + + +def ocf_exit_reason(msg): + """ + Print exit error string to stderr. + + Allows the OCF agent to provide a string describing + why the exit code was returned. + """ + cookie = env.get("OCF_EXIT_REASON_PREFIX", "ocf-exit-reason:") + sys.stderr.write("{}{}\n".format(cookie, msg)) + sys.stderr.flush() + logger.error(msg) + + +def have_binary(name): + """ + True if binary exists, False otherwise. + """ + def _access_check(fn): + return (os.path.exists(fn) and + os.access(fn, os.F_OK | os.X_OK) and + not os.path.isdir(fn)) + if _access_check(name): + return True + path = env.get("PATH", os.defpath).split(os.pathsep) + seen = set() + for dir in path: + dir = os.path.normcase(dir) + if dir not in seen: + seen.add(dir) + name2 = os.path.join(dir, name) + if _access_check(name2): + return True + return False + + +def is_true(val): + """ + Convert an OCF truth value to a + Python boolean. + """ + return val in ("yes", "true", "1", 1, "YES", "TRUE", "ja", "on", "ON", True) + + +def is_probe(): + """ + A probe is defined as a monitor operation + with an interval of zero. This is called + by Pacemaker to check the status of a possibly + not running resource. + """ + return (OCF_ACTION == "monitor" and + env.get("OCF_RESKEY_CRM_meta_interval", "") == "0") + + +def get_parameter(name, default=None): + """ + Extract the parameter value from the environment + """ + return env.get("OCF_RESKEY_{}".format(name), default) + + +class Parameter(object): + def __init__(self, name, shortdesc, longdesc, content_type, unique, required, default): + self.name = name + self.shortdesc = shortdesc + self.longdesc = longdesc + self.content_type = content_type + self.unique = unique + self.required = required + self.default = default + + def __str__(self): + ret = '' + "\n" + ret += '' + self.shortdesc + '' + "\n" + ret += ' + + +1.0 + +{longdesc} + +{shortdesc} + + +{parameters} + + + +{actions} + + + +""".format(name=self.name, + longdesc=self.longdesc, + shortdesc=self.shortdesc, + parameters="".join(str(p) for p in self.parameters), + actions="".join(str(a) for a in self.actions)) + + +def run(metadata, handlers): + """ + Main loop implementation for resource agents. + Does not return. + + Arguments: + + metadata: Metadata structure generated by ocf.Metadata + + handlers: Dict of action name to handler function. + + Handler functions can take parameters as arguments, + the run loop will read parameter values from the + environment and pass to the handler. + """ + import inspect + + def check_required_params(): + for p in metadata.parameters: + if p.required and get_parameter(p.name) is None: + ocf_exit_reason("{}: Required parameter not set".format(p.name)) + sys.exit(OCF_ERR_CONFIGURED) + + def call_handler(func): + if hasattr(inspect, 'signature'): + params = inspect.signature(func).parameters.keys() + else: + params = inspect.getargspec(func).args + def default_for_parameter(paramname): + for meta in metadata.parameters: + if meta.name == paramname: + return meta.default + return None + arglist = [get_parameter(p, default_for_parameter(p)) for p in params] + rc = func(*arglist) + if rc is None: + rc = OCF_SUCCESS + return rc + + if OCF_ACTION is None: + ocf_exit_reason("No action argument set") + sys.exit(OCF_ERR_UNIMPLEMENTED) + if OCF_ACTION in ('meta-data', 'usage', 'methods'): + sys.stdout.write(str(metadata) + "\n") + sys.exit(OCF_SUCCESS) + + check_required_params() + if OCF_ACTION in handlers: + rc = call_handler(handlers[OCF_ACTION]) + sys.exit(rc) + sys.exit(OCF_ERR_UNIMPLEMENTED) + + +if __name__ == "__main__": + import unittest + + class TestMetadata(unittest.TestCase): + def test_noparams_noactions(self): + m = Metadata("foo", shortdesc="shortdesc", longdesc="longdesc") + self.assertEqual(""" + + +1.0 + +longdesc + +shortdesc + + + + + + + + + + +""", str(m)) + + def test_params_actions(self): + m = Metadata("foo", shortdesc="shortdesc", longdesc="longdesc") + m.add_parameter("testparam") + m.add_action("start") + self.assertEqual(str(m.actions[0]), '\n') + + unittest.main()