From 980addbad2fa349befff4f22034b09afb489c9e0 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 16 Jun 2008 20:39:38 -0300 Subject: [PATCH] Add configuration and command-line option framework. The new pymin.config module integrates both ConfigParser and optparse Python modules, adding variables validation throw formencode library and hooks for plug-ins to add options in run-time. This closes #3, closes #4 and closes #6. --- config.py | 72 --- doc/config-examples/pymin.debian.ini | 66 +++ doc/config-examples/pymin.devel.ini | 72 +++ doc/config-examples/pymin.suse.ini | 66 +++ pymin/config.py | 696 +++++++++++++++++++++++++++ pymind | 133 ++++- services/dhcp/__init__.py | 10 + services/dns/__init__.py | 17 +- services/firewall/__init__.py | 10 + services/ip/__init__.py | 10 + services/nat/__init__.py | 8 + services/ppp/__init__.py | 23 +- services/proxy/__init__.py | 10 + services/qos/__init__.py | 10 + services/vpn/__init__.py | 10 + services/vrrp/__init__.py | 15 +- 16 files changed, 1131 insertions(+), 97 deletions(-) delete mode 100644 config.py create mode 100644 doc/config-examples/pymin.debian.ini create mode 100644 doc/config-examples/pymin.devel.ini create mode 100644 doc/config-examples/pymin.suse.ini create mode 100644 pymin/config.py diff --git a/config.py b/config.py deleted file mode 100644 index a2e36d7..0000000 --- a/config.py +++ /dev/null @@ -1,72 +0,0 @@ -# vim: set et sts=4 sw=4 encoding=utf-8 : - -from pymin.dispatcher import Handler -from os.path import join - -base_path = join('var', 'lib', 'pymin') -pickle_path = join(base_path, 'pickle') -# FIXME, this should be specific for each service -config_path = join(base_path, 'config') - -class firewall: - pickle_dir = join(pickle_path, 'firewall') - config_dir = join(config_path, 'firewall') - -class nat: - pickle_dir = join(pickle_path, 'nat') - -class ppp: - pickle_dir = join(pickle_path, 'ppp') - config_dir = { - 'pap-secrets': join(config_path, 'ppp'), - 'chap-secrets': join(config_path, 'ppp'), - 'options.X': join(config_path, 'ppp'), - 'nameX': join(config_path, 'ppp', 'peers'), - } - -class vpn: - pickle_dir = join(pickle_path, 'vpn') - config_dir = join(config_path, 'vpn') - -class ip: - pickle_dir = join(pickle_path, 'ip') - config_dir = join(config_path, 'ip') - -class dns: - pickle_dir = join(pickle_path, 'dns') - config_dir = { - 'named.conf': join(config_path, 'dns'), - 'zoneX.zone': join(config_path, 'dns', 'zones'), - } - -class dhcp: - pickle_dir = join(pickle_path, 'dhcp') - config_dir = join(config_path, 'dhcp') - -class proxy: - pickle_dir = join(pickle_path, 'proxy') - config_dir = join(config_path, 'proxy') - -class vrrp: - pickle_dir = join(pickle_path, 'vrrp') - config_dir = join(config_path, 'vrrp') - pid_dir = join(config_path, 'vrrp', 'run') - -class vpn: - pickle_dir = join(pickle_path, 'vpn') - config_dir = join(config_path, 'vpn') - -class qos: - pickle_dir = join(pickle_path, 'qos') - config_dir = join(config_path, 'qos') - -bind_addr = \ -( - '', # Bind IP ('' is ANY) - 9999, # Port -) - -services = 'firewall nat ppp vpn ip dns dhcp proxy vrrp qos'.split() - -services_dirs = ['services'] - diff --git a/doc/config-examples/pymin.debian.ini b/doc/config-examples/pymin.debian.ini new file mode 100644 index 0000000..9cb4b44 --- /dev/null +++ b/doc/config-examples/pymin.debian.ini @@ -0,0 +1,66 @@ + +; all configuration variables in the DEFAULT section are inherited by all +; other sections, so it's a good place to add common variables and default +; values +[DEFAULT] +; this variables are used by pymin's services, and are reasonable defaults +pickle-dir = %(pymind-pickle-dir)s/%(__name__)s +config-dir = %(pymind-config-dir)s/%(__name__)s + +; pymind daemon configuration +[pymind] +; IP and port where pymind should listen for commands +bind-addr = 127.0.0.1 +bind-port = 9999 +; services plug-ins to use +services = dhcp qos firewall nat ppp vpn ip dns proxy vrrp + + +; SERVICES CONFIGURATION +; ---------------------- +; +; all following sections are for services configuration, a section per service + +[firewall] +; use the pickle-dir value provided by DEFAULT section +; there are no config files really, only temporary scripts +; (that shouldn't exist) +config-dir = /tmp + +[nat] +; use the pickle-dir value provided by DEFAULT section + +[ppp] +; use the pickle-dir value provided by DEFAULT section +config-options-dir = /etc/ppp +config-pap-dir = /etc/ppp +config-chap-dir = /etc/ppp +config-peers-dir = /etc/ppp/peers + +[ip] +; use the pickle-dir and config-dir values provided by DEFAULT section + +[dns] +config-named-dir = /etc/bind9 +config-zones-dir = /var/lib/bind9 + +[dhcp] +; use the pickle-dir value provided by DEFAULT section +config-dir = /etc/dhcp3 + +[proxy] +; use the pickle-dir value provided by DEFAULT section +config-dir = /etc/squid + +[vrrp] +; use the pickle-dir and config-dir values provided by DEFAULT section +pid-dir = /var/run + +[vpn] +; use the pickle-dir value provided by DEFAULT section +config-dir = /etc/tinc + +[qos] +; use the pickle-dir value provided by DEFAULT section + +; vim: set filetype=dosini : diff --git a/doc/config-examples/pymin.devel.ini b/doc/config-examples/pymin.devel.ini new file mode 100644 index 0000000..4f34dd5 --- /dev/null +++ b/doc/config-examples/pymin.devel.ini @@ -0,0 +1,72 @@ + +; all configuration variables in the DEFAULT section are inherited by all +; other sections, so it's a good place to add common variables and default +; values +[DEFAULT] +; this are helper variables, not used by pymin (used for interpolation in +; other variables) +pymind-data-dir = var/lib/pymin +pymind-config-dir = %(pymind-data-dir)s/config +pymind-pickle-dir = %(pymind-data-dir)s/pickle +; this variables are used by pymin's services, and are reasonable defaults +pickle-dir = %(pymind-pickle-dir)s/%(__name__)s +config-dir = %(pymind-config-dir)s/%(__name__)s + +; pymind daemon configuration +[pymind] +; IP and port where pymind should listen for commands +bind-addr = 0.0.0.0 +bind-port = 9999 +; services plug-ins to use +services = dhcp qos firewall nat ppp vpn ip dns proxy vrrp +; directories where to find those plug-ins +services-dirs = services + + + +; SERVICES CONFIGURATION +; ---------------------- +; +; all following sections are for services configuration, a section per service + +[firewall] +; use the pickle-dir and config-dir values provided by DEFAULT section + +[nat] +; use the pickle-dir value provided by DEFAULT section + +[ppp] +; use the pickle-dir value provided by DEFAULT section +; helper variable, not used by pymin +config-base-dir = %(config-dir)s/%(__name__)s +; variables used by pymin +config-options-dir = %(config-base-dir)s +config-pap-dir = %(config-base-dir)s +config-chap-dir = %(config-base-dir)s +config-peers-dir = %(config-base-dir)s/peers + +[ip] +; use the pickle-dir and config-dir values provided by DEFAULT section + +[dns] +; use the pickle-dir value provided by DEFAULT section +config-named-dir = %(config-dir)s +config-zones-dir = %(config-dir)s/zones + +[dhcp] +; use the pickle-dir and config-dir values provided by DEFAULT section + +[proxy] +; use the pickle-dir and config-dir values provided by DEFAULT section + +[vrrp] +; use the pickle-dir and config-dir values provided by DEFAULT section +pid-dir = /tmp + +[vpn] +; use the pickle-dir and config-dir values provided by DEFAULT section + +[qos] +; use the pickle-dir and config-dir values provided by DEFAULT section + +; vim: set filetype=dosini : diff --git a/doc/config-examples/pymin.suse.ini b/doc/config-examples/pymin.suse.ini new file mode 100644 index 0000000..fc39078 --- /dev/null +++ b/doc/config-examples/pymin.suse.ini @@ -0,0 +1,66 @@ + +; all configuration variables in the DEFAULT section are inherited by all +; other sections, so it's a good place to add common variables and default +; values +[DEFAULT] +; this variables are used by pymin's services, and are reasonable defaults +pickle-dir = %(pymind-pickle-dir)s/%(__name__)s +config-dir = %(pymind-config-dir)s/%(__name__)s + +; pymind daemon configuration +[pymind] +; IP and port where pymind should listen for commands +bind-addr = 127.0.0.1 +bind-port = 9999 +; services plug-ins to use +services = dhcp qos firewall nat ppp vpn ip dns proxy vrrp + + +; SERVICES CONFIGURATION +; ---------------------- +; +; all following sections are for services configuration, a section per service + +[firewall] +; use the pickle-dir value provided by DEFAULT section +; there are no config files really, only temporary scripts +; (that shouldn't exist) +config-dir = /tmp + +[nat] +; use the pickle-dir value provided by DEFAULT section + +[ppp] +; use the pickle-dir value provided by DEFAULT section +config-options-dir = /etc/ppp +config-pap-dir = /etc/ppp +config-chap-dir = /etc/ppp +config-peers-dir = /etc/ppp/peers + +[ip] +; use the pickle-dir and config-dir values provided by DEFAULT section + +[dns] +config-named-dir = /etc +config-zones-dir = /var/lib/named + +[dhcp] +; use the pickle-dir value provided by DEFAULT section +config-dir = /etc + +[proxy] +; use the pickle-dir value provided by DEFAULT section +config-dir = /etc/squid + +[vrrp] +; use the pickle-dir and config-dir values provided by DEFAULT section +pid-dir = /var/run + +[vpn] +; use the pickle-dir value provided by DEFAULT section +config-dir = /etc/tinc + +[qos] +; use the pickle-dir value provided by DEFAULT section + +; vim: set filetype=dosini : diff --git a/pymin/config.py b/pymin/config.py new file mode 100644 index 0000000..ea9a039 --- /dev/null +++ b/pymin/config.py @@ -0,0 +1,696 @@ +# vim: set encoding=utf-8 et sw=4 sts=4 : + +# TODO +# +# * add 'append' mode to ListOption (like ConfigOption). +# +# * update/write documentation + +import os +import re +import sys +import shlex +import optparse +from formencode import Schema, ForEach, Invalid, validators +from ConfigParser import SafeConfigParser, InterpolationMissingOptionError, \ + InterpolationSyntaxError, InterpolationDepthError, \ + MissingSectionHeaderError, ParsingError +import logging ; log = logging.getLogger('pymin.config') + + +__all__ = ('ConfigError', 'MissingSectionHeaderError', 'ParsingError', + 'Options', 'OptionGroup', 'Option', + 'VOID', 'load_config', 'config', 'options') + + +# regular expression to check if a name is a valid python identifier +identifier_re = re.compile(r'^[a-zA-Z_]\w*$') + + +class ConfigError(RuntimeError): + """ + Raised when the problem is due to an user error. + """ + pass + + +class VoidClass: + def __repr__(self): + return "" + def __len__(self): + "For boolean expression evaluation" + return 0 +VOID = VoidClass() + + +class Options: + """ + Represent a set of options a program can have. + + Both command-line and configuration file options are handled. + + .. attr:: default_group + + The name of the default :class:`OptionGroup`. Options that + don't belong to any group are looked in the section with this + name in the config file. + + .. attr:: default_group + + Default :class:`OptionGroup` description. + + .. attr:: options + + A list of :class:`Option` or :class:`OptionGroup` + instances. Groups are translated to a section when parsing the + config file, and to prefixes in the long options when parsing + the command-line. + """ + + def __init__(self, schemacls=None): + self.default_group = '' + self.default_group_desc = '' + self.options = [] + if schemacls is None: + schemacls = Schema + self.schemacls = schemacls + self.schema = None # filled in process() + + def add(self, options): + log.debug('Options.add(%r)', options) + if isinstance(options, (list, tuple)): + self.options.extend(options) + else: + self.options.append(options) + + def add_group(self, *args, **kwargs): + g = OptionGroup(*args, **kwargs) + log.debug('Options.add_group(%r)', g) + self.add(g) + + def get_group(self, group_name): + for g in self.options: + if isinstance(g, OptionGroup) and g.name == group_name: + return g + + def init(self, default_group=None, default_group_desc=None, + options=None): + """ + Initialize the class. Since the class is instantiated by the + config framework, you should use this method to set it's values. + + Arguments are directly mapped to the class attributes, with + the particularity of options being appended to existing options + (instead of replaced). + """ + log.debug('Options.init(default_group=%r, default_group_desc=%r, ' + 'options=%r)', default_group, default_group_desc, options) + if not identifier_re.match(default_group): + raise ValueError("Invalid default group name '%s' (group names " + "must be valid python identifiers" % name) + if default_group is not None: + self.default_group = default_group + if default_group_desc is not None: + self.default_group_desc = default_group_desc + if options is not None: + self.options.extend(options) + + def process(self, parser): + """ + Process the command-line options. + + ``parser`` should be a :class:`optparse.OptionParser` instance. + """ + log.debug('Options.process()') + self.schema = self.schemacls() + config_opts = [] + for o in self.options: + o.process(parser, self.schema, config_opts, self.default_group) + return config_opts + + def values(self, confparser, parseropts, ignore_errors): + """ + Post process the command-line options. + + ``config`` should be an object where options are stored. If + ``prefix`` is specified, the prefix will be used to look for + the option. + """ + log.debug('Options.values()') + # first populate the confparser with the values from the command-line + # options to make the variable interpolation works as expected. + for o in self.options: + o.set_value(confparser, parseropts, self.default_group) + # then, get the actual values + v = dict() + for o in self.options: + v[o.name] = o.value(confparser, parseropts, self.default_group, + ignore_errors) + log.debug('Options.values() -> %r', v) + return v + + @property + def defaults(self): + log.debug('Options.defaults()') + defaults = dict() + for o in self.options: + defaults.update(o.defaults) + log.debug('Options.defaults() -> %r', defaults) + return defaults + + +class OptionGroup: + + def __init__(self, name, description=None, options=None): + log.debug('OptionGroup(name=%r, description=%r, options=%r)', name, + description, options) + if not identifier_re.match(name): + raise ValueError("Invalid group name '%s' (group names must be " + "valid python identifiers" % name) + self.name = name + self.oname = name.replace('_', '-') + self.description = description + self.options = [] + if options is not None: + self.add(options) + + def add(self, options): + log.debug('OptionGroup.add(%r)', options) + if not isinstance(options, (list, tuple)): + options = [options] + for o in options: + if isinstance(o, OptionGroup): + raise ConfigError("Groups can't be nested (group '%s' found " + "inside group '%s')" % (o.name, self.name)) + self.options.extend(options) + + def process(self, parser, schema, config_opts, default_group): + """ + Process the command-line options. + + ``parser`` should be a :class:`optparse.OptionParser` instance. + """ + subschema = schema.__class__() + group = optparse.OptionGroup(parser, self.description) + for o in self.options: + if o.short: + raise ValueError("Grouped options can't have short name " + "(option '%s' in grupo '%s' have this short " + "names: %s)" % (o.name, self.name, + ','.join('-' + o for o in o.short))) + o.process(group, subschema, config_opts, self) + log.debug('Adding group %s to optparse') + parser.add_option_group(group) + schema.add_field(self.name, subschema) + + def value(self, confparser, parseropts, default_group, ignore_errors): + """ + """ + v = dict() + for o in self.options: + v[o.name] = o.value(confparser, parseropts, self, ignore_errors) + return v + + def set_value(self, confparser, parseropts, default_group): + """ + """ + v = dict() + for o in self.options: + o.set_value(confparser, parseropts, self) + return v + + @property + def defaults(self): + defaults = dict() + for o in self.options: + defaults.update(o.defaults) + defaults = { self.oname: defaults } + return defaults + + def __repr__(self): + return 'OptionGroup<%s>%r' % (self.name, self.options) + + +class Option: + """ + A program's option. + + .. attr:: name + + The name of the option. The config object will have an attribute + with this name. When parsing configuration and command-line + options, the ``_`` in this name are replace with ``-``. + + .. attr:: validator + + See :mod:`formencode.validators` for available validators. + + .. attr:: short + + Short aliases (only for command-line options). + + .. attr:: long + + Long aliases. + + .. attr:: default + + Default value. + + .. attr:: help + + Help message for the option. + + .. attr:: metavar + + Name of the option argument used in the help message. + + """ + + def __init__(self, name, validator=None, short=(), long=(), default=VOID, + help=VOID, metavar=VOID): + log.debug('Option(name=%r, validator=%r, short=%r, long=%r, ' + 'default=%r, help=%r, metavar=%r)', name, validator, short, + long, default, help, metavar) + if not identifier_re.match(name): + raise ValueError("Invalid option name '%s' (option names must be " + "valid python identifiers" % name) + self.name = name + self.oname = name.replace('_', '-') + self.validator = validator + if isinstance(short, basestring): + short = [short] + self.short = list(short) + if isinstance(long, basestring): + long = [long] + self.long = [self.oname] + list(long) + self.default = default + self.help = help + self.metavar = metavar + + def process(self, parser, schema, config_opts, group): + """ + Process the command-line options. + + ``parser`` should be a :class:`optparse.OptionParser` instance. If + ``prefix`` is specified, all long command-line options are + prefixed with that. For example: ``some_option`` will become + ``--prefix-some-option``. + """ + if isinstance(group, basestring): + group = None + parser.add_option(type='string', dest=self.dest(group), default=VOID, + metavar=self.metavar, help=self.help, + *self.optparser_args(group)) + log.debug('Option<%s>.process() -> add_option(%s, type=%r, dest=%r, ' + 'default=%r, metavar=%r, help=%r)', self.name, + ', '.join(repr(i) for i in self.optparser_args(group)), + 'string', self.dest(group), VOID, self.metavar, self.help) + schema.add_field(self.name, self.validator) + + def value(self, confparser, parseropts, group, ignore_errors): + """ + Post process the command-line options. + + ``config`` should be an object where options are stored. If + ``prefix`` is specified, the prefix will be used to look for + the option. + """ + section = group + if isinstance(group, OptionGroup): + section = group.oname + if (confparser.has_section(section) + and confparser.has_option(section, self.oname)): + return confparser.get(section, self.oname) + if self.default is VOID: + if ignore_errors: + return None + raise ConfigError('mandatory option "%s" not present' % self.name) + return self.default + + def set_value(self, confparser, parseropts, group): + val = getattr(parseropts, self.dest(group)) + if val is not VOID: + section = group + if isinstance(group, OptionGroup): + section = group.oname + if not confparser.has_section(section): + confparser.add_section(section) + confparser.set(section, self.oname, val) + + def optparser_args(self, group=None): + args = [] + args.extend('-' + s for s in self.short) + prefix = '' + if group: + prefix = group.oname + '-' + args.extend('--' + prefix + l for l in self.long) + return args + + def dest(self, group=None): + prefix = '' + if group and not isinstance(group, basestring): + prefix = group.name + '.' + return prefix + self.name + + @property + def defaults(self): + default = {} + if self.default is not VOID: + default[self.oname] = str(self.default) + return default + + def __repr__(self): + return 'Option<%s>' % (self.name) + + +class ListOption(Option): + + def process(self, parser, schema, config_opts, group): + """ + """ + if isinstance(group, basestring): + group = None + parser.add_option(type='string', dest=self.dest(group), default=[], + metavar=self.metavar, help=self.help, + action='append', *self.optparser_args(group)) + log.debug('Option<%s>.process() -> add_option(%s, type=%r, dest=%r, ' + 'action=%r, default=%r, metavar=%r, help=%r)', self.name, + ', '.join(repr(i) for i in self.optparser_args(group)), + 'string', self.dest(group), 'append', [], self.metavar, + self.help) + schema.add_field(self.name, ForEach(self.validator, if_empty=[])) + + def value(self, confparser, parseropts, group, ignore_errors): + """ + """ + value = Option.value(self, confparser, parseropts, group, ignore_errors) + if not isinstance(value, (list, tuple)): + value = shlex.split(value) + return value + + def set_value(self, confparser, parseropts, group): + val = getattr(parseropts, self.dest(group)) + if val: + val = ' '.join([repr(i) for i in val]) + setattr(parseropts, self.dest(group), val) + Option.set_value(self, confparser, parseropts, group) + + def __repr__(self): + return 'ListOption<%s>' % (self.name) + + +class ConfigOption(ListOption): + + def __init__(self, name, short=(), long=(), default=VOID, help=VOID, + metavar=VOID, override=False): + ListOption.__init__(self, name, validators.String, short, long, default, + help, metavar) + self.override = override + + def process(self, parser, schema, config_opts, group): + ListOption.process(self, parser, schema, config_opts, group) + config_opts.append(self) + + def value(self, confparser, parseropts, group, ignore_errors): + pass + + def set_value(self, confparser, parseropts, group): + pass + + +class Config: + """ + Dummy object that stores all the configuration data. + """ + + def __repr__(self): + return 'Config(%r)' % self.__dict__ + + +config = None + +args = [] + +options = Options() + + +class LazyOptionParser(optparse.OptionParser): + + ignore_errors = False + + exit_status = 1 + + def exit(self, status=0, msg=None): + if self.ignore_errors: + return + optparse.OptionParser.exit(self, status, msg) + + def error(self, msg): + if self.ignore_errors: + return + self.print_usage(sys.stderr) + self.exit(self.exit_status, "%s: error: %s\n" + % (self.get_prog_name(), msg)) + + def print_help(self, file=None): + if self.ignore_errors: + return + optparse.OptionParser.print_help(self, file) + + def _process_short_opts(self, rargs, values): + try: + return optparse.OptionParser._process_short_opts(self, rargs, + values) + except Exception, e: + if not self.ignore_errors: + raise + + def _process_long_opt(self, rargs, values): + try: + return optparse.OptionParser._process_long_opt(self, rargs, values) + except Exception, e: + if not self.ignore_errors: + raise + + def _process_short_opts(self, rargs, values): + try: + return optparse.OptionParser._process_short_opts(self, rargs, values) + except Exception, e: + if not self.ignore_errors: + raise + + +def load_options(version=None, description=None, ignore_errors=False): + # load command-line options + optparser = LazyOptionParser(version=version, description=description) + optparser.ignore_errors = ignore_errors + config_opts = options.process(optparser) + (opts, args) = optparser.parse_args() + log.debug('load_options() -> %r %r', opts, args) + # help the GC + optparser.destroy() + return (opts, config_opts, args) + + +def make_config(values): + log.debug('make_config()') + config = Config() + for (name, value) in values.items(): + if isinstance(value, dict): + log.debug('make_config() -> processing group %s: %r', name, value) + setattr(config, name, make_config(value)) + else: + log.debug('make_config() -> processing value %s: %r', name, value) + setattr(config, name, value) + log.debug('make_config() -> config = %r', config) + return config + + +def load_conf(config_file_paths, version, description, defaults, ignore_errors): + log.debug('load_conf(%r, version=%r, description=%r, ignore_errors=%r)', + config_file_paths, version, description, ignore_errors) + global options + + # load command-line options + (opts, config_opts, args) = load_options(version, description, + ignore_errors=ignore_errors) + + # process config file options to see what config files to load + for opt in config_opts: + files = getattr(opts, opt.name) + log.debug('load_conf() -> processing configuration file option ' + '"%s": %r', opt.name, files) + if not files: + log.debug('load_conf() -> option not set! looking for the next') + continue + if opt.override: + log.debug('load_conf() -> overriding default config files') + config_file_paths = files + else: + log.debug('load_conf() -> appending to default config files') + config_file_paths.extend(files) + + confparser = SafeConfigParser(defaults) + readed = confparser.read(config_file_paths) + log.debug('load_conf() -> readed config files: %r', readed) + + try: + log.debug('load_conf() -> sections: %r', confparser.sections()) + log.debug('load_conf() -> readed values from config files: %r', + [confparser.items(s) for s in confparser.sections()]) + values = options.values(confparser, opts, ignore_errors) + except InterpolationMissingOptionError, e: + raise ConfigError('bad value substitution for option "%s" in ' + 'section [%s] (references an unexistent option ' + '"%s")' % (e.option, e.section, e.reference)) + except InterpolationDepthError, e: + raise ConfigError('value interpolation too deeply recursive in ' + 'option "%s", section [%s]' % (e.option, e.section)) + except InterpolationSyntaxError, e: + raise ConfigError('bad syntax for interpolation variable in option ' + '"%s", section [%s]' % (e.option, e.section)) + + values = options.schema.to_python(values) + log.debug('load_conf() -> validated values: %r', values) + + config = make_config(values) + + # TODO options.check_orphans(confparser, config) + + return (config, args) + + +def load_config(config_file_paths, version=None, description=None, + add_plugin_opts=None, defaults=None): + "callback signature: add_plugin_opts(config, args)" + log.debug('load_config(%r, version=%r, description=%r, add_plugin_opts=%r)', + config_file_paths, version, description, add_plugin_opts) + global args + global config + + (config, args) = load_conf(config_file_paths, version, description, + defaults, add_plugin_opts is not None) + while add_plugin_opts: + log.debug('load_config() -> calling %r', add_plugin_opts) + add_plugin_opts = add_plugin_opts(config, args) + log.debug('load_config() -> got config=%r / args=%r', config, args) + (config, args) = load_conf(config_file_paths, version, description, + defaults, add_plugin_opts is not None) + + return (config, args) + + +if __name__ == '__main__': + + import os + import tempfile + from formencode import validators as V + + logging.basicConfig( + level = logging.DEBUG, + format = '%(levelname)-8s %(message)s', + ) + + def print_config(config, prefix=''): + for attr in dir(config): + if not attr.startswith('__'): + val = getattr(config, attr) + if isinstance(val, Config): + print prefix, 'Group %s:' % attr + print_config(val, '\t') + else: + print prefix, attr, '=', val + + options.init('pymind', 'Default group description', [ + Option('dry_run', V.StringBool, 'd', default=True, + help="pretend, don't execute commands"), + Option('bind_addr', V.CIDR, 'a', default='127.0.0.1', metavar='ADDR', + help='IP address to bind to'), + Option('bind_port', V.Int, 'p', default=9999, metavar='PORT', + help="port to bind to"), + Option('mandatory', V.Int, 'm', metavar='N', + help="mandatory parameter"), + ConfigOption('config_file', 'c', metavar="FILE", + help="configuration file"), + ConfigOption('override_config_file', 'o', metavar="FILE", + help="configuration file", override=True), + ListOption('plugins', V.String, 's', metavar="SERV", default=[], + help="plugins pymin should use"), + OptionGroup('test_grp', 'A test group', [ + Option('test', V.StringBool, default=False, + help="test group option"), + Option('oneof', V.OneOf(['tcp', 'udp']), default='tcp', + help="test option for OneOf validator"), + ListOption('list', V.StringBool, default=[True, True, False], + help="test ListOption"), + ]), + ]) + + def add_more_plugin_opts(config, args): + print 'add_more_plugin_opts' + print '---------------' + print config, args + print '---------------' + print + g = options.get_group('plugin') + g.add(ListOption('other', V.Int, default=[1,2], metavar='OTHER', + help="a list of numbers")) + + def add_plugin_opts(config, args): + print 'add_plugin_opts' + print '---------------' + print config, args + print '---------------' + print + if 'plugin' in config.plugins: + options.add( + OptionGroup('plugin', 'some plug-in options', [ + Option('active', V.StringBool, default=True, metavar='ACT', + help="if ACT is true, the plug-in is active"), + ]) + ) + return add_more_plugin_opts + + + f = tempfile.NamedTemporaryFile() + f.write(""" +[pymind] +dry-run: yes +bind-addr: 0.0.0.0 +bind-port: 2000 +log-config-file: /etc/pymin/log.conf + +[test-grp] +test = yes +""") + f.flush() + f.seek(0) + print '-'*78 + print f.read() + print '-'*78 + + try: + (c, a) = load_config([f.name], '%prog 0.1', 'this is a program test', + add_plugin_opts) + print "Config:" + print_config(c) + print "Args:", a + print + print "Globals:" + print "Config:" + print_config(config) + print "Args:", args + except ConfigError, e: + print e + except MissingSectionHeaderError, e: + print "%s:%s: missing section header near: %s" \ + % (e.filename, e.lineno, e.line) + except ParsingError, e: + for (lineno, line) in e.errors: + print "%s:%s: invalid syntax near: %s" % (e.filename, lineno, line) + print e.errors + except Invalid, e: + print e.error_dict + f.close() + diff --git a/pymind b/pymind index 22fb092..29d296b 100755 --- a/pymind +++ b/pymind @@ -1,7 +1,6 @@ #!/usr/bin/env python # vim: set encoding=utf-8 et sw=4 sts=4 : -import sys import logging ; log = logging.getLogger('pymind') # First of all, we need to setup the logging framework # FIXME: this should go in a configuration file @@ -11,31 +10,65 @@ logging.basicConfig( datefmt = '%a, %d %b %Y %H:%M:%S', ) -from pymin.pymindaemon import PyminDaemon +import os +import sys +from formencode import Invalid, validators as V + +from pymin.config import OptionGroup, Option, ConfigOption, ListOption +from pymin.config import load_config, options +from pymin.config import ConfigError, MissingSectionHeaderError, ParsingError from pymin.dispatcher import Handler +from pymin.pymindaemon import PyminDaemon from pymin.service import load_service, LoadError -import config -# exit status -EXIT_NO_SERVICE = 1 +# exit status (1 is reserved for command-line errors) +EXIT_CONFIG_ERROR = 2 +EXIT_NO_SERVICE = 3 -class Root(Handler): - pass +# default locations where to look for configuration files +# all found files will be processed, overriding the previous configuration +# files values. +config_file_paths = [ + '/etc/pymin.ini', + '/etc/pymin/pymin.ini', + os.path.expanduser('~/.pymin.ini'), + os.path.expanduser('~/.pymin/pymin.ini'), +] + +# default locations where to look for service plug-ins +# search stops when a service plug-in is found +services_paths = [ + os.path.expanduser('~/.pymin/services'), + '/usr/share/pymin/services', +] + +# default configuration variables +# these are useful variables to help the user writing the configuration file +config_defaults = { + 'pymind-data-dir': '/var/lib/pymin', + 'pymind-pickle-dir': '/var/lib/pymin/pickle', + 'pymind-config-dir': '/var/lib/pymin/config', +} + +# Validator to check if is a valid Python identifier +PythonIdentifier = V.Regex(r'^[a-zA-Z_]\w*$') + +options.init('pymind', 'Pymin daemon global options', [ + Option('bind_addr', V.CIDR, 'a', default='127.0.0.1', metavar='ADDR', + help='Bind to IP ADDR'), + Option('bind_port', V.Int(min=1, max=65535), 'p', default=9999, + metavar='PORT', help="Bind to port PORT"), + ListOption('services', PythonIdentifier, 's', default=[], + metavar='SERVICE', help="manage service SERVICE"), + ListOption('services_dirs', V.String, 'd', default=[], + metavar='DIR', help="search for services in DIR"), + ConfigOption('config_file', 'c', metavar='FILE', + help="load the configuration file FILE after the default " + "configuration files"), + ConfigOption('replace_config_file', 'C', override=True, metavar='FILE', + help="don't load the default configuration files, just FILE"), +]) -def build_root(config): - # TODO check services dependencies - services = dict() - for service in config.services: - try: - s = load_service(service, config.services_dirs) - except LoadError, e: - log.error("Can't find service called '%s'\n", service) - sys.exit(EXIT_NO_SERVICE) - services[service] = s - root = Root() - for name, service in services.items(): - setattr(root, name, service.get_service(config)) - return root # FIXME try: @@ -45,5 +78,61 @@ try: except (IOError, OSError), e: log.warning("Can't set ip_forward: %s", e) -PyminDaemon(build_root(config), config.bind_addr).run() + +def get_config(paths, version, desc, add_options, defaults): + global config_file_paths + try: + (config, args) = load_config(paths, version, desc, add_options, defaults) + except ConfigError, e: + log.error(str(e)) + sys.exit(EXIT_CONFIG_ERROR) + except MissingSectionHeaderError, e: + log.error("%s:%s: missing section header near: %s", e.filename, e.lineno, + e.line) + sys.exit(EXIT_CONFIG_ERROR) + except ParsingError, e: + for (lineno, line) in e.errors: + log.error("%s:%s: invalid syntax near: %s", e.filename, lineno, line) + log.error(str(e.errors)) + sys.exit(EXIT_CONFIG_ERROR) + except Invalid, e: + log.error(str(e.unpack_errors())) + sys.exit(EXIT_CONFIG_ERROR) + except LoadError, e: + log.error("service '%s' not found (see option --services-dir)", + e.service_name) + sys.exit(EXIT_NO_SERVICE) + return (config, args) + + +class Services: + def __init__(self): + self.services = dict() + def add_config_options(self, config, args): + for service in config.services: + s = load_service(service, config.services_dirs) + s.setup_service(options, config) + self.services[service] = s + +def build_root(config, args, services): + from pymin.dispatcher import Handler + class Root(Handler): + pass + # TODO check services dependencies + root = Root() + for name, service in services.items(): + setattr(root, name, service.get_service(config)) + return root + + +def main(): + services = Services() + (config, args) = get_config(config_file_paths, '%prog 0.1', + 'Router services administration daemon', + services.add_config_options, config_defaults) + root_handler = build_root(config, args, services.services) + PyminDaemon(root_handler, (config.bind_addr, config.bind_port)).run() + +if __name__ == '__main__': + main() diff --git a/services/dhcp/__init__.py b/services/dhcp/__init__.py index d493b1c..8f9581f 100644 --- a/services/dhcp/__init__.py +++ b/services/dhcp/__init__.py @@ -1,7 +1,17 @@ # vim: set encoding=utf-8 et sw=4 sts=4 : +from formencode import validators as V +from pymin.config import Option from handler import DhcpHandler +def setup_service(options, config): + options.add_group('dhcp', 'DHCP service', [ + Option('pickle_dir', V.String, metavar='DIR', + help='store persistent data in DIR directory'), + Option('config_dir', V.String, metavar='DIR', + help='write config file in DIR directory'), + ]) + def get_service(config): return DhcpHandler(config.dhcp.pickle_dir, config.dhcp.config_dir) diff --git a/services/dns/__init__.py b/services/dns/__init__.py index 1b5e4e5..882cdd1 100644 --- a/services/dns/__init__.py +++ b/services/dns/__init__.py @@ -1,7 +1,22 @@ # vim: set encoding=utf-8 et sw=4 sts=4 : +from formencode import validators as V +from pymin.config import Option from handler import DnsHandler +def setup_service(options, config): + options.add_group('dns', 'DNS service', [ + Option('pickle_dir', V.String, metavar='DIR', + help='store persistent data in DIR directory'), + Option('config_named_dir', V.String, metavar='DIR', + help='write named config files in DIR directory'), + Option('config_zones_dir', V.String, metavar='DIR', + help='write zone config files in DIR directory'), + ]) + def get_service(config): - return DnsHandler(config.dns.pickle_dir, config.dns.config_dir) + return DnsHandler(config.dns.pickle_dir, { + 'named.conf': config.dns.config_named_dir, + 'zoneX.zone': config.dns.config_zones_dir, + }) diff --git a/services/firewall/__init__.py b/services/firewall/__init__.py index 2c8e091..6c1cbe3 100644 --- a/services/firewall/__init__.py +++ b/services/firewall/__init__.py @@ -1,7 +1,17 @@ # vim: set encoding=utf-8 et sw=4 sts=4 : +from formencode import validators as V +from pymin.config import Option from handler import FirewallHandler +def setup_service(options, config): + options.add_group('firewall', 'Firewall service', [ + Option('pickle_dir', V.String, metavar='DIR', + help='store persistent data in DIR directory'), + Option('config_dir', V.String, metavar='DIR', + help='write config file in DIR directory'), + ]) + def get_service(config): return FirewallHandler(config.firewall.pickle_dir, config.firewall.config_dir) diff --git a/services/ip/__init__.py b/services/ip/__init__.py index 8840e22..8ce72f7 100644 --- a/services/ip/__init__.py +++ b/services/ip/__init__.py @@ -1,7 +1,17 @@ # vim: set encoding=utf-8 et sw=4 sts=4 : +from formencode import validators as V +from pymin.config import Option from handler import IpHandler +def setup_service(options, config): + options.add_group('ip', 'IP network interfaces', [ + Option('pickle_dir', V.String, metavar='DIR', + help='store persistent data in DIR directory'), + Option('config_dir', V.String, metavar='DIR', + help='write config files in DIR directory'), + ]) + def get_service(config): return IpHandler(config.ip.pickle_dir, config.ip.config_dir) diff --git a/services/nat/__init__.py b/services/nat/__init__.py index 0deab9a..8a38047 100644 --- a/services/nat/__init__.py +++ b/services/nat/__init__.py @@ -1,7 +1,15 @@ # vim: set encoding=utf-8 et sw=4 sts=4 : +from formencode import validators as V +from pymin.config import Option from handler import NatHandler +def setup_service(options, config): + options.add_group('nat', 'Network Address Translation service', [ + Option('pickle_dir', V.String, metavar='DIR', + help='store persistent data in DIR directory'), + ]) + def get_service(config): return NatHandler(config.nat.pickle_dir) diff --git a/services/ppp/__init__.py b/services/ppp/__init__.py index cc53450..7681d28 100644 --- a/services/ppp/__init__.py +++ b/services/ppp/__init__.py @@ -1,7 +1,28 @@ # vim: set encoding=utf-8 et sw=4 sts=4 : +from formencode import validators as V +from pymin.config import Option from handler import PppHandler +def setup_service(options, config): + options.add_group('ppp', 'PPP network interfaces', [ + Option('pickle_dir', V.String, metavar='DIR', + help='store persistent data in DIR directory'), + Option('config_options_dir', V.String, metavar='DIR', + help='write options config files in DIR directory'), + Option('config_pap_dir', V.String, metavar='DIR', + help='write pap-secrets config file in DIR directory'), + Option('config_chap_dir', V.String, metavar='DIR', + help='write chap-secrets config file in DIR directory'), + Option('config_peers_dir', V.String, metavar='DIR', + help='write peer config files in DIR directory'), + ]) + def get_service(config): - return PppHandler(config.ppp.pickle_dir, config.ppp.config_dir) + return PppHandler(config.ppp.pickle_dir, { + 'options.X': config.ppp.config_options_dir, + 'pap-secrets': config.ppp.config_pap_dir, + 'chap-secrets': config.ppp.config_chap_dir, + 'nameX': config.ppp.config_peers_dir, + }) diff --git a/services/proxy/__init__.py b/services/proxy/__init__.py index ab2e84c..33e18d6 100644 --- a/services/proxy/__init__.py +++ b/services/proxy/__init__.py @@ -1,7 +1,17 @@ # vim: set encoding=utf-8 et sw=4 sts=4 : +from formencode import validators as V +from pymin.config import Option from handler import ProxyHandler +def setup_service(options, config): + options.add_group('proxy', 'Proxy service', [ + Option('pickle_dir', V.String, metavar='DIR', + help='store persistent data in DIR directory'), + Option('config_dir', V.String, metavar='DIR', + help='write config files in DIR directory'), + ]) + def get_service(config): return ProxyHandler(config.proxy.pickle_dir, config.proxy.config_dir) diff --git a/services/qos/__init__.py b/services/qos/__init__.py index 78e8134..83aacea 100644 --- a/services/qos/__init__.py +++ b/services/qos/__init__.py @@ -1,7 +1,17 @@ # vim: set encoding=utf-8 et sw=4 sts=4 : +from formencode import validators as V +from pymin.config import Option from handler import QoSHandler +def setup_service(options, config): + options.add_group('qos', 'Quality of Service', [ + Option('pickle_dir', V.String, metavar='DIR', + help='store persistent data in DIR directory'), + Option('config_dir', V.String, metavar='DIR', + help='write config files in DIR directory'), + ]) + def get_service(config): return QoSHandler(config.qos.pickle_dir, config.qos.config_dir) diff --git a/services/vpn/__init__.py b/services/vpn/__init__.py index 0e2747a..5071c7e 100644 --- a/services/vpn/__init__.py +++ b/services/vpn/__init__.py @@ -1,7 +1,17 @@ # vim: set encoding=utf-8 et sw=4 sts=4 : +from formencode import validators as V +from pymin.config import Option from handler import VpnHandler +def setup_service(options, config): + options.add_group('vpn', 'Virtual Private Networking service', [ + Option('pickle_dir', V.String, metavar='DIR', + help='store persistent data in DIR directory'), + Option('config_dir', V.String, metavar='DIR', + help='write config file in DIR directory'), + ]) + def get_service(config): return VpnHandler(config.vpn.pickle_dir, config.vpn.config_dir) diff --git a/services/vrrp/__init__.py b/services/vrrp/__init__.py index 15cf061..0d71b88 100644 --- a/services/vrrp/__init__.py +++ b/services/vrrp/__init__.py @@ -1,7 +1,20 @@ # vim: set encoding=utf-8 et sw=4 sts=4 : +from formencode import validators as V +from pymin.config import Option from handler import VrrpHandler +def setup_service(options, config): + options.add_group('vrrp', 'Virtual Router Redundancy service', [ + Option('pickle_dir', V.String, metavar='DIR', + help='store persistent data in DIR directory'), + Option('config_dir', V.String, metavar='DIR', + help='write config file in DIR directory'), + Option('pid_dir', V.String, metavar='DIR', + help='write PID file in DIR directory'), + ]) + def get_service(config): - return VrrpHandler(config.vrrp.pickle_dir, config.vrrp.config_dir) + return VrrpHandler(config.vrrp.pickle_dir, config.vrrp.config_dir, + config.vrrp.pid_dir) -- 2.43.0