]> git.ipfire.org Git - collecty.git/blame - src/collecty/plugins/base.py
Migrate to Python 3
[collecty.git] / src / collecty / plugins / base.py
CommitLineData
f37913e8 1#!/usr/bin/python3
eed405de
MT
2###############################################################################
3# #
4# collecty - A system statistics collection daemon for IPFire #
5# Copyright (C) 2012 IPFire development team #
6# #
7# This program is free software: you can redistribute it and/or modify #
8# it under the terms of the GNU General Public License as published by #
9# the Free Software Foundation, either version 3 of the License, or #
10# (at your option) any later version. #
11# #
12# This program is distributed in the hope that it will be useful, #
13# but WITHOUT ANY WARRANTY; without even the implied warranty of #
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
15# GNU General Public License for more details. #
16# #
17# You should have received a copy of the GNU General Public License #
18# along with this program. If not, see <http://www.gnu.org/licenses/>. #
19# #
20###############################################################################
21
72364063 22import datetime
4ac0cdf0 23import logging
965a9c51 24import math
4ac0cdf0
MT
25import os
26import rrdtool
c968f6d9 27import tempfile
49ce926e 28import threading
4ac0cdf0 29import time
f37913e8 30import unicodedata
4ac0cdf0 31
4ac0cdf0 32from ..constants import *
eed405de
MT
33from ..i18n import _
34
4be39bf9
MT
35class Timer(object):
36 def __init__(self, timeout, heartbeat=1):
37 self.timeout = timeout
38 self.heartbeat = heartbeat
39
e746a56e
MT
40 self.delay = 0
41
4be39bf9
MT
42 self.reset()
43
e746a56e 44 def reset(self, delay=0):
4be39bf9
MT
45 # Save start time.
46 self.start = time.time()
47
e746a56e
MT
48 self.delay = delay
49
4be39bf9
MT
50 # Has this timer been killed?
51 self.killed = False
52
53 @property
54 def elapsed(self):
e746a56e 55 return time.time() - self.start - self.delay
4be39bf9
MT
56
57 def cancel(self):
58 self.killed = True
59
60 def wait(self):
61 while self.elapsed < self.timeout and not self.killed:
62 time.sleep(self.heartbeat)
63
64 return self.elapsed > self.timeout
65
66
f37913e8
MT
67class PluginRegistration(type):
68 plugins = {}
69
70 def __init__(plugin, name, bases, dict):
71 type.__init__(plugin, name, bases, dict)
72
73 # The main class from which is inherited is not registered
74 # as a plugin.
75 if name == "Plugin":
76 return
77
78 if not all((plugin.name, plugin.description)):
79 raise RuntimeError(_("Plugin is not properly configured: %s") % plugin)
80
81 PluginRegistration.plugins[plugin.name] = plugin
82
83
84def get():
85 """
86 Returns a list with all automatically registered plugins.
87 """
88 return PluginRegistration.plugins.values()
89
90class Plugin(object, metaclass=PluginRegistration):
4ac0cdf0
MT
91 # The name of this plugin.
92 name = None
93
94 # A description for this plugin.
95 description = None
96
b1ea4956
MT
97 # Templates which can be used to generate a graph out of
98 # the data from this data source.
99 templates = []
100
72364063
MT
101 # The default interval for all plugins
102 interval = 60
4ac0cdf0 103
eed405de 104 def __init__(self, collecty, **kwargs):
eed405de
MT
105 self.collecty = collecty
106
4ac0cdf0
MT
107 # Check if this plugin was configured correctly.
108 assert self.name, "Name of the plugin is not set: %s" % self.name
109 assert self.description, "Description of the plugin is not set: %s" % self.description
4ac0cdf0
MT
110
111 # Initialize the logger.
112 self.log = logging.getLogger("collecty.plugins.%s" % self.name)
113 self.log.propagate = 1
eed405de 114
eed405de
MT
115 self.data = []
116
269f74cd
MT
117 # Run some custom initialization.
118 self.init(**kwargs)
119
0ee0c42d 120 self.log.debug(_("Successfully initialized %s") % self.__class__.__name__)
4ac0cdf0 121
73db5226 122 @property
72364063 123 def path(self):
73db5226 124 """
72364063
MT
125 Returns the name of the sub directory in which all RRD files
126 for this plugin should be stored in.
73db5226
MT
127 """
128 return self.name
129
72364063
MT
130 ### Basic methods
131
132 def init(self, **kwargs):
4ac0cdf0 133 """
72364063 134 Do some custom initialization stuff here.
4ac0cdf0 135 """
72364063
MT
136 pass
137
138 def collect(self):
139 """
140 Gathers the statistical data, this plugin collects.
141 """
142 time_start = time.time()
143
144 # Run through all objects of this plugin and call the collect method.
145 for o in self.objects:
146 now = datetime.datetime.utcnow()
147 try:
148 result = o.collect()
f648421a
MT
149
150 if isinstance(result, tuple) or isinstance(result, list):
151 result = ":".join(("%s" % e for e in result))
72364063
MT
152 except:
153 self.log.warning(_("Unhandled exception in %s.collect()") % o, exc_info=True)
154 continue
155
156 if not result:
157 self.log.warning(_("Received empty result: %s") % o)
158 continue
159
160 self.log.debug(_("Collected %s: %s") % (o, result))
161
162 # Add the object to the write queue so that the data is written
163 # to the databases later.
164 self.collecty.write_queue.add(o, now, result)
165
166 # Returns the time this function took to complete.
49c1b8fd 167 delay = time.time() - time_start
72364063 168
49c1b8fd
MT
169 # Log some warning when a collect method takes too long to return some data
170 if delay >= 60:
171 self.log.warning(_("A worker thread was stalled for %.4fs") % delay)
4ac0cdf0 172
c968f6d9
MT
173 def get_object(self, id):
174 for object in self.objects:
175 if not object.id == id:
176 continue
177
178 return object
179
0308c0f3 180 def get_template(self, template_name, object_id):
c968f6d9
MT
181 for template in self.templates:
182 if not template.name == template_name:
183 continue
184
0308c0f3 185 return template(self, object_id)
c968f6d9
MT
186
187 def generate_graph(self, template_name, object_id="default", **kwargs):
0308c0f3 188 template = self.get_template(template_name, object_id=object_id)
c968f6d9
MT
189 if not template:
190 raise RuntimeError("Could not find template %s" % template_name)
191
192 time_start = time.time()
193
0308c0f3 194 graph = template.generate_graph(**kwargs)
c968f6d9
MT
195
196 duration = time.time() - time_start
0ee0c42d 197 self.log.debug(_("Generated graph %s in %.1fms") \
c968f6d9
MT
198 % (template, duration * 1000))
199
200 return graph
201
72364063
MT
202
203class Object(object):
204 # The schema of the RRD database.
205 rrd_schema = None
206
207 # RRA properties.
208 rra_types = ["AVERAGE", "MIN", "MAX"]
209 rra_timespans = [3600, 86400, 604800, 2678400, 31622400]
210 rra_rows = 2880
211
212 def __init__(self, plugin, *args, **kwargs):
213 self.plugin = plugin
214
215 # Indicates if this object has collected its data
216 self.collected = False
217
218 # Initialise this object
219 self.init(*args, **kwargs)
220
221 # Create the database file.
222 self.create()
223
224 def __repr__(self):
225 return "<%s>" % self.__class__.__name__
4ac0cdf0 226
965a9c51 227 @property
72364063
MT
228 def collecty(self):
229 return self.plugin.collecty
965a9c51 230
881751ed 231 @property
72364063
MT
232 def log(self):
233 return self.plugin.log
234
235 @property
236 def id(self):
237 """
238 Returns a UNIQUE identifier for this object. As this is incorporated
239 into the path of RRD file, it must only contain ASCII characters.
240 """
241 raise NotImplementedError
881751ed 242
4ac0cdf0
MT
243 @property
244 def file(self):
245 """
246 The absolute path to the RRD file of this plugin.
247 """
57797e47
MT
248 filename = self._normalise_filename("%s.rrd" % self.id)
249
250 return os.path.join(DATABASE_DIR, self.plugin.path, filename)
251
252 @staticmethod
253 def _normalise_filename(filename):
254 # Convert the filename into ASCII characters only
f37913e8 255 filename = unicodedata.normalize("NFKC", filename)
57797e47
MT
256
257 # Replace any spaces by dashes
258 filename = filename.replace(" ", "-")
259
260 return filename
72364063
MT
261
262 ### Basic methods
263
264 def init(self, *args, **kwargs):
265 """
266 Do some custom initialization stuff here.
267 """
268 pass
eed405de 269
4ac0cdf0
MT
270 def create(self):
271 """
272 Creates an empty RRD file with the desired data structures.
273 """
274 # Skip if the file does already exist.
275 if os.path.exists(self.file):
276 return
eed405de 277
4ac0cdf0
MT
278 dirname = os.path.dirname(self.file)
279 if not os.path.exists(dirname):
280 os.makedirs(dirname)
eed405de 281
965a9c51 282 # Create argument list.
ff0bbd88 283 args = self.get_rrd_schema()
965a9c51
MT
284
285 rrdtool.create(self.file, *args)
eed405de 286
4ac0cdf0 287 self.log.debug(_("Created RRD file %s.") % self.file)
dadb8fb0
MT
288 for arg in args:
289 self.log.debug(" %s" % arg)
eed405de 290
72364063
MT
291 def info(self):
292 return rrdtool.info(self.file)
293
294 @property
295 def stepsize(self):
296 return self.plugin.interval
297
298 @property
299 def heartbeat(self):
300 return self.stepsize * 2
301
965a9c51
MT
302 def get_rrd_schema(self):
303 schema = [
304 "--step", "%s" % self.stepsize,
305 ]
306 for line in self.rrd_schema:
307 if line.startswith("DS:"):
308 try:
309 (prefix, name, type, lower_limit, upper_limit) = line.split(":")
310
311 line = ":".join((
312 prefix,
313 name,
314 type,
881751ed 315 "%s" % self.heartbeat,
965a9c51
MT
316 lower_limit,
317 upper_limit
318 ))
319 except ValueError:
320 pass
321
322 schema.append(line)
323
324 xff = 0.1
325
326 cdp_length = 0
327 for rra_timespan in self.rra_timespans:
328 if (rra_timespan / self.stepsize) < self.rra_rows:
329 rra_timespan = self.stepsize * self.rra_rows
330
331 if cdp_length == 0:
332 cdp_length = 1
333 else:
334 cdp_length = rra_timespan // (self.rra_rows * self.stepsize)
335
336 cdp_number = math.ceil(rra_timespan / (cdp_length * self.stepsize))
337
338 for rra_type in self.rra_types:
339 schema.append("RRA:%s:%.10f:%d:%d" % \
340 (rra_type, xff, cdp_length, cdp_number))
341
342 return schema
343
72364063
MT
344 def execute(self):
345 if self.collected:
346 raise RuntimeError("This object has already collected its data")
eed405de 347
72364063
MT
348 self.collected = True
349 self.now = datetime.datetime.utcnow()
4ac0cdf0 350
72364063
MT
351 # Call the collect
352 result = self.collect()
4ac0cdf0 353
72364063 354 def commit(self):
4ac0cdf0 355 """
72364063 356 Will commit the collected data to the database.
4ac0cdf0 357 """
72364063 358 # Make sure that the RRD database has been created
dadb8fb0
MT
359 self.create()
360
b1ea4956
MT
361
362class GraphTemplate(object):
363 # A unique name to identify this graph template.
364 name = None
365
f181246a
MT
366 # Headline of the graph image
367 graph_title = None
368
369 # Vertical label of the graph
370 graph_vertical_label = None
371
372 # Limits
373 lower_limit = None
374 upper_limit = None
375
b1ea4956
MT
376 # Instructions how to create the graph.
377 rrd_graph = None
378
379 # Extra arguments passed to rrdgraph.
380 rrd_graph_args = []
381
c968f6d9
MT
382 intervals = {
383 None : "-3h",
384 "hour" : "-1h",
385 "day" : "-25h",
386 "week" : "-360h",
387 "year" : "-365d",
388 }
389
390 # Default dimensions for this graph
391 height = GRAPH_DEFAULT_HEIGHT
392 width = GRAPH_DEFAULT_WIDTH
393
0308c0f3 394 def __init__(self, plugin, object_id):
c968f6d9
MT
395 self.plugin = plugin
396
0308c0f3
MT
397 # Get all required RRD objects
398 self.object_id = object_id
399
400 # Get the main object
401 self.object = self.get_object(self.object_id)
402
c968f6d9
MT
403 def __repr__(self):
404 return "<%s>" % self.__class__.__name__
b1ea4956
MT
405
406 @property
407 def collecty(self):
c968f6d9 408 return self.plugin.collecty
b1ea4956 409
c968f6d9
MT
410 @property
411 def log(self):
412 return self.plugin.log
413
5913a52c
MT
414 def _make_command_line(self, interval, format=DEFAULT_IMAGE_FORMAT,
415 width=None, height=None):
c968f6d9
MT
416 args = []
417
418 args += GRAPH_DEFAULT_ARGUMENTS
419
420 args += [
5913a52c 421 "--imgformat", format,
c968f6d9
MT
422 "--height", "%s" % (height or self.height),
423 "--width", "%s" % (width or self.width),
73db5226 424 ]
eed405de 425
c968f6d9 426 args += self.rrd_graph_args
eed405de 427
f181246a
MT
428 # Graph title
429 if self.graph_title:
430 args += ["--title", self.graph_title]
431
432 # Vertical label
433 if self.graph_vertical_label:
434 args += ["--vertical-label", self.graph_vertical_label]
435
436 if self.lower_limit is not None or self.upper_limit is not None:
437 # Force to honour the set limits
438 args.append("--rigid")
439
440 if self.lower_limit is not None:
441 args += ["--lower-limit", self.lower_limit]
442
443 if self.upper_limit is not None:
444 args += ["--upper-limit", self.upper_limit]
445
c968f6d9 446 # Add interval
eed405de 447 args.append("--start")
c968f6d9 448
b1ea4956 449 try:
c968f6d9 450 args.append(self.intervals[interval])
b1ea4956 451 except KeyError:
c968f6d9
MT
452 args.append(str(interval))
453
454 return args
455
0308c0f3
MT
456 def get_object(self, *args, **kwargs):
457 return self.plugin.get_object(*args, **kwargs)
458
459 def get_object_table(self):
c968f6d9 460 return {
0308c0f3 461 "file" : self.object,
c968f6d9
MT
462 }
463
fc359f4d
MT
464 @property
465 def object_table(self):
466 if not hasattr(self, "_object_table"):
467 self._object_table = self.get_object_table()
468
469 return self._object_table
470
0308c0f3 471 def get_object_files(self):
c968f6d9
MT
472 files = {}
473
fc359f4d 474 for id, obj in self.object_table.items():
c968f6d9
MT
475 files[id] = obj.file
476
477 return files
478
0308c0f3 479 def generate_graph(self, interval=None, **kwargs):
c968f6d9
MT
480 args = self._make_command_line(interval, **kwargs)
481
482 self.log.info(_("Generating graph %s") % self)
483 self.log.debug(" args: %s" % args)
484
0308c0f3 485 object_files = self.get_object_files()
eed405de 486
73db5226 487 for item in self.rrd_graph:
eed405de 488 try:
c968f6d9 489 args.append(item % object_files)
eed405de
MT
490 except TypeError:
491 args.append(item)
492
0308c0f3
MT
493 self.log.debug(" %s" % args[-1])
494
c968f6d9
MT
495 return self.write_graph(*args)
496
497 def write_graph(self, *args):
5913a52c
MT
498 # Convert all arguments to string
499 args = [str(e) for e in args]
500
c968f6d9
MT
501 with tempfile.NamedTemporaryFile() as f:
502 rrdtool.graph(f.name, *args)
503
504 # Get back to the beginning of the file
505 f.seek(0)
506
507 # Return all the content
508 return f.read()