]> git.ipfire.org Git - collecty.git/blame - src/collecty/plugins/base.py
Replace Nones with UNKNOWN instead of NaN
[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
9e84485f 217 # Replace all Nones by UNKNOWN
a9af411f
MT
218 s = []
219
220 for e in result:
221 if e is None:
9e84485f 222 e = "U"
a9af411f 223
9e84485f 224 s.append("%s" % e)
a9af411f
MT
225
226 return ":".join(s)
227
c968f6d9
MT
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
429ba506 235 def get_template(self, template_name, object_id, locale=None, timezone=None):
c968f6d9
MT
236 for template in self.templates:
237 if not template.name == template_name:
238 continue
239
429ba506 240 return template(self, object_id, locale=locale, timezone=timezone)
c968f6d9 241
429ba506
MT
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)
c968f6d9
MT
246 if not template:
247 raise RuntimeError("Could not find template %s" % template_name)
248
249 time_start = time.time()
250
0308c0f3 251 graph = template.generate_graph(**kwargs)
c968f6d9
MT
252
253 duration = time.time() - time_start
0ee0c42d 254 self.log.debug(_("Generated graph %s in %.1fms") \
c968f6d9
MT
255 % (template, duration * 1000))
256
257 return graph
258
a3864812
MT
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
8ee5a71a
MT
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
72364063
MT
275
276class Object(object):
277 # The schema of the RRD database.
278 rrd_schema = None
279
280 # RRA properties.
418174a4
MT
281 rra_types = ("AVERAGE", "MIN", "MAX")
282 rra_timespans = (
283 ("1m", "10d"),
284 ("1h", "18M"),
285 ("1d", "5y"),
286 )
72364063
MT
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__
4ac0cdf0 302
d5d4c0e7
MT
303 def __lt__(self, other):
304 return self.id < other.id
305
965a9c51 306 @property
72364063
MT
307 def collecty(self):
308 return self.plugin.collecty
965a9c51 309
881751ed 310 @property
72364063
MT
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
881751ed 321
4ac0cdf0
MT
322 @property
323 def file(self):
324 """
325 The absolute path to the RRD file of this plugin.
326 """
57797e47
MT
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
f37913e8 334 filename = unicodedata.normalize("NFKC", filename)
57797e47
MT
335
336 # Replace any spaces by dashes
337 filename = filename.replace(" ", "-")
338
339 return filename
72364063
MT
340
341 ### Basic methods
342
343 def init(self, *args, **kwargs):
344 """
345 Do some custom initialization stuff here.
346 """
347 pass
eed405de 348
4ac0cdf0
MT
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
eed405de 356
4ac0cdf0
MT
357 dirname = os.path.dirname(self.file)
358 if not os.path.exists(dirname):
359 os.makedirs(dirname)
eed405de 360
965a9c51 361 # Create argument list.
ff0bbd88 362 args = self.get_rrd_schema()
965a9c51
MT
363
364 rrdtool.create(self.file, *args)
eed405de 365
4ac0cdf0 366 self.log.debug(_("Created RRD file %s.") % self.file)
dadb8fb0
MT
367 for arg in args:
368 self.log.debug(" %s" % arg)
eed405de 369
72364063
MT
370 def info(self):
371 return rrdtool.info(self.file)
372
8ee5a71a
MT
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
72364063
MT
406 @property
407 def stepsize(self):
408 return self.plugin.interval
409
410 @property
411 def heartbeat(self):
412 return self.stepsize * 2
413
965a9c51
MT
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,
881751ed 427 "%s" % self.heartbeat,
965a9c51
MT
428 lower_limit,
429 upper_limit
430 ))
431 except ValueError:
432 pass
433
434 schema.append(line)
435
436 xff = 0.1
437
418174a4
MT
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))
965a9c51
MT
441
442 return schema
443
e0437caa
MT
444 @property
445 def rrd_schema_names(self):
446 ret = []
cd8bba0b
MT
447
448 for line in self.rrd_schema:
e0437caa
MT
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 = []
cd8bba0b 456
e0437caa 457 for name in self.rrd_schema_names:
cd8bba0b
MT
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
4d9ed86e
MT
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
72364063
MT
484 def execute(self):
485 if self.collected:
486 raise RuntimeError("This object has already collected its data")
eed405de 487
72364063
MT
488 self.collected = True
489 self.now = datetime.datetime.utcnow()
4ac0cdf0 490
72364063
MT
491 # Call the collect
492 result = self.collect()
4ac0cdf0 493
72364063 494 def commit(self):
4ac0cdf0 495 """
72364063 496 Will commit the collected data to the database.
4ac0cdf0 497 """
72364063 498 # Make sure that the RRD database has been created
dadb8fb0
MT
499 self.create()
500
ca9b9221
MT
501 # Write everything to disk that is in the write queue
502 self.collecty.write_queue.commit_file(self.file)
503
b1ea4956
MT
504
505class GraphTemplate(object):
506 # A unique name to identify this graph template.
507 name = None
508
f181246a
MT
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
b1ea4956
MT
519 # Instructions how to create the graph.
520 rrd_graph = None
521
522 # Extra arguments passed to rrdgraph.
523 rrd_graph_args = []
524
c968f6d9
MT
525 # Default dimensions for this graph
526 height = GRAPH_DEFAULT_HEIGHT
527 width = GRAPH_DEFAULT_WIDTH
528
429ba506 529 def __init__(self, plugin, object_id, locale=None, timezone=None):
c968f6d9
MT
530 self.plugin = plugin
531
429ba506
MT
532 # Save localisation parameters
533 self.locale = locales.get(locale)
534 self.timezone = timezone
535
0308c0f3
MT
536 # Get all required RRD objects
537 self.object_id = object_id
538
539 # Get the main object
d5d4c0e7
MT
540 self.objects = self.get_objects(self.object_id)
541 self.objects.sort()
0308c0f3 542
c968f6d9
MT
543 def __repr__(self):
544 return "<%s>" % self.__class__.__name__
b1ea4956
MT
545
546 @property
547 def collecty(self):
c968f6d9 548 return self.plugin.collecty
b1ea4956 549
c968f6d9
MT
550 @property
551 def log(self):
552 return self.plugin.log
553
d5d4c0e7
MT
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
5913a52c 562 def _make_command_line(self, interval, format=DEFAULT_IMAGE_FORMAT,
edf268c7
MT
563 width=None, height=None, with_title=True, thumbnail=False):
564 args = [e for e in GRAPH_DEFAULT_ARGUMENTS]
c968f6d9 565
edf268c7
MT
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
c968f6d9
MT
575
576 args += [
5913a52c 577 "--imgformat", format,
edf268c7
MT
578 "--height", "%s" % (height or default_height),
579 "--width", "%s" % (width or default_width),
73db5226 580 ]
eed405de 581
c968f6d9 582 args += self.rrd_graph_args
eed405de 583
f181246a 584 # Graph title
ca8a6cfa 585 if with_title and self.graph_title:
f181246a
MT
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
c9351f4f 602 # Add interval
4d9ed86e 603 args += ["--start", util.make_interval(interval)]
c968f6d9
MT
604
605 return args
606
d5d4c0e7
MT
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
cd8bba0b
MT
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
d5d4c0e7
MT
640 def get_objects(self, *args, **kwargs):
641 object = self.plugin.get_object(*args, **kwargs)
0308c0f3 642
d5d4c0e7
MT
643 if object:
644 return [object,]
c968f6d9 645
d5d4c0e7 646 return []
fc359f4d 647
429ba506 648 def generate_graph(self, interval=None, **kwargs):
d5d4c0e7
MT
649 assert self.objects, "Cannot render graph without any objects"
650
ca9b9221
MT
651 # Make sure that all collected data is in the database
652 # to get a recent graph image
d5d4c0e7
MT
653 for object in self.objects:
654 object.commit()
ca9b9221 655
c968f6d9
MT
656 args = self._make_command_line(interval, **kwargs)
657
658 self.log.info(_("Generating graph %s") % self)
c968f6d9 659
d5d4c0e7
MT
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()
eed405de 665
d5d4c0e7 666 args += rrd_graph
cd8bba0b 667 args = self._add_vdefs(args)
0308c0f3 668
699e99fb 669 # Convert arguments to string
5913a52c
MT
670 args = [str(e) for e in args]
671
cd8bba0b
MT
672 for arg in args:
673 self.log.debug(" %s" % arg)
674
429ba506 675 with Environment(self.timezone, self.locale.lang):
cb1ccb4f 676 graph = rrdtool.graphv("-", *args)
c968f6d9 677
a3864812
MT
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 }