2 # -*- coding: utf-8 -*-
4 # Description: netdata python modules supervisor
5 # Author: Pawel Krupa (paulfantom)
12 MODULE_EXTENSION = ".chart.py"
13 BASE_CONFIG = {'update_every': 1,
17 # -----------------------------------------------------------------------------
20 PROGRAM = os.path.basename(__file__).replace(".plugin", "")
26 Print message on stderr.
30 sys.stderr.write(PROGRAM + " DEBUG :")
32 sys.stderr.write(" " + str(i))
33 sys.stderr.write("\n")
39 Print message on stderr.
41 sys.stderr.write(PROGRAM + " ERROR :")
43 sys.stderr.write(" " + str(i))
44 sys.stderr.write("\n")
50 Print message on stderr.
52 sys.stderr.write(PROGRAM + " INFO :")
54 sys.stderr.write(" " + str(i))
55 sys.stderr.write("\n")
61 Print message on stderr and exit.
63 sys.stderr.write(PROGRAM + " FATAL :")
65 sys.stderr.write(" " + str(i))
66 sys.stderr.write("\n")
68 sys.stdout.write('DISABLE\n')
72 # -----------------------------------------------------------------------------
73 # globals & python modules management
76 # https://github.com/firehol/netdata/wiki/External-Plugins#environment-variables
77 MODULES_DIR = os.path.abspath(os.getenv('NETDATA_PLUGINS_DIR',
78 os.path.dirname(__file__)) + "/../python.d") + "/"
79 CONFIG_DIR = os.getenv('NETDATA_CONFIG_DIR', "/etc/netdata/")
80 UPDATE_EVERY = os.getenv('NETDATA_UPDATE_EVERY', None)
81 # directories should end with '/'
82 if CONFIG_DIR[-1] != "/":
84 sys.path.append(MODULES_DIR + "python_modules")
88 assert sys.version_info >= (3, 1)
89 import importlib.machinery
91 # change this hack below if we want PY_VERSION to be used in modules
93 # builtins.PY_VERSION = 3
95 info('Using python v3')
96 except (AssertionError, ImportError):
100 # change this hack below if we want PY_VERSION to be used in modules
102 # __builtin__.PY_VERSION = 2
104 info('Using python v2')
106 fatal('Cannot start. No importlib.machinery on python3 or lack of imp on python2')
110 fatal('Cannot find yaml library')
113 class PythonCharts(object):
115 Main class used to control every python module.
120 modules_path='../python.d/',
121 modules_configs='../conf.d/',
122 modules_disabled=None):
124 :param update_every: int
126 :param modules_path: str
127 :param modules_configs: str
128 :param modules_disabled: list
133 if modules_disabled is None:
134 modules_disabled = []
136 self.first_run = True
137 # set configuration directory
138 self.configs = modules_configs
141 loaded_modules = self._load_modules(modules_path, modules, modules_disabled)
143 # load configuration files
144 configured_modules = self._load_configs(loaded_modules)
146 # good economy and prosperity:
147 self.jobs = self._create_jobs(configured_modules) # type: list
149 # enable timetable override like `python.d.plugin mysql debug 1`
150 if DEBUG_FLAG and update_every is not None:
151 for job in self.jobs:
152 job.create_timetable(update_every)
155 def _import_module(path, name=None):
157 Try to import module using only its path.
164 name = path.split('/')[-1]
165 if name[-len(MODULE_EXTENSION):] != MODULE_EXTENSION:
167 name = name[:-len(MODULE_EXTENSION)]
170 return importlib.machinery.SourceFileLoader(name, path).load_module()
172 return imp.load_source(name, path)
173 except Exception as e:
177 def _load_modules(self, path, modules, disabled):
179 Load modules from 'modules' list or dynamically every file from 'path' (only .chart.py files)
182 :param disabled: list
186 # check if plugin directory exists
187 if not os.path.isdir(path):
188 fatal("cannot find charts directory ", path)
196 mod = self._import_module(path + m + MODULE_EXTENSION)
199 else: # exit if plugin is not found
200 fatal('no modules found.')
202 # scan directory specified in path and load all modules from there
203 names = os.listdir(path)
205 if mod.strip(MODULE_EXTENSION) in disabled:
206 error(mod + ": disabled module ", mod.strip(MODULE_EXTENSION))
208 m = self._import_module(path + mod)
210 debug(mod + ": loading module '" + path + mod + "'")
214 def _load_configs(self, modules):
216 Append configuration in list named `config` to every module.
217 For multi-job modules `config` list is created in _parse_config,
218 otherwise it is created here based on BASE_CONFIG prototype with None as identifier.
223 configfile = self.configs + mod.__name__ + ".conf"
224 if os.path.isfile(configfile):
225 debug(mod.__name__ + ": loading module configuration: '" + configfile + "'")
229 self._parse_config(mod, read_config(configfile)))
230 except Exception as e:
231 error(mod.__name__ + ": cannot parse configuration file '" + configfile + "':", str(e))
233 error(mod.__name__ + ": configuration file '" + configfile + "' not found. Using defaults.")
234 # set config if not found
235 if not hasattr(mod, 'config'):
236 mod.config = {None: {}}
237 for var in BASE_CONFIG:
239 mod.config[None][var] = getattr(mod, var)
240 except AttributeError:
241 mod.config[None][var] = BASE_CONFIG[var]
245 def _parse_config(module, config):
247 Parse configuration file or extract configuration from module file.
248 Example of returned dictionary:
254 :param module: object
260 for key in BASE_CONFIG:
262 # get defaults from module config
263 defaults[key] = int(config.pop(key))
264 except (KeyError, ValueError):
266 # get defaults from module source code
267 defaults[key] = getattr(module, key)
268 except (KeyError, ValueError):
269 # if above failed, get defaults from global dict
270 defaults[key] = BASE_CONFIG[key]
272 # check if there are dict in config dict
275 if type(config[name]) is dict:
279 # assign variables needed by supervisor to every job configuration
283 if key not in config[name]:
284 config[name][key] = defaults[key]
285 # if only one job is needed, values doesn't have to be in dict (in YAML)
287 config = {None: config.copy()}
288 config[None].update(defaults)
290 # return dictionary of jobs where every job has BASE_CONFIG variables
294 def _create_jobs(modules):
296 Create jobs based on module.config dictionary and module.Service class definition.
301 for module in modules:
302 for name in module.config:
304 conf = module.config[name]
306 job = module.Service(configuration=conf, name=name)
307 except Exception as e:
308 error(module.__name__ +
309 ("/" + str(name) if name is not None else "") +
310 ": cannot start job: '" +
314 # set chart_name (needed to plot run time graphs)
315 job.chart_name = module.__name__
317 job.chart_name += "_" + name
319 debug(module.__name__ + ("/" + str(name) if name is not None else "") + ": job added")
321 return [j for j in jobs if j is not None]
323 def _stop(self, job, reason=None):
325 Stop specified job and remove it from self.jobs list
326 Also notifies user about job failure if DEBUG_FLAG is set
330 prefix = job.__module__
331 if job.name is not None:
332 prefix += "/" + job.name
335 self.jobs.remove(job)
338 elif reason[:3] == "no ":
340 "does not seem to have " +
342 "() function. Disabling it.")
343 elif reason[:7] == "failed ":
346 "() function reports failure.")
347 elif reason[:13] == "configuration":
349 "configuration file '" +
352 ".conf' not found. Using defaults.")
353 elif reason[:11] == "misbehaving":
354 error(prefix + "is " + reason)
358 Tries to execute check() on every job.
359 This cannot fail thus it is catching every exception
360 If job.check() fails job is stopped
363 while i < len(self.jobs):
367 self._stop(job, "failed check")
370 except AttributeError:
371 self._stop(job, "no check")
372 except (UnboundLocalError, Exception) as e:
373 self._stop(job, "misbehaving. Reason: " + str(e))
377 Tries to execute create() on every job.
378 This cannot fail thus it is catching every exception.
379 If job.create() fails job is stopped.
380 This is also creating job run time chart.
383 while i < len(self.jobs):
387 self._stop(job, "failed create")
389 chart = job.chart_name
391 "CHART netdata.plugin_pythond_" +
393 " '' 'Execution time for " +
395 " plugin' 'milliseconds / run' python.d netdata.plugin_python area 145000 " +
396 str(job.timetable['freq']) +
398 sys.stdout.write("DIMENSION run_time 'run time' absolute 1 1\n\n")
401 except AttributeError:
402 self._stop(job, "no create")
403 except (UnboundLocalError, Exception) as e:
404 self._stop(job, "misbehaving. Reason: " + str(e))
406 def _update_job(self, job):
408 Tries to execute update() on specified job.
409 This cannot fail thus it is catching every exception.
410 If job.update() returns False, number of retries_left is decremented.
411 If there are no more retries, job is stopped.
412 Job is also stopped if it throws an exception.
413 This is also updating job run time chart.
414 Return False if job is stopped
418 t_start = time.time()
419 # check if it is time to execute job update() function
420 if job.timetable['next'] > t_start:
426 since_last = int((t_start - job.timetable['last']) * 1000000)
427 if not job.update(since_last):
428 if job.retries_left <= 0:
429 self._stop(job, "update failed")
431 job.retries_left -= 1
432 job.timetable['next'] += job.timetable['freq']
434 except AttributeError:
435 self._stop(job, "no update")
437 except (UnboundLocalError, Exception) as e:
438 self._stop(job, "misbehaving. Reason: " + str(e))
441 job.timetable['next'] = t_end - (t_end % job.timetable['freq']) + job.timetable['freq']
442 # draw performance graph
443 sys.stdout.write("BEGIN netdata.plugin_pythond_" + job.chart_name + " " + str(since_last) + '\n')
444 sys.stdout.write("SET run_time = " + str(int((t_end - t_start) * 1000)) + '\n')
445 sys.stdout.write("END\n")
447 job.timetable['last'] = t_start
448 job.retries_left = job.retries
449 self.first_run = False
454 Tries to execute update() on every job by using _update_job()
455 This will stay forever and ever and ever forever and ever it'll be the one...
457 self.first_run = True
461 while i < len(self.jobs):
463 if self._update_job(job):
465 next_runs.append(job.timetable['next'])
469 if len(next_runs) == 0:
470 fatal('no python.d modules loaded.')
471 time.sleep(min(next_runs) - time.time())
474 def read_config(path):
476 Read YAML configuration from specified file
481 with open(path, 'r') as stream:
482 config = yaml.load(stream)
483 except (OSError, IOError):
484 error(str(path), "is not a valid configuration file")
486 except yaml.YAMLError as e:
487 error(str(path), "is malformed:", e)
492 def parse_cmdline(directory, *commands):
494 Parse parameters from command line.
495 :param directory: str
496 :param commands: list of str
503 for cmd in commands[1:]:
506 elif cmd == "debug" or cmd == "all":
508 # redirect stderr to stdout?
509 elif os.path.isfile(directory + cmd + ".chart.py") or os.path.isfile(directory + cmd):
511 mods.append(cmd.replace(".chart.py", ""))
514 UPDATE_EVERY = int(cmd)
518 debug("started from", commands[0], "with options:", *commands[1:])
523 # if __name__ == '__main__':
530 # read configuration file
532 configfile = CONFIG_DIR + "python.d.conf"
534 update_every = UPDATE_EVERY
535 conf = read_config(configfile)
538 # exit the whole plugin when 'enable: no' is set in 'python.d.conf'
539 if str(conf['enable']) is False:
540 fatal('disabled in configuration file.\n')
541 except (KeyError, TypeError):
544 update_every = conf['update_every']
545 except (KeyError, TypeError):
546 pass # use default update_every from NETDATA_UPDATE_EVERY
548 DEBUG_FLAG = conf['debug']
549 except (KeyError, TypeError):
551 for k, v in conf.items():
552 if k in ("update_every", "debug", "enable"):
557 # parse passed command line arguments
558 modules = parse_cmdline(MODULES_DIR, *sys.argv)
559 info("MODULES_DIR='" + MODULES_DIR +
560 "', CONFIG_DIR='" + CONFIG_DIR +
561 "', UPDATE_EVERY=" + str(UPDATE_EVERY) +
562 ", ONLY_MODULES=" + str(modules))
565 charts = PythonCharts(update_every, modules, MODULES_DIR, CONFIG_DIR + "python.d/", disabled)
572 if __name__ == '__main__':