]> arthur.barton.de Git - netdata.git/blob - plugins.d/python.d.plugin
even more comments
[netdata.git] / plugins.d / python.d.plugin
1 #!/usr/bin/env python3
2
3 import os
4 import sys
5 import time
6
7 try:
8     assert sys.version_info >= (3, 1)
9     import importlib.machinery
10
11     # change this hack below if we want PY_VERSION to be used in modules
12     # import builtins
13     # builtins.PY_VERSION = 3
14     PY_VERSION = 3
15     sys.stderr.write('python.d.plugin: Using python 3\n')
16 except (AssertionError, ImportError):
17     try:
18         import imp
19
20         # change this hack below if we want PY_VERSION to be used in modules
21         # import __builtin__
22         # __builtin__.PY_VERSION = 2
23         PY_VERSION = 2
24         sys.stderr.write('python.d.plugin: Using python 2\n')
25     except (AssertionError, ImportError):
26         sys.stderr.write('python.d.plugin: Cannot start. No importlib.machinery on python3 or lack of imp on python2\n')
27         sys.stdout.write('DISABLE\n')
28         sys.exit(1)
29 try:
30     import yaml
31 except ImportError:
32     sys.stderr.write('python.d.plugin: Cannot find yaml library\n')
33     sys.stdout.write('DISABLE\n')
34     sys.exit(1)
35
36 DEBUG_FLAG = False
37 PROGRAM = "python.d.plugin"
38 MODULE_EXTENSION = ".chart.py"
39 BASE_CONFIG = {'update_every': 10,
40                'priority': 12345,
41                'retries': 0}
42
43
44 class PythonCharts(object):
45     """
46     Main class used to control every python module.
47     """
48     def __init__(self,
49                  interval=None,
50                  modules=None,
51                  modules_path='../python.d/',
52                  modules_configs='../conf.d/',
53                  modules_disabled=None):
54         """
55         :param interval: int
56         :param modules: list
57         :param modules_path: str
58         :param modules_configs: str
59         :param modules_disabled: list
60         """
61
62         if modules is None:
63             modules = []
64         if modules_disabled is None:
65             modules_disabled = []
66
67         self.first_run = True
68         # set configuration directory
69         self.configs = modules_configs
70
71         # load modules
72         loaded_modules = self._load_modules(modules_path, modules, modules_disabled)
73
74         # load configuration files
75         configured_modules = self._load_configs(loaded_modules)
76
77         # good economy and prosperity:
78         self.jobs = self._create_jobs(configured_modules)  # type: list
79         if DEBUG_FLAG and interval is not None:
80             for job in self.jobs:
81                 job.create_timetable(interval)
82
83     @staticmethod
84     def _import_module(path, name=None):
85         """
86         Try to import module using only its path.
87         :param path: str
88         :param name: str
89         :return: object
90         """
91
92         if name is None:
93             name = path.split('/')[-1]
94             if name[-len(MODULE_EXTENSION):] != MODULE_EXTENSION:
95                 return None
96             name = name[:-len(MODULE_EXTENSION)]
97         try:
98             if PY_VERSION == 3:
99                 return importlib.machinery.SourceFileLoader(name, path).load_module()
100             else:
101                 return imp.load_source(name, path)
102         except Exception as e:
103             debug(str(e))
104             return None
105
106     def _load_modules(self, path, modules, disabled):
107         """
108         Load modules from 'modules' list or dynamically every file from 'path' (only .chart.py files)
109         :param path: str
110         :param modules: list
111         :param disabled: list
112         :return: list
113         """
114
115         # check if plugin directory exists
116         if not os.path.isdir(path):
117             debug("cannot find charts directory ", path)
118             sys.stdout.write("DISABLE\n")
119             sys.exit(1)
120
121         # load modules
122         loaded = []
123         if len(modules) > 0:
124             for m in modules:
125                 if m in disabled:
126                     continue
127                 mod = self._import_module(path + m + MODULE_EXTENSION)
128                 if mod is not None:
129                     loaded.append(mod)
130                 else:  # exit if plugin is not found
131                     sys.stdout.write("DISABLE")
132                     sys.stdout.flush()
133                     sys.exit(1)
134         else:
135             # scan directory specified in path and load all modules from there
136             names = os.listdir(path)
137             for mod in names:
138                 if mod.strip(MODULE_EXTENSION) in disabled:
139                     debug("disabling:", mod.strip(MODULE_EXTENSION))
140                     continue
141                 m = self._import_module(path + mod)
142                 if m is not None:
143                     debug("loading chart: '" + path + mod + "'")
144                     loaded.append(m)
145         return loaded
146
147     def _load_configs(self, modules):
148         """
149         Append configuration in list named `config` to every module.
150         For multi-job modules `config` list is created in _parse_config,
151         otherwise it is created here based on BASE_CONFIG prototype with None as identifier.
152         :param modules: list
153         :return: list
154         """
155         for mod in modules:
156             configfile = self.configs + mod.__name__ + ".conf"
157             if os.path.isfile(configfile):
158                 debug("loading chart options: '" + configfile + "'")
159                 try:
160                     setattr(mod,
161                             'config',
162                             self._parse_config(mod, read_config(configfile)))
163                 except Exception as e:
164                     debug("something went wrong while loading configuration", e)
165             else:
166                 debug(mod.__name__ +
167                       ": configuration file '" +
168                       configfile +
169                       "' not found. Using defaults.")
170                 # set config if not found
171                 if not hasattr(mod, 'config'):
172                     mod.config = {None: {}}
173                     for var in BASE_CONFIG:
174                         try:
175                             mod.config[None][var] = getattr(mod, var)
176                         except AttributeError:
177                             mod.config[None][var] = BASE_CONFIG[var]
178         return modules
179
180     @staticmethod
181     def _parse_config(module, config):
182         """
183         Parse configuration file or extract configuration from module file.
184         Example of returned dictionary:
185             config = {'name': {
186                             'update_every': 2,
187                             'retries': 3,
188                             'priority': 30000
189                             'other_val': 123}}
190         :param module: object
191         :param config: dict
192         :return: dict
193         """
194         # get default values
195         defaults = {}
196         for key in BASE_CONFIG:
197             try:
198                 # get defaults from module config
199                 defaults[key] = int(config.pop(key))
200             except (KeyError, ValueError):
201                 try:
202                     # get defaults from module source code
203                     defaults[key] = getattr(module, key)
204                 except (KeyError, ValueError):
205                     # if above failed, get defaults from global dict
206                     defaults[key] = BASE_CONFIG[key]
207
208         # check if there are dict in config dict
209         many_jobs = False
210         for name in config:
211             if type(config[name]) is dict:
212                 many_jobs = True
213                 break
214
215         # assign variables needed by supervisor to every job configuration
216         if many_jobs:
217             for name in config:
218                 for key in defaults:
219                     if key not in config[name]:
220                         config[name][key] = defaults[key]
221         # if only one job is needed, values doesn't have to be in dict (in YAML)
222         else:
223             config = {None: config.copy()}
224             config[None].update(defaults)
225
226         # return dictionary of jobs where every job has BASE_CONFIG variables
227         return config
228
229     @staticmethod
230     def _create_jobs(modules):
231         """
232         Create jobs based on module.config dictionary and module.Service class definition.
233         :param modules: list
234         :return: list
235         """
236         jobs = []
237         for module in modules:
238             for name in module.config:
239                 # register a new job
240                 conf = module.config[name]
241                 try:
242                     job = module.Service(configuration=conf, name=name)
243                 except Exception as e:
244                     debug(module.__name__ +
245                           ": Couldn't start job named " +
246                           str(name) +
247                           ": " +
248                           str(e))
249                     return None
250                 else:
251                     # set execution_name (needed to plot run time graphs)
252                     job.execution_name = module.__name__
253                     if name is not None:
254                         job.execution_name += "_" + name
255                 jobs.append(job)
256
257         return [j for j in jobs if j is not None]
258
259     def _stop(self, job, reason=None):
260         """
261         Stop specified job and remove it from self.jobs list
262         Also notifies user about job failure if DEBUG_FLAG is set
263         :param job: object
264         :param reason: str
265         """
266         self.jobs.remove(job)
267         if reason is None:
268             return
269         elif reason[:3] == "no ":
270             debug("chart '" +
271                   job.execution_name,
272                   "' does not seem to have " +
273                   reason[3:] +
274                   "() function. Disabling it.")
275         elif reason[:7] == "failed ":
276             debug("chart '" +
277                   job.execution_name + "' " +
278                   reason[7:] +
279                   "() function reports failure.")
280         elif reason[:13] == "configuration":
281             debug(job.execution_name,
282                   "configuration file '" +
283                   self.configs +
284                   job.execution_name +
285                   ".conf' not found. Using defaults.")
286         elif reason[:11] == "misbehaving":
287             debug(job.execution_name, "is " + reason)
288
289     def check(self):
290         """
291         Tries to execute check() on every job.
292         This cannot fail thus it is catching every exception
293         If job.check() fails job is stopped
294         """
295         for job in self.jobs:
296             try:
297                 if not job.check():
298                     self._stop(job, "failed check")
299             except AttributeError:
300                 self._stop(job, "no check")
301             except (UnboundLocalError, Exception) as e:
302                 self._stop(job, "misbehaving. Reason: " + str(e))
303
304     def create(self):
305         """
306         Tries to execute create() on every job.
307         This cannot fail thus it is catching every exception.
308         If job.create() fails job is stopped.
309         This is also creating job run time chart.
310         """
311         for job in self.jobs:
312             try:
313                 if not job.create():
314                     self._stop(job, "failed create")
315                 else:
316                     chart = job.execution_name
317                     sys.stdout.write(
318                         "CHART netdata.plugin_pythond_" +
319                         chart +
320                         " '' 'Execution time for " +
321                         chart +
322                         " plugin' 'milliseconds / run' python.d netdata.plugin_python area 145000 " +
323                         str(job.timetable['freq']) +
324                         '\n')
325                     sys.stdout.write("DIMENSION run_time 'run time' absolute 1 1\n\n")
326                     sys.stdout.flush()
327             except AttributeError:
328                 self._stop(job, "no create")
329             except (UnboundLocalError, Exception) as e:
330                 self._stop(job, "misbehaving. Reason: " + str(e))
331
332     def _update_job(self, job):
333         """
334         Tries to execute update() on specified job.
335         This cannot fail thus it is catching every exception.
336         If job.update() returns False, number of retries_left is decremented.
337         If there are no more retries, job is stopped.
338         Job is also stopped if it throws an exception.
339         This is also updating job run time chart.
340         :param job: object
341         """
342         t_start = time.time()
343         # check if it is time to execute job update() function
344         if job.timetable['next'] > t_start:
345             return
346         try:
347             if self.first_run:
348                 since_last = 0
349             else:
350                 since_last = int((t_start - job.timetable['last']) * 1000000)
351             if not job.update(since_last):
352                 if job.retries_left <= 0:
353                     self._stop(job, "update failed")
354                 job.retries_left -= 1
355                 job.timetable['next'] += job.timetable['freq']
356                 return
357         except AttributeError:
358             self._stop(job, "no update")
359             return
360         except (UnboundLocalError, Exception) as e:
361             self._stop(job, "misbehaving. Reason: " + str(e))
362             return
363         t_end = time.time()
364         job.timetable['next'] = t_end - (t_end % job.timetable['freq']) + job.timetable['freq']
365         # draw performance graph
366         sys.stdout.write("BEGIN netdata.plugin_pythond_" + job.execution_name + " " + str(since_last) + '\n')
367         sys.stdout.write("SET run_time = " + str(int((t_end - t_start) * 1000)) + '\n')
368         sys.stdout.write("END\n")
369         sys.stdout.flush()
370         job.timetable['last'] = t_start
371         job.retries_left = job.retries
372         self.first_run = False
373
374     def update(self):
375         """
376         Tries to execute update() on every job by using _update_job()
377         This will stay forever and ever and ever forever and ever it'll be the one...
378         """
379         self.first_run = True
380         while True:
381             next_runs = []
382             for job in self.jobs:
383                 self._update_job(job)
384                 try:
385                     next_runs.append(job.timetable['next'])
386                 except KeyError:
387                     pass
388             if len(next_runs) == 0:
389                 debug("No plugins loaded")
390                 sys.stdout.write("DISABLE\n")
391                 sys.exit(1)
392             time.sleep(min(next_runs) - time.time())
393
394
395 def read_config(path):
396     """
397     Read YAML configuration from specified file
398     :param path: str
399     :return: dict
400     """
401     try:
402         with open(path, 'r') as stream:
403             config = yaml.load(stream)
404     except (OSError, IOError):
405         debug(str(path), "is not a valid configuration file")
406         return None
407     except yaml.YAMLError as e:
408         debug(str(path), "is malformed:", e)
409         return None
410     return config
411
412
413 def debug(*args):
414     """
415     Print message on stderr.
416     """
417     if not DEBUG_FLAG:
418         return
419     sys.stderr.write(PROGRAM + ":")
420     for i in args:
421         sys.stderr.write(" " + str(i))
422     sys.stderr.write("\n")
423     sys.stderr.flush()
424
425
426 def parse_cmdline(directory, *commands):
427     """
428     Parse parameters from command line.
429     :param directory: str
430     :param commands: list of str
431     :return: dict
432     """
433     global DEBUG_FLAG
434     interval = None
435
436     mods = []
437     for cmd in commands[1:]:
438         if cmd == "check":
439             pass
440         elif cmd == "debug" or cmd == "all":
441             DEBUG_FLAG = True
442             # redirect stderr to stdout?
443         elif os.path.isfile(directory + cmd + ".chart.py") or os.path.isfile(directory + cmd):
444             DEBUG_FLAG = True
445             mods.append(cmd.replace(".chart.py", ""))
446         else:
447             DEBUG_FLAG = False
448             try:
449                 interval = int(cmd)
450             except ValueError:
451                 pass
452
453     debug("started from", commands[0], "with options:", *commands[1:])
454     if len(mods) == 0 and DEBUG_FLAG is False:
455         interval = None
456
457     return {'interval': interval,
458             'modules': mods}
459
460
461 # if __name__ == '__main__':
462 def run():
463     """
464     Main program.
465     """
466     global PROGRAM, DEBUG_FLAG
467     PROGRAM = sys.argv[0].split('/')[-1].split('.plugin')[0]
468     # parse env variables
469     # https://github.com/firehol/netdata/wiki/External-Plugins#environment-variables
470     main_dir = os.getenv('NETDATA_PLUGINS_DIR',
471                          os.path.abspath(__file__).strip("python.d.plugin.py"))
472     config_dir = os.getenv('NETDATA_CONFIG_DIR', "/etc/netdata/")
473     interval = os.getenv('NETDATA_UPDATE_EVERY', None)
474
475     # read configuration file
476     disabled = []
477     if config_dir[-1] != '/':
478         config_dir += '/'
479     configfile = config_dir + "python.d.conf"
480
481     conf = read_config(configfile)
482     if conf is not None:
483         try:
484             if str(conf['enable']) is False:
485                 debug("disabled in configuration file")
486                 sys.stdout.write("DISABLE\n")
487                 sys.exit(1)
488         except (KeyError, TypeError):
489             pass
490         try:
491             modules_conf = conf['plugins_config_dir']
492         except (KeyError, TypeError):
493             modules_conf = config_dir + "python.d/"  # default configuration directory
494         try:
495             modules_dir = conf['plugins_dir']
496         except (KeyError, TypeError):
497             modules_dir = main_dir.replace("plugins.d", "python.d")
498         try:
499             interval = conf['interval']
500         except (KeyError, TypeError):
501             pass  # use default interval from NETDATA_UPDATE_EVERY
502         try:
503             DEBUG_FLAG = conf['debug']
504         except (KeyError, TypeError):
505             pass
506         for k, v in conf.items():
507             if k in ("plugins_config_dir", "plugins_dir", "interval", "debug"):
508                 continue
509             if v is False:
510                 disabled.append(k)
511     else:
512         modules_conf = config_dir + "python.d/"
513         modules_dir = main_dir.replace("plugins.d", "python.d")
514
515     # directories should end with '/'
516     if modules_dir[-1] != '/':
517         modules_dir += "/"
518     if modules_conf[-1] != '/':
519         modules_conf += "/"
520
521     # parse passed command line arguments
522     out = parse_cmdline(modules_dir, *sys.argv)
523     modules = out['modules']
524     if out['interval'] is not None:
525         interval = out['interval']
526
527     # configure environment to run modules
528     sys.path.append(modules_dir + "python_modules")  # append path to directory with modules dependencies
529
530     # run plugins
531     charts = PythonCharts(interval, modules, modules_dir, modules_conf, disabled)
532     charts.check()
533     charts.create()
534     charts.update()
535     sys.stdout.write("DISABLE")
536
537
538 if __name__ == '__main__':
539     run()