From 7d128146b3a6a860b6f6fc0d7e58669216a3ae19 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kristoffer=20Gr=C3=B6nlund?= <krig@koru.se>
Date: Mon, 1 Oct 2018 15:25:24 +0200
Subject: [PATCH 1/5] Dev: Add document describing how to write an agent in
 Python

---
 doc/writing-python-agents.md | 89 ++++++++++++++++++++++++++++++++++++
 1 file changed, 89 insertions(+)
 create mode 100644 doc/writing-python-agents.md

diff --git a/doc/writing-python-agents.md b/doc/writing-python-agents.md
new file mode 100644
index 000000000..01c65d1c4
--- /dev/null
+++ b/doc/writing-python-agents.md
@@ -0,0 +1,89 @@
+# 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`
+
+## 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.
+* `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.parameter("argument",
+                       shortdesc="Example argument",
+                       longdesc="This argument is just an example.",
+                       content_type="string",
+                       default="foobar")
+    metadata.action("start", timeout=60)
+    ocf.run(metadata,
+            handlers={
+                "start": start_action
+            })
+
+if __name__ == "__main__":
+    main()
+```

From b0bd2c058b322ceea3c78f533740f29f17b1c94b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kristoffer=20Gr=C3=B6nlund?= <krig@koru.se>
Date: Mon, 1 Oct 2018 15:27:58 +0200
Subject: [PATCH 2/5] Dev: ocf.py: Add helper functions, metadata generation,
 run loop

---
 heartbeat/ocf.py | 257 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 257 insertions(+)

diff --git a/heartbeat/ocf.py b/heartbeat/ocf.py
index 36e7ccccd..e6f1daa92 100644
--- a/heartbeat/ocf.py
+++ b/heartbeat/ocf.py
@@ -135,3 +135,260 @@ def emit(self, record):
 	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 = os.environ.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 = os.environ.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 (os.environ.get("__OCF_ACTION", "") == "monitor" and
+			os.environ.get("OCF_RESKEY_CRM_meta_interval", "") == "0")
+
+
+def parameter(name, default=None):
+	"""
+	Extract the parameter value from the environment
+	"""
+	return os.environ.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 = '<parameter name="' + self.name + '"'
+		if self.unique:
+			ret += ' unique="1"'
+		if self.required:
+			ret += ' required="1"'
+		ret += ">\n"
+		ret += '<longdesc lang="en">' + self.longdesc + '</longdesc>' + "\n"
+		ret += '<shortdesc lang="en">' + self.shortdesc + '</shortdesc>' + "\n"
+		ret += '<content type="' + self.content_type + '"'
+		if self.default is not None:
+			ret += ' default="{}"'.format(self.default)
+		ret += " />\n"
+		ret += "</parameter>\n"
+		return ret
+
+
+class Action(object):
+	def __init__(self, name, timeout, interval, depth):
+		self.name = name
+		self.timeout = timeout
+		self.interval = interval
+		self.depth = depth
+
+	def __str__(self):
+		def opt(s, name, var):
+			if var is not None:
+				if type(var) == int:
+					var = "{}s".format(var)
+				return s + ' {}="{}"'.format(name, var)
+			return s
+		ret = '<action name="{}"'.format(self.name)
+		ret = opt(ret, "timeout", self.timeout)
+		ret = opt(ret, "interval", self.interval)
+		ret = opt(ret, "depth", self.depth)
+		ret += " />\n"
+		return ret
+
+
+class Metadata(object):
+	"""
+	Metadata XML generator helper.
+	"""
+
+	def __init__(self, name, shortdesc, longdesc):
+		self.name = name
+		self.shortdesc = shortdesc
+		self.longdesc = longdesc
+		self.parameters = []
+		self.actions = []
+
+	def parameter(self, name, shortdesc="", longdesc="", content_type="string", unique=False, required=False, default=None):
+		self.parameters.append(Parameter(name=name,
+										 shortdesc=shortdesc,
+										 longdesc=longdesc,
+										 content_type=content_type,
+										 unique=unique,
+										 required=required,
+										 default=default))
+		return self
+
+	def action(self, name, timeout=None, interval=None, depth=None):
+		self.actions.append(Action(name=name,
+								   timeout=timeout,
+								   interval=interval,
+								   depth=depth))
+		return self
+
+	def __str__(self):
+		return """<?xml version="1.0"?>
+<!DOCTYPE resource-agent SYSTEM "ra-api-1.dtd">
+<resource-agent name="{name}">
+<version>1.0</version>
+<longdesc lang="en">
+{longdesc}
+</longdesc>
+<shortdesc lang="en">{shortdesc}</shortdesc>
+
+<parameters>
+{parameters}
+</parameters>
+
+<actions>
+{actions}
+</actions>
+
+</resource-agent>
+""".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 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 = [parameter(p, default_for_parameter(p)) for p in params]
+		rc = func(*arglist)
+		if rc is None:
+			rc = OCF_SUCCESS
+		return rc
+
+	if len(sys.argv) == 2:
+		action = sys.argv[1]
+	else:
+		action = os.environ.get("__OCF_ACTION")
+	if action is None:
+		ocf_exit_reason("No action argument set")
+		sys.exit(OCF_ERR_UNIMPLEMENTED)
+	if action in ('meta-data', 'usage', 'methods'):
+		sys.stdout.write(str(metadata) + "\n")
+		sys.exit(OCF_SUCCESS)
+
+	check_required_params()
+	if action in handlers:
+		rc = call_handler(handlers[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("""<?xml version="1.0"?>
+<!DOCTYPE resource-agent SYSTEM "ra-api-1.dtd">
+<resource-agent name="foo">
+<version>1.0</version>
+<longdesc lang="en">
+longdesc
+</longdesc>
+<shortdesc lang="en">shortdesc</shortdesc>
+
+<parameters>
+
+</parameters>
+
+<actions>
+
+</actions>
+
+</resource-agent>
+""", str(m))
+
+		def test_params_actions(self):
+			m = Metadata("foo", shortdesc="shortdesc", longdesc="longdesc")
+			m.parameter("testparam")
+			m.action("start")
+			self.assertEqual(str(m.actions[0]), '<action name="start" />\n')
+
+	unittest.main()

From 370761418a28aaff91efec7a61b0fbd629703228 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kristoffer=20Gr=C3=B6nlund?= <krig@koru.se>
Date: Mon, 1 Oct 2018 17:39:33 +0200
Subject: [PATCH 3/5] Dev: ocf.py: Add OCF_ACTION variable

---
 doc/writing-python-agents.md |  1 +
 heartbeat/ocf.py             | 26 +++++++++++++-------------
 2 files changed, 14 insertions(+), 13 deletions(-)

diff --git a/doc/writing-python-agents.md b/doc/writing-python-agents.md
index 01c65d1c4..178c3eed2 100644
--- a/doc/writing-python-agents.md
+++ b/doc/writing-python-agents.md
@@ -31,6 +31,7 @@ The following OCF constants are provided:
 * `HA_LOGFACILITY`
 * `HA_LOGFILE`
 * `HA_DEBUGLOG`
+* `OCF_ACTION` -- Set to `$__OCF_ACTION` if set, or to the first command line argument.
 
 ## Logger
 
diff --git a/heartbeat/ocf.py b/heartbeat/ocf.py
index e6f1daa92..582cef6e8 100644
--- a/heartbeat/ocf.py
+++ b/heartbeat/ocf.py
@@ -88,6 +88,10 @@ def emit(self, record):
 
 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")
@@ -144,7 +148,7 @@ def ocf_exit_reason(msg):
 	Allows the OCF agent to provide a string describing
 	why the exit code was returned.
 	"""
-	cookie = os.environ.get("OCF_EXIT_REASON_PREFIX", "ocf-exit-reason:")
+	cookie = env.get("OCF_EXIT_REASON_PREFIX", "ocf-exit-reason:")
 	sys.stderr.write("{}{}\n".format(cookie, msg))
 	sys.stderr.flush()
 	logger.error(msg)
@@ -160,7 +164,7 @@ def _access_check(fn):
 				not os.path.isdir(fn))
 	if _access_check(name):
 		return True
-	path = os.environ.get("PATH", os.defpath).split(os.pathsep)
+	path = env.get("PATH", os.defpath).split(os.pathsep)
 	seen = set()
 	for dir in path:
 		dir = os.path.normcase(dir)
@@ -187,15 +191,15 @@ def is_probe():
 	by Pacemaker to check the status of a possibly
 	not running resource.
 	"""
-	return (os.environ.get("__OCF_ACTION", "") == "monitor" and
-			os.environ.get("OCF_RESKEY_CRM_meta_interval", "") == "0")
+	return (OCF_ACTION == "monitor" and
+			env.get("OCF_RESKEY_CRM_meta_interval", "") == "0")
 
 
 def parameter(name, default=None):
 	"""
 	Extract the parameter value from the environment
 	"""
-	return os.environ.get("OCF_RESKEY_{}".format(name), default)
+	return env.get("OCF_RESKEY_{}".format(name), default)
 
 
 class Parameter(object):
@@ -341,20 +345,16 @@ def default_for_parameter(paramname):
 			rc = OCF_SUCCESS
 		return rc
 
-	if len(sys.argv) == 2:
-		action = sys.argv[1]
-	else:
-		action = os.environ.get("__OCF_ACTION")
-	if action is None:
+	if OCF_ACTION is None:
 		ocf_exit_reason("No action argument set")
 		sys.exit(OCF_ERR_UNIMPLEMENTED)
-	if action in ('meta-data', 'usage', 'methods'):
+	if OCF_ACTION in ('meta-data', 'usage', 'methods'):
 		sys.stdout.write(str(metadata) + "\n")
 		sys.exit(OCF_SUCCESS)
 
 	check_required_params()
-	if action in handlers:
-		rc = call_handler(handlers[action])
+	if OCF_ACTION in handlers:
+		rc = call_handler(handlers[OCF_ACTION])
 		sys.exit(rc)
 	sys.exit(OCF_ERR_UNIMPLEMENTED)
 

From d59b42c0f72809376b67e0797365babdf99df837 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kristoffer=20Gr=C3=B6nlund?= <krig@koru.se>
Date: Thu, 4 Oct 2018 14:41:25 +0200
Subject: [PATCH 4/5] dev: ocf.py: add_parameter() / add_action() name change

---
 doc/writing-python-agents.md | 12 ++++++------
 heartbeat/ocf.py             | 11 +++++++----
 2 files changed, 13 insertions(+), 10 deletions(-)

diff --git a/doc/writing-python-agents.md b/doc/writing-python-agents.md
index 178c3eed2..6c5d5c2ac 100644
--- a/doc/writing-python-agents.md
+++ b/doc/writing-python-agents.md
@@ -74,12 +74,12 @@ def main():
                             longdesc="An example of how to " +
                             "write an agent in Python using the ocf " +
                             "Python library.")
-    metadata.parameter("argument",
-                       shortdesc="Example argument",
-                       longdesc="This argument is just an example.",
-                       content_type="string",
-                       default="foobar")
-    metadata.action("start", timeout=60)
+    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
diff --git a/heartbeat/ocf.py b/heartbeat/ocf.py
index 582cef6e8..b640dfc6e 100644
--- a/heartbeat/ocf.py
+++ b/heartbeat/ocf.py
@@ -263,7 +263,10 @@ def __init__(self, name, shortdesc, longdesc):
 		self.parameters = []
 		self.actions = []
 
-	def parameter(self, name, shortdesc="", longdesc="", content_type="string", unique=False, required=False, default=None):
+	def add_parameter(self, name, shortdesc="", longdesc="", content_type="string", unique=False, required=False, default=None):
+		for param in self.parameters:
+			if param.name == name:
+				raise ValueError("Parameter {} defined twice in metadata".format(name))
 		self.parameters.append(Parameter(name=name,
 										 shortdesc=shortdesc,
 										 longdesc=longdesc,
@@ -273,7 +276,7 @@ def parameter(self, name, shortdesc="", longdesc="", content_type="string", uniq
 										 default=default))
 		return self
 
-	def action(self, name, timeout=None, interval=None, depth=None):
+	def add_action(self, name, timeout=None, interval=None, depth=None):
 		self.actions.append(Action(name=name,
 								   timeout=timeout,
 								   interval=interval,
@@ -387,8 +390,8 @@ def test_noparams_noactions(self):
 
 		def test_params_actions(self):
 			m = Metadata("foo", shortdesc="shortdesc", longdesc="longdesc")
-			m.parameter("testparam")
-			m.action("start")
+			m.add_parameter("testparam")
+			m.add_action("start")
 			self.assertEqual(str(m.actions[0]), '<action name="start" />\n')
 
 	unittest.main()

From ab2de7018055cb99d99d6bebf68758bf755b4d08 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kristoffer=20Gr=C3=B6nlund?= <krig@koru.se>
Date: Thu, 4 Oct 2018 14:43:53 +0200
Subject: [PATCH 5/5] dev: ocf.py: Rename parameter() to get_parameter()

---
 doc/writing-python-agents.md | 2 +-
 heartbeat/ocf.py             | 6 +++---
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/doc/writing-python-agents.md b/doc/writing-python-agents.md
index 6c5d5c2ac..aeb4acbd6 100644
--- a/doc/writing-python-agents.md
+++ b/doc/writing-python-agents.md
@@ -53,7 +53,7 @@ logger.error("Something went terribly wrong.")
 * `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.
-* `parameter`: Looks up the matching `OCF_RESKEY_` environment variable.
+* `get_parameter`: Looks up the matching `OCF_RESKEY_` environment variable.
 * `Metadata`: Class which helps to generate the XML metadata.
 * `run`: OCF run loop implementation.
 
diff --git a/heartbeat/ocf.py b/heartbeat/ocf.py
index b640dfc6e..451a41dec 100644
--- a/heartbeat/ocf.py
+++ b/heartbeat/ocf.py
@@ -195,7 +195,7 @@ def is_probe():
 			env.get("OCF_RESKEY_CRM_meta_interval", "") == "0")
 
 
-def parameter(name, default=None):
+def get_parameter(name, default=None):
 	"""
 	Extract the parameter value from the environment
 	"""
@@ -328,7 +328,7 @@ def run(metadata, handlers):
 
 	def check_required_params():
 		for p in metadata.parameters:
-			if p.required and parameter(p.name) is None:
+			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)
 
@@ -342,7 +342,7 @@ def default_for_parameter(paramname):
 				if meta.name == paramname:
 					return meta.default
 			return None
-		arglist = [parameter(p, default_for_parameter(p)) for p in params]
+		arglist = [get_parameter(p, default_for_parameter(p)) for p in params]
 		rc = func(*arglist)
 		if rc is None:
 			rc = OCF_SUCCESS
