]> arthur.barton.de Git - netdata.git/blob - plugins.d/python.d.plugin
More code cleanup
[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 except AssertionError:
11     sys.stderr.write('python.d.plugin: Not supported python version. Needed python >= 3.1\n')
12     sys.stdout.write('DISABLE\n')
13     sys.exit(1)
14 try:
15     import yaml
16 except ImportError:
17     sys.stderr.write('python.d.plugin: Cannot find yaml library\n')
18     sys.stdout.write('DISABLE\n')
19     sys.exit(1)
20
21 DEBUG_FLAG = False
22 PROGRAM = "python.d.plugin"
23 MODULE_EXTENSION = ".chart.py"
24 BASE_CONFIG = {'update_every': 10,
25                'priority': 12345,
26                'retries': 0}
27
28
29 class PythonCharts(object):
30     def __init__(self,
31                  interval=None,
32                  modules=[],
33                  modules_path='../python.d/',
34                  modules_configs='../conf.d/',
35                  modules_disabled=[]):
36         self.first_run = True
37         # set configuration directory
38         self.configs = modules_configs
39
40         # load modules
41         loaded_modules = self._load_modules(modules_path, modules, modules_disabled)
42
43         # load configuration files
44         configured_modules = self._load_configs(loaded_modules)
45
46         # good economy and prosperity:
47         self.jobs = self._create_jobs(configured_modules)
48         if DEBUG_FLAG and interval is not None:
49             for job in self.jobs:
50                 job.create_timetable(interval)
51
52     @staticmethod
53     def _create_jobs(modules):
54         # module store a definition of Service class
55         # module store configuration in module.config
56         # configs are list of dicts or a dict
57         # one dict is one service
58         # iterate over list of modules and inside one loop iterate over configs
59         jobs = []
60         for module in modules:
61             for name in module.config:
62                 # register a new job
63                 conf = module.config[name]
64                 try:
65                     job = module.Service(configuration=conf, name=name)
66                 except Exception as e:
67                     debug(module.__name__ +
68                           ": Couldn't start job named " +
69                           str(name) +
70                           ": " +
71                           str(e))
72                     return None
73                 else:
74                     # set execution_name (needed to plot run time graphs)
75                     job.execution_name = module.__name__
76                     if name is not None:
77                         job.execution_name += "_" + name
78                 jobs.append(job)
79
80         return [j for j in jobs if j is not None]
81
82     @staticmethod
83     def _import_module(path, name=None):
84         # try to import module using only its path
85         if name is None:
86             name = path.split('/')[-1]
87             if name[-len(MODULE_EXTENSION):] != MODULE_EXTENSION:
88                 return None
89             name = name[:-len(MODULE_EXTENSION)]
90         try:
91             return importlib.machinery.SourceFileLoader(name, path).load_module()
92         except Exception as e:
93             debug(str(e))
94             return None
95
96     def _load_modules(self, path, modules, disabled):
97         # check if plugin directory exists
98         if not os.path.isdir(path):
99             debug("cannot find charts directory ", path)
100             sys.stdout.write("DISABLE\n")
101             sys.exit(1)
102
103         # load modules
104         loaded = []
105         if len(modules) > 0:
106             for m in modules:
107                 if m in disabled:
108                     continue
109                 mod = self._import_module(path + m + MODULE_EXTENSION)
110                 if mod is not None:
111                     loaded.append(mod)
112         else:
113             # scan directory specified in path and load all modules from there
114             names = os.listdir(path)
115             for mod in names:
116                 if mod.strip(MODULE_EXTENSION) in disabled:
117                     debug("disabling:", mod.strip(MODULE_EXTENSION))
118                     continue
119                 m = self._import_module(path + mod)
120                 if m is not None:
121                     debug("loading chart: '" + path + mod + "'")
122                     loaded.append(m)
123         return loaded
124
125     def _load_configs(self, modules):
126         # function loads configuration files to modules
127         for mod in modules:
128             configfile = self.configs + mod.__name__ + ".conf"
129             if os.path.isfile(configfile):
130                 debug("loading chart options: '" + configfile + "'")
131                 try:
132                     setattr(mod,
133                             'config',
134                             self._parse_config(mod, read_config(configfile)))
135                 except Exception as e:
136                     debug("something went wrong while loading configuration", e)
137             else:
138                 debug(mod.__name__ +
139                       ": configuration file '" +
140                       configfile +
141                       "' not found. Using defaults.")
142                 # set config if not found
143                 if not hasattr(mod, 'config'):
144                     mod.config = {None: {}}
145                     for var in BASE_CONFIG:
146                         try:
147                             mod.config[None][var] = getattr(mod, var)
148                         except AttributeError:
149                             mod.config[None][var] = BASE_CONFIG[var]
150         return modules
151
152     @staticmethod
153     def _parse_config(module, config):
154         # get default values
155         defaults = {}
156         for key in BASE_CONFIG:
157             try:
158                 # get defaults from module config
159                 defaults[key] = int(config.pop(key))
160             except (KeyError, ValueError):
161                 try:
162                     # get defaults from module source code
163                     defaults[key] = getattr(module, key)
164                 except (KeyError, ValueError):
165                     # if above failed, get defaults from global dict
166                     defaults[key] = BASE_CONFIG[key]
167
168         # check if there are dict in config dict
169         many_jobs = False
170         for name in config:
171             if type(config[name]) is dict:
172                 many_jobs = True
173                 break
174
175         # assign variables needed by supervisor to every job configuration
176         if many_jobs:
177             for name in config:
178                 for key in defaults:
179                     if key not in config[name]:
180                         config[name][key] = defaults[key]
181         # if only one job is needed, values doesn't have to be in dict (in YAML)
182         else:
183             config = {None: config.copy()}
184             config[None].update(defaults)
185
186         # return dictionary of jobs where every job has BASE_CONFIG variables
187         return config
188
189     def _stop(self, job, reason=None):
190         # modifies self.jobs
191         self.jobs.remove(job)
192         if reason is None:
193             return
194         elif reason[:3] == "no ":
195             debug("chart '" +
196                   job.execution_name,
197                   "' does not seem to have " +
198                   reason[3:] +
199                   "() function. Disabling it.")
200         elif reason[:7] == "failed ":
201             debug("chart '" +
202                   job.execution_name + "' " +
203                   reason[7:] +
204                   "() function reports failure.")
205         elif reason[:13] == "configuration":
206             debug(job.execution_name,
207                   "configuration file '" +
208                   self.configs +
209                   job.execution_name +
210                   ".conf' not found. Using defaults.")
211         elif reason[:11] == "misbehaving":
212             debug(job.execution_name, "is " + reason)
213
214     def check(self):
215         # try to execute check() on every job
216         for job in self.jobs:
217             try:
218                 if not job.check():
219                     self._stop(job, "failed check")
220             except AttributeError:
221                 self._stop(job, "no check")
222             except (UnboundLocalError, Exception) as e:
223                 self._stop(job, "misbehaving. Reason: " + str(e))
224
225     def create(self):
226         # try to execute create() on every job
227         for job in self.jobs:
228             try:
229                 if not job.create():
230                     self._stop(job, "failed create")
231                 else:
232                     chart = job.execution_name
233                     sys.stdout.write(
234                         "CHART netdata.plugin_pythond_" +
235                         chart +
236                         " '' 'Execution time for " +
237                         chart +
238                         " plugin' 'milliseconds / run' python.d netdata.plugin_python area 145000 " +
239                         str(job.timetable['freq']) +
240                         '\n')
241                     sys.stdout.write("DIMENSION run_time 'run time' absolute 1 1\n\n")
242                     sys.stdout.flush()
243             except AttributeError:
244                 self._stop(job, "no create")
245             except (UnboundLocalError, Exception) as e:
246                 self._stop(job, "misbehaving. Reason: " + str(e))
247
248     def _update_job(self, job):
249         # try to execute update() on every job and draw run time graph
250         t_start = time.time()
251         # check if it is time to execute job update() function
252         if job.timetable['next'] > t_start:
253             return
254         try:
255             if self.first_run:
256                 since_last = 0
257             else:
258                 since_last = int((t_start - job.timetable['last']) * 1000000)
259             if not job.update(since_last):
260                 self._stop(job, "update failed")
261                 return
262         except AttributeError:
263             self._stop(job, "no update")
264             return
265         except (UnboundLocalError, Exception) as e:
266             self._stop(job, "misbehaving. Reason: " + str(e))
267             return
268         t_end = time.time()
269         job.timetable['next'] = t_end - (t_end % job.timetable['freq']) + job.timetable['freq']
270         # draw performance graph
271         sys.stdout.write("BEGIN netdata.plugin_pythond_" + job.execution_name + " " + str(since_last) + '\n')
272         sys.stdout.write("SET run_time = " + str(int((t_end - t_start) * 1000)) + '\n')
273         sys.stdout.write("END\n")
274         sys.stdout.flush()
275         job.timetable['last'] = t_start
276         self.first_run = False
277
278     def update(self):
279         # run updates (this will stay forever and ever and ever forever and ever it'll be the one...)
280         self.first_run = True
281         while True:
282             next_runs = []
283             for job in self.jobs:
284                 self._update_job(job)
285                 try:
286                     next_runs.append(job.timetable['next'])
287                 except KeyError:
288                     pass
289             if len(next_runs) == 0:
290                 debug("No plugins loaded")
291                 sys.stdout.write("DISABLE\n")
292                 sys.exit(1)
293             time.sleep(min(next_runs) - time.time())
294
295
296 def read_config(path):
297     try:
298         with open(path, 'r') as stream:
299             config = yaml.load(stream)
300     except IsADirectoryError:
301         debug(str(path), "is a directory")
302         return None
303     except yaml.YAMLError as e:
304         debug(str(path), "is malformed:", e)
305         return None
306     return config
307
308
309 def debug(*args):
310     if not DEBUG_FLAG:
311         return
312     sys.stderr.write(PROGRAM + ":")
313     for i in args:
314         sys.stderr.write(" " + str(i))
315     sys.stderr.write("\n")
316     sys.stderr.flush()
317
318
319 def parse_cmdline(directory, *commands):
320     global DEBUG_FLAG
321     interval = None
322
323     mods = []
324     for cmd in commands[1:]:
325         if cmd == "check":
326             pass
327         elif cmd == "debug" or cmd == "all":
328             DEBUG_FLAG = True
329             # redirect stderr to stdout?
330         elif os.path.isfile(directory + cmd + ".chart.py") or os.path.isfile(directory + cmd):
331             DEBUG_FLAG = True
332             mods.append(cmd.replace(".chart.py", ""))
333         else:
334             try:
335                 interval = int(cmd)
336             except ValueError:
337                 pass
338
339     debug("started from", commands[0], "with options:", *commands[1:])
340     if len(mods) == 0 and DEBUG_FLAG is False:
341         interval = None
342
343     return {'interval': interval,
344             'modules': mods}
345
346
347 # if __name__ == '__main__':
348 def run():
349     global DEBUG_FLAG, PROGRAM
350     DEBUG_FLAG = True
351     PROGRAM = sys.argv[0].split('/')[-1].split('.plugin')[0]
352     # parse env variables
353     # https://github.com/firehol/netdata/wiki/External-Plugins#environment-variables
354     main_dir = os.getenv('NETDATA_PLUGINS_DIR',
355                          os.path.abspath(__file__).strip("python.d.plugin.py"))
356     config_dir = os.getenv('NETDATA_CONFIG_DIR', "/etc/netdata/")
357     interval = os.getenv('NETDATA_UPDATE_EVERY', None)
358
359     # read configuration file
360     disabled = []
361     if config_dir[-1] != '/':
362         config_dir += '/'
363     configfile = config_dir + "python.d.conf"
364
365     try:
366         conf = read_config(configfile)
367         print(conf)
368         try:
369             if str(conf['enable']) is False:
370                 debug("disabled in configuration file")
371                 sys.stdout.write("DISABLE\n")
372                 sys.exit(1)
373         except (KeyError, TypeError):
374             pass
375         try:
376             modules_conf = conf['plugins_config_dir']
377         except KeyError:
378             modules_conf = config_dir + "python.d/"  # default configuration directory
379         try:
380             modules_dir = conf['plugins_dir']
381         except KeyError:
382             modules_dir = main_dir.replace("plugins.d", "python.d")
383         try:
384             interval = conf['interval']
385         except KeyError:
386             pass  # use default interval from NETDATA_UPDATE_EVERY
387         try:
388             DEBUG_FLAG = conf['debug']
389         except KeyError:
390             pass
391         for k, v in conf.items():
392             if k in ("plugins_config_dir", "plugins_dir", "interval", "debug"):
393                 continue
394             if v is False:
395                 disabled.append(k)
396     except FileNotFoundError:
397         modules_conf = config_dir + "python.d/"
398         modules_dir = main_dir.replace("plugins.d", "python.d")
399
400     # directories should end with '/'
401     if modules_dir[-1] != '/':
402         modules_dir += "/"
403     if modules_conf[-1] != '/':
404         modules_conf += "/"
405
406     # parse passed command line arguments
407     out = parse_cmdline(modules_dir, *sys.argv)
408     modules = out['modules']
409     if out['interval'] is not None:
410         interval = out['interval']
411
412     # configure environment to run modules
413     sys.path.append(modules_dir + "python_modules")  # append path to directory with modules dependencies
414
415     # run plugins
416     charts = PythonCharts(interval, modules, modules_dir, modules_conf, disabled)
417     charts.check()
418     charts.create()
419     charts.update()
420     sys.stdout.write("DISABLE")
421
422
423 if __name__ == '__main__':
424     run()