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