1#
2# Copyright (c) 2016 Red Hat, Inc, Oyvind Albrigtsen
3#                    All Rights Reserved.
4#
5#
6# This library is free software; you can redistribute it and/or
7# modify it under the terms of the GNU Lesser General Public
8# License as published by the Free Software Foundation; either
9# version 2.1 of the License, or (at your option) any later version.
10#
11# This library is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14# Lesser General Public License for more details.
15#
16# You should have received a copy of the GNU Lesser General Public
17# License along with this library; if not, write to the Free Software
18# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
19#
20
21import sys, os, logging, syslog
22
23argv=sys.argv
24env=os.environ
25
26#
27# 	Common variables for the OCF Resource Agents supplied by
28# 	heartbeat.
29#
30
31OCF_SUCCESS=0
32OCF_ERR_GENERIC=1
33OCF_ERR_ARGS=2
34OCF_ERR_UNIMPLEMENTED=3
35OCF_ERR_PERM=4
36OCF_ERR_INSTALLED=5
37OCF_ERR_CONFIGURED=6
38OCF_NOT_RUNNING=7
39
40# Non-standard values.
41#
42# OCF does not include the concept of master/slave resources so we
43#   need to extend it so we can discover a resource's complete state.
44#
45# OCF_RUNNING_MASTER:
46#    The resource is in "master" mode and fully operational
47# OCF_FAILED_MASTER:
48#    The resource is in "master" mode but in a failed state
49#
50# The extra two values should only be used during a probe.
51#
52# Probes are used to discover resources that were started outside of
53#    the CRM and/or left behind if the LRM fails.
54#
55# They can be identified in RA scripts by checking for:
56#   [ "${__OCF_ACTION}" = "monitor" -a "${OCF_RESKEY_CRM_meta_interval}" = "0" ]
57#
58# Failed "slaves" should continue to use: OCF_ERR_GENERIC
59# Fully operational "slaves" should continue to use: OCF_SUCCESS
60#
61OCF_RUNNING_MASTER=8
62OCF_FAILED_MASTER=9
63
64
65## Own logger handler that uses old-style syslog handler as otherwise
66## everything is sourced from /dev/syslog
67class SyslogLibHandler(logging.StreamHandler):
68	"""
69	A handler class that correctly push messages into syslog
70	"""
71	def emit(self, record):
72		syslog_level = {
73			logging.CRITICAL:syslog.LOG_CRIT,
74			logging.ERROR:syslog.LOG_ERR,
75			logging.WARNING:syslog.LOG_WARNING,
76			logging.INFO:syslog.LOG_INFO,
77			logging.DEBUG:syslog.LOG_DEBUG,
78			logging.NOTSET:syslog.LOG_DEBUG,
79		}[record.levelno]
80
81		msg = self.format(record)
82
83		# syslog.syslog can not have 0x00 character inside or exception
84		# is thrown
85		syslog.syslog(syslog_level, msg.replace("\x00","\n"))
86		return
87
88
89OCF_RESOURCE_INSTANCE = env.get("OCF_RESOURCE_INSTANCE")
90
91OCF_ACTION = env.get("__OCF_ACTION")
92if OCF_ACTION is None and len(argv) == 2:
93	OCF_ACTION = argv[1]
94
95HA_DEBUG = env.get("HA_debug", 0)
96HA_DATEFMT = env.get("HA_DATEFMT", "%b %d %T ")
97HA_LOGFACILITY = env.get("HA_LOGFACILITY")
98HA_LOGFILE = env.get("HA_LOGFILE")
99HA_DEBUGLOG = env.get("HA_DEBUGLOG")
100
101log = logging.getLogger(os.path.basename(argv[0]))
102log.setLevel(logging.DEBUG)
103
104## add logging to stderr
105if sys.stdout.isatty():
106	seh = logging.StreamHandler(stream=sys.stderr)
107	if HA_DEBUG == 0:
108		seh.setLevel(logging.WARNING)
109	sehformatter = logging.Formatter('%(filename)s(%(OCF_RESOURCE_INSTANCE)s)[%(process)s]:\t%(asctime)s%(levelname)s: %(message)s', datefmt=HA_DATEFMT)
110	seh.setFormatter(sehformatter)
111	log.addHandler(seh)
112
113## add logging to syslog
114if HA_LOGFACILITY:
115	slh = SyslogLibHandler()
116	if HA_DEBUG == 0:
117		slh.setLevel(logging.WARNING)
118	slhformatter = logging.Formatter('%(levelname)s: %(message)s')
119	slh.setFormatter(slhformatter)
120	log.addHandler(slh)
121
122## add logging to file
123if HA_LOGFILE:
124	lfh = logging.FileHandler(HA_LOGFILE)
125	if HA_DEBUG == 0:
126		lfh.setLevel(logging.WARNING)
127	lfhformatter = logging.Formatter('%(filename)s(%(OCF_RESOURCE_INSTANCE)s)[%(process)s]:\t%(asctime)s%(levelname)s: %(message)s', datefmt=HA_DATEFMT)
128	lfh.setFormatter(lfhformatter)
129	log.addHandler(lfh)
130
131## add debug logging to file
132if HA_DEBUGLOG and HA_LOGFILE != HA_DEBUGLOG:
133	dfh = logging.FileHandler(HA_DEBUGLOG)
134	if HA_DEBUG == 0:
135		dfh.setLevel(logging.WARNING)
136	dfhformatter = logging.Formatter('%(filename)s(%(OCF_RESOURCE_INSTANCE)s)[%(process)s]:\t%(asctime)s%(levelname)s: %(message)s', datefmt=HA_DATEFMT)
137	dfh.setFormatter(dfhformatter)
138	log.addHandler(dfh)
139
140logger = logging.LoggerAdapter(log, {'OCF_RESOURCE_INSTANCE': OCF_RESOURCE_INSTANCE})
141
142
143_exit_reason_set = False
144
145def ocf_exit_reason(msg):
146	"""
147	Print exit error string to stderr.
148
149	Allows the OCF agent to provide a string describing
150	why the exit code was returned.
151	"""
152	global _exit_reason_set
153	cookie = env.get("OCF_EXIT_REASON_PREFIX", "ocf-exit-reason:")
154	sys.stderr.write("{}{}\n".format(cookie, msg))
155	sys.stderr.flush()
156	logger.error(msg)
157	_exit_reason_set = True
158
159
160def have_binary(name):
161	"""
162	True if binary exists, False otherwise.
163	"""
164	def _access_check(fn):
165		return (os.path.exists(fn) and
166				os.access(fn, os.F_OK | os.X_OK) and
167				not os.path.isdir(fn))
168	if _access_check(name):
169		return True
170	path = env.get("PATH", os.defpath).split(os.pathsep)
171	seen = set()
172	for dir in path:
173		dir = os.path.normcase(dir)
174		if dir not in seen:
175			seen.add(dir)
176			name2 = os.path.join(dir, name)
177			if _access_check(name2):
178				return True
179	return False
180
181
182def is_true(val):
183	"""
184	Convert an OCF truth value to a
185	Python boolean.
186	"""
187	return val in ("yes", "true", "1", 1, "YES", "TRUE", "ja", "on", "ON", True)
188
189
190def is_probe():
191	"""
192	A probe is defined as a monitor operation
193	with an interval of zero. This is called
194	by Pacemaker to check the status of a possibly
195	not running resource.
196	"""
197	return (OCF_ACTION == "monitor" and
198			( env.get("OCF_RESKEY_CRM_meta_interval", "") == "0" or
199			  env.get("OCF_RESKEY_CRM_meta_interval", "") == "" ))
200
201
202def get_parameter(name, default=None):
203	"""
204	Extract the parameter value from the environment
205	"""
206	return env.get("OCF_RESKEY_{}".format(name), default)
207
208
209def distro():
210	"""
211	Return name of distribution/platform.
212
213	If possible, returns "name/version", else
214	just "name".
215	"""
216	import subprocess
217	import platform
218	try:
219		ret = subprocess.check_output(["lsb_release", "-si"])
220		if type(ret) != str:
221			ret = ret.decode()
222		distro = ret.strip()
223		ret = subprocess.check_output(["lsb_release", "-sr"])
224		if type(ret) != str:
225			ret = ret.decode()
226		version = ret.strip()
227		return "{}/{}".format(distro, version)
228	except Exception:
229		if os.path.exists("/etc/debian_version"):
230			return "Debian"
231		if os.path.exists("/etc/SuSE-release"):
232			return "SUSE"
233		if os.path.exists("/etc/redhat-release"):
234			return "Redhat"
235	return platform.system()
236
237
238class Parameter(object):
239	def __init__(self, name, shortdesc, longdesc, content_type, unique, required, default):
240		self.name = name
241		self.shortdesc = shortdesc
242		self.longdesc = longdesc
243		self.content_type = content_type
244		self.unique = unique
245		self.required = required
246		self.default = default
247
248	def __str__(self):
249		return self.to_xml()
250
251	def to_xml(self):
252		ret = '<parameter name="' + self.name + '"'
253		if self.unique:
254			ret += ' unique="1"'
255		if self.required:
256			ret += ' required="1"'
257		ret += ">\n"
258		ret += '<longdesc lang="en">' + self.longdesc + '</longdesc>' + "\n"
259		ret += '<shortdesc lang="en">' + self.shortdesc + '</shortdesc>' + "\n"
260		ret += '<content type="' + self.content_type + '"'
261		if self.default is not None:
262			ret += ' default="{}"'.format(self.default)
263		ret += " />\n"
264		ret += "</parameter>\n"
265		return ret
266
267
268
269class Action(object):
270	def __init__(self, name, timeout, interval, depth, role):
271		self.name = name
272		self.timeout = timeout
273		self.interval = interval
274		self.depth = depth
275		self.role = role
276
277	def __str__(self):
278		return self.to_xml()
279
280	def to_xml(self):
281		def opt(s, name, var):
282			if var is not None:
283				if type(var) == int and name in ("timeout", "interval"):
284					var = "{}s".format(var)
285				return s + ' {}="{}"'.format(name, var)
286			return s
287		ret = '<action name="{}"'.format(self.name)
288		ret = opt(ret, "timeout", self.timeout)
289		ret = opt(ret, "interval", self.interval)
290		ret = opt(ret, "depth", self.depth)
291		ret = opt(ret, "role", self.role)
292		ret += " />\n"
293		return ret
294
295
296class Agent(object):
297	"""
298	OCF Resource Agent metadata XML generator helper.
299
300	Use add_parameter/add_action to define parameters
301	and actions for the agent. Then call run() to
302	start the agent main loop.
303
304	See doc/dev-guides/writing-python-agents.md for an example
305	of how to use it.
306	"""
307
308	def __init__(self, name, shortdesc, longdesc):
309		self.name = name
310		self.shortdesc = shortdesc
311		self.longdesc = longdesc
312		self.parameters = []
313		self.actions = []
314		self._handlers = {}
315
316	def add_parameter(self, name, shortdesc="", longdesc="", content_type="string", unique=False, required=False, default=None):
317		for param in self.parameters:
318			if param.name == name:
319				raise ValueError("Parameter {} defined twice in metadata".format(name))
320		self.parameters.append(Parameter(name=name,
321										 shortdesc=shortdesc,
322										 longdesc=longdesc,
323										 content_type=content_type,
324										 unique=unique,
325										 required=required,
326										 default=default))
327		return self
328
329	def add_action(self, name, timeout=None, interval=None, depth=None, role=None, handler=None):
330		self.actions.append(Action(name=name,
331								   timeout=timeout,
332								   interval=interval,
333								   depth=depth,
334								   role=role))
335		if handler is not None:
336			self._handlers[name] = handler
337		return self
338
339	def __str__(self):
340		return self.to_xml()
341
342	def to_xml(self):
343		return """<?xml version="1.0"?>
344<!DOCTYPE resource-agent SYSTEM "ra-api-1.dtd">
345<resource-agent name="{name}">
346<version>1.0</version>
347<longdesc lang="en">
348{longdesc}
349</longdesc>
350<shortdesc lang="en">{shortdesc}</shortdesc>
351
352<parameters>
353{parameters}
354</parameters>
355
356<actions>
357{actions}
358</actions>
359
360</resource-agent>
361""".format(name=self.name,
362		   longdesc=self.longdesc,
363		   shortdesc=self.shortdesc,
364		   parameters="".join(p.to_xml() for p in self.parameters),
365		   actions="".join(a.to_xml() for a in self.actions))
366
367	def run(self):
368		run(self)
369
370
371def run(agent, handlers=None):
372	"""
373	Main loop implementation for resource agents.
374	Does not return.
375
376	Arguments:
377
378	agent: Agent object.
379
380	handlers: Dict of action name to handler function.
381
382	Handler functions can take parameters as arguments,
383	the run loop will read parameter values from the
384	environment and pass to the handler.
385	"""
386	import inspect
387
388	agent._handlers.update(handlers or {})
389	handlers = agent._handlers
390
391	def check_required_params():
392		for p in agent.parameters:
393			if p.required and get_parameter(p.name) is None:
394				ocf_exit_reason("{}: Required parameter not set".format(p.name))
395				sys.exit(OCF_ERR_CONFIGURED)
396
397	def call_handler(func):
398		if hasattr(inspect, 'signature'):
399			params = inspect.signature(func).parameters.keys()
400		else:
401			params = inspect.getargspec(func).args
402			if 'self' in params: params.remove('self')
403		def value_for_parameter(param):
404			val = get_parameter(param)
405			if val is not None:
406				return val
407			for p in agent.parameters:
408				if p.name == param:
409					return p.default
410		arglist = [value_for_parameter(p) for p in params]
411		try:
412			rc = func(*arglist)
413			if rc is None:
414				rc = OCF_SUCCESS
415			return rc
416		except Exception as err:
417			if not _exit_reason_set:
418				ocf_exit_reason(str(err))
419			else:
420				logger.error(str(err))
421			return OCF_ERR_GENERIC
422
423	meta_data_action = False
424	for action in agent.actions:
425		if action.name == "meta-data":
426			meta_data_action = True
427			break
428	if not meta_data_action:
429		agent.add_action("meta-data", timeout=10)
430
431	if len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help"):
432		sys.stdout.write("usage: %s {%s}\n\n" % (sys.argv[0], "|".join(sorted(handlers.keys()))) +
433		                 "Expects to have a fully populated OCF RA compliant environment set.\n")
434		sys.exit(OCF_SUCCESS)
435
436	if OCF_ACTION is None:
437		ocf_exit_reason("No action argument set")
438		sys.exit(OCF_ERR_UNIMPLEMENTED)
439	if OCF_ACTION in ('meta-data', 'usage', 'methods'):
440		sys.stdout.write(agent.to_xml() + "\n")
441		sys.exit(OCF_SUCCESS)
442
443	check_required_params()
444	if OCF_ACTION in handlers:
445		rc = call_handler(handlers[OCF_ACTION])
446		sys.exit(rc)
447	sys.exit(OCF_ERR_UNIMPLEMENTED)
448
449
450if __name__ == "__main__":
451	import unittest
452
453	class TestMetadata(unittest.TestCase):
454		def test_noparams_noactions(self):
455			m = Agent("foo", shortdesc="shortdesc", longdesc="longdesc")
456			self.assertEqual("""<?xml version="1.0"?>
457<!DOCTYPE resource-agent SYSTEM "ra-api-1.dtd">
458<resource-agent name="foo">
459<version>1.0</version>
460<longdesc lang="en">
461longdesc
462</longdesc>
463<shortdesc lang="en">shortdesc</shortdesc>
464
465<parameters>
466
467</parameters>
468
469<actions>
470
471</actions>
472
473</resource-agent>
474""", str(m))
475
476		def test_params_actions(self):
477			m = Agent("foo", shortdesc="shortdesc", longdesc="longdesc")
478			m.add_parameter("testparam")
479			m.add_action("start")
480			self.assertEqual(str(m.actions[0]), '<action name="start" />\n')
481
482	unittest.main()
483