]> git.ipfire.org Git - collecty.git/blame - src/collecty/plugins/base.py
plugins: Automatically replace None by 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
MT
25import os
26import rrdtool
c968f6d9 27import tempfile
49ce926e 28import threading
4ac0cdf0 29import time
f37913e8 30import unicodedata
4ac0cdf0 31
4ac0cdf0 32from ..constants import *
eed405de
MT
33from ..i18n import _
34
4be39bf9
MT
35class Timer(object):
36 def __init__(self, timeout, heartbeat=1):
37 self.timeout = timeout
38 self.heartbeat = heartbeat
39
e746a56e
MT
40 self.delay = 0
41
4be39bf9
MT
42 self.reset()
43
e746a56e 44 def reset(self, delay=0):
4be39bf9
MT
45 # Save start time.
46 self.start = time.time()
47
e746a56e
MT
48 self.delay = delay
49
4be39bf9
MT
50 # Has this timer been killed?
51 self.killed = False
52
53 @property
54 def elapsed(self):
e746a56e 55 return time.time() - self.start - self.delay
4be39bf9
MT
56
57 def cancel(self):
58 self.killed = True
59
60 def wait(self):
61 while self.elapsed < self.timeout and not self.killed:
62 time.sleep(self.heartbeat)
63
64 return self.elapsed > self.timeout
65
66
f37913e8
MT
67class PluginRegistration(type):
68 plugins = {}
69
70 def __init__(plugin, name, bases, dict):
71 type.__init__(plugin, name, bases, dict)
72
73 # The main class from which is inherited is not registered
74 # as a plugin.
75 if name == "Plugin":
76 return
77
78 if not all((plugin.name, plugin.description)):
79 raise RuntimeError(_("Plugin is not properly configured: %s") % plugin)
80
81 PluginRegistration.plugins[plugin.name] = plugin
82
83
84def get():
85 """
86 Returns a list with all automatically registered plugins.
87 """
88 return PluginRegistration.plugins.values()
89
90class Plugin(object, metaclass=PluginRegistration):
4ac0cdf0
MT
91 # The name of this plugin.
92 name = None
93
94 # A description for this plugin.
95 description = None
96
b1ea4956
MT
97 # Templates which can be used to generate a graph out of
98 # the data from this data source.
99 templates = []
100
72364063
MT
101 # The default interval for all plugins
102 interval = 60
4ac0cdf0 103
eed405de 104 def __init__(self, collecty, **kwargs):
eed405de
MT
105 self.collecty = collecty
106
4ac0cdf0
MT
107 # Check if this plugin was configured correctly.
108 assert self.name, "Name of the plugin is not set: %s" % self.name
109 assert self.description, "Description of the plugin is not set: %s" % self.description
4ac0cdf0
MT
110
111 # Initialize the logger.
112 self.log = logging.getLogger("collecty.plugins.%s" % self.name)
113 self.log.propagate = 1
eed405de 114
eed405de
MT
115 self.data = []
116
269f74cd
MT
117 # Run some custom initialization.
118 self.init(**kwargs)
119
0ee0c42d 120 self.log.debug(_("Successfully initialized %s") % self.__class__.__name__)
4ac0cdf0 121
73db5226 122 @property
72364063 123 def path(self):
73db5226 124 """
72364063
MT
125 Returns the name of the sub directory in which all RRD files
126 for this plugin should be stored in.
73db5226
MT
127 """
128 return self.name
129
72364063
MT
130 ### Basic methods
131
132 def init(self, **kwargs):
4ac0cdf0 133 """
72364063 134 Do some custom initialization stuff here.
4ac0cdf0 135 """
72364063
MT
136 pass
137
138 def collect(self):
139 """
140 Gathers the statistical data, this plugin collects.
141 """
142 time_start = time.time()
143
144 # Run through all objects of this plugin and call the collect method.
145 for o in self.objects:
146 now = datetime.datetime.utcnow()
147 try:
148 result = o.collect()
f648421a 149
a9af411f 150 result = self._format_result(result)
72364063
MT
151 except:
152 self.log.warning(_("Unhandled exception in %s.collect()") % o, exc_info=True)
153 continue
154
155 if not result:
156 self.log.warning(_("Received empty result: %s") % o)
157 continue
158
159 self.log.debug(_("Collected %s: %s") % (o, result))
160
161 # Add the object to the write queue so that the data is written
162 # to the databases later.
163 self.collecty.write_queue.add(o, now, result)
164
165 # Returns the time this function took to complete.
49c1b8fd 166 delay = time.time() - time_start
72364063 167
49c1b8fd
MT
168 # Log some warning when a collect method takes too long to return some data
169 if delay >= 60:
170 self.log.warning(_("A worker thread was stalled for %.4fs") % delay)
4ac0cdf0 171
a9af411f
MT
172 @staticmethod
173 def _format_result(result):
174 if not isinstance(result, tuple) and not isinstance(result, list):
175 return result
176
177 # Replace all Nones by NaN
178 s = []
179
180 for e in result:
181 if e is None:
182 e = "NaN"
183
184 # Format as string
185 e = "%s" % e
186
187 s.append(e)
188
189 return ":".join(s)
190
c968f6d9
MT
191 def get_object(self, id):
192 for object in self.objects:
193 if not object.id == id:
194 continue
195
196 return object
197
0308c0f3 198 def get_template(self, template_name, object_id):
c968f6d9
MT
199 for template in self.templates:
200 if not template.name == template_name:
201 continue
202
0308c0f3 203 return template(self, object_id)
c968f6d9
MT
204
205 def generate_graph(self, template_name, object_id="default", **kwargs):
0308c0f3 206 template = self.get_template(template_name, object_id=object_id)
c968f6d9
MT
207 if not template:
208 raise RuntimeError("Could not find template %s" % template_name)
209
210 time_start = time.time()
211
0308c0f3 212 graph = template.generate_graph(**kwargs)
c968f6d9
MT
213
214 duration = time.time() - time_start
0ee0c42d 215 self.log.debug(_("Generated graph %s in %.1fms") \
c968f6d9
MT
216 % (template, duration * 1000))
217
218 return graph
219
72364063
MT
220
221class Object(object):
222 # The schema of the RRD database.
223 rrd_schema = None
224
225 # RRA properties.
418174a4
MT
226 rra_types = ("AVERAGE", "MIN", "MAX")
227 rra_timespans = (
228 ("1m", "10d"),
229 ("1h", "18M"),
230 ("1d", "5y"),
231 )
72364063
MT
232
233 def __init__(self, plugin, *args, **kwargs):
234 self.plugin = plugin
235
236 # Indicates if this object has collected its data
237 self.collected = False
238
239 # Initialise this object
240 self.init(*args, **kwargs)
241
242 # Create the database file.
243 self.create()
244
245 def __repr__(self):
246 return "<%s>" % self.__class__.__name__
4ac0cdf0 247
965a9c51 248 @property
72364063
MT
249 def collecty(self):
250 return self.plugin.collecty
965a9c51 251
881751ed 252 @property
72364063
MT
253 def log(self):
254 return self.plugin.log
255
256 @property
257 def id(self):
258 """
259 Returns a UNIQUE identifier for this object. As this is incorporated
260 into the path of RRD file, it must only contain ASCII characters.
261 """
262 raise NotImplementedError
881751ed 263
4ac0cdf0
MT
264 @property
265 def file(self):
266 """
267 The absolute path to the RRD file of this plugin.
268 """
57797e47
MT
269 filename = self._normalise_filename("%s.rrd" % self.id)
270
271 return os.path.join(DATABASE_DIR, self.plugin.path, filename)
272
273 @staticmethod
274 def _normalise_filename(filename):
275 # Convert the filename into ASCII characters only
f37913e8 276 filename = unicodedata.normalize("NFKC", filename)
57797e47
MT
277
278 # Replace any spaces by dashes
279 filename = filename.replace(" ", "-")
280
281 return filename
72364063
MT
282
283 ### Basic methods
284
285 def init(self, *args, **kwargs):
286 """
287 Do some custom initialization stuff here.
288 """
289 pass
eed405de 290
4ac0cdf0
MT
291 def create(self):
292 """
293 Creates an empty RRD file with the desired data structures.
294 """
295 # Skip if the file does already exist.
296 if os.path.exists(self.file):
297 return
eed405de 298
4ac0cdf0
MT
299 dirname = os.path.dirname(self.file)
300 if not os.path.exists(dirname):
301 os.makedirs(dirname)
eed405de 302
965a9c51 303 # Create argument list.
ff0bbd88 304 args = self.get_rrd_schema()
965a9c51
MT
305
306 rrdtool.create(self.file, *args)
eed405de 307
4ac0cdf0 308 self.log.debug(_("Created RRD file %s.") % self.file)
dadb8fb0
MT
309 for arg in args:
310 self.log.debug(" %s" % arg)
eed405de 311
72364063
MT
312 def info(self):
313 return rrdtool.info(self.file)
314
315 @property
316 def stepsize(self):
317 return self.plugin.interval
318
319 @property
320 def heartbeat(self):
321 return self.stepsize * 2
322
965a9c51
MT
323 def get_rrd_schema(self):
324 schema = [
325 "--step", "%s" % self.stepsize,
326 ]
327 for line in self.rrd_schema:
328 if line.startswith("DS:"):
329 try:
330 (prefix, name, type, lower_limit, upper_limit) = line.split(":")
331
332 line = ":".join((
333 prefix,
334 name,
335 type,
881751ed 336 "%s" % self.heartbeat,
965a9c51
MT
337 lower_limit,
338 upper_limit
339 ))
340 except ValueError:
341 pass
342
343 schema.append(line)
344
345 xff = 0.1
346
418174a4
MT
347 for steps, rows in self.rra_timespans:
348 for type in self.rra_types:
349 schema.append("RRA:%s:%s:%s:%s" % (type, xff, steps, rows))
965a9c51
MT
350
351 return schema
352
72364063
MT
353 def execute(self):
354 if self.collected:
355 raise RuntimeError("This object has already collected its data")
eed405de 356
72364063
MT
357 self.collected = True
358 self.now = datetime.datetime.utcnow()
4ac0cdf0 359
72364063
MT
360 # Call the collect
361 result = self.collect()
4ac0cdf0 362
72364063 363 def commit(self):
4ac0cdf0 364 """
72364063 365 Will commit the collected data to the database.
4ac0cdf0 366 """
72364063 367 # Make sure that the RRD database has been created
dadb8fb0
MT
368 self.create()
369
b1ea4956
MT
370
371class GraphTemplate(object):
372 # A unique name to identify this graph template.
373 name = None
374
f181246a
MT
375 # Headline of the graph image
376 graph_title = None
377
378 # Vertical label of the graph
379 graph_vertical_label = None
380
381 # Limits
382 lower_limit = None
383 upper_limit = None
384
b1ea4956
MT
385 # Instructions how to create the graph.
386 rrd_graph = None
387
388 # Extra arguments passed to rrdgraph.
389 rrd_graph_args = []
390
c968f6d9
MT
391 intervals = {
392 None : "-3h",
393 "hour" : "-1h",
394 "day" : "-25h",
59385e95 395 "month": "-30d",
c968f6d9
MT
396 "week" : "-360h",
397 "year" : "-365d",
398 }
399
400 # Default dimensions for this graph
401 height = GRAPH_DEFAULT_HEIGHT
402 width = GRAPH_DEFAULT_WIDTH
403
0308c0f3 404 def __init__(self, plugin, object_id):
c968f6d9
MT
405 self.plugin = plugin
406
0308c0f3
MT
407 # Get all required RRD objects
408 self.object_id = object_id
409
410 # Get the main object
411 self.object = self.get_object(self.object_id)
412
c968f6d9
MT
413 def __repr__(self):
414 return "<%s>" % self.__class__.__name__
b1ea4956
MT
415
416 @property
417 def collecty(self):
c968f6d9 418 return self.plugin.collecty
b1ea4956 419
c968f6d9
MT
420 @property
421 def log(self):
422 return self.plugin.log
423
5913a52c
MT
424 def _make_command_line(self, interval, format=DEFAULT_IMAGE_FORMAT,
425 width=None, height=None):
c968f6d9
MT
426 args = []
427
428 args += GRAPH_DEFAULT_ARGUMENTS
429
430 args += [
5913a52c 431 "--imgformat", format,
c968f6d9
MT
432 "--height", "%s" % (height or self.height),
433 "--width", "%s" % (width or self.width),
73db5226 434 ]
eed405de 435
c968f6d9 436 args += self.rrd_graph_args
eed405de 437
f181246a
MT
438 # Graph title
439 if self.graph_title:
440 args += ["--title", self.graph_title]
441
442 # Vertical label
443 if self.graph_vertical_label:
444 args += ["--vertical-label", self.graph_vertical_label]
445
446 if self.lower_limit is not None or self.upper_limit is not None:
447 # Force to honour the set limits
448 args.append("--rigid")
449
450 if self.lower_limit is not None:
451 args += ["--lower-limit", self.lower_limit]
452
453 if self.upper_limit is not None:
454 args += ["--upper-limit", self.upper_limit]
455
b1ea4956 456 try:
c9351f4f 457 interval = self.intervals[interval]
b1ea4956 458 except KeyError:
c9351f4f
MT
459 interval = "end-%s" % interval
460
461 # Add interval
462 args += ["--start", interval]
c968f6d9
MT
463
464 return args
465
0308c0f3
MT
466 def get_object(self, *args, **kwargs):
467 return self.plugin.get_object(*args, **kwargs)
468
469 def get_object_table(self):
c968f6d9 470 return {
0308c0f3 471 "file" : self.object,
c968f6d9
MT
472 }
473
fc359f4d
MT
474 @property
475 def object_table(self):
476 if not hasattr(self, "_object_table"):
477 self._object_table = self.get_object_table()
478
479 return self._object_table
480
0308c0f3 481 def get_object_files(self):
c968f6d9
MT
482 files = {}
483
fc359f4d 484 for id, obj in self.object_table.items():
c968f6d9
MT
485 files[id] = obj.file
486
487 return files
488
0308c0f3 489 def generate_graph(self, interval=None, **kwargs):
c968f6d9
MT
490 args = self._make_command_line(interval, **kwargs)
491
492 self.log.info(_("Generating graph %s") % self)
493 self.log.debug(" args: %s" % args)
494
0308c0f3 495 object_files = self.get_object_files()
eed405de 496
73db5226 497 for item in self.rrd_graph:
eed405de 498 try:
c968f6d9 499 args.append(item % object_files)
eed405de
MT
500 except TypeError:
501 args.append(item)
502
0308c0f3
MT
503 self.log.debug(" %s" % args[-1])
504
699e99fb 505 # Convert arguments to string
5913a52c
MT
506 args = [str(e) for e in args]
507
699e99fb 508 graph = rrdtool.graphv("-", *args)
c968f6d9 509
699e99fb 510 return graph.get("image")