]> git.ipfire.org Git - collecty.git/blame - src/collecty/plugins/base.py
Drop unused Timer class
[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 25import os
cd8bba0b 26import re
4ac0cdf0 27import rrdtool
c968f6d9 28import tempfile
49ce926e 29import threading
4ac0cdf0 30import time
f37913e8 31import unicodedata
4ac0cdf0 32
429ba506 33from .. import locales
4d9ed86e 34from .. import util
4ac0cdf0 35from ..constants import *
eed405de
MT
36from ..i18n import _
37
cd8bba0b
MT
38DEF_MATCH = re.compile(r"C?DEF:([A-Za-z0-9_]+)=")
39
cb1ccb4f
MT
40class Environment(object):
41 """
42 Sets the correct environment for rrdtool to create
43 localised graphs and graphs in the correct timezone.
44 """
45 def __init__(self, timezone, locale):
46 # Build the new environment
47 self.new_environment = {
48 "TZ" : timezone or DEFAULT_TIMEZONE,
49 }
50
51 for k in ("LANG", "LC_ALL"):
52 self.new_environment[k] = locale or DEFAULT_LOCALE
53
54 def __enter__(self):
55 # Save the current environment
56 self.old_environment = {}
57 for k in self.new_environment:
58 self.old_environment[k] = os.environ.get(k, None)
59
60 # Apply the new one
61 os.environ.update(self.new_environment)
62
63 def __exit__(self, type, value, traceback):
64 # Roll back to the previous environment
65 for k, v in self.old_environment.items():
66 if v is None:
67 try:
68 del os.environ[k]
69 except KeyError:
70 pass
71 else:
72 os.environ[k] = v
73
74
f37913e8
MT
75class PluginRegistration(type):
76 plugins = {}
77
78 def __init__(plugin, name, bases, dict):
79 type.__init__(plugin, name, bases, dict)
80
81 # The main class from which is inherited is not registered
82 # as a plugin.
83 if name == "Plugin":
84 return
85
86 if not all((plugin.name, plugin.description)):
87 raise RuntimeError(_("Plugin is not properly configured: %s") % plugin)
88
89 PluginRegistration.plugins[plugin.name] = plugin
90
91
92def get():
93 """
94 Returns a list with all automatically registered plugins.
95 """
96 return PluginRegistration.plugins.values()
97
98class Plugin(object, metaclass=PluginRegistration):
4ac0cdf0
MT
99 # The name of this plugin.
100 name = None
101
102 # A description for this plugin.
103 description = None
104
b1ea4956
MT
105 # Templates which can be used to generate a graph out of
106 # the data from this data source.
107 templates = []
108
72364063
MT
109 # The default interval for all plugins
110 interval = 60
4ac0cdf0 111
6e603f14
MT
112 # Priority
113 priority = 0
114
eed405de 115 def __init__(self, collecty, **kwargs):
eed405de
MT
116 self.collecty = collecty
117
4ac0cdf0
MT
118 # Check if this plugin was configured correctly.
119 assert self.name, "Name of the plugin is not set: %s" % self.name
120 assert self.description, "Description of the plugin is not set: %s" % self.description
4ac0cdf0
MT
121
122 # Initialize the logger.
123 self.log = logging.getLogger("collecty.plugins.%s" % self.name)
eed405de 124
eed405de
MT
125 self.data = []
126
269f74cd
MT
127 # Run some custom initialization.
128 self.init(**kwargs)
129
0ee0c42d 130 self.log.debug(_("Successfully initialized %s") % self.__class__.__name__)
4ac0cdf0 131
73db5226 132 @property
72364063 133 def path(self):
73db5226 134 """
72364063
MT
135 Returns the name of the sub directory in which all RRD files
136 for this plugin should be stored in.
73db5226
MT
137 """
138 return self.name
139
72364063
MT
140 ### Basic methods
141
142 def init(self, **kwargs):
4ac0cdf0 143 """
72364063 144 Do some custom initialization stuff here.
4ac0cdf0 145 """
72364063
MT
146 pass
147
148 def collect(self):
149 """
150 Gathers the statistical data, this plugin collects.
151 """
152 time_start = time.time()
153
154 # Run through all objects of this plugin and call the collect method.
155 for o in self.objects:
156 now = datetime.datetime.utcnow()
157 try:
158 result = o.collect()
f648421a 159
a9af411f 160 result = self._format_result(result)
72364063
MT
161 except:
162 self.log.warning(_("Unhandled exception in %s.collect()") % o, exc_info=True)
163 continue
164
165 if not result:
166 self.log.warning(_("Received empty result: %s") % o)
167 continue
168
169 self.log.debug(_("Collected %s: %s") % (o, result))
170
171 # Add the object to the write queue so that the data is written
172 # to the databases later.
173 self.collecty.write_queue.add(o, now, result)
174
175 # Returns the time this function took to complete.
49c1b8fd 176 delay = time.time() - time_start
72364063 177
49c1b8fd
MT
178 # Log some warning when a collect method takes too long to return some data
179 if delay >= 60:
180 self.log.warning(_("A worker thread was stalled for %.4fs") % delay)
4ac0cdf0 181
a9af411f
MT
182 @staticmethod
183 def _format_result(result):
184 if not isinstance(result, tuple) and not isinstance(result, list):
185 return result
186
9e84485f 187 # Replace all Nones by UNKNOWN
a9af411f
MT
188 s = []
189
190 for e in result:
191 if e is None:
9e84485f 192 e = "U"
a9af411f 193
9e84485f 194 s.append("%s" % e)
a9af411f
MT
195
196 return ":".join(s)
197
c968f6d9
MT
198 def get_object(self, id):
199 for object in self.objects:
200 if not object.id == id:
201 continue
202
203 return object
204
429ba506 205 def get_template(self, template_name, object_id, locale=None, timezone=None):
c968f6d9
MT
206 for template in self.templates:
207 if not template.name == template_name:
208 continue
209
429ba506 210 return template(self, object_id, locale=locale, timezone=timezone)
c968f6d9 211
429ba506
MT
212 def generate_graph(self, template_name, object_id="default",
213 timezone=None, locale=None, **kwargs):
214 template = self.get_template(template_name, object_id=object_id,
215 timezone=timezone, locale=locale)
c968f6d9
MT
216 if not template:
217 raise RuntimeError("Could not find template %s" % template_name)
218
219 time_start = time.time()
220
0308c0f3 221 graph = template.generate_graph(**kwargs)
c968f6d9
MT
222
223 duration = time.time() - time_start
0ee0c42d 224 self.log.debug(_("Generated graph %s in %.1fms") \
c968f6d9
MT
225 % (template, duration * 1000))
226
227 return graph
228
a3864812
MT
229 def graph_info(self, template_name, object_id="default",
230 timezone=None, locale=None, **kwargs):
231 template = self.get_template(template_name, object_id=object_id,
232 timezone=timezone, locale=locale)
233 if not template:
234 raise RuntimeError("Could not find template %s" % template_name)
235
236 return template.graph_info()
237
8ee5a71a
MT
238 def last_update(self, object_id="default"):
239 object = self.get_object(object_id)
240 if not object:
241 raise RuntimeError("Could not find object %s" % object_id)
242
243 return object.last_update()
244
72364063
MT
245
246class Object(object):
247 # The schema of the RRD database.
248 rrd_schema = None
249
250 # RRA properties.
418174a4
MT
251 rra_types = ("AVERAGE", "MIN", "MAX")
252 rra_timespans = (
253 ("1m", "10d"),
254 ("1h", "18M"),
255 ("1d", "5y"),
256 )
72364063
MT
257
258 def __init__(self, plugin, *args, **kwargs):
259 self.plugin = plugin
260
261 # Indicates if this object has collected its data
262 self.collected = False
263
264 # Initialise this object
265 self.init(*args, **kwargs)
266
267 # Create the database file.
268 self.create()
269
270 def __repr__(self):
271 return "<%s>" % self.__class__.__name__
4ac0cdf0 272
d5d4c0e7
MT
273 def __lt__(self, other):
274 return self.id < other.id
275
965a9c51 276 @property
72364063
MT
277 def collecty(self):
278 return self.plugin.collecty
965a9c51 279
881751ed 280 @property
72364063
MT
281 def log(self):
282 return self.plugin.log
283
284 @property
285 def id(self):
286 """
287 Returns a UNIQUE identifier for this object. As this is incorporated
288 into the path of RRD file, it must only contain ASCII characters.
289 """
290 raise NotImplementedError
881751ed 291
4ac0cdf0
MT
292 @property
293 def file(self):
294 """
295 The absolute path to the RRD file of this plugin.
296 """
57797e47
MT
297 filename = self._normalise_filename("%s.rrd" % self.id)
298
299 return os.path.join(DATABASE_DIR, self.plugin.path, filename)
300
301 @staticmethod
302 def _normalise_filename(filename):
303 # Convert the filename into ASCII characters only
f37913e8 304 filename = unicodedata.normalize("NFKC", filename)
57797e47
MT
305
306 # Replace any spaces by dashes
307 filename = filename.replace(" ", "-")
308
309 return filename
72364063
MT
310
311 ### Basic methods
312
313 def init(self, *args, **kwargs):
314 """
315 Do some custom initialization stuff here.
316 """
317 pass
eed405de 318
4ac0cdf0
MT
319 def create(self):
320 """
321 Creates an empty RRD file with the desired data structures.
322 """
323 # Skip if the file does already exist.
324 if os.path.exists(self.file):
325 return
eed405de 326
4ac0cdf0
MT
327 dirname = os.path.dirname(self.file)
328 if not os.path.exists(dirname):
329 os.makedirs(dirname)
eed405de 330
965a9c51 331 # Create argument list.
ff0bbd88 332 args = self.get_rrd_schema()
965a9c51
MT
333
334 rrdtool.create(self.file, *args)
eed405de 335
4ac0cdf0 336 self.log.debug(_("Created RRD file %s.") % self.file)
dadb8fb0
MT
337 for arg in args:
338 self.log.debug(" %s" % arg)
eed405de 339
72364063
MT
340 def info(self):
341 return rrdtool.info(self.file)
342
8ee5a71a
MT
343 def last_update(self):
344 """
345 Returns a dictionary with the timestamp and
346 data set of the last database update.
347 """
348 return {
349 "dataset" : self.last_dataset,
350 "timestamp" : self.last_updated,
351 }
352
353 def _last_update(self):
354 return rrdtool.lastupdate(self.file)
355
356 @property
357 def last_updated(self):
358 """
359 Returns the timestamp when this database was last updated
360 """
361 lu = self._last_update()
362
363 if lu:
364 return lu.get("date")
365
366 @property
367 def last_dataset(self):
368 """
369 Returns the latest dataset in the database
370 """
371 lu = self._last_update()
372
373 if lu:
374 return lu.get("ds")
375
72364063
MT
376 @property
377 def stepsize(self):
378 return self.plugin.interval
379
380 @property
381 def heartbeat(self):
382 return self.stepsize * 2
383
965a9c51
MT
384 def get_rrd_schema(self):
385 schema = [
386 "--step", "%s" % self.stepsize,
387 ]
388 for line in self.rrd_schema:
389 if line.startswith("DS:"):
390 try:
391 (prefix, name, type, lower_limit, upper_limit) = line.split(":")
392
393 line = ":".join((
394 prefix,
395 name,
396 type,
881751ed 397 "%s" % self.heartbeat,
965a9c51
MT
398 lower_limit,
399 upper_limit
400 ))
401 except ValueError:
402 pass
403
404 schema.append(line)
405
406 xff = 0.1
407
418174a4
MT
408 for steps, rows in self.rra_timespans:
409 for type in self.rra_types:
410 schema.append("RRA:%s:%s:%s:%s" % (type, xff, steps, rows))
965a9c51
MT
411
412 return schema
413
e0437caa
MT
414 @property
415 def rrd_schema_names(self):
416 ret = []
cd8bba0b
MT
417
418 for line in self.rrd_schema:
e0437caa
MT
419 (prefix, name, type, lower_limit, upper_limit) = line.split(":")
420 ret.append(name)
421
422 return ret
423
424 def make_rrd_defs(self, prefix=None):
425 defs = []
cd8bba0b 426
e0437caa 427 for name in self.rrd_schema_names:
cd8bba0b
MT
428 if prefix:
429 p = "%s_%s" % (prefix, name)
430 else:
431 p = name
432
433 defs += [
434 "DEF:%s=%s:%s:AVERAGE" % (p, self.file, name),
435 ]
436
437 return defs
438
4d9ed86e
MT
439 def get_stddev(self, interval=None):
440 args = self.make_rrd_defs()
441
442 # Add the correct interval
443 args += ["--start", util.make_interval(interval)]
444
445 for name in self.rrd_schema_names:
446 args += [
447 "VDEF:%s_stddev=%s,STDEV" % (name, name),
448 "PRINT:%s_stddev:%%lf" % name,
449 ]
450
451 x, y, vals = rrdtool.graph("/dev/null", *args)
452 return dict(zip(self.rrd_schema_names, vals))
453
72364063
MT
454 def execute(self):
455 if self.collected:
456 raise RuntimeError("This object has already collected its data")
eed405de 457
72364063
MT
458 self.collected = True
459 self.now = datetime.datetime.utcnow()
4ac0cdf0 460
72364063
MT
461 # Call the collect
462 result = self.collect()
4ac0cdf0 463
72364063 464 def commit(self):
4ac0cdf0 465 """
72364063 466 Will commit the collected data to the database.
4ac0cdf0 467 """
72364063 468 # Make sure that the RRD database has been created
dadb8fb0
MT
469 self.create()
470
ca9b9221
MT
471 # Write everything to disk that is in the write queue
472 self.collecty.write_queue.commit_file(self.file)
473
b1ea4956
MT
474
475class GraphTemplate(object):
476 # A unique name to identify this graph template.
477 name = None
478
f181246a
MT
479 # Headline of the graph image
480 graph_title = None
481
482 # Vertical label of the graph
483 graph_vertical_label = None
484
485 # Limits
486 lower_limit = None
487 upper_limit = None
488
b1ea4956
MT
489 # Instructions how to create the graph.
490 rrd_graph = None
491
492 # Extra arguments passed to rrdgraph.
493 rrd_graph_args = []
494
c968f6d9
MT
495 # Default dimensions for this graph
496 height = GRAPH_DEFAULT_HEIGHT
497 width = GRAPH_DEFAULT_WIDTH
498
429ba506 499 def __init__(self, plugin, object_id, locale=None, timezone=None):
c968f6d9
MT
500 self.plugin = plugin
501
429ba506
MT
502 # Save localisation parameters
503 self.locale = locales.get(locale)
504 self.timezone = timezone
505
0308c0f3
MT
506 # Get all required RRD objects
507 self.object_id = object_id
508
509 # Get the main object
d5d4c0e7
MT
510 self.objects = self.get_objects(self.object_id)
511 self.objects.sort()
0308c0f3 512
c968f6d9
MT
513 def __repr__(self):
514 return "<%s>" % self.__class__.__name__
b1ea4956
MT
515
516 @property
517 def collecty(self):
c968f6d9 518 return self.plugin.collecty
b1ea4956 519
c968f6d9
MT
520 @property
521 def log(self):
522 return self.plugin.log
523
d5d4c0e7
MT
524 @property
525 def object(self):
526 """
527 Shortcut to the main object
528 """
529 if len(self.objects) == 1:
530 return self.objects[0]
531
5913a52c 532 def _make_command_line(self, interval, format=DEFAULT_IMAGE_FORMAT,
edf268c7
MT
533 width=None, height=None, with_title=True, thumbnail=False):
534 args = [e for e in GRAPH_DEFAULT_ARGUMENTS]
c968f6d9 535
edf268c7
MT
536 # Set the default dimensions
537 default_height, default_width = GRAPH_DEFAULT_HEIGHT, GRAPH_DEFAULT_WIDTH
538
539 # A thumbnail doesn't have a legend and other labels
540 if thumbnail:
541 args.append("--only-graph")
542
543 default_height = THUMBNAIL_DEFAULT_HEIGHT
544 default_width = THUMBNAIL_DEFAULT_WIDTH
c968f6d9
MT
545
546 args += [
5913a52c 547 "--imgformat", format,
edf268c7
MT
548 "--height", "%s" % (height or default_height),
549 "--width", "%s" % (width or default_width),
73db5226 550 ]
eed405de 551
c968f6d9 552 args += self.rrd_graph_args
eed405de 553
f181246a 554 # Graph title
ca8a6cfa 555 if with_title and self.graph_title:
f181246a
MT
556 args += ["--title", self.graph_title]
557
558 # Vertical label
559 if self.graph_vertical_label:
560 args += ["--vertical-label", self.graph_vertical_label]
561
562 if self.lower_limit is not None or self.upper_limit is not None:
563 # Force to honour the set limits
564 args.append("--rigid")
565
566 if self.lower_limit is not None:
567 args += ["--lower-limit", self.lower_limit]
568
569 if self.upper_limit is not None:
570 args += ["--upper-limit", self.upper_limit]
571
c9351f4f 572 # Add interval
4d9ed86e 573 args += ["--start", util.make_interval(interval)]
c968f6d9
MT
574
575 return args
576
d5d4c0e7
MT
577 def _add_defs(self):
578 use_prefix = len(self.objects) >= 2
579
580 args = []
581 for object in self.objects:
582 if use_prefix:
583 args += object.make_rrd_defs(object.id)
584 else:
585 args += object.make_rrd_defs()
586
587 return args
588
cd8bba0b
MT
589 def _add_vdefs(self, args):
590 ret = []
591
592 for arg in args:
593 ret.append(arg)
594
595 # Search for all DEFs and CDEFs
596 m = re.match(DEF_MATCH, "%s" % arg)
597 if m:
598 name = m.group(1)
599
600 # Add the VDEFs for minimum, maximum, etc. values
601 ret += [
602 "VDEF:%s_cur=%s,LAST" % (name, name),
603 "VDEF:%s_avg=%s,AVERAGE" % (name, name),
604 "VDEF:%s_max=%s,MAXIMUM" % (name, name),
605 "VDEF:%s_min=%s,MINIMUM" % (name, name),
606 ]
607
608 return ret
609
d5d4c0e7
MT
610 def get_objects(self, *args, **kwargs):
611 object = self.plugin.get_object(*args, **kwargs)
0308c0f3 612
d5d4c0e7
MT
613 if object:
614 return [object,]
c968f6d9 615
d5d4c0e7 616 return []
fc359f4d 617
429ba506 618 def generate_graph(self, interval=None, **kwargs):
d5d4c0e7
MT
619 assert self.objects, "Cannot render graph without any objects"
620
ca9b9221
MT
621 # Make sure that all collected data is in the database
622 # to get a recent graph image
d5d4c0e7
MT
623 for object in self.objects:
624 object.commit()
ca9b9221 625
c968f6d9
MT
626 args = self._make_command_line(interval, **kwargs)
627
628 self.log.info(_("Generating graph %s") % self)
c968f6d9 629
d5d4c0e7
MT
630 rrd_graph = self.rrd_graph
631
632 # Add DEFs for all objects
633 if not any((e.startswith("DEF:") for e in rrd_graph)):
634 args += self._add_defs()
eed405de 635
d5d4c0e7 636 args += rrd_graph
cd8bba0b 637 args = self._add_vdefs(args)
0308c0f3 638
699e99fb 639 # Convert arguments to string
5913a52c
MT
640 args = [str(e) for e in args]
641
cd8bba0b
MT
642 for arg in args:
643 self.log.debug(" %s" % arg)
644
429ba506 645 with Environment(self.timezone, self.locale.lang):
cb1ccb4f 646 graph = rrdtool.graphv("-", *args)
c968f6d9 647
a3864812
MT
648 return {
649 "image" : graph.get("image"),
650 "image_height" : graph.get("image_height"),
651 "image_width" : graph.get("image_width"),
652 }
653
654 def graph_info(self):
655 """
656 Returns a dictionary with useful information
657 about this graph.
658 """
659 return {
660 "title" : self.graph_title,
661 "object_id" : self.object_id or "",
662 "template" : self.name,
663 }