From bd05ae8f252f917da1937f34ac67f70c9f35531d Mon Sep 17 00:00:00 2001 From: ssamson-tis Date: Thu, 23 May 2013 10:19:43 +0200 Subject: [PATCH] Fist commit --- README.md | 31 +- iniparse/__init__.py | 25 + iniparse/compat.py | 343 + iniparse/config.py | 294 + iniparse/ini.py | 643 ++ iniparse/utils.py | 47 + libtisbackup/XenAPI.py | 242 + libtisbackup/__init__.py | 18 + libtisbackup/backup_mysql.py | 133 + libtisbackup/backup_null.py | 49 + libtisbackup/backup_pgsql.py | 127 + libtisbackup/backup_rdiff.py | 127 + libtisbackup/backup_rsync.py | 334 + libtisbackup/backup_switch.py | 181 + libtisbackup/backup_xcp_metadata.py | 108 + libtisbackup/backup_xva.py | 165 + libtisbackup/common.py | 909 ++ libtisbackup/copy_vm_xcp.py | 224 + samples/backup_button_jobs | 18 + samples/config.ini.sample | 55 + samples/tisbackup-config.ini | 84 + samples/tisbackup-pra.ini | 21 + samples/tisbackup.cron | 7 + samples/tisbackup_gui.ini | 10 + scripts/tisbackup_gui | 133 + static/images/back_disabled.png | Bin 0 -> 1361 bytes static/images/back_enabled.png | Bin 0 -> 1379 bytes static/images/back_enabled_hover.png | Bin 0 -> 1375 bytes static/images/bg_body.gif | Bin 0 -> 28295 bytes static/images/check.png | Bin 0 -> 2001 bytes static/images/forward_disabled.png | Bin 0 -> 1363 bytes static/images/forward_enabled.png | Bin 0 -> 1380 bytes static/images/forward_enabled_hover.png | Bin 0 -> 1379 bytes static/images/img01.jpg | Bin 0 -> 1763 bytes static/images/img02.jpg | Bin 0 -> 329 bytes static/images/img03.jpg | Bin 0 -> 372 bytes static/images/img04.jpg | Bin 0 -> 3478 bytes static/images/important.gif | Bin 0 -> 1492 bytes static/images/info.gif | Bin 0 -> 1487 bytes static/images/loader.gif | Bin 0 -> 4167 bytes static/images/logo-tis.png | Bin 0 -> 6230 bytes static/images/sort_asc.png | Bin 0 -> 1118 bytes static/images/sort_asc_disabled.png | Bin 0 -> 1050 bytes static/images/sort_both.png | Bin 0 -> 1136 bytes static/images/sort_desc.png | Bin 0 -> 1127 bytes static/images/sort_desc_disabled.png | Bin 0 -> 1045 bytes static/images/title.gif | Bin 0 -> 317 bytes static/js/jquery.alerts.js | 235 + static/js/jquery.dataTables.js | 12098 ++++++++++++++++++++++ static/js/jquery.min.js | 154 + static/js/jquery.ui.draggable.js | 1 + static/styles/datatables.css | 369 + static/styles/jquery.alerts.css | 57 + static/styles/style.css | 449 + templates/backups.html | 161 + templates/export_backup.html | 78 + templates/last_backups.html | 121 + templates/layout.html | 77 + tisbackup.py | 418 + tisbackup_gui.py | 321 + 60 files changed, 18864 insertions(+), 3 deletions(-) create mode 100755 iniparse/__init__.py create mode 100755 iniparse/compat.py create mode 100755 iniparse/config.py create mode 100755 iniparse/ini.py create mode 100755 iniparse/utils.py create mode 100644 libtisbackup/XenAPI.py create mode 100644 libtisbackup/__init__.py create mode 100644 libtisbackup/backup_mysql.py create mode 100755 libtisbackup/backup_null.py create mode 100644 libtisbackup/backup_pgsql.py create mode 100644 libtisbackup/backup_rdiff.py create mode 100644 libtisbackup/backup_rsync.py create mode 100644 libtisbackup/backup_switch.py create mode 100644 libtisbackup/backup_xcp_metadata.py create mode 100755 libtisbackup/backup_xva.py create mode 100644 libtisbackup/common.py create mode 100755 libtisbackup/copy_vm_xcp.py create mode 100644 samples/backup_button_jobs create mode 100644 samples/config.ini.sample create mode 100644 samples/tisbackup-config.ini create mode 100755 samples/tisbackup-pra.ini create mode 100644 samples/tisbackup.cron create mode 100644 samples/tisbackup_gui.ini create mode 100755 scripts/tisbackup_gui create mode 100644 static/images/back_disabled.png create mode 100644 static/images/back_enabled.png create mode 100644 static/images/back_enabled_hover.png create mode 100644 static/images/bg_body.gif create mode 100644 static/images/check.png create mode 100644 static/images/forward_disabled.png create mode 100644 static/images/forward_enabled.png create mode 100644 static/images/forward_enabled_hover.png create mode 100644 static/images/img01.jpg create mode 100644 static/images/img02.jpg create mode 100644 static/images/img03.jpg create mode 100644 static/images/img04.jpg create mode 100644 static/images/important.gif create mode 100644 static/images/info.gif create mode 100644 static/images/loader.gif create mode 100644 static/images/logo-tis.png create mode 100644 static/images/sort_asc.png create mode 100644 static/images/sort_asc_disabled.png create mode 100644 static/images/sort_both.png create mode 100644 static/images/sort_desc.png create mode 100644 static/images/sort_desc_disabled.png create mode 100644 static/images/title.gif create mode 100644 static/js/jquery.alerts.js create mode 100644 static/js/jquery.dataTables.js create mode 100644 static/js/jquery.min.js create mode 100644 static/js/jquery.ui.draggable.js create mode 100644 static/styles/datatables.css create mode 100644 static/styles/jquery.alerts.css create mode 100644 static/styles/style.css create mode 100755 templates/backups.html create mode 100755 templates/export_backup.html create mode 100755 templates/last_backups.html create mode 100644 templates/layout.html create mode 100644 tisbackup.py create mode 100755 tisbackup_gui.py diff --git a/README.md b/README.md index 5526c8b..29da480 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,29 @@ -tisbackup -========= +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- -backup server side executed python scripts for managing linux and windows system and application data backups, developed by adminsys for adminsys \ No newline at end of file + +Le script tisbackup se base sur un fichier de configuration .ini. Cf le fichier d'exemple pour le format + +Pour lancer le backup, lancer la commande +./tisbackup.py -c fichierconf.ini + +Pour lancer une section particulière du fichier .ini +./tisbackup.py -c fichierconf.ini -s section_choisi + +Pour mettre le mode debug +./tisbackup.py -c fichierconf.ini -l debug diff --git a/iniparse/__init__.py b/iniparse/__init__.py new file mode 100755 index 0000000..8de756f --- /dev/null +++ b/iniparse/__init__.py @@ -0,0 +1,25 @@ +# Copyright (c) 2001, 2002, 2003 Python Software Foundation +# Copyright (c) 2004-2008 Paramjit Oberoi +# Copyright (c) 2007 Tim Lauridsen +# All Rights Reserved. See LICENSE-PSF & LICENSE for details. + +from ini import INIConfig, change_comment_syntax +from config import BasicConfig, ConfigNamespace +from compat import RawConfigParser, ConfigParser, SafeConfigParser +from utils import tidy + +from ConfigParser import DuplicateSectionError, \ + NoSectionError, NoOptionError, \ + InterpolationMissingOptionError, \ + InterpolationDepthError, \ + InterpolationSyntaxError, \ + DEFAULTSECT, MAX_INTERPOLATION_DEPTH + +__all__ = [ + 'BasicConfig', 'ConfigNamespace', + 'INIConfig', 'tidy', 'change_comment_syntax', + 'RawConfigParser', 'ConfigParser', 'SafeConfigParser', + 'DuplicateSectionError', 'NoSectionError', 'NoOptionError', + 'InterpolationMissingOptionError', 'InterpolationDepthError', + 'InterpolationSyntaxError', 'DEFAULTSECT', 'MAX_INTERPOLATION_DEPTH', +] diff --git a/iniparse/compat.py b/iniparse/compat.py new file mode 100755 index 0000000..db89ed8 --- /dev/null +++ b/iniparse/compat.py @@ -0,0 +1,343 @@ +# Copyright (c) 2001, 2002, 2003 Python Software Foundation +# Copyright (c) 2004-2008 Paramjit Oberoi +# All Rights Reserved. See LICENSE-PSF & LICENSE for details. + +"""Compatibility interfaces for ConfigParser + +Interfaces of ConfigParser, RawConfigParser and SafeConfigParser +should be completely identical to the Python standard library +versions. Tested with the unit tests included with Python-2.3.4 + +The underlying INIConfig object can be accessed as cfg.data +""" + +import re +from ConfigParser import DuplicateSectionError, \ + NoSectionError, NoOptionError, \ + InterpolationMissingOptionError, \ + InterpolationDepthError, \ + InterpolationSyntaxError, \ + DEFAULTSECT, MAX_INTERPOLATION_DEPTH + +# These are imported only for compatiability. +# The code below does not reference them directly. +from ConfigParser import Error, InterpolationError, \ + MissingSectionHeaderError, ParsingError + +import ini + +class RawConfigParser(object): + def __init__(self, defaults=None, dict_type=dict): + if dict_type != dict: + raise ValueError('Custom dict types not supported') + self.data = ini.INIConfig(defaults=defaults, optionxformsource=self) + + def optionxform(self, optionstr): + return optionstr.lower() + + def defaults(self): + d = {} + secobj = self.data._defaults + for name in secobj._options: + d[name] = secobj._compat_get(name) + return d + + def sections(self): + """Return a list of section names, excluding [DEFAULT]""" + return list(self.data) + + def add_section(self, section): + """Create a new section in the configuration. + + Raise DuplicateSectionError if a section by the specified name + already exists. Raise ValueError if name is DEFAULT or any of + its case-insensitive variants. + """ + # The default section is the only one that gets the case-insensitive + # treatment - so it is special-cased here. + if section.lower() == "default": + raise ValueError, 'Invalid section name: %s' % section + + if self.has_section(section): + raise DuplicateSectionError(section) + else: + self.data._new_namespace(section) + + def has_section(self, section): + """Indicate whether the named section is present in the configuration. + + The DEFAULT section is not acknowledged. + """ + return (section in self.data) + + def options(self, section): + """Return a list of option names for the given section name.""" + if section in self.data: + return list(self.data[section]) + else: + raise NoSectionError(section) + + def read(self, filenames): + """Read and parse a filename or a list of filenames. + + Files that cannot be opened are silently ignored; this is + designed so that you can specify a list of potential + configuration file locations (e.g. current directory, user's + home directory, systemwide directory), and all existing + configuration files in the list will be read. A single + filename may also be given. + """ + files_read = [] + if isinstance(filenames, basestring): + filenames = [filenames] + for filename in filenames: + try: + fp = open(filename) + except IOError: + continue + files_read.append(filename) + self.data._readfp(fp) + fp.close() + return files_read + + def readfp(self, fp, filename=None): + """Like read() but the argument must be a file-like object. + + The `fp' argument must have a `readline' method. Optional + second argument is the `filename', which if not given, is + taken from fp.name. If fp has no `name' attribute, `' is + used. + """ + self.data._readfp(fp) + + def get(self, section, option, vars=None): + if not self.has_section(section): + raise NoSectionError(section) + if vars is not None and option in vars: + value = vars[option] + + sec = self.data[section] + if option in sec: + return sec._compat_get(option) + else: + raise NoOptionError(option, section) + + def items(self, section): + if section in self.data: + ans = [] + for opt in self.data[section]: + ans.append((opt, self.get(section, opt))) + return ans + else: + raise NoSectionError(section) + + def getint(self, section, option): + return int(self.get(section, option)) + + def getfloat(self, section, option): + return float(self.get(section, option)) + + _boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True, + '0': False, 'no': False, 'false': False, 'off': False} + + def getboolean(self, section, option): + v = self.get(section, option) + if v.lower() not in self._boolean_states: + raise ValueError, 'Not a boolean: %s' % v + return self._boolean_states[v.lower()] + + def has_option(self, section, option): + """Check for the existence of a given option in a given section.""" + if section in self.data: + sec = self.data[section] + else: + raise NoSectionError(section) + return (option in sec) + + def set(self, section, option, value): + """Set an option.""" + if section in self.data: + self.data[section][option] = value + else: + raise NoSectionError(section) + + def write(self, fp): + """Write an .ini-format representation of the configuration state.""" + fp.write(str(self.data)) + + def remove_option(self, section, option): + """Remove an option.""" + if section in self.data: + sec = self.data[section] + else: + raise NoSectionError(section) + if option in sec: + del sec[option] + return 1 + else: + return 0 + + def remove_section(self, section): + """Remove a file section.""" + if not self.has_section(section): + return False + del self.data[section] + return True + + +class ConfigDict(object): + """Present a dict interface to a ini section.""" + + def __init__(self, cfg, section, vars): + self.cfg = cfg + self.section = section + self.vars = vars + + def __getitem__(self, key): + try: + return RawConfigParser.get(self.cfg, self.section, key, self.vars) + except (NoOptionError, NoSectionError): + raise KeyError(key) + + +class ConfigParser(RawConfigParser): + + def get(self, section, option, raw=False, vars=None): + """Get an option value for a given section. + + All % interpolations are expanded in the return values, based on the + defaults passed into the constructor, unless the optional argument + `raw' is true. Additional substitutions may be provided using the + `vars' argument, which must be a dictionary whose contents overrides + any pre-existing defaults. + + The section DEFAULT is special. + """ + if section != DEFAULTSECT and not self.has_section(section): + raise NoSectionError(section) + + option = self.optionxform(option) + value = RawConfigParser.get(self, section, option, vars) + + if raw: + return value + else: + d = ConfigDict(self, section, vars) + return self._interpolate(section, option, value, d) + + def _interpolate(self, section, option, rawval, vars): + # do the string interpolation + value = rawval + depth = MAX_INTERPOLATION_DEPTH + while depth: # Loop through this until it's done + depth -= 1 + if "%(" in value: + try: + value = value % vars + except KeyError, e: + raise InterpolationMissingOptionError( + option, section, rawval, e.args[0]) + else: + break + if value.find("%(") != -1: + raise InterpolationDepthError(option, section, rawval) + return value + + def items(self, section, raw=False, vars=None): + """Return a list of tuples with (name, value) for each option + in the section. + + All % interpolations are expanded in the return values, based on the + defaults passed into the constructor, unless the optional argument + `raw' is true. Additional substitutions may be provided using the + `vars' argument, which must be a dictionary whose contents overrides + any pre-existing defaults. + + The section DEFAULT is special. + """ + if section != DEFAULTSECT and not self.has_section(section): + raise NoSectionError(section) + if vars is None: + options = list(self.data[section]) + else: + options = [] + for x in self.data[section]: + if x not in vars: + options.append(x) + options.extend(vars.keys()) + + if "__name__" in options: + options.remove("__name__") + + d = ConfigDict(self, section, vars) + if raw: + return [(option, d[option]) + for option in options] + else: + return [(option, self._interpolate(section, option, d[option], d)) + for option in options] + + +class SafeConfigParser(ConfigParser): + _interpvar_re = re.compile(r"%\(([^)]+)\)s") + _badpercent_re = re.compile(r"%[^%]|%$") + + def set(self, section, option, value): + if not isinstance(value, basestring): + raise TypeError("option values must be strings") + # check for bad percent signs: + # first, replace all "good" interpolations + tmp_value = self._interpvar_re.sub('', value) + # then, check if there's a lone percent sign left + m = self._badpercent_re.search(tmp_value) + if m: + raise ValueError("invalid interpolation syntax in %r at " + "position %d" % (value, m.start())) + + ConfigParser.set(self, section, option, value) + + def _interpolate(self, section, option, rawval, vars): + # do the string interpolation + L = [] + self._interpolate_some(option, L, rawval, section, vars, 1) + return ''.join(L) + + _interpvar_match = re.compile(r"%\(([^)]+)\)s").match + + def _interpolate_some(self, option, accum, rest, section, map, depth): + if depth > MAX_INTERPOLATION_DEPTH: + raise InterpolationDepthError(option, section, rest) + while rest: + p = rest.find("%") + if p < 0: + accum.append(rest) + return + if p > 0: + accum.append(rest[:p]) + rest = rest[p:] + # p is no longer used + c = rest[1:2] + if c == "%": + accum.append("%") + rest = rest[2:] + elif c == "(": + m = self._interpvar_match(rest) + if m is None: + raise InterpolationSyntaxError(option, section, + "bad interpolation variable reference %r" % rest) + var = m.group(1) + rest = rest[m.end():] + try: + v = map[var] + except KeyError: + raise InterpolationMissingOptionError( + option, section, rest, var) + if "%" in v: + self._interpolate_some(option, accum, v, + section, map, depth + 1) + else: + accum.append(v) + else: + raise InterpolationSyntaxError( + option, section, + "'%' must be followed by '%' or '(', found: " + repr(rest)) diff --git a/iniparse/config.py b/iniparse/config.py new file mode 100755 index 0000000..5cfa2ea --- /dev/null +++ b/iniparse/config.py @@ -0,0 +1,294 @@ +class ConfigNamespace(object): + """Abstract class representing the interface of Config objects. + + A ConfigNamespace is a collection of names mapped to values, where + the values may be nested namespaces. Values can be accessed via + container notation - obj[key] - or via dotted notation - obj.key. + Both these access methods are equivalent. + + To minimize name conflicts between namespace keys and class members, + the number of class members should be minimized, and the names of + all class members should start with an underscore. + + Subclasses must implement the methods for container-like access, + and this class will automatically provide dotted access. + + """ + + # Methods that must be implemented by subclasses + + def _getitem(self, key): + return NotImplementedError(key) + + def __setitem__(self, key, value): + raise NotImplementedError(key, value) + + def __delitem__(self, key): + raise NotImplementedError(key) + + def __iter__(self): + return NotImplementedError() + + def _new_namespace(self, name): + raise NotImplementedError(name) + + def __contains__(self, key): + try: + self._getitem(key) + except KeyError: + return False + return True + + # Machinery for converting dotted access into container access, + # and automatically creating new sections/namespaces. + # + # To distinguish between accesses of class members and namespace + # keys, we first call object.__getattribute__(). If that succeeds, + # the name is assumed to be a class member. Otherwise it is + # treated as a namespace key. + # + # Therefore, member variables should be defined in the class, + # not just in the __init__() function. See BasicNamespace for + # an example. + + def __getitem__(self, key): + try: + return self._getitem(key) + except KeyError: + return Undefined(key, self) + + def __getattr__(self, name): + try: + return self._getitem(name) + except KeyError: + if name.startswith('__') and name.endswith('__'): + raise AttributeError + return Undefined(name, self) + + def __setattr__(self, name, value): + try: + object.__getattribute__(self, name) + object.__setattr__(self, name, value) + except AttributeError: + self.__setitem__(name, value) + + def __delattr__(self, name): + try: + object.__getattribute__(self, name) + object.__delattr__(self, name) + except AttributeError: + self.__delitem__(name) + + # During unpickling, Python checks if the class has a __setstate__ + # method. But, the data dicts have not been initialised yet, which + # leads to _getitem and hence __getattr__ raising an exception. So + # we explicitly impement default __setstate__ behavior. + def __setstate__(self, state): + self.__dict__.update(state) + +class Undefined(object): + """Helper class used to hold undefined names until assignment. + + This class helps create any undefined subsections when an + assignment is made to a nested value. For example, if the + statement is "cfg.a.b.c = 42", but "cfg.a.b" does not exist yet. + """ + + def __init__(self, name, namespace): + object.__setattr__(self, 'name', name) + object.__setattr__(self, 'namespace', namespace) + + def __setattr__(self, name, value): + obj = self.namespace._new_namespace(self.name) + obj[name] = value + + def __setitem__(self, name, value): + obj = self.namespace._new_namespace(self.name) + obj[name] = value + + +# ---- Basic implementation of a ConfigNamespace + +class BasicConfig(ConfigNamespace): + """Represents a hierarchical collection of named values. + + Values are added using dotted notation: + + >>> n = BasicConfig() + >>> n.x = 7 + >>> n.name.first = 'paramjit' + >>> n.name.last = 'oberoi' + + ...and accessed the same way, or with [...]: + + >>> n.x + 7 + >>> n.name.first + 'paramjit' + >>> n.name.last + 'oberoi' + >>> n['x'] + 7 + >>> n['name']['first'] + 'paramjit' + + Iterating over the namespace object returns the keys: + + >>> l = list(n) + >>> l.sort() + >>> l + ['name', 'x'] + + Values can be deleted using 'del' and printed using 'print'. + + >>> n.aaa = 42 + >>> del n.x + >>> print n + aaa = 42 + name.first = paramjit + name.last = oberoi + + Nested namepsaces are also namespaces: + + >>> isinstance(n.name, ConfigNamespace) + True + >>> print n.name + first = paramjit + last = oberoi + >>> sorted(list(n.name)) + ['first', 'last'] + + Finally, values can be read from a file as follows: + + >>> from StringIO import StringIO + >>> sio = StringIO(''' + ... # comment + ... ui.height = 100 + ... ui.width = 150 + ... complexity = medium + ... have_python + ... data.secret.password = goodness=gracious me + ... ''') + >>> n = BasicConfig() + >>> n._readfp(sio) + >>> print n + complexity = medium + data.secret.password = goodness=gracious me + have_python + ui.height = 100 + ui.width = 150 + """ + + # this makes sure that __setattr__ knows this is not a namespace key + _data = None + + def __init__(self): + self._data = {} + + def _getitem(self, key): + return self._data[key] + + def __setitem__(self, key, value): + self._data[key] = value + + def __delitem__(self, key): + del self._data[key] + + def __iter__(self): + return iter(self._data) + + def __str__(self, prefix=''): + lines = [] + keys = self._data.keys() + keys.sort() + for name in keys: + value = self._data[name] + if isinstance(value, ConfigNamespace): + lines.append(value.__str__(prefix='%s%s.' % (prefix,name))) + else: + if value is None: + lines.append('%s%s' % (prefix, name)) + else: + lines.append('%s%s = %s' % (prefix, name, value)) + return '\n'.join(lines) + + def _new_namespace(self, name): + obj = BasicConfig() + self._data[name] = obj + return obj + + def _readfp(self, fp): + while True: + line = fp.readline() + if not line: + break + + line = line.strip() + if not line: continue + if line[0] == '#': continue + data = line.split('=', 1) + if len(data) == 1: + name = line + value = None + else: + name = data[0].strip() + value = data[1].strip() + name_components = name.split('.') + ns = self + for n in name_components[:-1]: + if n in ns: + ns = ns[n] + if not isinstance(ns, ConfigNamespace): + raise TypeError('value-namespace conflict', n) + else: + ns = ns._new_namespace(n) + ns[name_components[-1]] = value + + +# ---- Utility functions + +def update_config(target, source): + """Imports values from source into target. + + Recursively walks the ConfigNamespace and inserts values + into the ConfigNamespace. For example: + + >>> n = BasicConfig() + >>> n.playlist.expand_playlist = True + >>> n.ui.display_clock = True + >>> n.ui.display_qlength = True + >>> n.ui.width = 150 + >>> print n + playlist.expand_playlist = True + ui.display_clock = True + ui.display_qlength = True + ui.width = 150 + + >>> from iniparse import ini + >>> i = ini.INIConfig() + >>> update_config(i, n) + >>> print i + [playlist] + expand_playlist = True + + [ui] + display_clock = True + display_qlength = True + width = 150 + + """ + for name in source: + value = source[name] + if isinstance(value, ConfigNamespace): + if name in target: + myns = target[name] + if not isinstance(myns, ConfigNamespace): + raise TypeError('value-namespace conflict') + else: + myns = target._new_namespace(name) + update_config(myns, value) + else: + target[name] = value + + + diff --git a/iniparse/ini.py b/iniparse/ini.py new file mode 100755 index 0000000..68dd65c --- /dev/null +++ b/iniparse/ini.py @@ -0,0 +1,643 @@ +"""Access and/or modify INI files + +* Compatiable with ConfigParser +* Preserves order of sections & options +* Preserves comments/blank lines/etc +* More conveninet access to data + +Example: + + >>> from StringIO import StringIO + >>> sio = StringIO('''# configure foo-application + ... [foo] + ... bar1 = qualia + ... bar2 = 1977 + ... [foo-ext] + ... special = 1''') + + >>> cfg = INIConfig(sio) + >>> print cfg.foo.bar1 + qualia + >>> print cfg['foo-ext'].special + 1 + >>> cfg.foo.newopt = 'hi!' + >>> cfg.baz.enabled = 0 + + >>> print cfg + # configure foo-application + [foo] + bar1 = qualia + bar2 = 1977 + newopt = hi! + [foo-ext] + special = 1 + + [baz] + enabled = 0 + +""" + +# An ini parser that supports ordered sections/options +# Also supports updates, while preserving structure +# Backward-compatiable with ConfigParser + +import re +from ConfigParser import DEFAULTSECT, ParsingError, MissingSectionHeaderError + +import config + +class LineType(object): + line = None + + def __init__(self, line=None): + if line is not None: + self.line = line.strip('\n') + + # Return the original line for unmodified objects + # Otherwise construct using the current attribute values + def __str__(self): + if self.line is not None: + return self.line + else: + return self.to_string() + + # If an attribute is modified after initialization + # set line to None since it is no longer accurate. + def __setattr__(self, name, value): + if hasattr(self,name): + self.__dict__['line'] = None + self.__dict__[name] = value + + def to_string(self): + raise Exception('This method must be overridden in derived classes') + + +class SectionLine(LineType): + regex = re.compile(r'^\[' + r'(?P[^]]+)' + r'\]\s*' + r'((?P;|#)(?P.*))?$') + + def __init__(self, name, comment=None, comment_separator=None, + comment_offset=-1, line=None): + super(SectionLine, self).__init__(line) + self.name = name + self.comment = comment + self.comment_separator = comment_separator + self.comment_offset = comment_offset + + def to_string(self): + out = '[' + self.name + ']' + if self.comment is not None: + # try to preserve indentation of comments + out = (out+' ').ljust(self.comment_offset) + out = out + self.comment_separator + self.comment + return out + + def parse(cls, line): + m = cls.regex.match(line.rstrip()) + if m is None: + return None + return cls(m.group('name'), m.group('comment'), + m.group('csep'), m.start('csep'), + line) + parse = classmethod(parse) + + +class OptionLine(LineType): + def __init__(self, name, value, separator=' = ', comment=None, + comment_separator=None, comment_offset=-1, line=None): + super(OptionLine, self).__init__(line) + self.name = name + self.value = value + self.separator = separator + self.comment = comment + self.comment_separator = comment_separator + self.comment_offset = comment_offset + + def to_string(self): + out = '%s%s%s' % (self.name, self.separator, self.value) + if self.comment is not None: + # try to preserve indentation of comments + out = (out+' ').ljust(self.comment_offset) + out = out + self.comment_separator + self.comment + return out + + regex = re.compile(r'^(?P[^:=\s[][^:=]*)' + r'(?P[:=]\s*)' + r'(?P.*)$') + + def parse(cls, line): + m = cls.regex.match(line.rstrip()) + if m is None: + return None + + name = m.group('name').rstrip() + value = m.group('value') + sep = m.group('name')[len(name):] + m.group('sep') + + # comments are not detected in the regex because + # ensuring total compatibility with ConfigParser + # requires that: + # option = value ;comment // value=='value' + # option = value;1 ;comment // value=='value;1 ;comment' + # + # Doing this in a regex would be complicated. I + # think this is a bug. The whole issue of how to + # include ';' in the value needs to be addressed. + # Also, '#' doesn't mark comments in options... + + coff = value.find(';') + if coff != -1 and value[coff-1].isspace(): + comment = value[coff+1:] + csep = value[coff] + value = value[:coff].rstrip() + coff = m.start('value') + coff + else: + comment = None + csep = None + coff = -1 + + return cls(name, value, sep, comment, csep, coff, line) + parse = classmethod(parse) + + +def change_comment_syntax(comment_chars='%;#', allow_rem=False): + comment_chars = re.sub(r'([\]\-\^])', r'\\\1', comment_chars) + regex = r'^(?P[%s]' % comment_chars + if allow_rem: + regex += '|[rR][eE][mM]' + regex += r')(?P.*)$' + CommentLine.regex = re.compile(regex) + +class CommentLine(LineType): + regex = re.compile(r'^(?P[;#]|[rR][eE][mM] +)' + r'(?P.*)$') + + def __init__(self, comment='', separator='#', line=None): + super(CommentLine, self).__init__(line) + self.comment = comment + self.separator = separator + + def to_string(self): + return self.separator + self.comment + + def parse(cls, line): + m = cls.regex.match(line.rstrip()) + if m is None: + return None + return cls(m.group('comment'), m.group('csep'), line) + parse = classmethod(parse) + + +class EmptyLine(LineType): + # could make this a singleton + def to_string(self): + return '' + + value = property(lambda _: '') + + def parse(cls, line): + if line.strip(): return None + return cls(line) + parse = classmethod(parse) + + +class ContinuationLine(LineType): + regex = re.compile(r'^\s+(?P.*)$') + + def __init__(self, value, value_offset=None, line=None): + super(ContinuationLine, self).__init__(line) + self.value = value + if value_offset is None: + value_offset = 8 + self.value_offset = value_offset + + def to_string(self): + return ' '*self.value_offset + self.value + + def parse(cls, line): + m = cls.regex.match(line.rstrip()) + if m is None: + return None + return cls(m.group('value'), m.start('value'), line) + parse = classmethod(parse) + + +class LineContainer(object): + def __init__(self, d=None): + self.contents = [] + self.orgvalue = None + if d: + if isinstance(d, list): self.extend(d) + else: self.add(d) + + def add(self, x): + self.contents.append(x) + + def extend(self, x): + for i in x: self.add(i) + + def get_name(self): + return self.contents[0].name + + def set_name(self, data): + self.contents[0].name = data + + def get_value(self): + if self.orgvalue is not None: + return self.orgvalue + elif len(self.contents) == 1: + return self.contents[0].value + else: + return '\n'.join([('%s' % x.value) for x in self.contents + if not isinstance(x, CommentLine)]) + + def set_value(self, data): + self.orgvalue = data + lines = ('%s' % data).split('\n') + + # If there is an existing ContinuationLine, use its offset + value_offset = None + for v in self.contents: + if isinstance(v, ContinuationLine): + value_offset = v.value_offset + break + + # Rebuild contents list, preserving initial OptionLine + self.contents = self.contents[0:1] + self.contents[0].value = lines[0] + del lines[0] + for line in lines: + if line.strip(): + self.add(ContinuationLine(line, value_offset)) + else: + self.add(EmptyLine()) + + name = property(get_name, set_name) + value = property(get_value, set_value) + + def __str__(self): + s = [x.__str__() for x in self.contents] + return '\n'.join(s) + + def finditer(self, key): + for x in self.contents[::-1]: + if hasattr(x, 'name') and x.name==key: + yield x + + def find(self, key): + for x in self.finditer(key): + return x + raise KeyError(key) + + +def _make_xform_property(myattrname, srcattrname=None): + private_attrname = myattrname + 'value' + private_srcname = myattrname + 'source' + if srcattrname is None: + srcattrname = myattrname + + def getfn(self): + srcobj = getattr(self, private_srcname) + if srcobj is not None: + return getattr(srcobj, srcattrname) + else: + return getattr(self, private_attrname) + + def setfn(self, value): + srcobj = getattr(self, private_srcname) + if srcobj is not None: + setattr(srcobj, srcattrname, value) + else: + setattr(self, private_attrname, value) + + return property(getfn, setfn) + + +class INISection(config.ConfigNamespace): + _lines = None + _options = None + _defaults = None + _optionxformvalue = None + _optionxformsource = None + _compat_skip_empty_lines = set() + def __init__(self, lineobj, defaults = None, + optionxformvalue=None, optionxformsource=None): + self._lines = [lineobj] + self._defaults = defaults + self._optionxformvalue = optionxformvalue + self._optionxformsource = optionxformsource + self._options = {} + + _optionxform = _make_xform_property('_optionxform') + + def _compat_get(self, key): + # identical to __getitem__ except that _compat_XXX + # is checked for backward-compatible handling + if key == '__name__': + return self._lines[-1].name + if self._optionxform: key = self._optionxform(key) + try: + value = self._options[key].value + del_empty = key in self._compat_skip_empty_lines + except KeyError: + if self._defaults and key in self._defaults._options: + value = self._defaults._options[key].value + del_empty = key in self._defaults._compat_skip_empty_lines + else: + raise + if del_empty: + value = re.sub('\n+', '\n', value) + return value + + def _getitem(self, key): + if key == '__name__': + return self._lines[-1].name + if self._optionxform: key = self._optionxform(key) + try: + return self._options[key].value + except KeyError: + if self._defaults and key in self._defaults._options: + return self._defaults._options[key].value + else: + raise + + def __setitem__(self, key, value): + if self._optionxform: xkey = self._optionxform(key) + else: xkey = key + if xkey in self._compat_skip_empty_lines: + self._compat_skip_empty_lines.remove(xkey) + if xkey not in self._options: + # create a dummy object - value may have multiple lines + obj = LineContainer(OptionLine(key, '')) + self._lines[-1].add(obj) + self._options[xkey] = obj + # the set_value() function in LineContainer + # automatically handles multi-line values + self._options[xkey].value = value + + def __delitem__(self, key): + if self._optionxform: key = self._optionxform(key) + if key in self._compat_skip_empty_lines: + self._compat_skip_empty_lines.remove(key) + for l in self._lines: + remaining = [] + for o in l.contents: + if isinstance(o, LineContainer): + n = o.name + if self._optionxform: n = self._optionxform(n) + if key != n: remaining.append(o) + else: + remaining.append(o) + l.contents = remaining + del self._options[key] + + def __iter__(self): + d = set() + for l in self._lines: + for x in l.contents: + if isinstance(x, LineContainer): + if self._optionxform: + ans = self._optionxform(x.name) + else: + ans = x.name + if ans not in d: + yield ans + d.add(ans) + if self._defaults: + for x in self._defaults: + if x not in d: + yield x + d.add(x) + + def _new_namespace(self, name): + raise Exception('No sub-sections allowed', name) + + +def make_comment(line): + return CommentLine(line.rstrip('\n')) + + +def readline_iterator(f): + """iterate over a file by only using the file object's readline method""" + + have_newline = False + while True: + line = f.readline() + + if not line: + if have_newline: + yield "" + return + + if line.endswith('\n'): + have_newline = True + else: + have_newline = False + + yield line + + +def lower(x): + return x.lower() + + +class INIConfig(config.ConfigNamespace): + _data = None + _sections = None + _defaults = None + _optionxformvalue = None + _optionxformsource = None + _sectionxformvalue = None + _sectionxformsource = None + _parse_exc = None + _bom = False + def __init__(self, fp=None, defaults=None, parse_exc=True, + optionxformvalue=lower, optionxformsource=None, + sectionxformvalue=None, sectionxformsource=None): + self._data = LineContainer() + self._parse_exc = parse_exc + self._optionxformvalue = optionxformvalue + self._optionxformsource = optionxformsource + self._sectionxformvalue = sectionxformvalue + self._sectionxformsource = sectionxformsource + self._sections = {} + if defaults is None: defaults = {} + self._defaults = INISection(LineContainer(), optionxformsource=self) + for name, value in defaults.iteritems(): + self._defaults[name] = value + if fp is not None: + self._readfp(fp) + + _optionxform = _make_xform_property('_optionxform', 'optionxform') + _sectionxform = _make_xform_property('_sectionxform', 'optionxform') + + def _getitem(self, key): + if key == DEFAULTSECT: + return self._defaults + if self._sectionxform: key = self._sectionxform(key) + return self._sections[key] + + def __setitem__(self, key, value): + raise Exception('Values must be inside sections', key, value) + + def __delitem__(self, key): + if self._sectionxform: key = self._sectionxform(key) + for line in self._sections[key]._lines: + self._data.contents.remove(line) + del self._sections[key] + + def __iter__(self): + d = set() + d.add(DEFAULTSECT) + for x in self._data.contents: + if isinstance(x, LineContainer): + if x.name not in d: + yield x.name + d.add(x.name) + + def _new_namespace(self, name): + if self._data.contents: + self._data.add(EmptyLine()) + obj = LineContainer(SectionLine(name)) + self._data.add(obj) + if self._sectionxform: name = self._sectionxform(name) + if name in self._sections: + ns = self._sections[name] + ns._lines.append(obj) + else: + ns = INISection(obj, defaults=self._defaults, + optionxformsource=self) + self._sections[name] = ns + return ns + + def __str__(self): + if self._bom: + fmt = u'\ufeff%s' + else: + fmt = '%s' + return fmt % self._data.__str__() + + __unicode__ = __str__ + + _line_types = [EmptyLine, CommentLine, + SectionLine, OptionLine, + ContinuationLine] + + def _parse(self, line): + for linetype in self._line_types: + lineobj = linetype.parse(line) + if lineobj: + return lineobj + else: + # can't parse line + return None + + def _readfp(self, fp): + cur_section = None + cur_option = None + cur_section_name = None + cur_option_name = None + pending_lines = [] + pending_empty_lines = False + try: + fname = fp.name + except AttributeError: + fname = '' + linecount = 0 + exc = None + line = None + + for line in readline_iterator(fp): + # Check for BOM on first line + if linecount == 0 and isinstance(line, unicode): + if line[0] == u'\ufeff': + line = line[1:] + self._bom = True + + lineobj = self._parse(line) + linecount += 1 + + if not cur_section and not isinstance(lineobj, + (CommentLine, EmptyLine, SectionLine)): + if self._parse_exc: + raise MissingSectionHeaderError(fname, linecount, line) + else: + lineobj = make_comment(line) + + if lineobj is None: + if self._parse_exc: + if exc is None: exc = ParsingError(fname) + exc.append(linecount, line) + lineobj = make_comment(line) + + if isinstance(lineobj, ContinuationLine): + if cur_option: + if pending_lines: + cur_option.extend(pending_lines) + pending_lines = [] + if pending_empty_lines: + optobj._compat_skip_empty_lines.add(cur_option_name) + pending_empty_lines = False + cur_option.add(lineobj) + else: + # illegal continuation line - convert to comment + if self._parse_exc: + if exc is None: exc = ParsingError(fname) + exc.append(linecount, line) + lineobj = make_comment(line) + + if isinstance(lineobj, OptionLine): + if pending_lines: + cur_section.extend(pending_lines) + pending_lines = [] + pending_empty_lines = False + cur_option = LineContainer(lineobj) + cur_section.add(cur_option) + if self._optionxform: + cur_option_name = self._optionxform(cur_option.name) + else: + cur_option_name = cur_option.name + if cur_section_name == DEFAULTSECT: + optobj = self._defaults + else: + optobj = self._sections[cur_section_name] + optobj._options[cur_option_name] = cur_option + + if isinstance(lineobj, SectionLine): + self._data.extend(pending_lines) + pending_lines = [] + pending_empty_lines = False + cur_section = LineContainer(lineobj) + self._data.add(cur_section) + cur_option = None + cur_option_name = None + if cur_section.name == DEFAULTSECT: + self._defaults._lines.append(cur_section) + cur_section_name = DEFAULTSECT + else: + if self._sectionxform: + cur_section_name = self._sectionxform(cur_section.name) + else: + cur_section_name = cur_section.name + if cur_section_name not in self._sections: + self._sections[cur_section_name] = \ + INISection(cur_section, defaults=self._defaults, + optionxformsource=self) + else: + self._sections[cur_section_name]._lines.append(cur_section) + + if isinstance(lineobj, (CommentLine, EmptyLine)): + pending_lines.append(lineobj) + if isinstance(lineobj, EmptyLine): + pending_empty_lines = True + + self._data.extend(pending_lines) + if line and line[-1]=='\n': + self._data.add(EmptyLine()) + + if exc: + raise exc + + diff --git a/iniparse/utils.py b/iniparse/utils.py new file mode 100755 index 0000000..829fc28 --- /dev/null +++ b/iniparse/utils.py @@ -0,0 +1,47 @@ +import compat +from ini import LineContainer, EmptyLine + +def tidy(cfg): + """Clean up blank lines. + + This functions makes the configuration look clean and + handwritten - consecutive empty lines and empty lines at + the start of the file are removed, and one is guaranteed + to be at the end of the file. + """ + + if isinstance(cfg, compat.RawConfigParser): + cfg = cfg.data + cont = cfg._data.contents + i = 1 + while i < len(cont): + if isinstance(cont[i], LineContainer): + tidy_section(cont[i]) + i += 1 + elif (isinstance(cont[i-1], EmptyLine) and + isinstance(cont[i], EmptyLine)): + del cont[i] + else: + i += 1 + + # Remove empty first line + if cont and isinstance(cont[0], EmptyLine): + del cont[0] + + # Ensure a last line + if cont and not isinstance(cont[-1], EmptyLine): + cont.append(EmptyLine()) + +def tidy_section(lc): + cont = lc.contents + i = 1 + while i < len(cont): + if (isinstance(cont[i-1], EmptyLine) and + isinstance(cont[i], EmptyLine)): + del cont[i] + else: + i += 1 + + # Remove empty first line + if len(cont) > 1 and isinstance(cont[1], EmptyLine): + del cont[1] diff --git a/libtisbackup/XenAPI.py b/libtisbackup/XenAPI.py new file mode 100644 index 0000000..092b4fe --- /dev/null +++ b/libtisbackup/XenAPI.py @@ -0,0 +1,242 @@ +#============================================================================ +# This library is free software; you can redistribute it and/or +# modify it under the terms of version 2.1 of the GNU Lesser General Public +# License as published by the Free Software Foundation. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +#============================================================================ +# Copyright (C) 2006-2007 XenSource Inc. +#============================================================================ +# +# Parts of this file are based upon xmlrpclib.py, the XML-RPC client +# interface included in the Python distribution. +# +# Copyright (c) 1999-2002 by Secret Labs AB +# Copyright (c) 1999-2002 by Fredrik Lundh +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of +# Secret Labs AB or the author not be used in advertising or publicity +# pertaining to distribution of the software without specific, written +# prior permission. +# +# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD +# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- +# ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR +# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# -------------------------------------------------------------------- + +import gettext +import xmlrpclib +import httplib +import socket + +translation = gettext.translation('xen-xm', fallback = True) + +API_VERSION_1_1 = '1.1' +API_VERSION_1_2 = '1.2' + +# +# Methods that have different parameters between API versions 1.1 and 1.2, and +# the number of parameters in 1.1. +# +COMPATIBILITY_METHODS_1_1 = [ + ('SR.create' , 8), + ('SR.introduce' , 6), + ('SR.make' , 7), + ('VDI.snapshot' , 1), + ('VDI.clone' , 1), + ] + +class Failure(Exception): + def __init__(self, details): + self.details = details + + def __str__(self): + try: + return str(self.details) + except Exception, exn: + import sys + print >>sys.stderr, exn + return "Xen-API failure: %s" % str(self.details) + + def _details_map(self): + return dict([(str(i), self.details[i]) + for i in range(len(self.details))]) + + +_RECONNECT_AND_RETRY = (lambda _ : ()) + +class UDSHTTPConnection(httplib.HTTPConnection): + """HTTPConnection subclass to allow HTTP over Unix domain sockets. """ + def connect(self): + path = self.host.replace("_", "/") + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(path) + +class UDSHTTP(httplib.HTTP): + _connection_class = UDSHTTPConnection + +class UDSTransport(xmlrpclib.Transport): + def make_connection(self, host): + return UDSHTTP(host) + +class Session(xmlrpclib.ServerProxy): + """A server proxy and session manager for communicating with xapi using + the Xen-API. + + Example: + + session = Session('http://localhost/') + session.login_with_password('me', 'mypassword') + session.xenapi.VM.start(vm_uuid) + session.xenapi.session.logout() + """ + + def __init__(self, uri, transport=None, encoding=None, verbose=0, + allow_none=1): + xmlrpclib.ServerProxy.__init__(self, uri, transport, encoding, + verbose, allow_none) + self._session = None + self.last_login_method = None + self.last_login_params = None + self.API_version = API_VERSION_1_1 + + + def xenapi_request(self, methodname, params): + if methodname.startswith('login'): + self._login(methodname, params) + return None + elif methodname == 'logout': + self._logout() + return None + else: + retry_count = 0 + while retry_count < 3: + full_params = (self._session,) + params + result = _parse_result(getattr(self, methodname)(*full_params)) + if result == _RECONNECT_AND_RETRY: + retry_count += 1 + if self.last_login_method: + self._login(self.last_login_method, + self.last_login_params) + else: + raise xmlrpclib.Fault(401, 'You must log in') + else: + return result + raise xmlrpclib.Fault( + 500, 'Tried 3 times to get a valid session, but failed') + + + def _login(self, method, params): + result = _parse_result(getattr(self, 'session.%s' % method)(*params)) + if result == _RECONNECT_AND_RETRY: + raise xmlrpclib.Fault( + 500, 'Received SESSION_INVALID when logging in') + self._session = result + self.last_login_method = method + self.last_login_params = params + if method.startswith("slave_local"): + self.API_version = API_VERSION_1_2 + else: + self.API_version = self._get_api_version() + + def logout(self): + try: + if self.last_login_method.startswith("slave_local"): + return _parse_result(self.session.local_logout(self._session)) + else: + return _parse_result(self.session.logout(self._session)) + finally: + self._session = None + self.last_login_method = None + self.last_login_params = None + self.API_version = API_VERSION_1_1 + + def _get_api_version(self): + pool = self.xenapi.pool.get_all()[0] + host = self.xenapi.pool.get_master(pool) + if (self.xenapi.host.get_API_version_major(host) == "1" and + self.xenapi.host.get_API_version_minor(host) == "2"): + return API_VERSION_1_2 + else: + return API_VERSION_1_1 + + def __getattr__(self, name): + if name == 'handle': + return self._session + elif name == 'xenapi': + return _Dispatcher(self.API_version, self.xenapi_request, None) + elif name.startswith('login') or name.startswith('slave_local'): + return lambda *params: self._login(name, params) + else: + return xmlrpclib.ServerProxy.__getattr__(self, name) + +def xapi_local(): + return Session("http://_var_xapi_xapi/", transport=UDSTransport()) + +def _parse_result(result): + if type(result) != dict or 'Status' not in result: + raise xmlrpclib.Fault(500, 'Missing Status in response from server' + result) + if result['Status'] == 'Success': + if 'Value' in result: + return result['Value'] + else: + raise xmlrpclib.Fault(500, + 'Missing Value in response from server') + else: + if 'ErrorDescription' in result: + if result['ErrorDescription'][0] == 'SESSION_INVALID': + return _RECONNECT_AND_RETRY + else: + raise Failure(result['ErrorDescription']) + else: + raise xmlrpclib.Fault( + 500, 'Missing ErrorDescription in response from server') + + +# Based upon _Method from xmlrpclib. +class _Dispatcher: + def __init__(self, API_version, send, name): + self.__API_version = API_version + self.__send = send + self.__name = name + + def __repr__(self): + if self.__name: + return '' % self.__name + else: + return '' + + def __getattr__(self, name): + if self.__name is None: + return _Dispatcher(self.__API_version, self.__send, name) + else: + return _Dispatcher(self.__API_version, self.__send, "%s.%s" % (self.__name, name)) + + def __call__(self, *args): + if self.__API_version == API_VERSION_1_1: + for m in COMPATIBILITY_METHODS_1_1: + if self.__name == m[0]: + return self.__send(self.__name, args[0:m[1]]) + + return self.__send(self.__name, args) + diff --git a/libtisbackup/__init__.py b/libtisbackup/__init__.py new file mode 100644 index 0000000..b72c7a3 --- /dev/null +++ b/libtisbackup/__init__.py @@ -0,0 +1,18 @@ +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- + diff --git a/libtisbackup/backup_mysql.py b/libtisbackup/backup_mysql.py new file mode 100644 index 0000000..050f988 --- /dev/null +++ b/libtisbackup/backup_mysql.py @@ -0,0 +1,133 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- + + + +import sys +try: + sys.stderr = open('/dev/null') # Silence silly warnings from paramiko + import paramiko +except ImportError,e: + print "Error : can not load paramiko library %s" % e + raise + +sys.stderr = sys.__stderr__ + +import datetime +import base64 +import os +from common import * + +class backup_mysql(backup_generic): + """Backup a mysql database as gzipped sql file through ssh""" + type = 'mysql+ssh' + required_params = backup_generic.required_params + ['db_name','db_user','db_passwd','private_key'] + db_name='' + db_user='' + db_passwd='' + + def do_backup(self,stats): + + self.logger.debug('[%s] Connecting to %s with user root and key %s',self.backup_name,self.server_name,self.private_key) + try: + mykey = paramiko.RSAKey.from_private_key_file(self.private_key) + except paramiko.SSHException: + mykey = paramiko.DSSKey.from_private_key_file(self.private_key) + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(self.server_name,username='root',pkey = mykey, port=self.ssh_port) + + t = datetime.datetime.now() + backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S') + + # dump db + stats['status']='Dumping' + cmd = 'mysqldump -u' + self.db_user +' -p' + self.db_passwd + ' ' + self.db_name + ' > /tmp/' + self.db_name + '-' + backup_start_date + '.sql' + self.logger.debug('[%s] Dump DB : %s',self.backup_name,cmd) + if not self.dry_run: + (error_code,output) = ssh_exec(cmd,ssh=ssh) + self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output) + if error_code: + raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd)) + + # zip the file + stats['status']='Zipping' + cmd = 'gzip /tmp/' + self.db_name + '-' + backup_start_date + '.sql' + self.logger.debug('[%s] Compress backup : %s',self.backup_name,cmd) + if not self.dry_run: + (error_code,output) = ssh_exec(cmd,ssh=ssh) + self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output) + if error_code: + raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd)) + + # get the file + stats['status']='SFTP' + filepath = '/tmp/' + self.db_name + '-' + backup_start_date + '.sql.gz' + localpath = os.path.join(self.backup_dir , self.db_name + '-' + backup_start_date + '.sql.gz') + self.logger.debug('[%s] Get gz backup with sftp on %s from %s to %s',self.backup_name,self.server_name,filepath,localpath) + if not self.dry_run: + transport = ssh.get_transport() + sftp = paramiko.SFTPClient.from_transport(transport) + sftp.get(filepath, localpath) + sftp.close() + + if not self.dry_run: + stats['total_files_count']=1 + stats['written_files_count']=1 + stats['total_bytes']=os.stat(localpath).st_size + stats['written_bytes']=os.stat(localpath).st_size + stats['log']='gzip dump of DB %s:%s (%d bytes) to %s' % (self.server_name,self.db_name, stats['written_bytes'], localpath) + stats['backup_location'] = localpath + + stats['status']='RMTemp' + cmd = 'rm -f /tmp/' + self.db_name + '-' + backup_start_date + '.sql.gz' + self.logger.debug('[%s] Remove temp gzip : %s',self.backup_name,cmd) + if not self.dry_run: + (error_code,output) = ssh_exec(cmd,ssh=ssh) + self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output) + if error_code: + raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd)) + stats['status']='OK' + + def register_existingbackups(self): + """scan backup dir and insert stats in database""" + + registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))] + + filelist = os.listdir(self.backup_dir) + filelist.sort() + p = re.compile('^%s-(?P\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}).sql.gz$' % self.db_name) + for item in filelist: + sr = p.match(item) + if sr: + file_name = os.path.join(self.backup_dir,item) + start = datetime.datetime.strptime(sr.groups()[0],'%Y%m%d-%Hh%Mm%S').isoformat() + if not file_name in registered: + self.logger.info('Registering %s from %s',file_name,fileisodate(file_name)) + size_bytes = int(os.popen('du -sb "%s"' % file_name).read().split('\t')[0]) + self.logger.debug(' Size in bytes : %i',size_bytes) + if not self.dry_run: + self.dbstat.add(self.backup_name,self.server_name,'',\ + backup_start=start,backup_end=fileisodate(file_name),status='OK',total_bytes=size_bytes,backup_location=file_name) + else: + self.logger.info('Skipping %s from %s, already registered',file_name,fileisodate(file_name)) + +register_driver(backup_mysql) diff --git a/libtisbackup/backup_null.py b/libtisbackup/backup_null.py new file mode 100755 index 0000000..c5c58d3 --- /dev/null +++ b/libtisbackup/backup_null.py @@ -0,0 +1,49 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- + +import os +import datetime +from common import * + + +class backup_null(backup_generic): + """Null backup to register servers which don't need any backups + but we still want to know they are taken in account""" + type = 'null' + + required_params = ['type','server_name','backup_name'] + optional_params = [] + + def do_backup(self,stats): + pass + def process_backup(self): + pass + def cleanup_backup(self): + pass + def export_latestbackup(self,destdir): + return {} + def checknagios(self,maxage_hours=30): + return (nagiosStateOk,"No backups needs to be performed") + +register_driver(backup_null) + +if __name__=='__main__': + pass + diff --git a/libtisbackup/backup_pgsql.py b/libtisbackup/backup_pgsql.py new file mode 100644 index 0000000..5ec5077 --- /dev/null +++ b/libtisbackup/backup_pgsql.py @@ -0,0 +1,127 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- +import sys +try: + sys.stderr = open('/dev/null') # Silence silly warnings from paramiko + import paramiko +except ImportError,e: + print "Error : can not load paramiko library %s" % e + raise + +sys.stderr = sys.__stderr__ + +import datetime +import base64 +import os +import logging +import re +from common import * + +class backup_pgsql(backup_generic): + """Backup a postgresql database as gzipped sql file through ssh""" + type = 'pgsql+ssh' + required_params = backup_generic.required_params + ['db_name','private_key'] + db_name='' + + def do_backup(self,stats): + try: + mykey = paramiko.RSAKey.from_private_key_file(self.private_key) + except paramiko.SSHException: + mykey = paramiko.DSSKey.from_private_key_file(self.private_key) + + self.logger.debug('[%s] Trying to connect to "%s" with username root and key "%s"',self.backup_name,self.server_name,self.private_key) + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(self.server_name,username='root',pkey = mykey,port=self.ssh_port) + + t = datetime.datetime.now() + backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S') + + # dump db + cmd = 'sudo -u postgres pg_dump ' + self.db_name + ' > /tmp/' + self.db_name + '-' + backup_start_date + '.sql' + self.logger.debug('[%s] %s ',self.backup_name,cmd) + if not self.dry_run: + (error_code,output) = ssh_exec(cmd,ssh=ssh) + self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output) + if error_code: + raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd)) + + # zip the file + cmd = 'gzip /tmp/' + self.db_name + '-' + backup_start_date + '.sql' + self.logger.debug('[%s] %s ',self.backup_name,cmd) + if not self.dry_run: + (error_code,output) = ssh_exec(cmd,ssh=ssh) + self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output) + if error_code: + raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd)) + + # get the file + filepath = '/tmp/' + self.db_name + '-' + backup_start_date + '.sql.gz' + localpath = self.backup_dir + '/' + self.db_name + '-' + backup_start_date + '.sql.gz' + self.logger.debug('[%s] get the file using sftp from "%s" to "%s" ',self.backup_name,filepath,localpath) + if not self.dry_run: + transport = ssh.get_transport() + sftp = paramiko.SFTPClient.from_transport(transport) + sftp.get(filepath, localpath) + sftp.close() + + if not self.dry_run: + stats['total_files_count']=1 + stats['written_files_count']=1 + stats['total_bytes']=os.stat(localpath).st_size + stats['written_bytes']=os.stat(localpath).st_size + stats['log']='gzip dump of DB %s:%s (%d bytes) to %s' % (self.server_name,self.db_name, stats['written_bytes'], localpath) + + stats['backup_location'] = localpath + + cmd = 'rm -f /tmp/' + self.db_name + '-' + backup_start_date + '.sql.gz' + self.logger.debug('[%s] %s ',self.backup_name,cmd) + if not self.dry_run: + (error_code,output) = ssh_exec(cmd,ssh=ssh) + self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output) + if error_code: + raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd)) + + stats['status']='OK' + + def register_existingbackups(self): + """scan backup dir and insert stats in database""" + + registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))] + + filelist = os.listdir(self.backup_dir) + filelist.sort() + p = re.compile('^%s-(?P\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}).sql.gz$' % self.db_name) + for item in filelist: + sr = p.match(item) + if sr: + file_name = os.path.join(self.backup_dir,item) + start = datetime.datetime.strptime(sr.groups()[0],'%Y%m%d-%Hh%Mm%S').isoformat() + if not file_name in registered: + self.logger.info('Registering %s from %s',file_name,fileisodate(file_name)) + size_bytes = int(os.popen('du -sb "%s"' % file_name).read().split('\t')[0]) + self.logger.debug(' Size in bytes : %i',size_bytes) + if not self.dry_run: + self.dbstat.add(self.backup_name,self.server_name,'',\ + backup_start=start,backup_end=fileisodate(file_name),status='OK',total_bytes=size_bytes,backup_location=file_name) + else: + self.logger.info('Skipping %s from %s, already registered',file_name,fileisodate(file_name)) + +register_driver(backup_pgsql) diff --git a/libtisbackup/backup_rdiff.py b/libtisbackup/backup_rdiff.py new file mode 100644 index 0000000..746332e --- /dev/null +++ b/libtisbackup/backup_rdiff.py @@ -0,0 +1,127 @@ +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- + + +import os +import datetime +from common import * +import time + +class backup_rdiff: + backup_dir='' + backup_start_date=None + backup_name='' + server_name='' + exclude_list='' + ssh_port='22' + remote_user='root' + remote_dir='' + dest_dir='' + verbose = False + dry_run=False + + + + def __init__(self, backup_name, backup_base_dir): + self.backup_dir = backup_base_dir + '/' + backup_name + + if os.path.isdir(self.backup_dir )==False: + os.makedirs(self.backup_dir) + + self.backup_name = backup_name + t = datetime.datetime.now() + self.backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S') + + def get_latest_backup(self): + filelist = os.listdir(self.backup_dir) + if len(filelist) == 0: + return '' + + filelist.sort() + + return filelist[-1] + + def cleanup_backup(self): + filelist = os.listdir(self.backup_dir) + if len(filelist) == 0: + return '' + + filelist.sort() + for backup_date in filelist: + today = time.time() + print backup_date + datestring = backup_date[0:8] + c = time.strptime(datestring,"%Y%m%d") + # TODO: improve + if today - c < 60 * 60 * 24* 30: + print time.strftime("%Y%m%d",c) + " is to be deleted" + + + def copy_latest_to_new(self): + # TODO check that latest exist + # TODO check that new does not exist + + + last_backup = self.get_latest_backup() + if last_backup=='': + print "*********************************" + print "*first backup for " + self.backup_name + else: + latest_backup_path = self.backup_dir + '/' + last_backup + new_backup_path = self.backup_dir + '/' + self.backup_start_date + print "#cp -al starting" + cmd = 'cp -al ' + latest_backup_path + ' ' + new_backup_path + print cmd + if self.dry_run==False: + call_external_process(cmd) + print "#cp -al finished" + + + def rsync_to_new(self): + + self.dest_dir = self.backup_dir + '/' + self.backup_start_date + '/' + src_server = self.remote_user + '@' + self.server_name + ':"' + self.remote_dir.strip() + '/"' + + print "#starting rsync" + verbose_arg="" + if self.verbose==True: + verbose_arg = "-P " + + cmd = "rdiff-backup " + verbose_arg + ' --compress-level=9 --numeric-ids -az --partial -e "ssh -o StrictHostKeyChecking=no -c Blowfish -p ' + self.ssh_port + ' -i ' + self.private_key + '" --stats --delete-after ' + self.exclude_list + ' ' + src_server + ' ' + self.dest_dir + print cmd + + ## deal with exit code 24 (file vanished) + if self.dry_run==False: + p = subprocess.call(cmd, shell=True) + if (p ==24): + print "Note: some files vanished before transfer" + if (p != 0 and p != 24 ): + raise Exception('shell program exited with error code ' + str(p), cmd) + + + print "#finished rsync" + + def process_backup(self): + print "" + print "#========Starting backup item =========" + self.copy_latest_to_new() + + self.rsync_to_new() + print "#========Backup item finished==========" + + diff --git a/libtisbackup/backup_rsync.py b/libtisbackup/backup_rsync.py new file mode 100644 index 0000000..3a6df87 --- /dev/null +++ b/libtisbackup/backup_rsync.py @@ -0,0 +1,334 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- + +import os +import datetime +from common import * +import time +import logging +import re +import os.path +import datetime + + +class backup_rsync(backup_generic): + """Backup a directory on remote server with rsync and rsync protocol (requires running remote rsync daemon)""" + type = 'rsync' + required_params = backup_generic.required_params + ['remote_user','remote_dir','rsync_module','password_file'] + optional_params = backup_generic.optional_params + ['compressionlevel','compression','bwlimit','exclude_list','protect_args','overload_args'] + + remote_user='root' + remote_dir='' + + exclude_list='' + rsync_module='' + password_file = '' + compression = '' + bwlimit = 0 + protect_args = '1' + overload_args = None + compressionlevel = 0 + + + + def read_config(self,iniconf): + assert(isinstance(iniconf,ConfigParser)) + backup_generic.read_config(self,iniconf) + if not self.bwlimit and iniconf.has_option('global','bw_limit'): + self.bwlimit = iniconf.getint('global','bw_limit') + if not self.compressionlevel and iniconf.has_option('global','compression_level'): + self.compressionlevel = iniconf.getint('global','compression_level') + + def do_backup(self,stats): + if not self.set_lock(): + self.logger.error("[%s] a lock file is set, a backup maybe already running!!",self.backup_name) + return False + + try: + try: + backup_source = 'undefined' + dest_dir = os.path.join(self.backup_dir,self.backup_start_date+'.rsync/') + if not os.path.isdir(dest_dir): + if not self.dry_run: + os.makedirs(dest_dir) + else: + print 'mkdir "%s"' % dest_dir + else: + raise Exception('backup destination directory already exists : %s' % dest_dir) + + options = ['-rt','--stats','--delete-excluded','--numeric-ids','--delete-after'] + if self.logger.level: + options.append('-P') + + if self.dry_run: + options.append('-d') + + if self.overload_args <> None: + options.append(self.overload_args) + elif not "cygdrive" in self.remote_dir: + # we don't preserve owner, group, links, hardlinks, perms for windows/cygwin as it is not reliable nor useful + options.append('-lpgoD') + + # the protect-args option is not available in all rsync version + if not self.protect_args.lower() in ('false','no','0'): + options.append('--protect-args') + + if self.compression.lower() in ('true','yes','1'): + options.append('-z') + + if self.compressionlevel: + options.append('--compress-level=%s' % self.compressionlevel) + + if self.bwlimit: + options.append('--bwlimit %s' % self.bwlimit) + + latest = self.get_latest_backup(self.backup_start_date) + if latest: + options.extend(['--link-dest="%s"' % os.path.join('..',b,'') for b in latest]) + + def strip_quotes(s): + if s[0] == '"': + s = s[1:] + if s[-1] == '"': + s = s[:-1] + return s + + # Add excludes + if "--exclude" in self.exclude_list: + # old settings with exclude_list=--exclude toto --exclude=titi + excludes = [strip_quotes(s).strip() for s in self.exclude_list.replace('--exclude=','').replace('--exclude ','').split()] + else: + try: + # newsettings with exclude_list='too','titi', parsed as a str python list content + excludes = eval('[%s]' % self.exclude_list) + except Exception,e: + raise Exception('Error reading exclude list : value %s, eval error %s (don\'t forget quotes and comma...)' % (self.exclude_list,e)) + options.extend(['--exclude="%s"' % x for x in excludes]) + + if (self.rsync_module and not self.password_file): + raise Exception('You must specify a password file if you specify a rsync module') + + if (not self.rsync_module and not self.private_key): + raise Exception('If you don''t use SSH, you must specify a rsync module') + + #rsync_re = re.compile('(?P[^:]*)::(?P[^/]*)/(?P.*)') + #ssh_re = re.compile('((?P.*)@)?(?P[^:]*):(?P/.*)') + + # Add ssh connection params + if self.rsync_module: + # Case of rsync exports + if self.password_file: + options.append('--password-file="%s"' % self.password_file) + backup_source = '%s@%s::%s%s' % (self.remote_user, self.server_name, self.rsync_module, self.remote_dir) + else: + # case of rsync + ssh + ssh_params = ['-o StrictHostKeyChecking=no','-c blowfish'] + if self.private_key: + ssh_params.append('-i %s' % self.private_key) + if self.ssh_port <> 22: + ssh_params.append('-p %i' % self.ssh_port) + options.append('-e "/usr/bin/ssh %s"' % (" ".join(ssh_params))) + backup_source = '%s@%s:%s' % (self.remote_user,self.server_name,self.remote_dir) + + # ensure there is a slash at end + if backup_source[-1] <> '/': + backup_source += '/' + + options_params = " ".join(options) + + cmd = '/usr/bin/rsync %s %s %s 2>&1' % (options_params,backup_source,dest_dir) + self.logger.debug("[%s] rsync : %s",self.backup_name,cmd) + + if not self.dry_run: + self.line = '' + process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True) + def ondata(data,context): + if context.verbose: + print data + context.logger.debug(data) + + log = monitor_stdout(process,ondata,self) + + for l in log.splitlines(): + if l.startswith('Number of files:'): + stats['total_files_count'] += int(l.split(':')[1]) + if l.startswith('Number of files transferred:'): + stats['written_files_count'] += int(l.split(':')[1]) + if l.startswith('Total file size:'): + stats['total_bytes'] += int(l.split(':')[1].split()[0]) + if l.startswith('Total transferred file size:'): + stats['written_bytes'] += int(l.split(':')[1].split()[0]) + + returncode = process.returncode + ## deal with exit code 24 (file vanished) + if (returncode == 24): + self.logger.warning("[" + self.backup_name + "] Note: some files vanished before transfer") + elif (returncode == 23): + self.logger.warning("[" + self.backup_name + "] unable so set uid on some files") + elif (returncode != 0): + self.logger.error("[" + self.backup_name + "] shell program exited with error code ") + raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd) + else: + print cmd + + #we suppress the .rsync suffix if everything went well + finaldest = os.path.join(self.backup_dir,self.backup_start_date) + self.logger.debug("[%s] renaming target directory from %s to %s" ,self.backup_name,dest_dir,finaldest) + if not self.dry_run: + os.rename(dest_dir, finaldest) + self.logger.debug("[%s] touching datetime of target directory %s" ,self.backup_name,finaldest) + print os.popen('touch "%s"' % finaldest).read() + else: + print "mv" ,dest_dir,finaldest + stats['backup_location'] = finaldest + stats['status']='OK' + stats['log']='ssh+rsync backup from %s OK, %d bytes written for %d changed files' % (backup_source,stats['written_bytes'],stats['written_files_count']) + + except BaseException , e: + stats['status']='ERROR' + stats['log']=str(e) + raise + + + finally: + self.remove_lock() + + def get_latest_backup(self,current): + result = [] + filelist = os.listdir(self.backup_dir) + filelist.sort() + filelist.reverse() + full = '' + r_full = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$') + r_partial = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}.rsync$') + # we take all latest partials younger than the latest full and the latest full + for item in filelist: + if r_partial.match(item) and itemstart: + stop = fileisodate(dir_name) + else: + stop = start + self.logger.info('Registering %s started on %s',dir_name,start) + self.logger.debug(' Disk usage %s','du -sb "%s"' % dir_name) + if not self.dry_run: + size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split('\t')[0]) + else: + size_bytes = 0 + self.logger.debug(' Size in bytes : %i',size_bytes) + if not self.dry_run: + self.dbstat.add(self.backup_name,self.server_name,'',\ + backup_start=start,backup_end = stop,status='OK',total_bytes=size_bytes,backup_location=dir_name) + else: + self.logger.info('Skipping %s, already registered',dir_name) + + + def is_pid_still_running(self,lockfile): + f = open(lockfile) + lines = f.readlines() + f.close() + if len(lines)==0 : + self.logger.info("[" + self.backup_name + "] empty lock file, removing...") + return False + + for line in lines: + if line.startswith('pid='): + pid = line.split('=')[1].strip() + if os.path.exists("/proc/" + pid): + self.logger.info("[" + self.backup_name + "] process still there") + return True + else: + self.logger.info("[" + self.backup_name + "] process not there anymore remove lock") + return False + else: + self.logger.info("[" + self.backup_name + "] incorrrect lock file : no pid line") + return False + + + def set_lock(self): + self.logger.debug("[" + self.backup_name + "] setting lock") + + #TODO: improve for race condition + #TODO: also check if process is really there + if os.path.isfile(self.backup_dir + '/lock'): + self.logger.debug("[" + self.backup_name + "] File " + self.backup_dir + '/lock already exist') + if self.is_pid_still_running(self.backup_dir + '/lock')==False: + self.logger.info("[" + self.backup_name + "] removing lock file " + self.backup_dir + '/lock') + os.unlink(self.backup_dir + '/lock') + else: + return False + + lockfile = open(self.backup_dir + '/lock',"w") + # Write all the lines at once: + lockfile.write('pid='+str(os.getpid())) + lockfile.write('\nbackup_time=' + self.backup_start_date) + lockfile.close() + return True + + def remove_lock(self): + self.logger.debug("[%s] removing lock",self.backup_name ) + os.unlink(self.backup_dir + '/lock') + +class backup_rsync_ssh(backup_rsync): + """Backup a directory on remote server with rsync and ssh protocol (requires rsync software on remote host)""" + type = 'rsync+ssh' + required_params = backup_generic.required_params + ['remote_user','remote_dir','private_key'] + optional_params = backup_generic.optional_params + ['compression','bwlimit','ssh_port','exclude_list','protect_args','overload_args'] + + +register_driver(backup_rsync) +register_driver(backup_rsync_ssh) + +if __name__=='__main__': + logger = logging.getLogger('tisbackup') + logger.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s') + handler = logging.StreamHandler() + handler.setFormatter(formatter) + logger.addHandler(handler) + + cp = ConfigParser() + cp.read('/opt/tisbackup/configtest.ini') + dbstat = BackupStat('/backup/data/log/tisbackup.sqlite') + b = backup_rsync('htouvet','/backup/data/htouvet',dbstat) + b.read_config(cp) + b.process_backup() + print b.checknagios() + diff --git a/libtisbackup/backup_switch.py b/libtisbackup/backup_switch.py new file mode 100644 index 0000000..0309651 --- /dev/null +++ b/libtisbackup/backup_switch.py @@ -0,0 +1,181 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- + +import os +import datetime +from common import * +import XenAPI +import time +import logging +import re +import os.path +import datetime +import select +import urllib2, urllib +import base64 +import socket +import pexpect +from stat import * + + +class backup_switch(backup_generic): + """Backup a startup-config on a switch""" + type = 'switch' + + required_params = backup_generic.required_params + ['switch_ip','switch_user' , 'switch_type'] + optional_params = backup_generic.optional_params + ['switch_password'] + + def switch_hp(self, filename): + + s = socket.socket() + try: + s.connect((self.switch_ip, 23)) + s.close() + except: + raise + + child=pexpect.spawn('telnet '+self.switch_ip) + time.sleep(1) + if self.switch_user != "": + child.sendline(self.switch_user) + child.sendline(self.switch_password+'\r') + else: + child.sendline(self.switch_password+'\r') + try: + child.expect("#") + except: + raise Exception("Bad Credentials") + child.sendline( "terminal length 1000\r") + child.expect("#") + child.sendline( "show config\r") + child.maxread = 100000000 + child.expect("Startup.+$") + lines = child.after + if "-- MORE --" in lines: + raise Exception("Terminal lenght is not sufficient") + child.expect("#") + lines += child.before + child.sendline("logout\r") + child.send('y\r') + for line in lines.split("\n")[1:-1]: + open(filename,"a").write(line.strip()+"\n") + + def switch_linksys_SRW2024(self, filename): + s = socket.socket() + try: + s.connect((self.switch_ip, 23)) + s.close() + except: + raise + + child=pexpect.spawn('telnet '+self.switch_ip) + time.sleep(1) + if hasattr(self,'switch_password'): + child.sendline(self.switch_user+'\t') + child.sendline(self.switch_password+'\r') + else: + child.sendline(self.switch_user+'\r') + try: + child.expect('Menu') + except: + raise Exception("Bad Credentials") + child.sendline('\032') + child.expect('>') + child.sendline('lcli') + child.expect("Name:") + if hasattr(self,'switch_password'): + child.send(self.switch_user+'\r'+self.switch_password+'\r') + else: + child.sendline(self.switch_user) + child.expect(".*#") + child.sendline( "terminal datadump") + child.expect("#") + child.sendline( "show startup-config") + child.expect("#") + lines = child.before + if "Unrecognized command" in lines: + raise Exception("Bad Credentials") + child.sendline("exit") + child.expect( ">") + child.sendline("logout") + for line in lines.split("\n")[1:-1]: + open(filename,"a").write(line.strip()+"\n") + + + def switch_dlink_DGS1210(self, filename): + login_data = urllib.urlencode({'Login' : self.switch_user, 'Password' : self.switch_password, 'currlang' : 0, 'BrowsingPage' : 'index_dlink.htm', 'changlang' : 0}) + req = urllib2.Request('http://%s/' % self.switch_ip, login_data) + resp = urllib2.urlopen(req) + if "Wrong password" in resp.read(): + raise Exception("Wrong password") + resp = urllib2.urlopen("http://%s/config.bin?Gambit=gdkdcdgdidbdkdadkdbgegngjgogkdbgegngjgog&dumy=1348649950256" % self.switch_ip) + f = open(filename, 'w') + f.write(resp.read()) + + + def do_backup(self,stats): + try: + dest_filename = os.path.join(self.backup_dir,"%s-%s" % (self.backup_name,self.backup_start_date)) + + options = [] + options_params = " ".join(options) + if "LINKSYS-SRW2024" == self.switch_type: + dest_filename += '.txt' + self.switch_linksys_SRW2024(dest_filename) + elif self.switch_type in [ "HP-PROCURVE-4104GL", "HP-PROCURVE-2524" ]: + dest_filename += '.txt' + self.switch_hp(dest_filename) + elif "DLINK-DGS1210" == self.switch_type: + dest_filename += '.bin' + self.switch_dlink_DGS1210(dest_filename) + else: + raise Exception("Unknown Switch type") + + stats['total_files_count']=1 + stats['written_files_count']=1 + stats['total_bytes']= os.stat(dest_filename).st_size + stats['written_bytes'] = stats['total_bytes'] + stats['backup_location'] = dest_filename + stats['status']='OK' + stats['log']='Switch backup from %s OK, %d bytes written' % (self.server_name,stats['written_bytes']) + + + except BaseException , e: + stats['status']='ERROR' + stats['log']=str(e) + raise + + + +register_driver(backup_switch) + +if __name__=='__main__': + logger = logging.getLogger('tisbackup') + logger.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s') + handler = logging.StreamHandler() + handler.setFormatter(formatter) + logger.addHandler(handler) + + cp = ConfigParser() + cp.read('/opt/tisbackup/configtest.ini') + b = backup_xva() + b.read_config(cp) + diff --git a/libtisbackup/backup_xcp_metadata.py b/libtisbackup/backup_xcp_metadata.py new file mode 100644 index 0000000..86f3acd --- /dev/null +++ b/libtisbackup/backup_xcp_metadata.py @@ -0,0 +1,108 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- + + + +import sys +import shutil + +import datetime +import base64 +import os +from common import * + +class backup_xcp_metadata(backup_generic): + """Backup metatdata of a xcp pool using xe pool-dump-database""" + type = 'xcp-dump-metadata' + required_params = ['type','server_name','xcp_user','xcp_passwd','backup_name'] + xcp_user='' + xcp_passwd='' + + def do_backup(self,stats): + + self.logger.debug('[%s] Connecting to %s with user root and key %s',self.backup_name,self.server_name,self.private_key) + + if os.path.isfile('/opt/xensource/bin/xe') == False: + raise Exception('Aborting, /opt/xensource/bin/xe binary not present"') + + + t = datetime.datetime.now() + backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S') + + # dump pool medatadata + localpath = os.path.join(self.backup_dir , 'xcp_metadata-' + backup_start_date + '.dump.gz') + temppath = '/tmp/xcp_metadata-' + backup_start_date + '.dump' + + stats['status']='Dumping' + + if not self.dry_run: + cmd = "/opt/xensource/bin/xe -s %s -u %s -pw %s pool-dump-database file-name=%s" %(self.server_name,self.xcp_user,self.xcp_passwd,temppath) + self.logger.debug('[%s] Dump XCP Metadata : %s',self.backup_name,cmd) + call_external_process(cmd) + + + # zip the file + stats['status']='Zipping' + cmd = 'gzip %s ' %temppath + self.logger.debug('[%s] Compress backup : %s',self.backup_name,cmd) + if not self.dry_run: + call_external_process(cmd) + + # get the file + stats['status']='move to backup directory' + self.logger.debug('[%s] Moving temp backup file %s to backup new path %s',self.backup_name,self.server_name,localpath) + if not self.dry_run: + shutil.move (temppath + '.gz' ,localpath) + + if not self.dry_run: + stats['total_files_count']=1 + stats['written_files_count']=1 + stats['total_bytes']=os.stat(localpath).st_size + stats['written_bytes']=os.stat(localpath).st_size + stats['log']='gzip dump of DB %s:%s (%d bytes) to %s' % (self.server_name,'xcp metadata dump', stats['written_bytes'], localpath) + stats['backup_location'] = localpath + stats['status']='OK' + + + + def register_existingbackups(self): + """scan metatdata backup files and insert stats in database""" + + registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))] + + filelist = os.listdir(self.backup_dir) + filelist.sort() + p = re.compile('^%s-(?P\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}).dump.gz$' % self.server_name) + for item in filelist: + sr = p.match(item) + if sr: + file_name = os.path.join(self.backup_dir,item) + start = datetime.datetime.strptime(sr.groups()[0],'%Y%m%d-%Hh%Mm%S').isoformat() + if not file_name in registered: + self.logger.info('Registering %s from %s',file_name,fileisodate(file_name)) + size_bytes = int(os.popen('du -sb "%s"' % file_name).read().split('\t')[0]) + self.logger.debug(' Size in bytes : %i',size_bytes) + if not self.dry_run: + self.dbstat.add(self.backup_name,self.server_name,'',\ + backup_start=start,backup_end=fileisodate(file_name),status='OK',total_bytes=size_bytes,backup_location=file_name) + else: + self.logger.info('Skipping %s from %s, already registered',file_name,fileisodate(file_name)) + +register_driver(backup_xcp_metadata) diff --git a/libtisbackup/backup_xva.py b/libtisbackup/backup_xva.py new file mode 100755 index 0000000..2b3758f --- /dev/null +++ b/libtisbackup/backup_xva.py @@ -0,0 +1,165 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- + +import os +import datetime +from common import * +import XenAPI +import time +import logging +import re +import os.path +import os +import datetime +import select +import urllib2 +import base64 +import socket +from stat import * + + +class backup_xva(backup_generic): + """Backup a VM running on a XCP server as a XVA file (requires xe tools and XenAPI)""" + type = 'xen-xva' + + required_params = backup_generic.required_params + ['xcphost','password_file','server_name'] + optional_params = backup_generic.optional_params + ['excluded_vbds','remote_user','private_key'] + + def export_xva(self, vdi_name, filename, dry_run): + + user_xen, password_xen, null = open(self.password_file).read().split('\n') + session = XenAPI.Session('https://'+self.xcphost) + try: + session.login_with_password(user_xen,password_xen) + except XenAPI.Failure, error: + msg,ip = error.details + + if msg == 'HOST_IS_SLAVE': + xcphost = ip + session = XenAPI.Session('https://'+xcphost) + session.login_with_password(user_xen,password_xen) + + vm = session.xenapi.VM.get_by_name_label(vdi_name)[0] + status_vm = session.xenapi.VM.get_power_state(vm) + + self.logger.debug("[%s] Status of VM: %s",self.backup_name,status_vm) + if status_vm == "Running": + self.logger.debug("[%s] Shudown in progress",self.backup_name) + if dry_run: + print "session.xenapi.VM.clean_shutdown(vm)" + + else: + session.xenapi.VM.clean_shutdown(vm) + + try: + try: + self.logger.debug("[%s] Copy in progress",self.backup_name) + + socket.setdefaulttimeout(120) + auth = base64.encodestring("%s:%s" % (user_xen, password_xen)).strip() + url = "https://"+self.xcphost+"/export?uuid="+session.xenapi.VM.get_uuid(vm) + request = urllib2.Request(url) + request.add_header("Authorization", "Basic %s" % auth) + result = urllib2.urlopen(request) + + if dry_run: + print "request = urllib2.Request(%s)" % url + print 'outputfile = open(%s, "wb")' % filename + else: + outputfile = open(filename, "wb") + for line in result: + outputfile.write(line) + outputfile.close() + + except: + if os.path.exists(filename): + os.unlink(filename) + raise + + finally: + if status_vm == "Running": + self.logger.debug("[%s] Starting in progress",self.backup_name) + if dry_run: + print "session.xenapi.Async.VM.start(vm,False,True)" + else: + session.xenapi.Async.VM.start(vm,False,True) + + session.logout() + + if os.path.exists(filename): + import tarfile + tar = tarfile.open(filename) + if not tar.getnames(): + unlink(filename) + return("Tar error") + tar.close() + + return(0) + + + + + def do_backup(self,stats): + try: + dest_filename = os.path.join(self.backup_dir,"%s-%s.%s" % (self.backup_name,self.backup_start_date,'xva')) + + options = [] + options_params = " ".join(options) + cmd = self.export_xva( self.server_name, dest_filename, self.dry_run) + if os.path.exists(dest_filename): + stats['written_bytes'] = os.stat(dest_filename)[ST_SIZE] + stats['total_files_count'] = 1 + stats['written_files_count'] = 1 + stats['total_bytes'] = stats['written_bytes'] + else: + stats['written_bytes'] = 0 + + stats['backup_location'] = dest_filename + if cmd == 0: + stats['log']='XVA backup from %s OK, %d bytes written' % (self.server_name,stats['written_bytes']) + stats['status']='OK' + else: + stats['status']='ERROR' + stats['log']=cmd + + + + except BaseException , e: + stats['status']='ERROR' + stats['log']=str(e) + raise + + + +register_driver(backup_xva) + +if __name__=='__main__': + logger = logging.getLogger('tisbackup') + logger.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s') + handler = logging.StreamHandler() + handler.setFormatter(formatter) + logger.addHandler(handler) + + cp = ConfigParser() + cp.read('/opt/tisbackup/configtest.ini') + b = backup_xva() + b.read_config(cp) + diff --git a/libtisbackup/common.py b/libtisbackup/common.py new file mode 100644 index 0000000..7d545f9 --- /dev/null +++ b/libtisbackup/common.py @@ -0,0 +1,909 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- + +import os +import subprocess +import re +import logging +import datetime +import time +from iniparse import ConfigParser +import sqlite3 +import shutil +import select + +import sys + +try: + sys.stderr = open('/dev/null') # Silence silly warnings from paramiko + import paramiko +except ImportError,e: + print "Error : can not load paramiko library %s" % e + raise + +sys.stderr = sys.__stderr__ + +nagiosStateOk = 0 +nagiosStateWarning = 1 +nagiosStateCritical = 2 +nagiosStateUnknown = 3 + +backup_drivers = {} +def register_driver(driverclass): + backup_drivers[driverclass.type] = driverclass + +def datetime2isodate(adatetime=None): + if not adatetime: + adatetime = datetime.datetime.now() + assert(isinstance(adatetime,datetime.datetime)) + return adatetime.isoformat() + +def isodate2datetime(isodatestr): + # we remove the microseconds part as it is not working for python2.5 strptime + return datetime.datetime.strptime(isodatestr.split('.')[0] , "%Y-%m-%dT%H:%M:%S") + +def time2display(adatetime): + return adatetime.strftime("%Y-%m-%d %H:%M") + +def hours_minutes(hours): + if hours is None: + return None + else: + return "%02i:%02i" % ( int(hours) , int((hours - int(hours)) * 60.0)) + +def fileisodate(filename): + return datetime.datetime.fromtimestamp(os.stat(filename).st_mtime).isoformat() + +def dateof(adatetime): + return adatetime.replace(hour=0,minute=0,second=0,microsecond=0) + +##################################### +# http://code.activestate.com/recipes/498181-add-thousands-separator-commas-to-formatted-number/ +# Code from Michael Robellard's comment made 28 Feb 2010 +# Modified for leading +, -, space on 1 Mar 2010 by Glenn Linderman +# +# Tail recursion removed and leading garbage handled on March 12 2010, Alessandro Forghieri +def splitThousands( s, tSep=',', dSep='.'): + '''Splits a general float on thousands. GIGO on general input''' + if s == None: + return 0 + if not isinstance( s, str ): + s = str( s ) + + cnt=0 + numChars=dSep+'0123456789' + ls=len(s) + while cnt < ls and s[cnt] not in numChars: cnt += 1 + + lhs = s[ 0:cnt ] + s = s[ cnt: ] + if dSep == '': + cnt = -1 + else: + cnt = s.rfind( dSep ) + if cnt > 0: + rhs = dSep + s[ cnt+1: ] + s = s[ :cnt ] + else: + rhs = '' + + splt='' + while s != '': + splt= s[ -3: ] + tSep + splt + s = s[ :-3 ] + + return lhs + splt[ :-1 ] + rhs + + +def call_external_process(shell_string): + p = subprocess.call(shell_string, shell=True) + if (p != 0 ): + raise Exception('shell program exited with error code ' + str(p), shell_string) + +def check_string(test_string): + pattern = r'[^\.A-Za-z0-9\-_]' + if re.search(pattern, test_string): + #Character other then . a-z 0-9 was found + print 'Invalid : %r' % (test_string,) + +def convert_bytes(bytes): + if bytes is None: + return None + else: + bytes = float(bytes) + if bytes >= 1099511627776: + terabytes = bytes / 1099511627776 + size = '%.2fT' % terabytes + elif bytes >= 1073741824: + gigabytes = bytes / 1073741824 + size = '%.2fG' % gigabytes + elif bytes >= 1048576: + megabytes = bytes / 1048576 + size = '%.2fM' % megabytes + elif bytes >= 1024: + kilobytes = bytes / 1024 + size = '%.2fK' % kilobytes + else: + size = '%.2fb' % bytes + return size + +## {{{ http://code.activestate.com/recipes/81189/ (r2) +def pp(cursor, data=None, rowlens=0, callback=None): + """ + pretty print a query result as a table + callback is a function called for each field (fieldname,value) to format the output + """ + def defaultcb(fieldname,value): + return value + + if not callback: + callback = defaultcb + + d = cursor.description + if not d: + return "#### NO RESULTS ###" + names = [] + lengths = [] + rules = [] + if not data: + data = cursor.fetchall() + for dd in d: # iterate over description + l = dd[1] + if not l: + l = 12 # or default arg ... + l = max(l, len(dd[0])) # handle long names + names.append(dd[0]) + lengths.append(l) + for col in range(len(lengths)): + if rowlens: + rls = [len(str(callback(d[col][0],row[col]))) for row in data if row[col]] + lengths[col] = max([lengths[col]]+rls) + rules.append("-"*lengths[col]) + format = " ".join(["%%-%ss" % l for l in lengths]) + result = [format % tuple(names)] + result.append(format % tuple(rules)) + for row in data: + row_cb=[] + for col in range(len(d)): + row_cb.append(callback(d[col][0],row[col])) + result.append(format % tuple(row_cb)) + return "\n".join(result) +## end of http://code.activestate.com/recipes/81189/ }}} + + +def html_table(cur,callback=None): + """ + cur est un cursor issu d'une requete + callback est une fonction qui prend (rowmap,fieldname,value) + et renvoie une representation texte + """ + def safe_unicode(iso): + if iso is None: + return None + elif isinstance(iso, str): + return iso.decode('iso8859') + else: + return iso + + def itermap(cur): + for row in cur: + yield dict((cur.description[idx][0], value) + for idx, value in enumerate(row)) + + head=u""+"".join([""+c[0]+"" for c in cur.description])+"" + lines="" + if callback: + for r in itermap(cur): + lines=lines+""+"".join([""+str(callback(r,c[0],safe_unicode(r[c[0]])))+"" for c in cur.description])+"" + else: + for r in cur: + lines=lines+""+"".join([""+safe_unicode(c)+"" for c in r])+"" + + return "%s%s
" % (head,lines) + + + +def monitor_stdout(aprocess, onoutputdata,context): + """Reads data from stdout and stderr from aprocess and return as a string + on each chunk, call a call back onoutputdata(dataread) + """ + assert(isinstance(aprocess,subprocess.Popen)) + read_set = [] + stdout = [] + line = '' + + if aprocess.stdout: + read_set.append(aprocess.stdout) + if aprocess.stderr: + read_set.append(aprocess.stderr) + + while read_set: + try: + rlist, wlist, xlist = select.select(read_set, [], []) + except select.error, e: + if e.args[0] == errno.EINTR: + continue + raise + + # Reads one line from stdout + if aprocess.stdout in rlist: + data = os.read(aprocess.stdout.fileno(), 1) + if data == "": + aprocess.stdout.close() + read_set.remove(aprocess.stdout) + while data and not data in ('\n','\r'): + line += data + data = os.read(aprocess.stdout.fileno(), 1) + if line or data in ('\n','\r'): + stdout.append(line) + if onoutputdata: + onoutputdata(line,context) + line='' + + # Reads one line from stderr + if aprocess.stderr in rlist: + data = os.read(aprocess.stderr.fileno(), 1) + if data == "": + aprocess.stderr.close() + read_set.remove(aprocess.stderr) + while data and not data in ('\n','\r'): + line += data + data = os.read(aprocess.stderr.fileno(), 1) + if line or data in ('\n','\r'): + stdout.append(line) + if onoutputdata: + onoutputdata(line,context) + line='' + + aprocess.wait() + if line: + stdout.append(line) + if onoutputdata: + onoutputdata(line,context) + return "\n".join(stdout) + + +class BackupStat: + dbpath = '' + db = None + logger = logging.getLogger('tisbackup') + + def __init__(self,dbpath): + self.dbpath = dbpath + if not os.path.isfile(self.dbpath): + self.db=sqlite3.connect(self.dbpath) + self.initdb() + else: + self.db=sqlite3.connect(self.dbpath) + if not "'TYPE'" in str(self.db.execute("select * from stats").description): + self.updatedb() + + + def updatedb(self): + self.logger.debug('Update stat database') + self.db.execute("alter table stats add column TYPE TEXT;") + self.db.execute("update stats set TYPE='BACKUP';") + self.db.commit() + + def initdb(self): + assert(isinstance(self.db,sqlite3.Connection)) + self.logger.debug('Initialize stat database') + self.db.execute(""" +create table stats ( + backup_name TEXT, + server_name TEXT, + description TEXT, + backup_start TEXT, + backup_end TEXT, + backup_duration NUMERIC, + total_files_count INT, + written_files_count INT, + total_bytes INT, + written_bytes INT, + status TEXT, + log TEXT, + backup_location TEXT, + TYPE TEXT)""") + self.db.execute(""" +create index idx_stats_backup_name on stats(backup_name);""") + self.db.execute(""" +create index idx_stats_backup_location on stats(backup_location);""") + self.db.commit() + + def start(self,backup_name,server_name,TYPE,description='',backup_location=None): + """ Add in stat DB a record for the newly running backup""" + return self.add(backup_name=backup_name,server_name=server_name,description=description,backup_start=datetime2isodate(),status='Running',TYPE=TYPE) + + def finish(self,rowid,total_files_count=None,written_files_count=None,total_bytes=None,written_bytes=None,log=None,status='OK',backup_end=None,backup_duration=None,backup_location=None): + """ Update record in stat DB for the finished backup""" + if not backup_end: + backup_end = datetime2isodate() + if backup_duration == None: + try: + # get duration using start of backup datetime + backup_duration = (isodate2datetime(backup_end) - isodate2datetime(self.query('select backup_start from stats where rowid=?',(rowid,))[0]['backup_start'])).seconds / 3600.0 + except: + backup_duration = None + + # update stat record + self.db.execute("""\ + update stats set + total_files_count=?,written_files_count=?,total_bytes=?,written_bytes=?,log=?,status=?,backup_end=?,backup_duration=?,backup_location=? + where + rowid = ? + """,(total_files_count,written_files_count,total_bytes,written_bytes,log,status,backup_end,backup_duration,backup_location,rowid)) + self.db.commit() + + def add(self, + backup_name='', + server_name='', + description='', + backup_start=None, + backup_end=None, + backup_duration=None, + total_files_count=None, + written_files_count=None, + total_bytes=None, + written_bytes=None, + status='draft', + log='', + TYPE='', + backup_location=None): + if not backup_start: + backup_start=datetime2isodate() + if not backup_end: + backup_end=datetime2isodate() + + cur = self.db.execute("""\ + insert into stats ( + backup_name, + server_name, + description, + backup_start, + backup_end, + backup_duration, + total_files_count, + written_files_count, + total_bytes, + written_bytes, + status, + log, + backup_location, + TYPE) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """,( + backup_name, + server_name, + description, + backup_start, + backup_end, + backup_duration, + total_files_count, + written_files_count, + total_bytes, + written_bytes, + status, + log, + backup_location, + TYPE) + ) + + self.db.commit() + return cur.lastrowid + + def query(self,query, args=(), one=False): + """ + execute la requete query sur la db et renvoie un tableau de dictionnaires + """ + cur = self.db.execute(query, args) + rv = [dict((cur.description[idx][0], value) + for idx, value in enumerate(row)) for row in cur.fetchall()] + return (rv[0] if rv else None) if one else rv + + def last_backups(self,backup_name,count=30): + if backup_name: + cur = self.db.execute('select * from stats where backup_name=? order by backup_end desc limit ?',(backup_name,count)) + else: + cur = self.db.execute('select * from stats order by backup_end desc limit ?',(count,)) + + def fcb(fieldname,value): + if fieldname in ('backup_start','backup_end'): + return time2display(isodate2datetime(value)) + elif 'bytes' in fieldname: + return convert_bytes(value) + elif 'count' in fieldname: + return splitThousands(value,' ','.') + elif 'backup_duration' in fieldname: + return hours_minutes(value) + else: + return value + + #for r in self.query('select * from stats where backup_name=? order by backup_end desc limit ?',(backup_name,count)): + print pp(cur,None,1,fcb) + + + def fcb(self,fields,fieldname,value): + if fieldname in ('backup_start','backup_end'): + return time2display(isodate2datetime(value)) + elif 'bytes' in fieldname: + return convert_bytes(value) + elif 'count' in fieldname: + return splitThousands(value,' ','.') + elif 'backup_duration' in fieldname: + return hours_minutes(value) + else: + return value + + def as_html(self,cur): + if cur: + return html_table(cur,self.fcb) + else: + return html_table(self.db.execute('select * from stats order by backup_start asc'),self.fcb) + + +def ssh_exec(command,ssh=None,server_name='',remote_user='',private_key='',ssh_port=22): + """execute command on server_name using the provided ssh connection + or creates a new connection if ssh is not provided. + returns (exit_code,output) + + output is the concatenation of stdout and stderr + """ + if not ssh: + assert(server_name and remote_user and private_key) + try: + mykey = paramiko.RSAKey.from_private_key_file(private_key) + except paramiko.SSHException: + mykey = paramiko.DSSKey.from_private_key_file(private_key) + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(server_name,username=remote_user,pkey = private_key,port=ssh_port) + + tran = ssh.get_transport() + chan = tran.open_session() + + # chan.set_combine_stderr(True) + chan.get_pty() + stdout = chan.makefile() + + chan.exec_command(command) + stdout.flush() + output = stdout.read() + exit_code = chan.recv_exit_status() + return (exit_code,output) + + +class backup_generic: + """Generic ancestor class for backups, not registered""" + type = 'generic' + required_params = ['type','backup_name','backup_dir','server_name','backup_retention_time','maximum_backup_age'] + optional_params = ['preexec','postexec','description','private_key','remote_user','ssh_port'] + + logger = logging.getLogger('tisbackup') + backup_name = '' + backup_dir = '' + server_name = '' + remote_user = 'root' + description = '' + dbstat = None + dry_run = False + preexec = '' + postexec = '' + maximum_backup_age = None + backup_retention_time = None + verbose = False + private_key='' + ssh_port=22 + + def __init__(self,backup_name, backup_dir,dbstat=None,dry_run=False): + if not re.match('^[A-Za-z0-9_\-\.]*$',backup_name): + raise Exception('The backup name %s should contain only alphanumerical characters' % backup_name) + self.backup_name = backup_name + self.backup_dir = backup_dir + + self.dbstat = dbstat + assert(isinstance(self.dbstat,BackupStat) or self.dbstat==None) + + if not os.path.isdir(self.backup_dir): + os.makedirs(self.backup_dir) + + self.dry_run = dry_run + + @classmethod + def get_help(cls): + return """\ +%(type)s : %(desc)s + Required params : %(required)s + Optional params : %(optional)s +""" % {'type':cls.type, + 'desc':cls.__doc__, + 'required':",".join(cls.required_params), + 'optional':",".join(cls.optional_params)} + + def check_required_params(self): + for name in self.required_params: + if not hasattr(self,name) or not getattr(self,name): + raise Exception('[%s] Config Attribute %s is required' % (self.backup_name,name)) + if (self.preexec or self.postexec) and (not self.private_key or not self.remote_user): + raise Exception('[%s] remote_user and private_key file required if preexec or postexec is used' % self.backup_name) + + + def read_config(self,iniconf): + assert(isinstance(iniconf,ConfigParser)) + allowed_params = self.required_params+self.optional_params + for (name,value) in iniconf.items(self.backup_name): + if not name in allowed_params: + self.logger.critical('[%s] Invalid param name "%s"', self.backup_name,name); + raise Exception('[%s] Invalid param name "%s"', self.backup_name,name) + self.logger.debug('[%s] reading param %s = %s ', self.backup_name,name,value) + setattr(self,name,value) + + # if retention (in days) is not defined at section level, get default global one. + if not self.backup_retention_time: + self.backup_retention_time = iniconf.getint('global','backup_retention_time') + + # for nagios, if maximum last backup age (in hours) is not defined at section level, get default global one. + if not self.maximum_backup_age: + self.maximum_backup_age = iniconf.getint('global','maximum_backup_age') + + self.ssh_port = int(self.ssh_port) + self.backup_retention_time = int(self.backup_retention_time) + self.maximum_backup_age = int(self.maximum_backup_age) + + self.check_required_params() + + + def do_preexec(self,stats): + self.logger.info("[%s] executing preexec %s ",self.backup_name,self.preexec) + try: + mykey = paramiko.RSAKey.from_private_key_file(self.private_key) + except paramiko.SSHException: + mykey = paramiko.DSSKey.from_private_key_file(self.private_key) + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(self.server_name,username=self.remote_user,pkey = mykey) + tran = ssh.get_transport() + chan = tran.open_session() + + # chan.set_combine_stderr(True) + chan.get_pty() + stdout = chan.makefile() + + if not self.dry_run: + chan.exec_command(self.preexec) + output = stdout.read() + exit_code = chan.recv_exit_status() + self.logger.info('[%s] preexec exit code : "%i", output : %s',self.backup_name , exit_code, output ) + return exit_code + else: + return 0 + + def do_postexec(self,stats): + self.logger.info("[%s] executing postexec %s ",self.backup_name,self.postexec) + try: + mykey = paramiko.RSAKey.from_private_key_file(self.private_key) + except paramiko.SSHException: + mykey = paramiko.DSSKey.from_private_key_file(self.private_key) + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(self.server_name,username=self.remote_user,pkey = mykey) + tran = ssh.get_transport() + chan = tran.open_session() + + # chan.set_combine_stderr(True) + chan.get_pty() + stdout = chan.makefile() + + if not self.dry_run: + chan.exec_command(self.postexec) + output = stdout.read() + exit_code = chan.recv_exit_status() + self.logger.info('[%s] postexec exit code : "%i", output : %s',self.backup_name , exit_code, output ) + return exit_code + else: + return 0 + + + def do_backup(self,stats): + """stats dict with keys : total_files_count,written_files_count,total_bytes,written_bytes""" + pass + + def check_params_connections(self): + """Perform a dry run trying to connect without actually doing backup""" + self.check_required_params() + + def process_backup(self): + """Process the backup. + launch + - do_preexec + - do_backup + - do_postexec + + returns a dict for stats + """ + self.logger.info('[%s] ######### Starting backup',self.backup_name) + + starttime = time.time() + self.backup_start_date = datetime.datetime.now().strftime('%Y%m%d-%Hh%Mm%S') + + if not self.dry_run and self.dbstat: + stat_rowid = self.dbstat.start(backup_name=self.backup_name,server_name=self.server_name,TYPE="BACKUP") + else: + stat_rowid = None + + try: + stats = {} + stats['total_files_count']=0 + stats['written_files_count']=0 + stats['total_bytes']=0 + stats['written_bytes']=0 + stats['log']='' + stats['status']='Running' + stats['backup_location']=None + + if self.preexec.strip(): + exit_code = self.do_preexec(stats) + if exit_code != 0 : + raise Exception('Preexec "%s" failed with exit code "%i"' % (self.preexec,exit_code)) + + self.do_backup(stats) + + if self.postexec.strip(): + exit_code = self.do_postexec(stats) + if exit_code != 0 : + raise Exception('Postexec "%s" failed with exit code "%i"' % (self.postexec,exit_code)) + + endtime = time.time() + duration = (endtime-starttime)/3600.0 + if not self.dry_run and self.dbstat: + self.dbstat.finish(stat_rowid, + backup_end=datetime2isodate(datetime.datetime.now()), + backup_duration = duration, + total_files_count=stats['total_files_count'], + written_files_count=stats['written_files_count'], + total_bytes=stats['total_bytes'], + written_bytes=stats['written_bytes'], + status=stats['status'], + log=stats['log'], + backup_location=stats['backup_location']) + + self.logger.info('[%s] ######### Backup finished : %s',self.backup_name,stats['log']) + return stats + + except BaseException, e: + stats['status']='ERROR' + stats['log']=str(e) + endtime = time.time() + duration = (endtime-starttime)/3600.0 + if not self.dry_run and self.dbstat: + self.dbstat.finish(stat_rowid, + backup_end=datetime2isodate(datetime.datetime.now()), + backup_duration = duration, + total_files_count=stats['total_files_count'], + written_files_count=stats['written_files_count'], + total_bytes=stats['total_bytes'], + written_bytes=stats['written_bytes'], + status=stats['status'], + log=stats['log'], + backup_location=stats['backup_location']) + + self.logger.error('[%s] ######### Backup finished with ERROR: %s',self.backup_name,stats['log']) + raise + + + def checknagios(self,maxage_hours=30): + """ + Returns a tuple (nagiosstatus,message) for the current backup_name + Read status from dbstat database + """ + if not self.dbstat: + self.logger.warn('[%s] checknagios : no database provided',self.backup_name) + return ('No database provided',nagiosStateUnknown) + else: + self.logger.debug('[%s] checknagios : sql query "%s" %s',self.backup_name,'select status, backup_end, log from stats where TYPE=\'BACKUP\' AND backup_name=? order by backup_end desc limit 30',self.backup_name) + q = self.dbstat.query('select status, backup_start, backup_end, log, backup_location, total_bytes from stats where TYPE=\'BACKUP\' AND backup_name=? order by backup_start desc limit 30',(self.backup_name,)) + if not q: + self.logger.debug('[%s] checknagios : no result from query',self.backup_name) + return (nagiosStateCritical,'CRITICAL : No backup found for %s in database' % self.backup_name) + else: + mindate = datetime2isodate((datetime.datetime.now() - datetime.timedelta(hours=maxage_hours))) + self.logger.debug('[%s] checknagios : looking for most recent OK not older than %s',self.backup_name,mindate) + for b in q: + if b['backup_end'] >= mindate and b['status'] == 'OK': + # check if backup actually exists on registered backup location and is newer than backup start date + if b['total_bytes'] == 0: + return (nagiosStateWarning,"WARNING : No data to backup was found for %s" % (self.backup_name,)) + + if not b['backup_location']: + return (nagiosStateWarning,"WARNING : No Backup location found for %s finished on (%s) %s" % (self.backup_name,isodate2datetime(b['backup_end']),b['log'])) + + if os.path.isfile(b['backup_location']): + backup_actual_date = datetime.datetime.fromtimestamp(os.stat(b['backup_location']).st_ctime) + if backup_actual_date + datetime.timedelta(hours = 1) > isodate2datetime(b['backup_start']): + return (nagiosStateOk,"OK Backup %s (%s), %s" % (self.backup_name,isodate2datetime(b['backup_end']),b['log'])) + else: + return (nagiosStateCritical,"CRITICAL Backup %s (%s), %s seems older than start of backup" % (self.backup_name,isodate2datetime(b['backup_end']),b['log'])) + elif os.path.isdir(b['backup_location']): + return (nagiosStateOk,"OK Backup %s (%s), %s" % (self.backup_name,isodate2datetime(b['backup_end']),b['log'])) + else: + return (nagiosStateCritical,"CRITICAL Backup %s (%s), %s has disapeared from backup location %s" % (self.backup_name,isodate2datetime(b['backup_end']),b['log'],b['backup_location'])) + + self.logger.debug('[%s] checknagios : looking for most recent Warning or Running not older than %s',self.backup_name,mindate) + for b in q: + if b['backup_end'] >= mindate and b['status'] in ('Warning','Running'): + return (nagiosStateWarning,'WARNING : Backup %s still running or warning. %s' % (self.backup_name,b['log'])) + + self.logger.debug('[%s] checknagios : No Ok or warning recent backup found',self.backup_name) + return (nagiosStateCritical,'CRITICAL : No recent backup for %s' % self.backup_name ) + + def cleanup_backup(self): + """Removes obsolete backups (older than backup_retention_time)""" + mindate = datetime2isodate((dateof(datetime.datetime.now()) - datetime.timedelta(days=self.backup_retention_time))) + # check if there is at least 1 "OK" backup left after cleanup : + ok_backups = self.dbstat.query('select backup_location from stats where TYPE="BACKUP" and backup_name=? and backup_start>=? and status="OK" order by backup_start desc',(self.backup_name,mindate)) + removed = [] + if ok_backups and os.path.exists(ok_backups[0]['backup_location']): + records = self.dbstat.query('select status, backup_start, backup_end, log, backup_location from stats where backup_name=? and backup_start '/': + backup_source += '/' + if backup_dest[-1] <> '/': + backup_dest += '/' + + if not os.path.isdir(backup_dest): + os.makedirs(backup_dest) + + options = ['-aP','--stats','--delete-excluded','--numeric-ids','--delete-after'] + if self.logger.level: + options.append('-P') + + if self.dry_run: + options.append('-d') + + options_params = " ".join(options) + + cmd = '/usr/bin/rsync %s %s %s 2>&1' % (options_params,backup_source,backup_dest) + self.logger.debug("[%s] rsync : %s",self.backup_name,cmd) + + if not self.dry_run: + self.line = '' + starttime = time.time() + stat_rowid = self.dbstat.start(backup_name=self.backup_name,server_name=self.server_name, TYPE="EXPORT") + + process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True) + def ondata(data,context): + if context.verbose: + print data + context.logger.debug(data) + + log = monitor_stdout(process,ondata,self) + + for l in log.splitlines(): + if l.startswith('Number of files:'): + stats['total_files_count'] += int(l.split(':')[1]) + if l.startswith('Number of files transferred:'): + stats['written_files_count'] += int(l.split(':')[1]) + if l.startswith('Total file size:'): + stats['total_bytes'] += int(l.split(':')[1].split()[0]) + if l.startswith('Total transferred file size:'): + stats['written_bytes'] += int(l.split(':')[1].split()[0]) + returncode = process.returncode + ## deal with exit code 24 (file vanished) + if (returncode == 24): + self.logger.warning("[" + self.backup_name + "] Note: some files vanished before transfer") + elif (returncode == 23): + self.logger.warning("[" + self.backup_name + "] unable so set uid on some files") + elif (returncode != 0): + self.logger.error("[" + self.backup_name + "] shell program exited with error code ") + raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd) + else: + print cmd + + stats['status']='OK' + self.logger.info('export backup from %s to %s OK, %d bytes written for %d changed files' % (backup_source,backup_dest,stats['written_bytes'],stats['written_files_count'])) + + endtime = time.time() + duration = (endtime-starttime)/3600.0 + + if not self.dry_run and self.dbstat: + self.dbstat.finish(stat_rowid, + backup_end=datetime2isodate(datetime.datetime.now()), + backup_duration = duration, + total_files_count=stats['total_files_count'], + written_files_count=stats['written_files_count'], + total_bytes=stats['total_bytes'], + written_bytes=stats['written_bytes'], + status=stats['status'], + log=stats['log'], + backup_location=backup_dest) + return stats + + +if __name__ == '__main__': + logger = logging.getLogger('tisbackup') + logger.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s') + handler = logging.StreamHandler() + handler.setFormatter(formatter) + logger.addHandler(handler) + dbstat = BackupStat('/backup/data/log/tisbackup.sqlite') diff --git a/libtisbackup/copy_vm_xcp.py b/libtisbackup/copy_vm_xcp.py new file mode 100755 index 0000000..ad2ea6e --- /dev/null +++ b/libtisbackup/copy_vm_xcp.py @@ -0,0 +1,224 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------- +# This file is part of TISBackup +# +# TISBackup is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# TISBackup is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with TISBackup. If not, see . +# +# ----------------------------------------------------------------------- + +import os +import datetime +from common import * +import XenAPI +import time +import logging +import re +import os.path +import os +import datetime +import select +import urllib2 +import base64 +import socket +from stat import * + + +class copy_vm_xcp(backup_generic): + """Backup a VM running on a XCP server on a second SR (requires xe tools and XenAPI)""" + type = 'copy-vm-xcp' + + required_params = backup_generic.required_params + ['server_name','storage_name','password_file','vm_name','network_name'] + optional_params = backup_generic.optional_params + ['start_vm','max_copies'] + + start_vm = "no" + max_copies = 1 + + def read_config(self,iniconf): + assert(isinstance(iniconf,ConfigParser)) + backup_generic.read_config(self,iniconf) + if self.start_vm in 'no' and iniconf.has_option('global','start_vm'): + self.start_vm = iniconf.get('global','start_vm') + if self.max_copies == 1 and iniconf.has_option('global','max_copies'): + self.max_copies = iniconf.getint('global','max_copies') + + + def copy_vm_to_sr(self, vm_name, storage_name, dry_run): + + user_xen, password_xen, null = open(self.password_file).read().split('\n') + session = XenAPI.Session('https://'+self.server_name) + try: + session.login_with_password(user_xen,password_xen) + except XenAPI.Failure, error: + msg,ip = error.details + + if msg == 'HOST_IS_SLAVE': + server_name = ip + session = XenAPI.Session('https://'+server_name) + session.login_with_password(user_xen,password_xen) + + + self.logger.debug("[%s] VM (%s) to backup in storage: %s",self.backup_name,vm_name,storage_name) + now = datetime.datetime.now() + + #get storage opaqueRef + try: + storage = session.xenapi.SR.get_by_name_label(storage_name)[0] + except IndexError,error: + return("error get storage opaqueref %s"%(error)) + + #get vm to copy opaqueRef + try: + vm = session.xenapi.VM.get_by_name_label(vm_name)[0] + except IndexError,error: + return("error get VM opaqueref %s"%(error)) + + #do the snapshot + self.logger.debug("[%s] Snapshot in progress",self.backup_name) + try: + snapshot = session.xenapi.VM.snapshot(vm,"tisbackup-%s"%(vm_name)) + except XenAPI.Failure, error: + return("error when snapshot %s"%(error)) + + #get snapshot opaqueRef + snapshot = session.xenapi.VM.get_by_name_label("tisbackup-%s"%(vm_name))[0] + session.xenapi.VM.set_name_description(snapshot,"snapshot created by tisbackup on : %s"%(now.strftime("%Y-%m-%d %H:%M"))) + + + + vm_backup_name = "zzz-%s-"%(vm_name) + + + #Check if old backup exit + list_backups = [] + for vm_ref in session.xenapi.VM.get_all(): + name_lablel = session.xenapi.VM.get_name_label(vm_ref) + if vm_backup_name in name_lablel: + list_backups.append(name_lablel) + + list_backups.sort() + + if len(list_backups) >= 1: + + # Shutting last backup if started + last_backup_vm = session.xenapi.VM.get_by_name_label(list_backups[-1])[0] + if not "Halted" in session.xenapi.VM.get_power_state(last_backup_vm): + self.logger.debug("[%s] Shutting down last backup vm : %s", self.backup_name, list_backups[-1] ) + session.xenapi.VM.hard_shutdown(last_backup_vm) + + # Delete oldest backup if exist + if len(list_backups) >= int(self.max_copies): + for i in range(len(list_backups)-int(self.max_copies)+1): + oldest_backup_vm = session.xenapi.VM.get_by_name_label(list_backups[i])[0] + if not "Halted" in session.xenapi.VM.get_power_state(oldest_backup_vm): + self.logger.debug("[%s] Shutting down old vm : %s", self.backup_name, list_backups[i] ) + session.xenapi.VM.hard_shutdown(oldest_backup_vm) + + try: + self.logger.debug("[%s] Deleting old vm : %s", self.backup_name, list_backups[i]) + for vbd in session.xenapi.VM.get_VBDs(oldest_backup_vm): + vdi = session.xenapi.VBD.get_VDI(vbd) + if not 'NULL' in vdi: + session.xenapi.VDI.destroy(vdi) + + session.xenapi.VM.destroy(oldest_backup_vm) + except XenAPI.Failure, error: + return("error when destroy old backup vm %s"%(error)) + + + self.logger.debug("[%s] Copy %s in progress on %s",self.backup_name,vm_name,storage_name) + try: + backup_vm = session.xenapi.VM.copy(snapshot,vm_backup_name+now.strftime("%Y-%m-%d %H:%M"),storage) + except XenAPI.Failure, error: + return("error when copy %s"%(error)) + + + # define VM as a template + session.xenapi.VM.set_is_a_template(backup_vm,False) + + #change the network of the new VM + try: + vifDestroy = session.xenapi.VM.get_VIFs(backup_vm) + except IndexError,error: + return("error get VIF opaqueref %s"%(error)) + + for i in vifDestroy: + vifRecord = session.xenapi.VIF.get_record(i) + session.xenapi.VIF.destroy(i) + networkRef = session.xenapi.network.get_by_name_label(self.network_name)[0] + data = {'MAC': vifRecord['MAC'], + 'MAC_autogenerated': False, + 'MTU': vifRecord['MTU'], + 'VM': backup_vm, + 'current_operations': vifRecord['current_operations'], + 'currently_attached': vifRecord['currently_attached'], + 'device': vifRecord['device'], + 'ipv4_allowed': vifRecord['ipv4_allowed'], + 'ipv6_allowed': vifRecord['ipv6_allowed'], + 'locking_mode': vifRecord['locking_mode'], + 'network': networkRef, + 'other_config': vifRecord['other_config'], + 'qos_algorithm_params': vifRecord['qos_algorithm_params'], + 'qos_algorithm_type': vifRecord['qos_algorithm_type'], + 'qos_supported_algorithms': vifRecord['qos_supported_algorithms'], + 'runtime_properties': vifRecord['runtime_properties'], + 'status_code': vifRecord['status_code'], + 'status_detail': vifRecord['status_detail'] + } + try: + session.xenapi.VIF.create(data) + except Exception, error: + return(error) + + + if self.start_vm in ['true', '1', 't', 'y', 'yes', 'oui']: + session.xenapi.VM.start(backup_vm,False,True) + + session.xenapi.VM.set_name_description(backup_vm,"snapshot created by tisbackup on : %s"%(now.strftime("%Y-%m-%d %H:%M"))) + #delete the snapshot + try: + session.xenapi.VM.destroy(snapshot) + except XenAPI.Failure, error: + return("error when destroy snapshot %s"%(error)) + + return(0) + + + def do_backup(self,stats): + try: + timestamp = int(time.time()) + cmd = self.copy_vm_to_sr(self.vm_name, self.storage_name, self.dry_run) + if cmd == 0: + timeExec = int(time.time()) - timestamp + stats['log']='copy of %s to an other storage OK' % (self.backup_name) + stats['status']='OK' + stats['total_files_count'] = 1 + stats['backup_location'] = self.storage_name + else: + stats['status']='ERROR' + stats['log']=cmd + + except BaseException,e: + stats['status']='ERROR' + stats['log']=str(e) + raise + + + +register_driver(copy_vm_xcp) + + + + + diff --git a/samples/backup_button_jobs b/samples/backup_button_jobs new file mode 100644 index 0000000..25f8e60 --- /dev/null +++ b/samples/backup_button_jobs @@ -0,0 +1,18 @@ +#!/bin/sh +. /frontview/bin/functions + +target=$(/frontview/bin/get_front_panel_usb_hdd) + +echo $(date +%Y-%m-%d\ %H:%M:%S) : Export TISBackup sur Disque USB : $target >> /var/log/tisbackup.log +if [ -n "$target" ]; then + hotplug_lcd "Start TISBackup export" + /usr/local/bin/tisbackup -x /$target/export exportbackup >> /var/log/tisbackup.log 2>&1 + hotplug_lcd "Finish TISBackup export" + sleep 3 +else + hotplug_lcd "Error, no USB disk" + sleep 3 +fi +echo $(date +%Y-%m-%d\ %H:%M:%S) : Fin Export TISBackup sur Disque USB : $target >> /var/log/tisbackup.log + + diff --git a/samples/config.ini.sample b/samples/config.ini.sample new file mode 100644 index 0000000..a620b07 --- /dev/null +++ b/samples/config.ini.sample @@ -0,0 +1,55 @@ +[global] +backup_base_dir = /root/tisbackup/backup_dir + +# backup retention in days +backup_retention_time=90 + +# for nagios check in hours +maximum_backup_age=30 + +;[srvopenerp-slash] +;type=rsync+ssh +;server_name=srvopenerp +;remote_dir=/ +;compression=True +;exclude_list="/proc/**","/sys/**","/dev/**" +;private_key=/root/.ssh/id_dsa +;ssh_port = 22 + +;[srvzimbra-slash] +;type=rsync+ssh +;server_name=srvzimbra +;remote_dir=/ +;exclude_list="/proc/**","/sys/**","/dev/**" +;private_key=/root/.ssh/id_dsa +;ssh_port = 22 + +;[backup_mysql_srvintranet] +;type=mysql+ssh +;server_name=srvintranet +;private_keys=/root/.ssh/id_dsa +;db_name=* +;db_user=root +;db_passwd=mypassword + +;[srvopenerp-pgsql] +;type=pgsql+ssh +;server_name=srvopenerp +;db_name=tranquil-production +;private_key=/root/.ssh/id_dsa +;ssh_port = 22 + +;[test-backup-xva2] +;type=xen-xva +;xcphost=srvxen1-test +;server_name=test-backup-xva2 +;password_file=/root/xen_passwd + +;[sw-serveur] +;type=switch +;server_name=sw-serveur +;switch_ip=192.168.149.253 +;switch_user=admin +;switch_password=toto +;switch_type=LINKSYS-SRW2024 + diff --git a/samples/tisbackup-config.ini b/samples/tisbackup-config.ini new file mode 100644 index 0000000..f92cd11 --- /dev/null +++ b/samples/tisbackup-config.ini @@ -0,0 +1,84 @@ +[global] +backup_base_dir = /backup/data/ + +# backup retention in days +backup_retention_time=15 + +# for nagios check in hours +maximum_backup_age=30 + +# bandwith limit for rsync +#bw_limit = 300 + +#compression level for rsync (0 to 9) +#compression_level=7 + +[srvfichiers-partages] +type=rsync+ssh +server_name=srvfichiers +remote_dir=/home/partages +exclude_list= +private_key=/root/.ssh/id_dsa +ssh_port = 22 + +[srvintranet-slash] +type=rsync+ssh +server_name=srvintranet +remote_dir=/ +exclude_list="/proc/**","/sys/**","/dev/**" +private_key=/root/.ssh/id_dsa +ssh_port = 22 + +[srvads-slash] +type=rsync+ssh +server_name=srvads +remote_dir=/ +exclude_list="/proc/**","/sys/**","/dev/**" +private_key=/root/.ssh/id_dsa + +[srvzimbra-slash] +type=rsync+ssh +server_name=srvzimbra +remote_dir=/ +exclude_list="/proc/**","/sys/**","/dev/**","/opt/**" +private_key=/root/.ssh/id_dsa +ssh_port = 22 + +[srvzimbra-opt] +type=rsync+ssh +server_name=srvzimbra +remote_dir=/opt +exclude_list= +private_key=/root/.ssh/id_dsa +ssh_port = 22 + +[gateway] +type=null +server_name=fwall + +[srvopenerp6-prod-pgsql] +type=pgsql+ssh +server_name=srvopenerp6-prod +db_name=tranquil_production +private_key=/root/.ssh/id_dsa +ssh_port = 22 + +[srvopenerp6-form-script] +type=rsync+ssh +server_name=srvopenerp6-form +remote_dir=/home/openerp/instances/form/openobject-library/ +exclude_list= +private_key=/root/.ssh/id_rsa +ssh_port = 22 + +;preexec=/etc/init.d/zimbra stop +;postexec=/etc/init.d/zimbra start + +;[backup_mysql_srvintranet] +;type=mysql+ssh +;server_name=srvintranet +;private_keys=/root/.ssh/id_dsa +;db_name= +;db_user=root +;db_passwd= + diff --git a/samples/tisbackup-pra.ini b/samples/tisbackup-pra.ini new file mode 100755 index 0000000..f179f03 --- /dev/null +++ b/samples/tisbackup-pra.ini @@ -0,0 +1,21 @@ +[global] +backup_base_dir = /home/homes/ssamson/ + +# backup retention in day +backup_retention_time=30 + +# for nagios check in hours +maximum_backup_age=30 +compression_level=7 +#max_copies=2 + +[test-copysr] +type=copy-vm-xcp +server_name=srvxen1-test +vm_name=test-pra +storage_name=FAST_SR2 +password_file=/home/homes/ssamson/tisbackup-pra/xen_passwd +network_name=net-test +#start_vm=no +#max_copies=3 + diff --git a/samples/tisbackup.cron b/samples/tisbackup.cron new file mode 100644 index 0000000..0fb80c1 --- /dev/null +++ b/samples/tisbackup.cron @@ -0,0 +1,7 @@ +#SHELL=/bin/sh +#PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin + +# m h dom mon dow user command +30 22 * * * root /opt/tisbackup/tisbackup.py -c /etc/tis/tisbackup-config.ini backup >> /var/log/tisbackup.log 2>&1 +30 12 * * * root /opt/tisbackup/tisbackup.py -c /etc/tis/tisbackup-config.ini cleanup >> /var/log/tisbackup.log 2>&1 + diff --git a/samples/tisbackup_gui.ini b/samples/tisbackup_gui.ini new file mode 100644 index 0000000..78b1e30 --- /dev/null +++ b/samples/tisbackup_gui.ini @@ -0,0 +1,10 @@ +[uwsgi] +http = 0.0.0.0:8080 +master = true +processes = 1 +wsgi=tisbackup_gui:app +chdir=/opt/tisbackup +config= /etc/tis/tisbackup-config.ini +sections= +spooler=/opt/tisbackup/myspool +ADMIN_EMAIL=technique@tranquil-it-systems.fr diff --git a/scripts/tisbackup_gui b/scripts/tisbackup_gui new file mode 100755 index 0000000..e155b5f --- /dev/null +++ b/scripts/tisbackup_gui @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +### BEGIN INIT INFO +# Provides: tisbackup_gui-uwsgi +# Required-Start: $all +# Required-Stop: $all +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: starts the uwsgi app server for tisbackup_gui +# Description: starts uwsgi app server for tisbackup_gui using start-stop-daemon +### END INIT INFO +set -e + +VERSION=$(basename $0) +PATH=/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +DAEMON=/usr/local/bin/$VERSION +RUN=/var/run/ +NAME=$VERSION +CONFIG_FILE=/etc/tis/tisbackup_gui.ini +LOGFILE=/var/log/$NAME.log +OWNER=root +DESC=$VERSION +OP=$1 + +DAEMON_OPTS="" + +# Include uwsgi defaults if available +if [[ -f /etc/default/$VERSION ]]; then + . /etc/default/$VERSION +fi + +do_pid_check() +{ + local PIDFILE=$1 + [[ -f $PIDFILE ]] || return 0 + local PID=$(cat $PIDFILE) + for p in $(pgrep $VERSION); do + [[ $p == $PID ]] && return 1 + done + return 0 +} + + +do_start() +{ +# for config in $ENABLED_CONFIGS; do + local PIDFILE=$RUN/$NAME.pid + if do_pid_check $PIDFILE; then + uwsgi -d $LOGFILE --pidfile $PIDFILE --ini $CONFIG_FILE + +# sudo -u $OWNER -i $VERSION $config $DAEMON_OPTS --pidfile $PIDFILE + else + echo "Already running!" + fi +# done +} + +send_sig() +{ + local PIDFILE=$RUN/$NAME.pid + set +e + [[ -f $PIDFILE ]] && kill $1 $(cat $PIDFILE) > /dev/null 2>&1 + set -e +} + +wait_and_clean_pidfiles() +{ + local PIDFILE=$RUN/$NAME.pid + until do_pid_check $PIDFILE; do + echo -n ""; + done + rm -f $PIDFILE +} + +do_stop() +{ + send_sig -3 + wait_and_clean_pidfiles +} + +do_reload() +{ + send_sig -1 +} + +do_force_reload() +{ + send_sig -15 +} + +get_status() +{ + send_sig -10 +} + +case "$OP" in + start) + echo "Starting $DESC: " + do_start + echo "$NAME." + ;; + stop) + echo -n "Stopping $DESC: " + do_stop + echo "$NAME." + ;; + reload) + echo -n "Reloading $DESC: " + do_reload + echo "$NAME." + ;; + force-reload) + echo -n "Force-reloading $DESC: " + do_force_reload + echo "$NAME." + ;; + restart) + echo "Restarting $DESC: " + do_stop + sleep 3 + do_start + echo "$NAME." + ;; + status) + get_status + ;; + *) + N=/etc/init.d/$NAME + echo "Usage: $N {start|stop|restart|reload|force-reload|status}" >&2 + exit 1 + ;; +esac +exit 0 diff --git a/static/images/back_disabled.png b/static/images/back_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..881de7976ff98955e2a5487dca66e618a0655f3d GIT binary patch literal 1361 zcmeAS@N?(olHy`uVBq!ia0vp^!XV7S1|*9D%+3HQ$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWuD@%u1Od5hW46K32*3xq68pHF_1f1wh>l3^w)^1&PVosU-?Y zsp*+{wo31J?^jaDOtDo8H}y5}EpSfF$n>ZxN)4{^3rViZPPR-@vbR&PsjvbXkegbP zs8ErclUHn2VXFi-*9yo63F|80+w{G(j&jGsVin+1c5}%-r12$=KP@(AChw*vQht)WY1&)XmVs z#Kj1v*Cju>G&eP`1g19yq1OVZUQklVEdbi=l3J8mmYU*Ll%J~r_OewbZnv1?G!Lpb z1-DzwaO%|uIz}H9u}BdO69T3l5EGtkfgE_kPt60S_99@i-f{BMZ3YI$qn<8~Ar-gQ zOt$tu93XPMc=CmoA1oW4o1+WYIB!k3r72K4*@xA>;l+b1ClB@qaOE!8@r8RwiN1wc zl+_Cb3(lEQAv#$bmh>Mfe(I7Woy4{G!}EFf?)^FUc%F02^;EH~5owLt3k}+qS-P)U z616t%^wT0PX2liq$807FHoQ4d$0`5rkQ{U2Kkgclu0)Awnd5T>ob((vrABTq*u(RS z>E?oy`!@uw+@i*D$dKV&#I(EL&;9;u$GTQAMM5faVZejQCzo(NvvlZ75dZt%IYnd( z*RL;GX3W}R-P)5>-Zsu`&nUT&Ho^GzHq{#}Ya$hT91>M0@O`!9^}b&yQ@87fac$Vj zkb72h`!2pMTYunpu!r@;1)uFRXDvHO zu{mw?PmW#JOy1f}J}ILj)G2cQ^TrhhTtyr5@7eCYm=Uoy?6qTPsOnt5?2i>S(pg>~ z>M~M93=YlxcD}*xKl_@hz5j0IZGT@9Yu_^8_RN8=&YkAdFR(fGAAWd4CvkG#0^hlN z8>(g;?I=2=cEEAHaL+WiOvRp~fl`n5dTf5V)bNv|ZcjyzLHRnz@2)SdPWo#3tF@Nf ZfT6u;&2Oo-XD5RSIZszVmvv4FO#nm}^+Nyv literal 0 HcmV?d00001 diff --git a/static/images/back_enabled.png b/static/images/back_enabled.png new file mode 100644 index 0000000000000000000000000000000000000000..c608682b04a6d9b8002602450c8ef7e80ebba099 GIT binary patch literal 1379 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 literal 0 HcmV?d00001 diff --git a/static/images/forward_disabled.png b/static/images/forward_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..6a6ded7de821619aedc71d1738c0b73463a4452e GIT binary patch literal 1363 zcmeAS@N?(olHy`uVBq!ia0vp^!XV7S1|*9D%+3HQ$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWuD@%u1Od5hW46K32*3xq68pHF_1f1wh>l3^w)^1&PVosU-?Y zsp*+{wo31J?^jaDOtDo8H}y5}EpSfF$n>ZxN)4{^3rViZPPR-@vbR&PsjvbXkegbP zs8ErclUHn2VXFi-*9yo63F|80+w{G(j&jGsVin+1c5}%-qS)$=KP@(AChw*vQht)WY1&)XmVs z#Kj1v*Cju>G&eP`1g19yq1PFwUQklVEdbi=l3J8mmYU*Ll%J~r_OewbZns$CG!Lpb z1-Dxqaq86vIz}H9u}BdO69T3l5EGtkfgE_kPt60S_99@ies{+CJ_7^eaZeY=kcwMt zrucd@1&XviUp=W&hC_Vigxz9qe=+JQiRZ>#-OmxVbJNmo0?O0%xE7^$7hDVzS=7nq zBvt-aWrpIMi;ByGX0JatRXb+C`iBGE*Nwm1%=tcVr*ri@cU^6Xtj>)g66P@%1db+s zWSF+QYf((v=9^+`Y}d3l_I1DM*_La4%-~FX zOD#3=6_)Nj)~$1T^@J$qollO4A6gI{9eUN=bCTSutFMe5M0p+_TYIkc<*t8mtFJEc zTKXwVqK$2(et`HF?-Em4wsukSTsY4VW0ic bdL|wQU%!)=<|i%z6_yO1u6{1-oD!M<;V$ow literal 0 HcmV?d00001 diff --git a/static/images/forward_enabled.png b/static/images/forward_enabled.png new file mode 100644 index 0000000000000000000000000000000000000000..a4e6b5384b8454ee7f44a8f7c75b0321b7eeb9b1 GIT binary patch literal 1380 zcmeAS@N?(olHy`uVBq!ia0vp^!XV7S1|*9D%+3HQ$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWuD@%u1Od5hW46K32*3xq68pHF_1f1wh>l3^w)^1&PVosU-?Y zsp*+{wo31J?^jaDOtDo8H}y5}EpSfF$n>ZxN)4{^3rViZPPR-@vbR&PsjvbXkegbP zs8ErclUHn2VXFi-*9yo63F|80+w{G(j&jGsVip+||I$+{MJu$=KP@(AChw*vQht)WY1&)XmVs z#Kj1v*Cju>G&eP`1g19yq1P0rUQklVEdbi=l3J8mmYU*Ll%J~r_OewbZnqfWG!Lpb z1-Dy_aq86vIz}H9u}BdO69T3l5EGtkfgE_kPt60S_99@iW_)h2mVtrshNp{TNX4zB zKmY&RGjC-OW)@`(g)dh`10)R-NpU#bM1=1eo8Z2AYd)<-?^`_sqOFY@6XS7KM&8JXTA5$g^A9S z{TX;`xlCJF@2;-kt^WS)-(qj^YjbRh3Qo21zn9<7##PJs=)J=q?vtT6c$tqWSn$;S z{q^hJt*2*?^Szy4@bTi|;PVYm@{K=qh5oS|v0uoRWd7#=|NZ;-*VL7!+I+Ui=RzOdW2D@cF)|8@0kar@euUy>OYxL3%Wh|X{LVLRn7b4UH-Q)xXL zlsIOXI?k$WjNtJ4&-&^N(`qv`*KNBfVYRHPjKr+8GT t=fC*;><^O18kWptJjt-AJE?(znPJ1m)BPofdH;jTJx^CZmvv4FO#l_Q2A%)_ literal 0 HcmV?d00001 diff --git a/static/images/forward_enabled_hover.png b/static/images/forward_enabled_hover.png new file mode 100644 index 0000000000000000000000000000000000000000..fc46c5ebf0524b72a509fe2d7c1bc74995cb8a9d GIT binary patch literal 1379 zcmeAS@N?(olHy`uVBq!ia0vp^!XV7S1|*9D%+3HQ$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWuD@%u1Od5hW46K32*3xq68pHF_1f1wh>l3^w)^1&PVosU-?Y zsp*+{wo31J?^jaDOtDo8H}y5}EpSfF$n>ZxN)4{^3rViZPPR-@vbR&PsjvbXkegbP zs8ErclUHn2VXFi-*9yo63F|80+w{G(j&jGsVi#&A{2f&C=1($=KD<(AChw*vQht)WY1&)XmVs z#Kj1v*Cju>G&eP`1g19yq1OqgUQklVEdbi=l3J8mmYU*Ll%J~r_OewbZns$AG!Lpb z1-Dx)aq86vIz}H9u}BdO69T3l5EGtkfgE_kPt60S_99@imN7BvVqjpr?&;zfQgJKk z&;S4S%v%{AKQQkRwI?VL9s)YsRaG&b6_ zgRo<91_J$Y%O?`e|&#$ZtS+eXp_f= zjUUrLb7!pi%4at>{rxttxB!poqO-MU1}Hz4_scgh+`O`|pzGU*Ppd<;SGk>1td!-8 z=;-~nzrMci*RP)sFMiM0pIiAYgm$R?>m2vx|wYgZ#kAH7}W-quPuJDiV$o>g2t#=I)c$y9~%y7A9 zTfgc1jk&Lj_4ZX)f0PV(p)9c9_0ar=AL>^e-n9Mw|Nr~<^YZ!qF*`48{dsWi)b|VZ zD%w*xPFz?Y(EM+~bAgn@|LT8!dGP4_dwn}*-YUka%$2Ws1@^aZs%Q9^A9It(GQY{> zGt)+aDgU^S%wM?fC!gsP`E`4^jM#4)q-B&8J6^t!zrwrDf%l)k#^S|Oq%QdIe*R^@ tLh*lxrsW37V+~Wv{w==9!pqEIz%cpgo%)U5J^Y|z&(qb2Z8_q literal 0 HcmV?d00001 diff --git a/static/images/img01.jpg b/static/images/img01.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0f44794beff065b421301a6e2104c3e18ca3855d GIT binary patch literal 1763 zcmex=R-tZb|xz{bJG4g_o*9GqNST$~&{V890g0&u{{ z%)-LP#>T_J!6U@S!zUyHk`WOE%L_s%0VoC6gTnZKfI*Oh(U~EMnNg5|Nsy6Qkn#T! zhBBbDnSluCP8eWfW?^Jw=U@Z_WEn;#W;RxK4gp36CMHHER#un>21da^Az{VD!iCH% zQ?9UvvP%duGBPkQbHWucFbIBNWDZPhoX8|}NmT6rEe0N-rA&g%f(-TypH{cOFu$sL zK%60lv49nY;`oxq?t~q3eG9n3+klE1!&TM2Eh#B9zkvP`?vG3u4AyE7X&g}d_U=I6-4o2-Io#gKocDwmD^v8=<~tGP zVzNiu3lp(0dXGi*h)XKOq%;;zVm;=8ovD4(Il+t>6|tC2zy;k=Rf08!?|Br}m2WYL z?Q^FB5mc|6%ThO|sghhFDVl*3urpWO;lPM}P+I7BTTr5a9rhk-MM*5sq{9JEb&RVhB^L~+%w%VEcGeZ}!vQ*LD2j<((U|>62~QvNr|xJ@v(P;cx2o@BI~o znpE}H_r`;IiGR=jtiSOue(x{k7ba$#gR=jenAV z&;GPO`Dfkt7ySz^+`M{PU-^1g-f`w1$G@9Dtvmkd@B54Ljte)ho)+i6?#kUh_K*GV z@=yKkfBO6WBD-ei^l+U${d92t!Eevz?wQB=U!! zJ%`NOCwP&i8kct!94>e?hs9z!BcJ90=dJ<)oE*W;oyT@NcPz2!!b!O86SsQJCp6)q zCQd@?$HG&)4tH@m6GCol70>R-WaL#)T*6|~&^AHf5|fmJvj+!UsQXLOu2~``8W{l> z7A|ylYHQ`?l7es-<*a}wT`UZ1hX4^)Mr2|s3J*;Z3-_&)VgRdyC_@M%3dOttZrlHx E05NM{RR910 literal 0 HcmV?d00001 diff --git a/static/images/img02.jpg b/static/images/img02.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9d12f46aea3933fe822c46c8feb64cc26b17d716 GIT binary patch literal 329 zcmY+8Jxc>Y5Qg8m* zB!c}q8zpkjz`nyX%da11@A+z4t1}|lSbBa`HlD&o1IRR z77&kpSSCRs#6sx-XCNZ|R-tZb|xz{bJG4g_o*9GqNST$~&{V890g0&u{{ z%)-LP#>T_J!6U@S!zUyHk`WOE%L_s%0VoC6gTnZKfI*OhL6w1vnNg5|Nsy6Qkn#T! zhC~KNAON}&0a(~rfg()I2oWHQk%ft!LjdS)HWYCt;Y6XJOU%KOMc4#^no#5z1Q`_* z|KDQZVFsEe$SlZU&v1K3iE|Jiqx7)=IRS@1p6rj<&3E@L$`j^$XVrR8LUXU!l22NP T+J0^EeX&)2Vi@n2`u{fpf$%vd literal 0 HcmV?d00001 diff --git a/static/images/img04.jpg b/static/images/img04.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3453cadfc3141e2257eb5a7c951ca9c65b7c1454 GIT binary patch literal 3478 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 literal 0 HcmV?d00001 diff --git a/static/images/loader.gif b/static/images/loader.gif new file mode 100644 index 0000000000000000000000000000000000000000..e482b69e18ea173bef89e9bf137d97a7c8ee65d2 GIT binary patch literal 4167 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 AQUCw| literal 0 HcmV?d00001 diff --git a/static/images/sort_asc.png b/static/images/sort_asc.png new file mode 100644 index 0000000000000000000000000000000000000000..a88d7975fe9017e4e5f2289a94bd1ed66a5f59dc GIT binary patch literal 1118 zcmbVLO=#0l98awuV{uMt6P_}u4rcI3idKFP2SpU%ZJIE?RL`X zK?Oyo=*5GG2SxDYK=7akJqV&hrl{aWJa`y*5xh+1%i2y4V}gO?edPc9{r;a9vjc}) zn|Cxb4AYwFmvVG%_ui)U^y_4!ujsO!qzYuv8YUIR!Aw%KiWp=JrG#@>(I!s4#N7H->?w+cxsH2#GA};A>g8lyFDGPKh!5)vuP_{)}*83+N zJUBU!S0_i+E{*Lu1iGsNB``2iK-CyCU7?y_mv{xb_pUh>ESZqe1Y2{eAZLMSIT%EO zFrdOH1W^=3p>Qk~I{J+k#s5zQ@j{%aIA!l^GQjJ zqA1Uc2%!{8qBKfMNh#9DCnKS_*uZ8?mnf!+8@f8xtz#prVg=E`3bCBLWsNmDAX~PG z<(4fQh=UOzE2?gKXRkc9XeI3Er?HlHECVd%SI}3`hy1_du3@$R$r(qT;k@Sft63UX zv;)2Ea_iH>^6+4jPK-lGM{Zw37Tz>~~zlHzO61x51(V4jcaKrcIVDG$-d>)z}S|7f!xxYhfUE}Kj zug_h&HZN}go22$5Ym1}P8~vYNx7-~$TWFJ;_nh!wFYSAQJF{CCo=xpK8^7?iY1^!H haOA^1D_`VC7fU=jcT literal 0 HcmV?d00001 diff --git a/static/images/sort_asc_disabled.png b/static/images/sort_asc_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..4e144cf0b1f786a9248a2998311e8109998d8a2d GIT binary patch literal 1050 zcmaJ=O-K|`93R^(@goeAz6hRU)S-61)?IaU({*Myml|8vh4x}JJM&z}b>>Yo&vx~s z*1>{0Sk$FMM28~iln?~vp$ zL+z1TilQ3g{c@7*P2Q^6L;g-8^nq-LaWstw(J;=d4x}PF%0Lh|)htXxRiC)>9(Gfd zk2X?ioL0_@8ZsHx!!QNYCTxo8?kU)+mV+2%VAin0^v_psXkh4J`eIPw6kCELM*pM( z2PX$o+GI}a)ajlxpt~Rv0TW^s6wEQp6$@dys4J4Qcg@nE2*J59y%|(mNdO5s5Cj>{ zuW=y`gm{Jzw6(Xlp9TWQb1WyYyx`~C#eg7k94LJ>@#g5mVp5Kkd=V>5k6>(zESt~g znS6jjPL}06J3BoMLGTlV-<`0qTJT$LYgs{tuI3mvHjo88MKy!QahN8NZl++`Te6m0 zDibLfTTpG5XE-mVGEhip#w2PB)JKi0I-PD8)*(7w)xTBHA4Yzu(Y*BKcijk8- zXslet#0bL39YHpb27^FRHN*1kB3@C%xaDHi(qLQ;(?o$W3|;IHBC-_Xc|nkPo{#bo zKE_Anh#c$_BEdk1ROZS^8#kea%Upe%D^%oqqhQ*^vkV>MD%4{RGC?tA(byrDwm?jZ z#$wTmdL^!2ITo%WmnFflUSt1hboq)*k9XV}TViYtKD5ZRJ7lb1H!ZJ`CviJ2M^c5A z%=*Sgk8hr8@-S*Kr`Ol~RJX(fddmmK4eR}O=#0l98WD1Hz^GK+C=e@fhgE~b#2$Ux^~T`1v5)mw1NlIe}zC z+ge9alrMQeN|SYi`>tC{zIG}!O_oO7k;UC8kBf>8sknx65F`zy2d1H-4fel=trX>@ z^-LCL<%6P%3`TJ=Ov$hao1$9VN|vJbLJV@SM>nJN{L>dS(6uOiBq(#Tm4F5Pz>p2Q zhq^NAP_G)%=(c^JwImV&17Zb~j6Ty5OHq1RS0sD)n5Dro1ouYi-$7;N6i6T&f*`~B zRW8JV5YO;|=5RQ?2M8R`v7Es2f}anI0YT(Au=3Evo2})=wA8uci&#;*fUzaAY_V8m ziU9`MJuDxIL|hF)@DqgJ88op{@|#XmML~j&YU>u(kqKNyC5HxZlqQk>PQkENWld+L zOr&6JNwHX-;oOueKw17j)G$`j4o<^A@%~fT$qZVMO+yC_*eYpUzR7iEi3uAj7}*(w z`YKgS6%a;F0a+l?9R#wX>ZWTi<7HV)nhsV>6(*%9O%xbi*F?TK!383rh#(|*p6}q} zd?z25;!?0(hzA2Li3(Rj>VN@FT;Xbexbdo7cN7eZc$T28pMYAYjSR4yvZz;&C0tc+ zg{xJMrKKvDCBd+6WB+P&<%mp=yImbyVyq56G|9BvWUP^I>ms=lb4e+lDSgg;Us`JO zKB6{wH+j~F#-A4FY3K3qm~Z6m@V6}oQ%8?p-E$dw`#0C$PJfmCV8)v}3>Ydha%`fZ zJk~G*M^A3LGk$Td;R`icF67R~`sBOHv)Hlqlc%$jy~9_oZJcNyWxkbb_O9u#|7hLF z-<-NMLzh3S0YA@8gd1Pt(Df|3@16Y-n=aSvsF@AkI`ioeFg>&H3bXU&vBnE6gIChkL+(Ey+0iB4Z$Eze7t_CX>Hq)$ literal 0 HcmV?d00001 diff --git a/static/images/sort_desc.png b/static/images/sort_desc.png new file mode 100644 index 0000000000000000000000000000000000000000..def071ed5afd264a036f6d9e75856366fd6ad153 GIT binary patch literal 1127 zcmbVMOK8+U7*1U&zKRu5sR)h{1;yRWWV^4}ShvZpU2*HWU2!iy(qy)cZ89;Lb+`3m zMbruv!GjkO!3qksP*5)lD)k}=Dp*ht-n@8G5m8XoN!zU+ih_Y;=AZe$?|)|~*Ri8v z(dtDU$2DZy)jV65`|pB!_H}d7Cv0h=sUqzpC0fy3%q0!dg+a#Bx^W(BM*oq=xP{{a zC9_bZ#q2IgCss)FbwX9kVQ7wPX{|b%-is;d!ri7V^Y8E8=YeU+{JuyQW*r6hnC$~D z?i}bS=mWia!r)uCftISo2rNuBP__DOPpZoN6tBeg{;|M=DHYl)^V3chvpJv;7lTL$ z26Y&PAc{gL+#HL=wg3?#C_qs_Vi3iouqZ(YW*(kdbB&UeSJN}Lm?ZN(lsb|iR4SEF zB^)Adw}29fgwG+0L8cM(`faLJgSNN6#-L(PcTI+l@K3y+Xf(g*^61+0|J+O6zN2mb?UNGh6GU@A{1+eF%d@N2(^XdVmhis(y25|iAr;gV=io5OsYy0 zB}Gv|2&GUGrBPB%s*yG^841Ug8a88lRI_zlvuiTDGuXsmv6A9qjS{y&NMEf3ay^6+ zuZK85>5PD^rkl1e`{kLAR>iJ)6dP%mSYRr@k~xQcDE=$%X{_--ITM&Og5Ml}G)wJ> zb)dhUZG9%p4iC23#JFrUCcmwHz{cugMoku~ue-kg{Mj0~%`FeCcz9jAdg}QET-kSG za`+2B_+lRTaeAVz>E`F1pN7h>B=BbGqcz13d%ywZR&4OjkNNrF_U}#EcXDGa@V52B z>JnIW7#s%CHi literal 0 HcmV?d00001 diff --git a/static/images/sort_desc_disabled.png b/static/images/sort_desc_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..7824973cc60fc1841b16f2cb39323cefcdc3f942 GIT binary patch literal 1045 zcmaJ=&rj1(9IuWjVlWt@h#q(rlc~7%$2P_q>KN??ODrK{#&I!}_Kh{rzS=%m2N%F- zAW={L0VZBJnRrkSCK{q1NKA||(ZmA>6Hgw9o;Z-;>)3_|u*vIt-(X0AeGY5Bm`Mgoq{>2>Xkbiu%Ds= zw2?31f^tL9kQr8eOxQDR!ltPHq-U$zG{j&MP8pU+Z@qp?149?-TQP-IYzdZ(;duv+ z&5z`@`Drbo)5+_g-xG*{39$-1bH;K7Po%550y+EF3=OIfJT20DK^2ryARz~WSeOlI zY%dFXxiA-r#^dp8fM+?DVR?q*LtI>l@B+(%+D8*_j$RaUa;D~sSR!4**cKS3TrP*p zkuY+m7%q`W_!>MPB8ZS%v9RieEVsL^AVXJk3>zEB0=}X;iDt1#lSubcFztq{<<`nX z3dVS<&2VAXPpJ-6l>b9bvw?PT4(`W$ps<^-*pSIV7tJ~vX67YQ8ELa7v~ZoP?{i~^a{W;-ZQ@ymjxh)IjDt*2O<6Dwh=q$vY$VY; zc&J{Ds~-?cjVm3>Wk@iL-`IZ|UB4pJ;~yJiON_?gLyJtiL&kbxZhV_OiPfx}%6s1@ zcXoG^ffrPJ;LQ4(`t<(ickJ1j|E0&fC8lSh8sUh5lwUg=l~QoqsK t`nTanN|e2@a&yVMdhyaX1K}04b%WX}Ye94MPwF)JDW42FALk8HT|avmJZ1Lc3i`yOJcip7+J`vMh_D zG|8yn^VidsEfmtRdOgm? IU-!VyACo}9@&Et; literal 0 HcmV?d00001 diff --git a/static/js/jquery.alerts.js b/static/js/jquery.alerts.js new file mode 100644 index 0000000..0b32996 --- /dev/null +++ b/static/js/jquery.alerts.js @@ -0,0 +1,235 @@ +// jQuery Alert Dialogs Plugin +// +// Version 1.1 +// +// Cory S.N. LaViska +// A Beautiful Site (http://abeautifulsite.net/) +// 14 May 2009 +// +// Website: http://abeautifulsite.net/blog/2008/12/jquery-alert-dialogs/ +// +// Usage: +// jAlert( message, [title, callback] ) +// jConfirm( message, [title, callback] ) +// jPrompt( message, [value, title, callback] ) +// +// History: +// +// 1.00 - Released (29 December 2008) +// +// 1.01 - Fixed bug where unbinding would destroy all resize events +// +// License: +// +// This plugin is dual-licensed under the GNU General Public License and the MIT License and +// is copyright 2008 A Beautiful Site, LLC. +// +(function($) { + + $.alerts = { + + // These properties can be read/written by accessing $.alerts.propertyName from your scripts at any time + + verticalOffset: -75, // vertical offset of the dialog from center screen, in pixels + horizontalOffset: 0, // horizontal offset of the dialog from center screen, in pixels/ + repositionOnResize: true, // re-centers the dialog on window resize + overlayOpacity: .01, // transparency level of overlay + overlayColor: '#FFF', // base color of overlay + draggable: true, // make the dialogs draggable (requires UI Draggables plugin) + okButton: ' OK ', // text for the OK button + cancelButton: ' Cancel ', // text for the Cancel button + dialogClass: null, // if specified, this class will be applied to all dialogs + + // Public methods + + alert: function(message, title, callback) { + if( title == null ) title = 'Alert'; + $.alerts._show(title, message, null, 'alert', function(result) { + if( callback ) callback(result); + }); + }, + + confirm: function(message, title, callback) { + if( title == null ) title = 'Confirm'; + $.alerts._show(title, message, null, 'confirm', function(result) { + if( callback ) callback(result); + }); + }, + + prompt: function(message, value, title, callback) { + if( title == null ) title = 'Prompt'; + $.alerts._show(title, message, value, 'prompt', function(result) { + if( callback ) callback(result); + }); + }, + + // Private methods + + _show: function(title, msg, value, type, callback) { + + $.alerts._hide(); + $.alerts._overlay('show'); + + $("BODY").append( + ''); + + if( $.alerts.dialogClass ) $("#popup_container").addClass($.alerts.dialogClass); + + // IE6 Fix + var pos = ($.browser.msie && parseInt($.browser.version) <= 6 ) ? 'absolute' : 'fixed'; + + $("#popup_container").css({ + position: pos, + zIndex: 99999, + padding: 0, + margin: 0 + }); + + $("#popup_title").text(title); + $("#popup_content").addClass(type); + $("#popup_message").text(msg); + $("#popup_message").html( $("#popup_message").text().replace(/\n/g, '
') ); + + $("#popup_container").css({ + minWidth: $("#popup_container").outerWidth(), + maxWidth: $("#popup_container").outerWidth() + }); + + $.alerts._reposition(); + $.alerts._maintainPosition(true); + + switch( type ) { + case 'alert': + $("#popup_message").after(''); + $("#popup_ok").click( function() { + $.alerts._hide(); + callback(true); + }); + $("#popup_ok").focus().keypress( function(e) { + if( e.keyCode == 13 || e.keyCode == 27 ) $("#popup_ok").trigger('click'); + }); + break; + case 'confirm': + $("#popup_message").after(''); + $("#popup_ok").click( function() { + $.alerts._hide(); + if( callback ) callback(true); + }); + $("#popup_cancel").click( function() { + $.alerts._hide(); + if( callback ) callback(false); + }); + $("#popup_ok").focus(); + $("#popup_ok, #popup_cancel").keypress( function(e) { + if( e.keyCode == 13 ) $("#popup_ok").trigger('click'); + if( e.keyCode == 27 ) $("#popup_cancel").trigger('click'); + }); + break; + case 'prompt': + $("#popup_message").append('
').after(''); + $("#popup_prompt").width( $("#popup_message").width() ); + $("#popup_ok").click( function() { + var val = $("#popup_prompt").val(); + $.alerts._hide(); + if( callback ) callback( val ); + }); + $("#popup_cancel").click( function() { + $.alerts._hide(); + if( callback ) callback( null ); + }); + $("#popup_prompt, #popup_ok, #popup_cancel").keypress( function(e) { + if( e.keyCode == 13 ) $("#popup_ok").trigger('click'); + if( e.keyCode == 27 ) $("#popup_cancel").trigger('click'); + }); + if( value ) $("#popup_prompt").val(value); + $("#popup_prompt").focus().select(); + break; + } + + // Make draggable + if( $.alerts.draggable ) { + try { + $("#popup_container").draggable({ handle: $("#popup_title") }); + $("#popup_title").css({ cursor: 'move' }); + } catch(e) { /* requires jQuery UI draggables */ } + } + }, + + _hide: function() { + $("#popup_container").remove(); + $.alerts._overlay('hide'); + $.alerts._maintainPosition(false); + }, + + _overlay: function(status) { + switch( status ) { + case 'show': + $.alerts._overlay('hide'); + $("BODY").append(''); + $("#popup_overlay").css({ + position: 'absolute', + zIndex: 99998, + top: '0px', + left: '0px', + width: '100%', + height: $(document).height(), + background: $.alerts.overlayColor, + opacity: $.alerts.overlayOpacity + }); + break; + case 'hide': + $("#popup_overlay").remove(); + break; + } + }, + + _reposition: function() { + var top = (($(window).height() / 2) - ($("#popup_container").outerHeight() / 2)) + $.alerts.verticalOffset; + var left = (($(window).width() / 2) - ($("#popup_container").outerWidth() / 2)) + $.alerts.horizontalOffset; + if( top < 0 ) top = 0; + if( left < 0 ) left = 0; + + // IE6 fix + if( $.browser.msie && parseInt($.browser.version) <= 6 ) top = top + $(window).scrollTop(); + + $("#popup_container").css({ + top: top + 'px', + left: left + 'px' + }); + $("#popup_overlay").height( $(document).height() ); + }, + + _maintainPosition: function(status) { + if( $.alerts.repositionOnResize ) { + switch(status) { + case true: + $(window).bind('resize', $.alerts._reposition); + break; + case false: + $(window).unbind('resize', $.alerts._reposition); + break; + } + } + } + + } + + // Shortuct functions + jAlert = function(message, title, callback) { + $.alerts.alert(message, title, callback); + } + + jConfirm = function(message, title, callback) { + $.alerts.confirm(message, title, callback); + }; + + jPrompt = function(message, value, title, callback) { + $.alerts.prompt(message, value, title, callback); + }; + +})(jQuery); \ No newline at end of file diff --git a/static/js/jquery.dataTables.js b/static/js/jquery.dataTables.js new file mode 100644 index 0000000..6b4d452 --- /dev/null +++ b/static/js/jquery.dataTables.js @@ -0,0 +1,12098 @@ +/** + * @summary DataTables + * @description Paginate, search and sort HTML tables + * @version 1.9.4 + * @file jquery.dataTables.js + * @author Allan Jardine (www.sprymedia.co.uk) + * @contact www.sprymedia.co.uk/contact + * + * @copyright Copyright 2008-2012 Allan Jardine, all rights reserved. + * + * This source file is free software, under either the GPL v2 license or a + * BSD style license, available at: + * http://datatables.net/license_gpl2 + * http://datatables.net/license_bsd + * + * This source file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the license files for details. + * + * For details please refer to: http://www.datatables.net + */ + +/*jslint evil: true, undef: true, browser: true */ +/*globals $, jQuery,define,_fnExternApiFunc,_fnInitialise,_fnInitComplete,_fnLanguageCompat,_fnAddColumn,_fnColumnOptions,_fnAddData,_fnCreateTr,_fnGatherData,_fnBuildHead,_fnDrawHead,_fnDraw,_fnReDraw,_fnAjaxUpdate,_fnAjaxParameters,_fnAjaxUpdateDraw,_fnServerParams,_fnAddOptionsHtml,_fnFeatureHtmlTable,_fnScrollDraw,_fnAdjustColumnSizing,_fnFeatureHtmlFilter,_fnFilterComplete,_fnFilterCustom,_fnFilterColumn,_fnFilter,_fnBuildSearchArray,_fnBuildSearchRow,_fnFilterCreateSearch,_fnDataToSearch,_fnSort,_fnSortAttachListener,_fnSortingClasses,_fnFeatureHtmlPaginate,_fnPageChange,_fnFeatureHtmlInfo,_fnUpdateInfo,_fnFeatureHtmlLength,_fnFeatureHtmlProcessing,_fnProcessingDisplay,_fnVisibleToColumnIndex,_fnColumnIndexToVisible,_fnNodeToDataIndex,_fnVisbleColumns,_fnCalculateEnd,_fnConvertToWidth,_fnCalculateColumnWidths,_fnScrollingWidthAdjust,_fnGetWidestNode,_fnGetMaxLenString,_fnStringToCss,_fnDetectType,_fnSettingsFromNode,_fnGetDataMaster,_fnGetTrNodes,_fnGetTdNodes,_fnEscapeRegex,_fnDeleteIndex,_fnReOrderIndex,_fnColumnOrdering,_fnLog,_fnClearTable,_fnSaveState,_fnLoadState,_fnCreateCookie,_fnReadCookie,_fnDetectHeader,_fnGetUniqueThs,_fnScrollBarWidth,_fnApplyToChildren,_fnMap,_fnGetRowData,_fnGetCellData,_fnSetCellData,_fnGetObjectDataFn,_fnSetObjectDataFn,_fnApplyColumnDefs,_fnBindAction,_fnCallbackReg,_fnCallbackFire,_fnJsonString,_fnRender,_fnNodeToColumnIndex,_fnInfoMacros,_fnBrowserDetect,_fnGetColumns*/ + +(/** @lends */function( window, document, undefined ) { + +(function( factory ) { + "use strict"; + + // Define as an AMD module if possible + if ( typeof define === 'function' && define.amd ) + { + define( ['jquery'], factory ); + } + /* Define using browser globals otherwise + * Prevent multiple instantiations if the script is loaded twice + */ + else if ( jQuery && !jQuery.fn.dataTable ) + { + factory( jQuery ); + } +} +(/** @lends */function( $ ) { + "use strict"; + /** + * DataTables is a plug-in for the jQuery Javascript library. It is a + * highly flexible tool, based upon the foundations of progressive + * enhancement, which will add advanced interaction controls to any + * HTML table. For a full list of features please refer to + * DataTables.net. + * + * Note that the DataTable object is not a global variable but is + * aliased to jQuery.fn.DataTable and jQuery.fn.dataTable through which + * it may be accessed. + * + * @class + * @param {object} [oInit={}] Configuration object for DataTables. Options + * are defined by {@link DataTable.defaults} + * @requires jQuery 1.3+ + * + * @example + * // Basic initialisation + * $(document).ready( function { + * $('#example').dataTable(); + * } ); + * + * @example + * // Initialisation with configuration options - in this case, disable + * // pagination and sorting. + * $(document).ready( function { + * $('#example').dataTable( { + * "bPaginate": false, + * "bSort": false + * } ); + * } ); + */ + var DataTable = function( oInit ) + { + + + /** + * Add a column to the list used for the table with default values + * @param {object} oSettings dataTables settings object + * @param {node} nTh The th element for this column + * @memberof DataTable#oApi + */ + function _fnAddColumn( oSettings, nTh ) + { + var oDefaults = DataTable.defaults.columns; + var iCol = oSettings.aoColumns.length; + var oCol = $.extend( {}, DataTable.models.oColumn, oDefaults, { + "sSortingClass": oSettings.oClasses.sSortable, + "sSortingClassJUI": oSettings.oClasses.sSortJUI, + "nTh": nTh ? nTh : document.createElement('th'), + "sTitle": oDefaults.sTitle ? oDefaults.sTitle : nTh ? nTh.innerHTML : '', + "aDataSort": oDefaults.aDataSort ? oDefaults.aDataSort : [iCol], + "mData": oDefaults.mData ? oDefaults.oDefaults : iCol + } ); + oSettings.aoColumns.push( oCol ); + + /* Add a column specific filter */ + if ( oSettings.aoPreSearchCols[ iCol ] === undefined || oSettings.aoPreSearchCols[ iCol ] === null ) + { + oSettings.aoPreSearchCols[ iCol ] = $.extend( {}, DataTable.models.oSearch ); + } + else + { + var oPre = oSettings.aoPreSearchCols[ iCol ]; + + /* Don't require that the user must specify bRegex, bSmart or bCaseInsensitive */ + if ( oPre.bRegex === undefined ) + { + oPre.bRegex = true; + } + + if ( oPre.bSmart === undefined ) + { + oPre.bSmart = true; + } + + if ( oPre.bCaseInsensitive === undefined ) + { + oPre.bCaseInsensitive = true; + } + } + + /* Use the column options function to initialise classes etc */ + _fnColumnOptions( oSettings, iCol, null ); + } + + + /** + * Apply options for a column + * @param {object} oSettings dataTables settings object + * @param {int} iCol column index to consider + * @param {object} oOptions object with sType, bVisible and bSearchable etc + * @memberof DataTable#oApi + */ + function _fnColumnOptions( oSettings, iCol, oOptions ) + { + var oCol = oSettings.aoColumns[ iCol ]; + + /* User specified column options */ + if ( oOptions !== undefined && oOptions !== null ) + { + /* Backwards compatibility for mDataProp */ + if ( oOptions.mDataProp && !oOptions.mData ) + { + oOptions.mData = oOptions.mDataProp; + } + + if ( oOptions.sType !== undefined ) + { + oCol.sType = oOptions.sType; + oCol._bAutoType = false; + } + + $.extend( oCol, oOptions ); + _fnMap( oCol, oOptions, "sWidth", "sWidthOrig" ); + + /* iDataSort to be applied (backwards compatibility), but aDataSort will take + * priority if defined + */ + if ( oOptions.iDataSort !== undefined ) + { + oCol.aDataSort = [ oOptions.iDataSort ]; + } + _fnMap( oCol, oOptions, "aDataSort" ); + } + + /* Cache the data get and set functions for speed */ + var mRender = oCol.mRender ? _fnGetObjectDataFn( oCol.mRender ) : null; + var mData = _fnGetObjectDataFn( oCol.mData ); + + oCol.fnGetData = function (oData, sSpecific) { + var innerData = mData( oData, sSpecific ); + + if ( oCol.mRender && (sSpecific && sSpecific !== '') ) + { + return mRender( innerData, sSpecific, oData ); + } + return innerData; + }; + oCol.fnSetData = _fnSetObjectDataFn( oCol.mData ); + + /* Feature sorting overrides column specific when off */ + if ( !oSettings.oFeatures.bSort ) + { + oCol.bSortable = false; + } + + /* Check that the class assignment is correct for sorting */ + if ( !oCol.bSortable || + ($.inArray('asc', oCol.asSorting) == -1 && $.inArray('desc', oCol.asSorting) == -1) ) + { + oCol.sSortingClass = oSettings.oClasses.sSortableNone; + oCol.sSortingClassJUI = ""; + } + else if ( $.inArray('asc', oCol.asSorting) == -1 && $.inArray('desc', oCol.asSorting) == -1 ) + { + oCol.sSortingClass = oSettings.oClasses.sSortable; + oCol.sSortingClassJUI = oSettings.oClasses.sSortJUI; + } + else if ( $.inArray('asc', oCol.asSorting) != -1 && $.inArray('desc', oCol.asSorting) == -1 ) + { + oCol.sSortingClass = oSettings.oClasses.sSortableAsc; + oCol.sSortingClassJUI = oSettings.oClasses.sSortJUIAscAllowed; + } + else if ( $.inArray('asc', oCol.asSorting) == -1 && $.inArray('desc', oCol.asSorting) != -1 ) + { + oCol.sSortingClass = oSettings.oClasses.sSortableDesc; + oCol.sSortingClassJUI = oSettings.oClasses.sSortJUIDescAllowed; + } + } + + + /** + * Adjust the table column widths for new data. Note: you would probably want to + * do a redraw after calling this function! + * @param {object} oSettings dataTables settings object + * @memberof DataTable#oApi + */ + function _fnAdjustColumnSizing ( oSettings ) + { + /* Not interested in doing column width calculation if auto-width is disabled */ + if ( oSettings.oFeatures.bAutoWidth === false ) + { + return false; + } + + _fnCalculateColumnWidths( oSettings ); + for ( var i=0 , iLen=oSettings.aoColumns.length ; i