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