From 16248737bab5ddd30667216eaa5d5d6acfa15f06 Mon Sep 17 00:00:00 2001 From: Jeremie Dimino Date: Sat, 15 Dec 2007 23:32:23 +0100 Subject: [PATCH] Plugin Python pour bcfg2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout d'un plugin pour bcfg2 qui éxécute un script en python pour générer une entrée de type ConfigFile. Ça peut s'avérer assez pratique notamment pour la conf de monit, munin et co. darcs-hash:20071215223223-af139-778a3830aabcfb7d2fb1af84850973d7db437f63.gz --- bcfg2/plugins/Python.py | 234 ++++++++++++++++++++++++++++++++++++++++ bcfg2/pygen.py | 150 ++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 bcfg2/plugins/Python.py create mode 100644 bcfg2/pygen.py diff --git a/bcfg2/plugins/Python.py b/bcfg2/plugins/Python.py new file mode 100644 index 00000000..7a0964e9 --- /dev/null +++ b/bcfg2/plugins/Python.py @@ -0,0 +1,234 @@ +# -*- mode: python; coding: utf-8 -*- +# +# Python.py +# --------- +# +# Copyright (C) 2007 Jeremie Dimino +# +# This file 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 2 of the License, or +# (at your option) any later version. +# +# This 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 +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Street #330, Boston, MA 02111-1307, USA. + +''' Plugin pour bcfg2 pour générer des fichiers de conf en prenant la sortie d'un +script python''' + +import logging, lxml.etree, posixpath, re, os, sys +from cStringIO import StringIO +import Bcfg2.Server.Plugin + +sys.path.append('/usr/scripts/bcfg2') +import pygen + +logger = logging.getLogger('Bcfg2.Plugins.Python') + +# Helper pour le deboggage +if os.getenv("BCFG2_DEBUG"): + debug_colored = os.getenv("BCFG2_DEBUG_COLOR") + color_code = {'grey': 30, + 'red': 31, + 'green': 32, + 'yellow': 33, + 'blue': 34, + 'purple': 35, + 'cyan': 36} + def debug(msg, color=None): + if debug_colored and color: + logger.info("\033[1;%dm%s\033[0m" % (color_code[color], msg)) + else: + logger.info(msg) +else: + def debug(msg, color=None): + pass + +# Les fichiers qui sont surveillés par le plugin dans les dossier /etc/../machin.cf/ +_monitored_files = ["info.xml", "gen.py"] + +# Dico nom de fichier -> code +includes = {} + +def include(env, incfile): + exec(includes[incfile], env) + +def load_file(filename): + '''Charge un script et affiche un message d'erreur en cas d'exception''' + try: + return pygen.load(filename) + except Exception, e: + logger.error('Python compilation error: %s: %s' % (str(e.__class__).split('.', 2)[1], str(e))) + return None + +class PythonFile: + '''Template file creates Python template structures for the loaded file''' + def __init__(self, name, properties): + self.name = name + self.info = None + self.code = None + self.properties = properties + + def HandleEvent(self, event): + '''Handle all fs events for this template''' + if event.filename == 'gen.py': + self.code = load_file(self.name + '/' + event.filename) + elif event.filename == 'info.xml': + if not self.info: + self.info = Bcfg2.Server.Plugin.XMLSrc(os.path.join(self.name[1:], event.filename), True) + self.info.HandleEvent(event) + else: + logger.info('Ignoring event for %s' % event.filename) + + def Created(self, path, event): + '''Traitement des créations de fichier''' + if event.filename == "gen.py": + self.code = load_file(path) + else: + self.info = Bcfg2.Server.Plugin.XMLSrc(path, True) + self.info.HandleEvent(event) + + def Changed(self, path, event): + '''Traitement des modifications de fichier''' + if event.filename == "gen.py": + self.code = load_file(path) + else: + self.info.HandleEvent(event) + + def Deleted(self, path, event): + '''Traitement des suppressions de fichier''' + if event.filename == "gen.py": + self.code = None + else: + self.info = None + return self.code or self.info + + def BuildFile(self, entry, metadata): + '''Build literal file information''' + fname = entry.get('realname', entry.get('name')) + try: + env = pygen.Environment() + env["metadata"] = metadata + env["properties"] = self.properties + env["include"] = lambda incfile: include(env, incfile) + include(env, "common") + entry.text = pygen.generate(self.code, env) + except Exception, e: + logger.error('Python exec error: %s: %s' % (str(e.__class__).split('.', 2)[1], str(e))) + raise Bcfg2.Server.Plugin.PluginExecutionError + if self.info: + mdata = {} + self.info.pnode.Match(metadata, mdata) + mdata = mdata['Info'][None] + [entry.attrib.__setitem__(key, value) + for (key, value) in mdata.iteritems()] + +class PythonProperties(Bcfg2.Server.Plugin.SingleXMLFileBacked): + '''Class for Python properties''' + def Index(self): + '''Build data into an elementtree object for templating usage''' + try: + self.properties = lxml.etree.XML(self.data) + del self.data + except lxml.etree.XMLSyntaxError: + logger.error("Failed to parse properties") + +class FakeProperties: + '''Dummy class used when properties dont exist''' + def __init__(self): + self.properties = lxml.etree.Element("Properties") + +class Python(Bcfg2.Server.Plugin.Plugin): + '''The Python generator implements a templating mechanism for configuration files''' + __name__ = 'Python' + __version__ = '1.0' + __author__ = 'dimino@crans.org' + + def __init__(self, core, datastore): + Bcfg2.Server.Plugin.Plugin.__init__(self, core, datastore) + # Les entrées pour bcfg2 + self.Entries['ConfigFile'] = {} + # Nos entrées pour nous (dico de PythonFile) + self.entries = {} + # Dico ID de requête GAM -> Dossier surveillé + self.handles = {} + # Le dossier qui contient les fichiers à inclure + self.include = os.path.abspath(self.data + '/../etc/python') + + self.AddDirectoryMonitor(self.data) + self.AddDirectoryMonitor(self.include) + + try: + self.properties = PythonProperties('%s/../etc/properties.xml' \ + % (self.data), self.core.fam) + except: + self.properties = FakeProperties() + self.logger.info("Failed to read properties file; Python properties disabled") + + def BuildEntry(self, entry, metadata): + '''Dispatch fetch calls to the correct object''' + self.entries[entry.get('name')].BuildFile(entry, metadata) + + def HandleEvent(self, event): + '''Traitement des événements de FAM''' + action = event.code2str() + debug("event received: action=%s filename=%s requestID=%s" % + (action, event.filename, event.requestID), 'purple') + + if event.filename[0] == '/' or event.filename.endswith(".pyc"): + # Rien d'intéressant + return + + hdle_path = self.handles[event.requestID] + path = hdle_path + "/" + event.filename + debug("absolute filename: %s" % path, 'yellow') + + if path.startswith(self.include): + if posixpath.isfile(path) and path.endswith(".py"): + # Les fichiers d'includes... + if action in ['exists', 'created', 'changed']: + debug("adding include file: %s" % event.filename[:-3], 'green') + includes[event.filename[:-3]] = load_file(path) + elif action == 'deleted': + debug("deleting include file: %s" % event.filename[:-3], 'red') + del includes[event.filename[:-3]] + + elif posixpath.isfile(path) and event.filename in _monitored_files: + # Le nom du dossier est le nom du fichier du fichier de + # configuration à générer + identifier = hdle_path[len(self.data):] + if action in ['exists', 'created']: + if not self.entries.has_key(identifier): + debug("adding config file: %s" % identifier, 'green') + self.entries[identifier] = PythonFile(identifier, self.properties) + self.Entries['ConfigFile'][identifier] = self.BuildEntry + self.entries[identifier].Created(path, event) + elif action == 'changed': + self.entries[identifier].Changed(path, event) + elif action == 'deleted': + if self.entries[identifier].Deleted(path, event): + debug("deleting config file: %s" % identifier, 'red') + del self.entries[identifier] + del self.Entries['ConfigFile'][identifier] + + elif posixpath.isdir(path): + if action in ['exists', 'created']: + self.AddDirectoryMonitor(path) + + else: + logger.info('Ignoring file %s' % path) + + def AddDirectoryMonitor(self, path): + '''Surveille un dossier avec FAM''' + if path not in self.handles.values(): + if not posixpath.isdir(path): + print "Python: Failed to open directory %s" % (name) + return + reqid = self.core.fam.AddMonitor(path, self) + self.handles[reqid] = path diff --git a/bcfg2/pygen.py b/bcfg2/pygen.py new file mode 100644 index 00000000..dce550c1 --- /dev/null +++ b/bcfg2/pygen.py @@ -0,0 +1,150 @@ +# -*- mode: python; coding: utf-8 -*- +# +# pygen.py +# -------- +# +# Copyright (C) 2007 Jeremie Dimino +# +# This file 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 2 of the License, or +# (at your option) any later version. +# +# This 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 +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Street #330, Boston, MA 02111-1307, USA. + +'''Module utilisé par le plugin Python de bcfg2 pour générer un +fichier de configuration à partir d'un script python''' + +import re, marshal, os +from cStringIO import StringIO + +# Pour l'arrêt d'un script +class Done(Exception): + pass + +def done(): + raise Done() + +class Environment(dict): + '''Environment dans lequel sont éxécuté les scripts''' + + # Dernière variable exportée à avoir été définie + last_definition = None + + # Le flux de sortie + stream = None + + # Les variables qui sont partagées entre le script éxécuté + # et le programme appelant + shared = ["out", "exports", "export_definition", + "last_definition", "conv", "tostring"] + + def __init__(self): + # Les variables à "exporter" dans le fichier produit + self.exports = {} + # Fonctions de convertions initiales + self.conv = {bool: {True: "yes", False: "no"}, + list: lambda l: ", ".join([str(x) for x in l]), + tuple: lambda l: ", ".join([str(x) for x in l])} + # Création de l'environment initial + dict.__init__(self, { + # Permet d'accéder aux variables exportées + "exports": self.exports, + "export": self.export, + # On met a disposition le nécessaire pour surcharger + # la gestion des variables exportée + "last_definition": self.last_definition, + "export_definition": self.export_definition, + # La convertion + "conv": self.conv, + "tostring": self.tostring, + # Fonction de base pour imprimer quelque chose + "out": self.out, + # Arrêt du script + "done": done}) + + def __setitem__(self, variable, value): + '''Lorsqu'on définie une variable, on "l'exporte" dans + le fichier produit''' +# if variable in self.shared: +# self.__setattr__(variable, value) + if variable in self["exports"]: + self["export_definition"](variable, value) + dict.__setitem__(self, "last_definition", variable) + dict.__setitem__(self, variable, value) + + def export_definition(self, variable, value): + '''Fonction par défaut pour gérée la définition des variables exportée. + écrit 'variable = tostring(value)' dans le fichier produit''' + self["out"]("%s = %s\n" % (variable, self["tostring"](value))) + + def out(self, string): + '''Fonction de base pour écrire une chaine dans le fichier produit''' + self.stream.write(string) + + def tostring(self, value): + '''Fonction de convertion objet python -> chaine dans un format sympa''' + convertor = self["conv"].get(type(value)) + if convertor: + if type(convertor) == dict: + return convertor[value] + else: + return convertor(value) + else: + return str(value) + + # Cette fonction pourrait être écrite directement dans les scripts + # mais elle est pratique donc on la met pour tout le monde + def export(variable): + '''Exporte une variable''' + self["exports"][variable] = True + +__re_special_line = re.compile(r"^([ \t]*)@(.*)$", re.MULTILINE) + +def compileSource(source, filename=""): + '''Compile un script''' + # On commence par remplacer les lignes de la forme + # @xxx par out("xxx") + newsource = StringIO() + start = 0 + for m in __re_special_line.finditer(source): + newsource.write(source[start:m.start()]) + newsource.write(m.group(1)) + newsource.write('out("') + newsource.write(m.group(2).replace("\\", "\\\\").replace('"', '\\"')) + newsource.write('\\n")') + start = m.end() + newsource.write(source[start:]) + return compile(newsource.getvalue(), filename, "exec") + +def generate(code, environment=None): + '''Évalue un script''' + if type(code) == str: + code = compileSource(code) + if not environment: + environment = Environment() + environment.stream = StringIO() + try: + exec(code, environment) + except Done, _: + pass + return environment.stream.getvalue() + +def load(fname, cfname=None): + '''Charge un script et le compile, en créant/utilisant un fichier de + cache''' + if not cfname: + cfname = fname + 'c' + if os.path.exists(cfname) and os.stat(fname).st_mtime <= os.stat(cfname).st_mtime: + code = marshal.load(file(cfname)) + else: + code = compileSource(file(fname).read(), fname) + marshal.dump(code, open(cfname, "w")) + return code