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