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