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