]> git.ipfire.org Git - collecty.git/blob - src/collecty/plugins/base.py
Remove some unused code
[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 NaN
218 s = []
219
220 for e in result:
221 if e is None:
222 e = "NaN"
223
224 # Format as string
225 e = "%s" % e
226
227 s.append(e)
228
229 return ":".join(s)
230
231 def get_object(self, id):
232 for object in self.objects:
233 if not object.id == id:
234 continue
235
236 return object
237
238 def get_template(self, template_name, object_id, locale=None, timezone=None):
239 for template in self.templates:
240 if not template.name == template_name:
241 continue
242
243 return template(self, object_id, locale=locale, timezone=timezone)
244
245 def generate_graph(self, template_name, object_id="default",
246 timezone=None, locale=None, **kwargs):
247 template = self.get_template(template_name, object_id=object_id,
248 timezone=timezone, locale=locale)
249 if not template:
250 raise RuntimeError("Could not find template %s" % template_name)
251
252 time_start = time.time()
253
254 graph = template.generate_graph(**kwargs)
255
256 duration = time.time() - time_start
257 self.log.debug(_("Generated graph %s in %.1fms") \
258 % (template, duration * 1000))
259
260 return graph
261
262 def graph_info(self, template_name, object_id="default",
263 timezone=None, locale=None, **kwargs):
264 template = self.get_template(template_name, object_id=object_id,
265 timezone=timezone, locale=locale)
266 if not template:
267 raise RuntimeError("Could not find template %s" % template_name)
268
269 return template.graph_info()
270
271 def last_update(self, object_id="default"):
272 object = self.get_object(object_id)
273 if not object:
274 raise RuntimeError("Could not find object %s" % object_id)
275
276 return object.last_update()
277
278
279 class Object(object):
280 # The schema of the RRD database.
281 rrd_schema = None
282
283 # RRA properties.
284 rra_types = ("AVERAGE", "MIN", "MAX")
285 rra_timespans = (
286 ("1m", "10d"),
287 ("1h", "18M"),
288 ("1d", "5y"),
289 )
290
291 def __init__(self, plugin, *args, **kwargs):
292 self.plugin = plugin
293
294 # Indicates if this object has collected its data
295 self.collected = False
296
297 # Initialise this object
298 self.init(*args, **kwargs)
299
300 # Create the database file.
301 self.create()
302
303 def __repr__(self):
304 return "<%s>" % self.__class__.__name__
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.object = self.get_object(self.object_id)
541
542 def __repr__(self):
543 return "<%s>" % self.__class__.__name__
544
545 @property
546 def collecty(self):
547 return self.plugin.collecty
548
549 @property
550 def log(self):
551 return self.plugin.log
552
553 def _make_command_line(self, interval, format=DEFAULT_IMAGE_FORMAT,
554 width=None, height=None, with_title=True, thumbnail=False):
555 args = [e for e in GRAPH_DEFAULT_ARGUMENTS]
556
557 # Set the default dimensions
558 default_height, default_width = GRAPH_DEFAULT_HEIGHT, GRAPH_DEFAULT_WIDTH
559
560 # A thumbnail doesn't have a legend and other labels
561 if thumbnail:
562 args.append("--only-graph")
563
564 default_height = THUMBNAIL_DEFAULT_HEIGHT
565 default_width = THUMBNAIL_DEFAULT_WIDTH
566
567 args += [
568 "--imgformat", format,
569 "--height", "%s" % (height or default_height),
570 "--width", "%s" % (width or default_width),
571 ]
572
573 args += self.rrd_graph_args
574
575 # Graph title
576 if with_title and self.graph_title:
577 args += ["--title", self.graph_title]
578
579 # Vertical label
580 if self.graph_vertical_label:
581 args += ["--vertical-label", self.graph_vertical_label]
582
583 if self.lower_limit is not None or self.upper_limit is not None:
584 # Force to honour the set limits
585 args.append("--rigid")
586
587 if self.lower_limit is not None:
588 args += ["--lower-limit", self.lower_limit]
589
590 if self.upper_limit is not None:
591 args += ["--upper-limit", self.upper_limit]
592
593 # Add interval
594 args += ["--start", util.make_interval(interval)]
595
596 return args
597
598 def _add_vdefs(self, args):
599 ret = []
600
601 for arg in args:
602 ret.append(arg)
603
604 # Search for all DEFs and CDEFs
605 m = re.match(DEF_MATCH, "%s" % arg)
606 if m:
607 name = m.group(1)
608
609 # Add the VDEFs for minimum, maximum, etc. values
610 ret += [
611 "VDEF:%s_cur=%s,LAST" % (name, name),
612 "VDEF:%s_avg=%s,AVERAGE" % (name, name),
613 "VDEF:%s_max=%s,MAXIMUM" % (name, name),
614 "VDEF:%s_min=%s,MINIMUM" % (name, name),
615 ]
616
617 return ret
618
619 def get_object(self, *args, **kwargs):
620 return self.plugin.get_object(*args, **kwargs)
621
622 def get_object_table(self):
623 return {
624 "file" : self.object,
625 }
626
627 @property
628 def object_table(self):
629 if not hasattr(self, "_object_table"):
630 self._object_table = self.get_object_table()
631
632 return self._object_table
633
634 def generate_graph(self, interval=None, **kwargs):
635 # Make sure that all collected data is in the database
636 # to get a recent graph image
637 if self.object:
638 self.object.commit()
639
640 args = self._make_command_line(interval, **kwargs)
641
642 self.log.info(_("Generating graph %s") % self)
643
644 if self.object:
645 args += self.object.make_rrd_defs()
646
647 args += self.rrd_graph
648 args = self._add_vdefs(args)
649
650 # Convert arguments to string
651 args = [str(e) for e in args]
652
653 for arg in args:
654 self.log.debug(" %s" % arg)
655
656 with Environment(self.timezone, self.locale.lang):
657 graph = rrdtool.graphv("-", *args)
658
659 return {
660 "image" : graph.get("image"),
661 "image_height" : graph.get("image_height"),
662 "image_width" : graph.get("image_width"),
663 }
664
665 def graph_info(self):
666 """
667 Returns a dictionary with useful information
668 about this graph.
669 """
670 return {
671 "title" : self.graph_title,
672 "object_id" : self.object_id or "",
673 "template" : self.name,
674 }