]> git.ipfire.org Git - oddments/collecty.git/blame - src/collecty/plugins/base.py
processors: Collect usage for all individual cores
[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 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
5579c922
MT
500 def read_proc_stat(self):
501 """
502 Reads /proc/stat and returns it as a dictionary
503 """
504 ret = {}
505
506 with open("/proc/stat") as f:
507 for line in f:
508 # Split the key from the rest of the line
509 key, line = line.split(" ", 1)
510
511 # Remove any line breaks
512 ret[key] = line.rstrip()
513
514 return ret
515
b1ea4956
MT
516
517class GraphTemplate(object):
518 # A unique name to identify this graph template.
519 name = None
520
f181246a
MT
521 # Headline of the graph image
522 graph_title = None
523
524 # Vertical label of the graph
525 graph_vertical_label = None
526
527 # Limits
528 lower_limit = None
529 upper_limit = None
530
b1ea4956
MT
531 # Instructions how to create the graph.
532 rrd_graph = None
533
534 # Extra arguments passed to rrdgraph.
535 rrd_graph_args = []
536
c968f6d9
MT
537 # Default dimensions for this graph
538 height = GRAPH_DEFAULT_HEIGHT
539 width = GRAPH_DEFAULT_WIDTH
540
429ba506 541 def __init__(self, plugin, object_id, locale=None, timezone=None):
c968f6d9
MT
542 self.plugin = plugin
543
429ba506
MT
544 # Save localisation parameters
545 self.locale = locales.get(locale)
546 self.timezone = timezone
547
0308c0f3
MT
548 # Get all required RRD objects
549 self.object_id = object_id
550
551 # Get the main object
d5d4c0e7
MT
552 self.objects = self.get_objects(self.object_id)
553 self.objects.sort()
0308c0f3 554
c968f6d9
MT
555 def __repr__(self):
556 return "<%s>" % self.__class__.__name__
b1ea4956
MT
557
558 @property
559 def collecty(self):
c968f6d9 560 return self.plugin.collecty
b1ea4956 561
c968f6d9
MT
562 @property
563 def log(self):
564 return self.plugin.log
565
d5d4c0e7
MT
566 @property
567 def object(self):
568 """
569 Shortcut to the main object
570 """
571 if len(self.objects) == 1:
572 return self.objects[0]
573
5913a52c 574 def _make_command_line(self, interval, format=DEFAULT_IMAGE_FORMAT,
edf268c7
MT
575 width=None, height=None, with_title=True, thumbnail=False):
576 args = [e for e in GRAPH_DEFAULT_ARGUMENTS]
c968f6d9 577
edf268c7
MT
578 # Set the default dimensions
579 default_height, default_width = GRAPH_DEFAULT_HEIGHT, GRAPH_DEFAULT_WIDTH
580
581 # A thumbnail doesn't have a legend and other labels
582 if thumbnail:
583 args.append("--only-graph")
584
585 default_height = THUMBNAIL_DEFAULT_HEIGHT
586 default_width = THUMBNAIL_DEFAULT_WIDTH
c968f6d9
MT
587
588 args += [
5913a52c 589 "--imgformat", format,
edf268c7
MT
590 "--height", "%s" % (height or default_height),
591 "--width", "%s" % (width or default_width),
73db5226 592 ]
eed405de 593
c968f6d9 594 args += self.rrd_graph_args
eed405de 595
f181246a 596 # Graph title
ca8a6cfa 597 if with_title and self.graph_title:
f181246a
MT
598 args += ["--title", self.graph_title]
599
600 # Vertical label
601 if self.graph_vertical_label:
602 args += ["--vertical-label", self.graph_vertical_label]
603
604 if self.lower_limit is not None or self.upper_limit is not None:
605 # Force to honour the set limits
606 args.append("--rigid")
607
608 if self.lower_limit is not None:
609 args += ["--lower-limit", self.lower_limit]
610
611 if self.upper_limit is not None:
612 args += ["--upper-limit", self.upper_limit]
613
c9351f4f 614 # Add interval
4d9ed86e 615 args += ["--start", util.make_interval(interval)]
c968f6d9
MT
616
617 return args
618
d5d4c0e7
MT
619 def _add_defs(self):
620 use_prefix = len(self.objects) >= 2
621
622 args = []
623 for object in self.objects:
624 if use_prefix:
625 args += object.make_rrd_defs(object.id)
626 else:
627 args += object.make_rrd_defs()
628
629 return args
630
cd8bba0b
MT
631 def _add_vdefs(self, args):
632 ret = []
633
634 for arg in args:
635 ret.append(arg)
636
637 # Search for all DEFs and CDEFs
638 m = re.match(DEF_MATCH, "%s" % arg)
639 if m:
640 name = m.group(1)
641
642 # Add the VDEFs for minimum, maximum, etc. values
643 ret += [
644 "VDEF:%s_cur=%s,LAST" % (name, name),
645 "VDEF:%s_avg=%s,AVERAGE" % (name, name),
646 "VDEF:%s_max=%s,MAXIMUM" % (name, name),
647 "VDEF:%s_min=%s,MINIMUM" % (name, name),
648 ]
649
650 return ret
651
d5d4c0e7
MT
652 def get_objects(self, *args, **kwargs):
653 object = self.plugin.get_object(*args, **kwargs)
0308c0f3 654
d5d4c0e7
MT
655 if object:
656 return [object,]
c968f6d9 657
d5d4c0e7 658 return []
fc359f4d 659
429ba506 660 def generate_graph(self, interval=None, **kwargs):
d5d4c0e7
MT
661 assert self.objects, "Cannot render graph without any objects"
662
ca9b9221
MT
663 # Make sure that all collected data is in the database
664 # to get a recent graph image
d5d4c0e7
MT
665 for object in self.objects:
666 object.commit()
ca9b9221 667
c968f6d9
MT
668 args = self._make_command_line(interval, **kwargs)
669
670 self.log.info(_("Generating graph %s") % self)
c968f6d9 671
d5d4c0e7
MT
672 rrd_graph = self.rrd_graph
673
674 # Add DEFs for all objects
675 if not any((e.startswith("DEF:") for e in rrd_graph)):
676 args += self._add_defs()
eed405de 677
d5d4c0e7 678 args += rrd_graph
cd8bba0b 679 args = self._add_vdefs(args)
0308c0f3 680
699e99fb 681 # Convert arguments to string
5913a52c
MT
682 args = [str(e) for e in args]
683
cd8bba0b
MT
684 for arg in args:
685 self.log.debug(" %s" % arg)
686
429ba506 687 with Environment(self.timezone, self.locale.lang):
cb1ccb4f 688 graph = rrdtool.graphv("-", *args)
c968f6d9 689
a3864812
MT
690 return {
691 "image" : graph.get("image"),
692 "image_height" : graph.get("image_height"),
693 "image_width" : graph.get("image_width"),
694 }
695
696 def graph_info(self):
697 """
698 Returns a dictionary with useful information
699 about this graph.
700 """
701 return {
702 "title" : self.graph_title,
703 "object_id" : self.object_id or "",
704 "template" : self.name,
705 }