diff options
Diffstat (limited to 'ipalib')
32 files changed, 11053 insertions, 0 deletions
diff --git a/ipalib/__init__.py b/ipalib/__init__.py new file mode 100644 index 00000000..29344e18 --- /dev/null +++ b/ipalib/__init__.py @@ -0,0 +1,915 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + + +''' +Package containing the core library. + +============================= + Tutorial for Plugin Authors +============================= + +This tutorial will introduce you to writing plugins for freeIPA v2. It does +not cover every detail, but it provides enough to get you started and is +heavily cross-referenced with further documentation that (hopefully) fills +in the missing details. + +In addition to this tutorial, the many built-in plugins in `ipalib.plugins` +and `ipaserver.plugins` provide real-life examples of how to write good +plugins. + + +---------------------------- +How this tutorial is written +---------------------------- + +The code examples in this tutorial are presented as if entered into a Python +interactive interpreter session. As such, when you create a real plugin in +a source file, a few details will be different (in addition to the fact that +you will never include the ``>>>`` nor ``...`` that the interpreter places at +the beginning of each line of code). + +The tutorial examples all have this pattern: + + :: + + >>> from ipalib import Command, create_api + >>> api = create_api() + >>> class my_command(Command): + ... pass + ... + >>> api.register(my_command) + >>> api.finalize() + +In the tutorial we call `create_api()` to create an *example* instance +of `plugable.API` to work with. But a real plugin will simply use +``ipalib.api``, the standard run-time instance of `plugable.API`. + +A real plugin will have this pattern: + + :: + + from ipalib import Command, api + + class my_command(Command): + pass + api.register(my_command) + +As seen above, also note that in a real plugin you will *not* call +`plugable.API.finalize()`. When in doubt, look at some of the built-in +plugins for guidance, like those in `ipalib.plugins`. + +If you don't know what the Python *interactive interpreter* is, or are +confused about what this *Python* is in the first place, then you probably +should start with the Python tutorial: + + http://docs.python.org/tutorial/index.html + + +------------------------------------ +First steps: A simple command plugin +------------------------------------ + +Our first example will create the most basic command plugin possible. This +command will be seen in the list of command plugins, but it wont be capable +of actually doing anything yet. + +A command plugin simultaneously adds a new command that can be called through +the command-line ``ipa`` script *and* adds a new XML-RPC method... the two are +one in the same, simply invoked in different ways. + +A freeIPA plugin is a Python class, and when you create a plugin, you register +this class itself (instead of an instance of the class). To be a command +plugin, your plugin must subclass from `frontend.Command` (or from a subclass +thereof). Here is our first example: + +>>> from ipalib import Command, create_api +>>> api = create_api() +>>> class my_command(Command): # Step 1, define class +... """My example plugin.""" +... +>>> api.register(my_command) # Step 2, register class + +Notice that we are registering the ``my_command`` class itself, not an +instance of ``my_command``. + +Until `plugable.API.finalize()` is called, your plugin class has not been +instantiated nor does the ``Command`` namespace yet exist. For example: + +>>> hasattr(api, 'Command') +False +>>> api.finalize() # plugable.API.finalize() +>>> hasattr(api.Command, 'my_command') +True +>>> api.Command.my_command.doc +'My example plugin.' + +Notice that your plugin instance is accessed through an attribute named +``my_command``, the same name as your plugin class name. + + +------------------------------ +Make your command do something +------------------------------ + +This simplest way to make your example command plugin do something is to +implement a ``run()`` method, like this: + +>>> class my_command(Command): +... """My example plugin with run().""" +... +... def run(self): +... return 'My run() method was called!' +... +>>> api = create_api() +>>> api.register(my_command) +>>> api.finalize() +>>> api.Command.my_command() # Call your command +'My run() method was called!' + +When `frontend.Command.__call__()` is called, it first validates any arguments +and options your command plugin takes (if any) and then calls its ``run()`` +method. + + +------------------------ +Forwarding vs. execution +------------------------ + +However, unlike the example above, a typical command plugin will implement an +``execute()`` method instead of a ``run()`` method. Your command plugin can +be loaded in two distinct contexts: + + 1. In a *client* context - Your command plugin is only used to validate + any arguments and options it takes, and then ``self.forward()`` is + called, which forwards the call over XML-RPC to an IPA server where + the actual work is done. + + 2. In a *server* context - Your same command plugin validates any + arguments and options it takes, and then ``self.execute()`` is called, + which you should implement to perform whatever work your plugin does. + +The base `frontend.Command.run()` method simply dispatches the call to +``self.execute()`` if ``self.env.in_server`` is True, or otherwise +dispatches the call to ``self.forward()``. + +For example, say you have a command plugin like this: + +>>> class my_command(Command): +... """Forwarding vs. execution.""" +... +... def forward(self): +... return 'in_server=%r; forward() was called.' % self.env.in_server +... +... def execute(self): +... return 'in_server=%r; execute() was called.' % self.env.in_server +... + +If ``my_command`` is loaded in a *client* context, ``forward()`` will be +called: + +>>> api = create_api() +>>> api.env.in_server = False # run() will dispatch to forward() +>>> api.register(my_command) +>>> api.finalize() +>>> api.Command.my_command() # Call your command plugin +'in_server=False; forward() was called.' + +On the other hand, if ``my_command`` is loaded in a *server* context, +``execute()`` will be called: + +>>> api = create_api() +>>> api.env.in_server = True # run() will dispatch to execute() +>>> api.register(my_command) +>>> api.finalize() +>>> api.Command.my_command() # Call your command plugin +'in_server=True; execute() was called.' + +Normally there should be no reason to override `frontend.Command.forward()`, +but, as above, it can be done for demonstration purposes. In contrast, there +*is* a reason you might want to override `frontend.Command.run()`: if it only +makes sense to execute your command locally, if it should never be forwarded +to the server. In this case, you should implement your *do-stuff* in the +``run()`` method instead of in the ``execute()`` method. + +For example, the ``ipa`` command line script has a ``help`` command +(`ipalib.cli.help`) that is specific to the command-line-interface and should +never be forwarded to the server. + + +--------------- +Backend plugins +--------------- + +There are two types of plugins: + + 1. *Frontend plugins* - These are loaded in both the *client* and *server* + contexts. These need to be installed with any application built atop + the `ipalib` library. The built-in frontend plugins can be found in + `ipalib.plugins`. The ``my_command`` example above is a frontend + plugin. + + 2. *Backend plugins* - These are only loaded in a *server* context and + only need to be installed on the IPA server. The built-in backend + plugins can be found in `ipaserver.plugins`. + +Backend plugins should provide a set of methods that standardize how IPA +interacts with some external system or library. For example, all interaction +with LDAP is done through the ``ldap`` backend plugin defined in +`ipaserver.plugins.b_ldap`. As a good rule of thumb, anytime you need to +import some package that is not part of the Python standard library, you +should probably interact with that package via a corresponding backend +plugin you implement. + +Backend plugins are much more free-form than command plugins. Aside from a +few reserved attribute names, you can define arbitrary public methods on your +backend plugin (in contrast, frontend plugins get wrapped in a +`plugable.PluginProxy`, which allow access to only specific attributes on the +frontend plugin). + +Here is a simple example: + +>>> from ipalib import Backend +>>> class my_backend(Backend): +... """My example backend plugin.""" +... +... def do_stuff(self): +... """Part of your API.""" +... return 'Stuff got done.' +... +>>> api = create_api() +>>> api.register(my_backend) +>>> api.finalize() +>>> api.Backend.my_backend.do_stuff() +'Stuff got done.' + + +------------------------------- +How your command should do work +------------------------------- + +We now return to our ``my_command`` plugin example. + +Plugins are separated into frontend and backend plugins so that there are not +unnecessary dependencies required by an application that only uses `ipalib` and +its built-in frontend plugins (and then forwards over XML-RPC for execution). + +But how do we avoid introducing additional dependencies? For example, the +``user_add`` command needs to talk to LDAP to add the user, yet we want to +somehow load the ``user_add`` plugin on client machines without requiring the +``python-ldap`` package (Python bindings to openldap) to be installed. To +answer that, we consult our golden rule: + + **The golden rule:** A command plugin should implement its ``execute()`` + method strictly via calls to methods on one or more backend plugins. + +So the module containing the ``user_add`` command does not itself import the +Python LDAP bindings, only the module containing the ``ldap`` backend plugin +does that, and the backend plugins are only installed on the server. The +``user_add.execute()`` method, which is only called when in a server context, +is implemented as a series of calls to methods on the ``ldap`` backend plugin. + +When `plugable.Plugin.set_api()` is called, each plugin stores a reference to +the `plugable.API` instance it has been loaded into. So your plugin can +access the ``my_backend`` plugin as ``self.api.Backend.my_backend``. + +Additionally, convenience attributes are set for each namespace, so your +plugin can also access the ``my_backend`` plugin as simply +``self.Backend.my_backend``. + +This next example will tie everything together. First we create our backend +plugin: + +>>> api = create_api() +>>> api.env.in_server = True # We want to execute, not forward +>>> class my_backend(Backend): +... """My example backend plugin.""" +... +... def do_stuff(self): +... """my_command.execute() calls this.""" +... return 'my_backend.do_stuff() indeed did do stuff!' +... +>>> api.register(my_backend) + +Second, we have our frontend plugin, the command: + +>>> class my_command(Command): +... """My example command plugin.""" +... +... def execute(self): +... """Implemented against Backend.my_backend""" +... return self.Backend.my_backend.do_stuff() +... +>>> api.register(my_command) + +Lastly, we call ``api.finalize()`` and see what happens when we call +``my_command()``: + +>>> api.finalize() +>>> api.Command.my_command() +'my_backend.do_stuff() indeed did do stuff!' + +When not in a server context, ``my_command.execute()`` never gets called, so +it never tries to access the non-existent backend plugin at +``self.Backend.my_backend.`` To emphasize this point, here is one last +example: + +>>> api = create_api() +>>> api.env.in_server = False # We want to forward, not execute +>>> class my_command(Command): +... """My example command plugin.""" +... +... def execute(self): +... """Same as above.""" +... return self.Backend.my_backend.do_stuff() +... +... def forward(self): +... return 'Just my_command.forward() getting called here.' +... +>>> api.register(my_command) +>>> api.finalize() + +Notice that the ``my_backend`` plugin has certainly not be registered: + +>>> hasattr(api.Backend, 'my_backend') +False + +And yet we can call ``my_command()``: + +>>> api.Command.my_command() +'Just my_command.forward() getting called here.' + + +---------------------------------------- +Calling other commands from your command +---------------------------------------- + +It can be useful to have your ``execute()`` method call other command plugins. +Among other things, this allows for meta-commands that conveniently call +several other commands in a single operation. For example: + +>>> api = create_api() +>>> api.env.in_server = True # We want to execute, not forward +>>> class meta_command(Command): +... """My meta-command plugin.""" +... +... def execute(self): +... """Calls command_1(), command_2()""" +... return '%s; %s.' % ( +... self.Command.command_1(), +... self.Command.command_2() +... ) +>>> class command_1(Command): +... def execute(self): +... return 'command_1.execute() called' +... +>>> class command_2(Command): +... def execute(self): +... return 'command_2.execute() called' +... +>>> api.register(meta_command) +>>> api.register(command_1) +>>> api.register(command_2) +>>> api.finalize() +>>> api.Command.meta_command() +'command_1.execute() called; command_2.execute() called.' + +Because this is quite useful, we are going to revise our golden rule somewhat: + + **The revised golden rule:** A command plugin should implement its + ``execute()`` method strictly via what it can access through ``self.api``, + most likely via the backend plugins in ``self.api.Backend`` (which can also + be conveniently accessed as ``self.Backend``). + + +----------------------------------------------- +Defining arguments and options for your command +----------------------------------------------- + +You can define a command that will accept specific arguments and options. +For example: + +>>> from ipalib import Str +>>> class nudge(Command): +... """Takes one argument, one option""" +... +... takes_args = ['programmer'] +... +... takes_options = [Str('stuff', default=u'documentation')] +... +... def execute(self, programmer, **kw): +... return '%s, go write more %s!' % (programmer, kw['stuff']) +... +>>> api = create_api() +>>> api.env.in_server = True +>>> api.register(nudge) +>>> api.finalize() +>>> api.Command.nudge(u'Jason') +u'Jason, go write more documentation!' +>>> api.Command.nudge(u'Jason', stuff=u'unit tests') +u'Jason, go write more unit tests!' + +The ``args`` and ``options`` attributes are `plugable.NameSpace` instances +containing a command's arguments and options, respectively, as you can see: + +>>> list(api.Command.nudge.args) # Iterates through argument names +['programmer'] +>>> api.Command.nudge.args.programmer +Str('programmer') +>>> list(api.Command.nudge.options) # Iterates through option names +['stuff'] +>>> api.Command.nudge.options.stuff +Str('stuff', default=u'documentation') +>>> api.Command.nudge.options.stuff.default +u'documentation' + +The arguments and options must not contain colliding names. They are both +merged together into the ``params`` attribute, another `plugable.NameSpace` +instance, as you can see: + +>>> api.Command.nudge.params +NameSpace(<2 members>, sort=False) +>>> list(api.Command.nudge.params) # Iterates through the param names +['programmer', 'stuff'] + +When calling a command, its positional arguments can also be provided as +keyword arguments, and in any order. For example: + +>>> api.Command.nudge(stuff=u'lines of code', programmer=u'Jason') +u'Jason, go write more lines of code!' + +When a command plugin is called, the values supplied for its parameters are +put through a sophisticated processing pipeline that includes steps for +normalization, type conversion, validation, and dynamically constructing +the defaults for missing values. The details wont be covered here; however, +here is a quick teaser: + +>>> from ipalib import Int +>>> class create_player(Command): +... takes_options = [ +... 'first', +... 'last', +... Str('nick', +... normalizer=lambda value: value.lower(), +... default_from=lambda first, last: first[0] + last, +... ), +... Int('points', default=0), +... ] +... +>>> cp = create_player() +>>> cp.finalize() +>>> cp.convert(points=u' 1000 ') +{'points': 1000} +>>> cp.normalize(nick=u'NickName') +{'nick': u'nickname'} +>>> cp.get_default(first=u'Jason', last=u'DeRose') +{'nick': u'jderose', 'points': 0} + +For the full details on the parameter system, see the +`frontend.parse_param_spec()` function, and the `frontend.Param` and +`frontend.Command` classes. + + +--------------------------------------- +Allowed return values from your command +--------------------------------------- + +The return values from your command can be rendered by different user +interfaces (CLI, web-UI); furthermore, a call to your command can be +transparently forwarded over the network (XML-RPC, JSON). As such, the return +values from your command must be usable by the least common denominator. + +Your command should return only simple data types and simple data structures, +the kinds that can be represented in an XML-RPC request or in the JSON format. +The return values from your command's ``execute()`` method can include only +the following: + + Simple scalar values: + These can be ``str``, ``unicode``, ``int``, and ``float`` instances, + plus the ``True``, ``False``, and ``None`` constants. + + Simple compound values: + These can be ``dict``, ``list``, and ``tuple`` instances. These + compound values must contain only the simple scalar values above or + other simple compound values. These compound values can also be empty. + For our purposes here, the ``list`` and ``tuple`` types are equivalent + and can be used interchangeably. + +Also note that your ``execute()`` method should not contain any ``print`` +statements or otherwise cause any output on ``sys.stdout``. Your command can +(and should) produce log messages by using ``self.log`` (see below). + +To learn more about XML-RPC (XML Remote Procedure Call), see: + + http://docs.python.org/library/xmlrpclib.html + + http://en.wikipedia.org/wiki/XML-RPC + +To learn more about JSON (Java Script Object Notation), see: + + http://docs.python.org/library/json.html + + http://www.json.org/ + + +--------------------------------------- +How your command should print to stdout +--------------------------------------- + +As noted above, your command should not print anything while in its +``execute()`` method. So how does your command format its output when +called from the ``ipa`` script? + +After the `cli.CLI.run_cmd()` method calls your command, it will call your +command's ``output_for_cli()`` method (if you have implemented one). + +If you implement an ``output_for_cli()`` method, it must have the following +signature: + + :: + + output_for_cli(textui, result, *args, **options) + + textui + An object implementing methods for outputting to the console. + Currently the `ipalib.cli.textui` plugin is passed, which your method + can also access as ``self.Backend.textui``. However, in case this + changes in the future, your method should use the instance passed to + it in this first argument. + + result + This is the return value from calling your command plugin. Depending + upon how your command is implemented, this is probably the return + value from your ``execute()`` method. + + args + The arguments your command was called with. If your command takes no + arguments, you can omit this. You can also explicitly list your + arguments rather than using the generic ``*args`` form. + + options + The options your command was called with. If your command takes no + options, you can omit this. If your command takes any options, you + must use the ``**options`` form as they will be provided strictly as + keyword arguments. + +For example, say we setup a command like this: + +>>> class show_items(Command): +... +... takes_args = ['key?'] +... +... takes_options = [Flag('reverse')] +... +... def execute(self, key, **options): +... items = dict( +... fruit='apple', +... pet='dog', +... city='Berlin', +... ) +... if key in items: +... return items[key] +... return [ +... (k, items[k]) for k in sorted(items, reverse=options['reverse']) +... ] +... +... def output_for_cli(self, textui, result, key, **options): +... if key is not None: +... textui.print_plain('%s = %r' % (key, result)) +... else: +... textui.print_name(self.name) +... textui.print_keyval(result) +... format = '%d items' +... if options['reverse']: +... format += ' (in reverse order)' +... textui.print_count(result, format) +... +>>> api = create_api() +>>> api.env.in_server = True # We want to execute, not forward. +>>> api.register(show_items) +>>> api.finalize() + +Normally when you invoke the ``ipa`` script, `cli.CLI.load_plugins()` will +register the `cli.textui` backend plugin, but for the sake of our example, +we will just create an instance here: + +>>> from ipalib import cli +>>> textui = cli.textui() # We'll pass this to output_for_cli() + +Now for what we are concerned with in this example, calling your command +through the ``ipa`` script basically will do the following: + +>>> result = api.Command.show_items() +>>> api.Command.show_items.output_for_cli(textui, result, None, reverse=False) +----------- +show-items: +----------- + city = 'Berlin' + fruit = 'apple' + pet = 'dog' +------- +3 items +------- + +Similarly, calling it with ``reverse=True`` would result in the following: + +>>> result = api.Command.show_items(reverse=True) +>>> api.Command.show_items.output_for_cli(textui, result, None, reverse=True) +----------- +show-items: +----------- + pet = 'dog' + fruit = 'apple' + city = 'Berlin' +-------------------------- +3 items (in reverse order) +-------------------------- + +Lastly, providing a ``key`` would result in the following: + +>>> result = api.Command.show_items(u'city') +>>> api.Command.show_items.output_for_cli(textui, result, 'city', reverse=False) +city = 'Berlin' + +See the `ipalib.cli.textui` plugin for a description of its methods. + + +------------------------ +Logging from your plugin +------------------------ + +After `plugable.Plugin.set_api()` is called, your plugin will have a +``self.log`` attribute. Plugins should only log through this attribute. +For example: + +>>> class paint_house(Command): +... +... takes_args = ['color'] +... +... def execute(self, color): +... """Uses self.log.error()""" +... if color not in ('red', 'blue', 'green'): +... self.log.error("I don't have %s paint!", color) # Log error +... return +... return 'I painted the house %s.' % color +... + +Some basic knowledge of the Python ``logging`` module might be helpful. See: + + http://docs.python.org/library/logging.html + +The important thing to remember is that your plugin should not configure +logging itself, but should instead simply use the ``self.log`` logger. + +Also see the `plugable.API.bootstrap()` method for details on how the logging +is configured. + + +--------------------- +Environment variables +--------------------- + +Plugins access configuration variables and run-time information through +``self.api.env`` (or for convenience, ``self.env`` is equivalent). This +attribute is a refences to the `ipalib.config.Env` instance created in +`plugable.API.__init__()`. + +After `API.bootstrap()` has been called, the `Env` instance will be populated +with all the environment information used by the built-in plugins. +This will be called before any plugins are registered, so plugin authors can +assume these variables will all exist by the time the module containing their +plugin (or plugins) is imported. For example: + +>>> api = create_api() +>>> len(api.env) +1 +>>> api.bootstrap(in_server=True) # We want to execute, not forward +>>> len(api.env) +35 + +`Env._bootstrap()`, which is called by `API.bootstrap()`, will create several +run-time variables that connot be overriden in configuration files or through +command-line options. Here is an overview of this run-time information: + +============= ============================= ======================= +Key Example value Description +============= ============================= ======================= +bin '/usr/bin' Dir. containing script +dot_ipa '/home/jderose/.ipa' User config directory +home os.environ['HOME'] User home dir. +ipalib '.../site-packages/ipalib' Dir. of ipalib package +mode 'unit_test' The mode ipalib is in +script sys.argv[0] Path of script +site_packages '.../python2.5/site-packages' Dir. containing ipalib/ +============= ============================= ======================= + +If your plugin requires new environment variables *and* will be included in +the freeIPA built-in plugins, you should add the defaults for your variables +in `ipalib.constants.DEFAULT_CONFIG`. Also, you should consider whether your +new environment variables should have any auto-magic logic to determine their +values if they haven't already been set by the time `config.Env._bootstrap()`, +`config.Env._finalize_core()`, or `config.Env._finalize()` is called. + +On the other hand, if your plugin requires new environment variables and will +be installed in a 3rd-party package, your plugin should set these variables +in the module it is defined in. + +`config.Env` values work on a first-one-wins basis... after a value has been +set, it can not be overridden with a new value. As any variables can be set +using the command-line ``-e`` global option or set in a configuration file, +your module must check whether a variable has already been set before +setting its default value. For example: + +>>> if 'message_of_the_day' not in api.env: +... api.env.message_of_the_day = 'Hello, world!' +... + +Your plugin can access any environment variables via ``self.env``. +For example: + +>>> class motd(Command): +... """Print message of the day.""" +... +... def execute(self): +... return self.env.message_of_the_day +... +>>> api.register(motd) +>>> api.finalize() +>>> api.Command.motd() +'Hello, world!' + +Also see the `plugable.API.bootstrap_with_global_options()` method. + + +--------------------------------------------- +Indispensable ipa script commands and options +--------------------------------------------- + +The ``console`` command will launch a custom interactive Python interpreter +session. The global environment will have an ``api`` variable, which is the +standard `plugable.API` instance found at ``ipalib.api``. All plugins will +have been loaded (well, except the backend plugins if ``in_server`` is False) +and ``api`` will be fully initialized. To launch the console from within the +top-level directory in the the source tree, just run ``ipa console`` from a +terminal, like this: + + :: + + $ ./ipa console + +By default, ``in_server`` is False. If you want to start the console in a +server context (so that all the backend plugins are loaded), you can use the +``-e`` option to set the ``in_server`` environment variable, like this: + + :: + + $ ./ipa -e in_server=True console + +You can specify multiple environment variables by including the ``-e`` option +multiple times, like this: + + :: + + $ ./ipa -e in_server=True -e mode=dummy console + +The space after the ``-e`` is optional. This is equivalent to the above command: + + :: + + $ ./ipa -ein_server=True -emode=dummy console + +The ``env`` command will print out the full environment in key=value pairs, +like this: + + :: + + $ ./ipa env + +If you use the ``--server`` option, it will forward the call to the server +over XML-RPC and print out what the environment is on the server, like this: + + :: + + $ ./ipa env --server + +The ``plugins`` command will show details of all the plugin that are loaded, +like this: + + :: + + $ ./ipa plugins + + +----------------------------------- +Learning more about freeIPA plugins +----------------------------------- + +To learn more about writing freeIPA plugins, you should: + + 1. Look at some of the built-in plugins, like the frontend plugins in + `ipalib.plugins.f_user` and the backend plugins in + `ipaserver.plugins.b_ldap`. + + 2. Learn about the base classes for frontend plugins in `ipalib.frontend`. + + 3. Learn about the core plugin framework in `ipalib.plugable`. + +Furthermore, the freeIPA plugin architecture was inspired by the Bazaar plugin +architecture. Although the two are different enough that learning how to +write plugins for Bazaar will not particularly help you write plugins for +freeIPA, some might be interested in the documentation on writing plugins for +Bazaar, available here: + + http://bazaar-vcs.org/WritingPlugins + +If nothing else, we just want to give credit where credit is deserved! +However, freeIPA does not use any *code* from Bazaar... it merely borrows a +little inspiration. + + +-------------------------- +A note on docstring markup +-------------------------- + +Lastly, a quick note on markup: All the Python docstrings in freeIPA v2 +(including this tutorial) use the *reStructuredText* markup language. For +information on reStructuredText, see: + + http://docutils.sourceforge.net/rst.html + +For information on using reStructuredText markup with epydoc, see: + + http://epydoc.sourceforge.net/manual-othermarkup.html + + +-------------------------------------------------- +Next steps: get involved with freeIPA development! +-------------------------------------------------- + +The freeIPA team is always interested in feedback and contribution from the +community. To get involved with freeIPA, see the *Contribute* page on +freeIPA.org: + + http://freeipa.org/page/Contribute + +''' + +import plugable +from backend import Backend, Context +from frontend import Command, LocalOrRemote, Application +from frontend import Object, Method, Property +from parameters import DefaultFrom, Bool, Flag, Int, Float, Bytes, Str, Password +from parameters import BytesEnum, StrEnum + +try: + import uuid +except ImportError: + import ipauuid as uuid + +def create_api(mode='dummy'): + """ + Return standard `plugable.API` instance. + + This standard instance allows plugins that subclass from the following + base classes: + + - `frontend.Command` + + - `frontend.Object` + + - `frontend.Method` + + - `frontend.Property` + + - `frontend.Application` + + - `backend.Backend` + + - `backend.Context` + """ + api = plugable.API( + Command, Object, Method, Property, Application, + Backend, Context, + ) + if mode is not None: + api.env.mode = mode + return api + + +api = create_api(mode=None) diff --git a/ipalib/aci.py b/ipalib/aci.py new file mode 100755 index 00000000..9dde767c --- /dev/null +++ b/ipalib/aci.py @@ -0,0 +1,245 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +import shlex +import re +import ldap + +# The Python re module doesn't do nested parenthesis + +# Break the ACI into 3 pieces: target, name, permissions/bind_rules +ACIPat = re.compile(r'\s*(\(.*\)+)\s*\(version\s+3.0\s*;\s*acl\s+\"(.*)\"\s*;\s*(.*);\)') + +# Break the permissions/bind_rules out +PermPat = re.compile(r'(\w+)\s*\((.*)\)\s+(.*)') + + +class ACI: + """ + Holds the basic data for an ACI entry, as stored in the cn=accounts + entry in LDAP. Has methods to parse an ACI string and export to an + ACI String. + """ + + # Don't allow arbitrary attributes to be set in our __setattr__ implementation. + _objectattrs = ["name", "orig_acistr", "target", "action", "permissions", + "bindrule"] + + __actions = ["allow", "deny"] + + __permissions = ["read", "write", "add", "delete", "search", "compare", + "selfwrite", "proxy", "all"] + + def __init__(self,acistr=None): + self.name = None + self.orig_acistr = acistr + self.target = {} + self.action = "allow" + self.permissions = ["write"] + self.bindrule = None + if acistr is not None: + self._parse_acistr(acistr) + + def __getitem__(self,key): + """Fake getting attributes by key for sorting""" + if key == 0: + return self.name + if key == 1: + return self.source_group + if key == 2: + return self.dest_group + raise TypeError("Unknown key value %s" % key) + + def __repr__(self): + """An alias for export_to_string()""" + return self.export_to_string() + + def __getattr__(self, name): + """ + Backward compatibility for the old ACI class. + + The following extra attributes are available: + + - source_group + - dest_group + - attrs + """ + if name == 'source_group': + group = '' + dn = self.bindrule.split('=',1) + if dn[0] == "groupdn": + group = self._remove_quotes(dn[1]) + if group.startswith("ldap:///"): + group = group[8:] + return group + if name == 'dest_group': + group = self.target.get('targetfilter', '') + if group: + g = group.split('=',1)[1] + if g.endswith(')'): + g = g[:-1] + return g + return '' + if name == 'attrs': + return self.target.get('targetattr', None) + raise AttributeError, "object has no attribute '%s'" % name + + def __setattr__(self, name, value): + """ + Backward compatibility for the old ACI class. + + The following extra attributes are available: + - source_group + - dest_group + - attrs + """ + if name == 'source_group': + self.__dict__['bindrule'] = 'groupdn="ldap:///%s"' % value + elif name == 'dest_group': + if value.startswith('('): + self.__dict__['target']['targetfilter'] = 'memberOf=%s' % value + else: + self.__dict__['target']['targetfilter'] = '(memberOf=%s)' % value + elif name == 'attrs': + self.__dict__['target']['targetattr'] = value + elif name in self._objectattrs: + self.__dict__[name] = value + else: + raise AttributeError, "object has no attribute '%s'" % name + + def export_to_string(self): + """Output a Directory Server-compatible ACI string""" + self.validate() + aci = "" + for t in self.target: + if isinstance(self.target[t], list): + target = "" + for l in self.target[t]: + target = target + l + " || " + target = target[:-4] + aci = aci + "(%s=\"%s\")" % (t, target) + else: + aci = aci + "(%s=\"%s\")" % (t, self.target[t]) + aci = aci + "(version 3.0;acl \"%s\";%s (%s) %s" % (self.name, self.action, ",".join(self.permissions), self.bindrule) + ";)" + return aci + + def _remove_quotes(self, s): + # Remove leading and trailing quotes + if s.startswith('"'): + s = s[1:] + if s.endswith('"'): + s = s[:-1] + return s + + def _parse_target(self, aci): + lexer = shlex.shlex(aci) + lexer.wordchars = lexer.wordchars + "." + + l = [] + + var = False + for token in lexer: + # We should have the form (a = b)(a = b)... + if token == "(": + var = lexer.next().strip() + operator = lexer.next() + if operator != "=" and operator != "!=": + raise SyntaxError('No operator in target, got %s' % operator) + val = lexer.next().strip() + val = self._remove_quotes(val) + end = lexer.next() + if end != ")": + raise SyntaxError('No end parenthesis in target, got %s' % end) + + if var == 'targetattr': + # Make a string of the form attr || attr || ... into a list + t = re.split('[\W]+', val) + self.target[var] = t + else: + self.target[var] = val + + def _parse_acistr(self, acistr): + acimatch = ACIPat.match(acistr) + if not acimatch or len(acimatch.groups()) < 3: + raise SyntaxError, "malformed ACI" + self._parse_target(acimatch.group(1)) + self.name = acimatch.group(2) + bindperms = PermPat.match(acimatch.group(3)) + if not bindperms or len(bindperms.groups()) < 3: + raise SyntaxError, "malformed ACI" + self.action = bindperms.group(1) + self.permissions = bindperms.group(2).split(',') + self.bindrule = bindperms.group(3) + + def validate(self): + """Do some basic verification that this will produce a + valid LDAP ACI. + + returns True if valid + """ + if not isinstance(self.permissions, list): + raise SyntaxError, "permissions must be a list" + for p in self.permissions: + if not p.lower() in self.__permissions: + raise SyntaxError, "invalid permission: '%s'" % p + if not self.name: + raise SyntaxError, "name must be set" + if not isinstance(self.name, basestring): + raise SyntaxError, "name must be a string" + if not isinstance(self.target, dict) or len(self.target) == 0: + raise SyntaxError, "target must be a non-empty dictionary" + return True + +def extract_group_cns(aci_list, client): + """Extracts all the cn's from a list of aci's and returns them as a hash + from group_dn to group_cn. + + It first tries to cheat by looking at the first rdn for the + group dn. If that's not cn for some reason, it looks up the group.""" + group_dn_to_cn = {} + for aci in aci_list: + for dn in (aci.source_group, aci.dest_group): + if not group_dn_to_cn.has_key(dn): + rdn_list = ldap.explode_dn(dn, 0) + first_rdn = rdn_list[0] + (type,value) = first_rdn.split('=') + if type == "cn": + group_dn_to_cn[dn] = value + else: + try: + group = client.get_entry_by_dn(dn, ['cn']) + group_dn_to_cn[dn] = group.getValue('cn') + except ipaerror.IPAError, e: + group_dn_to_cn[dn] = 'unknown' + + return group_dn_to_cn + +if __name__ == '__main__': + # Pass in an ACI as a string + a = ACI('(targetattr="title")(targetfilter="(memberOf=cn=bar,cn=groups,cn=accounts ,dc=example,dc=com)")(version 3.0;acl "foobar";allow (write) groupdn="ldap:///cn=foo,cn=groups,cn=accounts,dc=example,dc=com";)') + print a + + # Create an ACI in pieces + a = ACI() + a.name ="foobar" + a.source_group="cn=foo,cn=groups,dc=example,dc=org" + a.dest_group="cn=bar,cn=groups,dc=example,dc=org" + a.attrs = ['title'] + a.permissions = ['read','write','add'] + print a diff --git a/ipalib/backend.py b/ipalib/backend.py new file mode 100644 index 00000000..b1e15f33 --- /dev/null +++ b/ipalib/backend.py @@ -0,0 +1,45 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Base classes for all backed-end plugins. +""" + +import plugable + + +class Backend(plugable.Plugin): + """ + Base class for all backend plugins. + """ + + __proxy__ = False # Backend plugins are not wrapped in a PluginProxy + + +class Context(plugable.Plugin): + """ + Base class for plugable context components. + """ + + __proxy__ = False # Backend plugins are not wrapped in a PluginProxy + + def get_value(self): + raise NotImplementedError( + '%s.get_value()' % self.__class__.__name__ + ) diff --git a/ipalib/base.py b/ipalib/base.py new file mode 100644 index 00000000..bff8f195 --- /dev/null +++ b/ipalib/base.py @@ -0,0 +1,486 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Foundational classes and functions. +""" + +import re +from constants import NAME_REGEX, NAME_ERROR +from constants import TYPE_ERROR, SET_ERROR, DEL_ERROR, OVERRIDE_ERROR + + +class ReadOnly(object): + """ + Base class for classes that can be locked into a read-only state. + + Be forewarned that Python does not offer true read-only attributes for + user-defined classes. Do *not* rely upon the read-only-ness of this + class for security purposes! + + The point of this class is not to make it impossible to set or to delete + attributes after an instance is locked, but to make it impossible to do so + *accidentally*. Rather than constantly reminding our programmers of things + like, for example, "Don't set any attributes on this ``FooBar`` instance + because doing so wont be thread-safe", this class offers a real way to + enforce read-only attribute usage. + + For example, before a `ReadOnly` instance is locked, you can set and delete + its attributes as normal: + + >>> class Person(ReadOnly): + ... pass + ... + >>> p = Person() + >>> p.name = 'John Doe' + >>> p.phone = '123-456-7890' + >>> del p.phone + + But after an instance is locked, you cannot set its attributes: + + >>> p.__islocked__() # Is this instance locked? + False + >>> p.__lock__() # This will lock the instance + >>> p.__islocked__() + True + >>> p.department = 'Engineering' + Traceback (most recent call last): + ... + AttributeError: locked: cannot set Person.department to 'Engineering' + + Nor can you deleted its attributes: + + >>> del p.name + Traceback (most recent call last): + ... + AttributeError: locked: cannot delete Person.name + + However, as noted at the start, there are still obscure ways in which + attributes can be set or deleted on a locked `ReadOnly` instance. For + example: + + >>> object.__setattr__(p, 'department', 'Engineering') + >>> p.department + 'Engineering' + >>> object.__delattr__(p, 'name') + >>> hasattr(p, 'name') + False + + But again, the point is that a programmer would never employ the above + techniques *accidentally*. + + Lastly, this example aside, you should use the `lock()` function rather + than the `ReadOnly.__lock__()` method. And likewise, you should + use the `islocked()` function rather than the `ReadOnly.__islocked__()` + method. For example: + + >>> readonly = ReadOnly() + >>> islocked(readonly) + False + >>> lock(readonly) is readonly # lock() returns the instance + True + >>> islocked(readonly) + True + """ + + __locked = False + + def __lock__(self): + """ + Put this instance into a read-only state. + + After the instance has been locked, attempting to set or delete an + attribute will raise an AttributeError. + """ + assert self.__locked is False, '__lock__() can only be called once' + self.__locked = True + + def __islocked__(self): + """ + Return True if instance is locked, otherwise False. + """ + return self.__locked + + def __setattr__(self, name, value): + """ + If unlocked, set attribute named ``name`` to ``value``. + + If this instance is locked, an AttributeError will be raised. + + :param name: Name of attribute to set. + :param value: Value to assign to attribute. + """ + if self.__locked: + raise AttributeError( + SET_ERROR % (self.__class__.__name__, name, value) + ) + return object.__setattr__(self, name, value) + + def __delattr__(self, name): + """ + If unlocked, delete attribute named ``name``. + + If this instance is locked, an AttributeError will be raised. + + :param name: Name of attribute to delete. + """ + if self.__locked: + raise AttributeError( + DEL_ERROR % (self.__class__.__name__, name) + ) + return object.__delattr__(self, name) + + +def lock(instance): + """ + Lock an instance of the `ReadOnly` class or similar. + + This function can be used to lock instances of any class that implements + the same locking API as the `ReadOnly` class. For example, this function + can lock instances of the `config.Env` class. + + So that this function can be easily used within an assignment, ``instance`` + is returned after it is locked. For example: + + >>> readonly = ReadOnly() + >>> readonly is lock(readonly) + True + >>> readonly.attr = 'This wont work' + Traceback (most recent call last): + ... + AttributeError: locked: cannot set ReadOnly.attr to 'This wont work' + + Also see the `islocked()` function. + + :param instance: The instance of `ReadOnly` (or similar) to lock. + """ + assert instance.__islocked__() is False, 'already locked: %r' % instance + instance.__lock__() + assert instance.__islocked__() is True, 'failed to lock: %r' % instance + return instance + + +def islocked(instance): + """ + Return ``True`` if ``instance`` is locked. + + This function can be used on an instance of the `ReadOnly` class or an + instance of any other class implemented the same locking API. + + For example: + + >>> readonly = ReadOnly() + >>> islocked(readonly) + False + >>> readonly.__lock__() + >>> islocked(readonly) + True + + Also see the `lock()` function. + + :param instance: The instance of `ReadOnly` (or similar) to interrogate. + """ + assert ( + hasattr(instance, '__lock__') and callable(instance.__lock__) + ), 'no __lock__() method: %r' % instance + return instance.__islocked__() + + +def check_name(name): + """ + Verify that ``name`` is suitable for a `NameSpace` member name. + + In short, ``name`` must be a valid lower-case Python identifier that + neither starts nor ends with an underscore. Otherwise an exception is + raised. + + This function will raise a ``ValueError`` if ``name`` does not match the + `constants.NAME_REGEX` regular expression. For example: + + >>> check_name('MyName') + Traceback (most recent call last): + ... + ValueError: name must match '^[a-z][_a-z0-9]*[a-z0-9]$'; got 'MyName' + + Also, this function will raise a ``TypeError`` if ``name`` is not an + ``str`` instance. For example: + + >>> check_name(u'my_name') + Traceback (most recent call last): + ... + TypeError: name: need a <type 'str'>; got u'my_name' (a <type 'unicode'>) + + So that `check_name()` can be easily used within an assignment, ``name`` + is returned unchanged if it passes the check. For example: + + >>> n = check_name('my_name') + >>> n + 'my_name' + + :param name: Identifier to test. + """ + if type(name) is not str: + raise TypeError( + TYPE_ERROR % ('name', str, name, type(name)) + ) + if re.match(NAME_REGEX, name) is None: + raise ValueError( + NAME_ERROR % (NAME_REGEX, name) + ) + return name + + +class NameSpace(ReadOnly): + """ + A read-only name-space with handy container behaviours. + + A `NameSpace` instance is an ordered, immutable mapping object whose values + can also be accessed as attributes. A `NameSpace` instance is constructed + from an iterable providing its *members*, which are simply arbitrary objects + with a ``name`` attribute whose value: + + 1. Is unique among the members + + 2. Passes the `check_name()` function + + Beyond that, no restrictions are placed on the members: they can be + classes or instances, and of any type. + + The members can be accessed as attributes on the `NameSpace` instance or + through a dictionary interface. For example, say we create a `NameSpace` + instance from a list containing a single member, like this: + + >>> class my_member(object): + ... name = 'my_name' + ... + >>> namespace = NameSpace([my_member]) + >>> namespace + NameSpace(<1 member>, sort=True) + + We can then access ``my_member`` both as an attribute and as a dictionary + item: + + >>> my_member is namespace.my_name # As an attribute + True + >>> my_member is namespace['my_name'] # As dictionary item + True + + For a more detailed example, say we create a `NameSpace` instance from a + generator like this: + + >>> class Member(object): + ... def __init__(self, i): + ... self.i = i + ... self.name = 'member%d' % i + ... def __repr__(self): + ... return 'Member(%d)' % self.i + ... + >>> ns = NameSpace(Member(i) for i in xrange(3)) + >>> ns + NameSpace(<3 members>, sort=True) + + As above, the members can be accessed as attributes and as dictionary items: + + >>> ns.member0 is ns['member0'] + True + >>> ns.member1 is ns['member1'] + True + >>> ns.member2 is ns['member2'] + True + + Members can also be accessed by index and by slice. For example: + + >>> ns[0] + Member(0) + >>> ns[-1] + Member(2) + >>> ns[1:] + (Member(1), Member(2)) + + (Note that slicing a `NameSpace` returns a ``tuple``.) + + `NameSpace` instances provide standard container emulation for membership + testing, counting, and iteration. For example: + + >>> 'member3' in ns # Is there a member named 'member3'? + False + >>> 'member2' in ns # But there is a member named 'member2' + True + >>> len(ns) # The number of members + 3 + >>> list(ns) # Iterate through the member names + ['member0', 'member1', 'member2'] + + Although not a standard container feature, the `NameSpace.__call__()` method + provides a convenient (and efficient) way to iterate through the *members* + (as opposed to the member names). Think of it like an ordered version of + the ``dict.itervalues()`` method. For example: + + >>> list(ns[name] for name in ns) # One way to do it + [Member(0), Member(1), Member(2)] + >>> list(ns()) # A more efficient, simpler way to do it + [Member(0), Member(1), Member(2)] + + Another convenience method is `NameSpace.__todict__()`, which will return + a copy of the ``dict`` mapping the member names to the members. + For example: + + >>> ns.__todict__() + {'member1': Member(1), 'member0': Member(0), 'member2': Member(2)} + + As `NameSpace.__init__()` locks the instance, `NameSpace` instances are + read-only from the get-go. An ``AttributeError`` is raised if you try to + set *any* attribute on a `NameSpace` instance. For example: + + >>> ns.member3 = Member(3) # Lets add that missing 'member3' + Traceback (most recent call last): + ... + AttributeError: locked: cannot set NameSpace.member3 to Member(3) + + (For information on the locking protocol, see the `ReadOnly` class, of which + `NameSpace` is a subclass.) + + By default the members will be sorted alphabetically by the member name. + For example: + + >>> sorted_ns = NameSpace([Member(7), Member(3), Member(5)]) + >>> sorted_ns + NameSpace(<3 members>, sort=True) + >>> list(sorted_ns) + ['member3', 'member5', 'member7'] + >>> sorted_ns[0] + Member(3) + + But if the instance is created with the ``sort=False`` keyword argument, the + original order of the members is preserved. For example: + + >>> unsorted_ns = NameSpace([Member(7), Member(3), Member(5)], sort=False) + >>> unsorted_ns + NameSpace(<3 members>, sort=False) + >>> list(unsorted_ns) + ['member7', 'member3', 'member5'] + >>> unsorted_ns[0] + Member(7) + + The `NameSpace` class is used in many places throughout freeIPA. For a few + examples, see the `plugable.API` and the `frontend.Command` classes. + """ + + def __init__(self, members, sort=True): + """ + :param members: An iterable providing the members. + :param sort: Whether to sort the members by member name. + """ + if type(sort) is not bool: + raise TypeError( + TYPE_ERROR % ('sort', bool, sort, type(sort)) + ) + self.__sort = sort + if sort: + self.__members = tuple( + sorted(members, key=lambda m: m.name) + ) + else: + self.__members = tuple(members) + self.__names = tuple(m.name for m in self.__members) + self.__map = dict() + for member in self.__members: + name = check_name(member.name) + if name in self.__map: + raise AttributeError(OVERRIDE_ERROR % + (self.__class__.__name__, name, self.__map[name], member) + ) + assert not hasattr(self, name), 'Ouch! Has attribute %r' % name + self.__map[name] = member + setattr(self, name, member) + lock(self) + + def __len__(self): + """ + Return the number of members. + """ + return len(self.__members) + + def __iter__(self): + """ + Iterate through the member names. + + If this instance was created with ``sort=False``, the names will be in + the same order as the members were passed to the constructor; otherwise + the names will be in alphabetical order (which is the default). + + This method is like an ordered version of ``dict.iterkeys()``. + """ + for name in self.__names: + yield name + + def __call__(self): + """ + Iterate through the members. + + If this instance was created with ``sort=False``, the members will be + in the same order as they were passed to the constructor; otherwise the + members will be in alphabetical order by name (which is the default). + + This method is like an ordered version of ``dict.itervalues()``. + """ + for member in self.__members: + yield member + + def __contains__(self, name): + """ + Return ``True`` if namespace has a member named ``name``. + """ + return name in self.__map + + def __getitem__(self, key): + """ + Return a member by name or index, or return a slice of members. + + :param key: The name or index of a member, or a slice object. + """ + if type(key) is str: + return self.__map[key] + if type(key) in (int, slice): + return self.__members[key] + raise TypeError( + TYPE_ERROR % ('key', (str, int, slice), key, type(key)) + ) + + def __repr__(self): + """ + Return a pseudo-valid expression that could create this instance. + """ + cnt = len(self) + if cnt == 1: + m = 'member' + else: + m = 'members' + return '%s(<%d %s>, sort=%r)' % ( + self.__class__.__name__, + cnt, + m, + self.__sort, + ) + + def __todict__(self): + """ + Return a copy of the private dict mapping member name to member. + """ + return dict(self.__map) diff --git a/ipalib/cli.py b/ipalib/cli.py new file mode 100644 index 00000000..fb2fd95f --- /dev/null +++ b/ipalib/cli.py @@ -0,0 +1,854 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Functionality for Command Line Interface. +""" + +import re +import textwrap +import sys +import getpass +import code +import optparse +import socket +import fcntl +import termios +import struct + +import frontend +import backend +import errors +import plugable +import util +from constants import CLI_TAB +from parameters import Password + + +def to_cli(name): + """ + Takes a Python identifier and transforms it into form suitable for the + Command Line Interface. + """ + assert isinstance(name, str) + return name.replace('_', '-') + + +def from_cli(cli_name): + """ + Takes a string from the Command Line Interface and transforms it into a + Python identifier. + """ + return str(cli_name).replace('-', '_') + + +class textui(backend.Backend): + """ + Backend plugin to nicely format output to stdout. + """ + + def get_tty_width(self): + """ + Return the width (in characters) of output tty. + + If stdout is not a tty, this method will return ``None``. + """ + # /usr/include/asm/termios.h says that struct winsize has four + # unsigned shorts, hence the HHHH + if sys.stdout.isatty(): + try: + winsize = fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, + struct.pack('HHHH', 0, 0, 0, 0)) + return struct.unpack('HHHH', winsize)[1] + except IOError: + return None + + def max_col_width(self, rows, col=None): + """ + Return the max width (in characters) of a specified column. + + For example: + + >>> ui = textui() + >>> rows = [ + ... ('a', 'package'), + ... ('an', 'egg'), + ... ] + >>> ui.max_col_width(rows, col=0) # len('an') + 2 + >>> ui.max_col_width(rows, col=1) # len('package') + 7 + >>> ui.max_col_width(['a', 'cherry', 'py']) # len('cherry') + 6 + """ + if type(rows) not in (list, tuple): + raise TypeError( + 'rows: need %r or %r; got %r' % (list, tuple, rows) + ) + if len(rows) == 0: + return 0 + if col is None: + return max(len(row) for row in rows) + return max(len(row[col]) for row in rows) + + def __get_encoding(self, stream): + assert stream in (sys.stdin, sys.stdout) + if stream.encoding is None: + if stream.isatty(): + return sys.getdefaultencoding() + return 'UTF-8' + return stream.encoding + + def decode(self, str_buffer): + """ + Decode text from stdin. + """ + assert type(str_buffer) is str + encoding = self.__get_encoding(sys.stdin) + return str_buffer.decode(encoding) + + def encode(self, unicode_text): + """ + Encode text for output to stdout. + """ + assert type(unicode_text) is unicode + encoding = self.__get_encoding(sys.stdout) + return unicode_text.encode(encoding) + + def choose_number(self, n, singular, plural=None): + if n == 1 or plural is None: + return singular % n + return plural % n + + def print_plain(self, string): + """ + Print exactly like ``print`` statement would. + """ + print string + + def print_line(self, text, width=None): + """ + Force printing on a single line, using ellipsis if needed. + + For example: + + >>> ui = textui() + >>> ui.print_line('This line can fit!', width=18) + This line can fit! + >>> ui.print_line('This line wont quite fit!', width=18) + This line wont ... + + The above example aside, you normally should not specify the + ``width``. When you don't, it is automatically determined by calling + `textui.get_tty_width()`. + """ + if width is None: + width = self.get_tty_width() + if width is not None and width < len(text): + text = text[:width - 3] + '...' + print text + + def print_paragraph(self, text, width=None): + """ + Print a paragraph, automatically word-wrapping to tty width. + + For example: + + >>> text = ''' + ... Python is a dynamic object-oriented programming language that can + ... be used for many kinds of software development. + ... ''' + >>> ui = textui() + >>> ui.print_paragraph(text, width=45) + Python is a dynamic object-oriented + programming language that can be used for + many kinds of software development. + + The above example aside, you normally should not specify the + ``width``. When you don't, it is automatically determined by calling + `textui.get_tty_width()`. + + The word-wrapping is done using the Python ``textwrap`` module. See: + + http://docs.python.org/library/textwrap.html + """ + if width is None: + width = self.get_tty_width() + for line in textwrap.wrap(text.strip(), width): + print line + + def print_indented(self, text, indent=1): + """ + Print at specified indentation level. + + For example: + + >>> ui = textui() + >>> ui.print_indented('One indentation level.') + One indentation level. + >>> ui.print_indented('Two indentation levels.', indent=2) + Two indentation levels. + >>> ui.print_indented('No indentation.', indent=0) + No indentation. + """ + print (CLI_TAB * indent + text) + + def print_keyval(self, rows, indent=1): + """ + Print (key = value) pairs, one pair per line. + + For example: + + >>> items = [ + ... ('in_server', True), + ... ('mode', 'production'), + ... ] + >>> ui = textui() + >>> ui.print_keyval(items) + in_server = True + mode = 'production' + >>> ui.print_keyval(items, indent=0) + in_server = True + mode = 'production' + + Also see `textui.print_indented`. + """ + for (key, value) in rows: + self.print_indented('%s = %r' % (key, value), indent) + + def print_entry(self, entry, indent=1): + """ + Print an ldap entry dict. + + For example: + + >>> entry = dict(sn='Last', givenname='First', uid='flast') + >>> ui = textui() + >>> ui.print_entry(entry) + givenname: 'First' + sn: 'Last' + uid: 'flast' + """ + assert type(entry) is dict + for key in sorted(entry): + value = entry[key] + if type(value) in (list, tuple): + value = ', '.join(repr(v) for v in value) + else: + value = repr(value) + self.print_indented('%s: %s' % (key, value), indent) + + def print_dashed(self, string, above=True, below=True, indent=0, dash='-'): + """ + Print a string with a dashed line above and/or below. + + For example: + + >>> ui = textui() + >>> ui.print_dashed('Dashed above and below.') + ----------------------- + Dashed above and below. + ----------------------- + >>> ui.print_dashed('Only dashed below.', above=False) + Only dashed below. + ------------------ + >>> ui.print_dashed('Only dashed above.', below=False) + ------------------ + Only dashed above. + """ + assert isinstance(dash, basestring) + assert len(dash) == 1 + dashes = dash * len(string) + if above: + self.print_indented(dashes, indent) + self.print_indented(string, indent) + if below: + self.print_indented(dashes, indent) + + def print_h1(self, text): + """ + Print a primary header at indentation level 0. + + For example: + + >>> ui = textui() + >>> ui.print_h1('A primary header') + ================ + A primary header + ================ + """ + self.print_dashed(text, indent=0, dash='=') + + def print_h2(self, text): + """ + Print a secondary header at indentation level 1. + + For example: + + >>> ui = textui() + >>> ui.print_h2('A secondary header') + ------------------ + A secondary header + ------------------ + """ + self.print_dashed(text, indent=1, dash='-') + + def print_name(self, name): + """ + Print a command name. + + The typical use for this is to mark the start of output from a + command. For example, a hypothetical ``show_status`` command would + output something like this: + + >>> ui = textui() + >>> ui.print_name('show_status') + ------------ + show-status: + ------------ + """ + self.print_dashed('%s:' % to_cli(name)) + + def print_count(self, count, singular, plural=None): + """ + Print a summary count. + + The typical use for this is to print the number of items returned + by a command, especially when this return count can vary. This + preferably should be used as a summary and should be the final text + a command outputs. + + For example: + + >>> ui = textui() + >>> ui.print_count(1, '%d goose', '%d geese') + ------- + 1 goose + ------- + >>> ui.print_count(['Don', 'Sue'], 'Found %d user', 'Found %d users') + ------------- + Found 2 users + ------------- + + If ``count`` is not an integer, it must be a list or tuple, and then + ``len(count)`` is used as the count. + """ + if type(count) is not int: + assert type(count) in (list, tuple) + count = len(count) + self.print_dashed( + self.choose_number(count, singular, plural) + ) + + def prompt(self, label, default=None, get_values=None): + """ + Prompt user for input. + """ + # TODO: Add tab completion using readline + if default is None: + prompt = u'%s: ' % label + else: + prompt = u'%s [%s]: ' % (label, default) + return self.decode( + raw_input(self.encode(prompt)) + ) + + def prompt_password(self, label): + """ + Prompt user for a password. + """ + try: + while True: + pw1 = getpass.getpass('%s: ' % label) + pw2 = getpass.getpass('Enter again to verify: ') + if pw1 == pw2: + return self.decode(pw1) + print ' ** Passwords do not match. Please enter again. **' + except KeyboardInterrupt: + print '' + print ' ** Cancelled. **' + + +class help(frontend.Application): + '''Display help on a command.''' + + takes_args = ['command?'] + + def run(self, command): + textui = self.Backend.textui + if command is None: + self.print_commands() + return + key = str(command) + if key not in self.application: + raise errors.UnknownHelpError(key) + cmd = self.application[key] + print 'Purpose: %s' % cmd.doc + self.application.build_parser(cmd).print_help() + + def print_commands(self): + std = set(self.Command) - set(self.Application) + print '\nStandard IPA commands:' + for key in sorted(std): + cmd = self.api.Command[key] + self.print_cmd(cmd) + print '\nSpecial CLI commands:' + for cmd in self.api.Application(): + self.print_cmd(cmd) + print '\nUse the --help option to see all the global options' + print '' + + def print_cmd(self, cmd): + print ' %s %s' % ( + to_cli(cmd.name).ljust(self.application.mcl), + cmd.doc, + ) + + +class console(frontend.Application): + """Start the IPA interactive Python console.""" + + def run(self): + code.interact( + '(Custom IPA interactive Python console)', + local=dict(api=self.api) + ) + + +class show_api(frontend.Application): + 'Show attributes on dynamic API object' + + takes_args = ('namespaces*',) + + def run(self, namespaces): + if namespaces is None: + names = tuple(self.api) + else: + for name in namespaces: + if name not in self.api: + raise errors.NoSuchNamespaceError(name) + names = namespaces + lines = self.__traverse(names) + ml = max(len(l[1]) for l in lines) + self.Backend.textui.print_name('run') + first = True + for line in lines: + if line[0] == 0 and not first: + print '' + if first: + first = False + print '%s%s %r' % ( + ' ' * line[0], + line[1].ljust(ml), + line[2], + ) + if len(lines) == 1: + s = '1 attribute shown.' + else: + s = '%d attributes show.' % len(lines) + self.Backend.textui.print_dashed(s) + + + def __traverse(self, names): + lines = [] + for name in names: + namespace = self.api[name] + self.__traverse_namespace('%s' % name, namespace, lines) + return lines + + def __traverse_namespace(self, name, namespace, lines, tab=0): + lines.append((tab, name, namespace)) + for member_name in namespace: + member = namespace[member_name] + lines.append((tab + 1, member_name, member)) + if not hasattr(member, '__iter__'): + continue + for n in member: + attr = member[n] + if isinstance(attr, plugable.NameSpace) and len(attr) > 0: + self.__traverse_namespace(n, attr, lines, tab + 2) + + +cli_application_commands = ( + help, + console, + show_api, +) + + +class KWCollector(object): + def __init__(self): + object.__setattr__(self, '_KWCollector__d', {}) + + def __setattr__(self, name, value): + if name in self.__d: + v = self.__d[name] + if type(v) is tuple: + value = v + (value,) + else: + value = (v, value) + self.__d[name] = value + object.__setattr__(self, name, value) + + def __todict__(self): + return dict(self.__d) + + +class CLI(object): + """ + All logic for dispatching over command line interface. + """ + + __d = None + __mcl = None + + def __init__(self, api, argv): + self.api = api + self.argv = tuple(argv) + self.__done = set() + + def run(self): + """ + Call `CLI.run_real` in a try/except. + """ + self.bootstrap() + try: + self.run_real() + except KeyboardInterrupt: + print '' + self.api.log.info('operation aborted') + sys.exit() + except errors.IPAError, e: + self.api.log.error(unicode(e)) + sys.exit(e.faultCode) + + def run_real(self): + """ + Parse ``argv`` and potentially run a command. + + This method requires several initialization steps to be completed + first, all of which all automatically called with a single call to + `CLI.finalize()`. The initialization steps are broken into separate + methods simply to make it easy to write unit tests. + + The initialization involves these steps: + + 1. `CLI.parse_globals` parses the global options, which get stored + in ``CLI.options``, and stores the remaining args in + ``CLI.cmd_argv``. + + 2. `CLI.bootstrap` initializes the environment information in + ``CLI.api.env``. + + 3. `CLI.load_plugins` registers all plugins, including the + CLI-specific plugins. + + 4. `CLI.finalize` instantiates all plugins and performs the + remaining initialization needed to use the `plugable.API` + instance. + """ + self.__doing('run_real') + self.finalize() + if self.api.env.mode == 'unit_test': + return + if len(self.cmd_argv) < 1: + self.api.Command.help() + return + key = self.cmd_argv[0] + if key not in self: + raise errors.UnknownCommandError(key) + self.run_cmd(self[key]) + + # FIXME: Stuff that might need special handling still: +# # Now run the command +# try: +# ret = cmd(**kw) +# if callable(cmd.output_for_cli): +# (args, options) = cmd.params_2_args_options(kw) +# cmd.output_for_cli(self.api.Backend.textui, ret, *args, **options) +# return 0 +# except socket.error, e: +# print e[1] +# return 1 +# except errors.GenericError, err: +# code = getattr(err,'faultCode',None) +# faultString = getattr(err,'faultString',None) +# if not code: +# raise err +# if code < errors.IPA_ERROR_BASE: +# print "%s: %s" % (code, faultString) +# else: +# print "%s: %s" % (code, getattr(err,'__doc__','')) +# return 1 +# except StandardError, e: +# print e +# return 2 + + def finalize(self): + """ + Fully initialize ``CLI.api`` `plugable.API` instance. + + This method first calls `CLI.load_plugins` to perform some dependant + initialization steps, after which `plugable.API.finalize` is called. + + Finally, the CLI-specific commands are passed a reference to this + `CLI` instance by calling `frontend.Application.set_application`. + """ + self.__doing('finalize') + self.load_plugins() + self.api.finalize() + for a in self.api.Application(): + a.set_application(self) + assert self.__d is None + self.__d = dict( + (c.name.replace('_', '-'), c) for c in self.api.Command() + ) + self.textui = self.api.Backend.textui + + def load_plugins(self): + """ + Load all standard plugins plus the CLI-specific plugins. + + This method first calls `CLI.bootstrap` to preform some dependant + initialization steps, after which `plugable.API.load_plugins` is + called. + + Finally, all the CLI-specific plugins are registered. + """ + self.__doing('load_plugins') + if 'bootstrap' not in self.__done: + self.bootstrap() + self.api.load_plugins() + for klass in cli_application_commands: + self.api.register(klass) + self.api.register(textui) + + def bootstrap(self): + """ + Initialize the ``CLI.api.env`` environment variables. + + This method first calls `CLI.parse_globals` to perform some dependant + initialization steps. Then, using environment variables that may have + been passed in the global options, the ``overrides`` are constructed + and `plugable.API.bootstrap` is called. + """ + self.__doing('bootstrap') + self.parse_globals() + self.api.bootstrap_with_global_options(self.options, context='cli') + + def parse_globals(self): + """ + Parse out the global options. + + This method parses the global options out of the ``CLI.argv`` instance + attribute, after which two new instance attributes are available: + + 1. ``CLI.options`` - an ``optparse.Values`` instance containing + the global options. + + 2. ``CLI.cmd_argv`` - a tuple containing the remainder of + ``CLI.argv`` after the global options have been consumed. + + The common global options are added using the + `util.add_global_options` function. + """ + self.__doing('parse_globals') + parser = optparse.OptionParser() + parser.disable_interspersed_args() + parser.add_option('-a', dest='prompt_all', action='store_true', + help='Prompt for all missing options interactively') + parser.add_option('-n', dest='interactive', action='store_false', + help='Don\'t prompt for any options interactively') + parser.set_defaults( + prompt_all=False, + interactive=True, + ) + util.add_global_options(parser) + (options, args) = parser.parse_args(list(self.argv)) + self.options = options + self.cmd_argv = tuple(args) + + def __doing(self, name): + if name in self.__done: + raise StandardError( + '%s.%s() already called' % (self.__class__.__name__, name) + ) + self.__done.add(name) + + def run_cmd(self, cmd): + kw = self.parse(cmd) + if self.options.interactive: + self.prompt_interactively(cmd, kw) + self.prompt_for_passwords(cmd, kw) + self.set_defaults(cmd, kw) + result = cmd(**kw) + if callable(cmd.output_for_cli): + for param in cmd.params(): + if isinstance(param, Password): + try: + del kw[param.name] + except KeyError: + pass + (args, options) = cmd.params_2_args_options(kw) + cmd.output_for_cli(self.api.Backend.textui, result, *args, **options) + + def set_defaults(self, cmd, kw): + for param in cmd.params(): + if not kw.get(param.name): + value = param.get_default(**kw) + if value: + kw[param.name] = value + + def prompt_for_passwords(self, cmd, kw): + for param in cmd.params(): + if 'password' not in param.flags: + continue + if kw.get(param.name, False) is True or param.name in cmd.args: + kw[param.name] = self.textui.prompt_password( + param.cli_name + ) + else: + kw.pop(param.name, None) + return kw + + def prompt_interactively(self, cmd, kw): + """ + Interactively prompt for missing or invalid values. + + By default this method will only prompt for *required* Param that + have a missing or invalid value. However, if + ``CLI.options.prompt_all`` is True, this method will prompt for any + params that have a missing or required values, even if the param is + optional. + """ + for param in cmd.params(): + if 'password' in param.flags: + continue + elif param.name not in kw: + if not (param.required or self.options.prompt_all): + continue + default = param.get_default(**kw) + error = None + while True: + if error is not None: + print '>>> %s: %s' % (param.cli_name, error) + raw = self.textui.prompt(param.cli_name, default) + try: + value = param(raw, **kw) + if value is not None: + kw[param.name] = value + break + except errors.ValidationError, e: + error = e.error + return kw + +# FIXME: This should be done as the plugins are loaded +# if self.api.env.server_context: +# try: +# import krbV +# import ldap +# from ipaserver import conn +# from ipaserver.servercore import context +# krbccache = krbV.default_context().default_ccache().name +# context.conn = conn.IPAConn(self.api.env.ldaphost, self.api.env.ldapport, krbccache) +# except ImportError: +# print >> sys.stderr, "There was a problem importing a Python module: %s" % sys.exc_value +# return 2 +# except ldap.LDAPError, e: +# print >> sys.stderr, "There was a problem connecting to the LDAP server: %s" % e[0].get('desc') +# return 2 +# ret = cmd(**kw) +# if callable(cmd.output_for_cli): +# return cmd.output_for_cli(ret) +# else: +# return 0 + + def parse(self, cmd): + parser = self.build_parser(cmd) + (kwc, args) = parser.parse_args( + list(self.cmd_argv[1:]), KWCollector() + ) + kw = kwc.__todict__() + arg_kw = cmd.args_to_kw(*args) + assert set(arg_kw).intersection(kw) == set() + kw.update(arg_kw) + return kw + + def build_parser(self, cmd): + parser = optparse.OptionParser( + usage=self.get_usage(cmd), + ) + for option in cmd.options(): + kw = dict( + dest=option.name, + help=option.doc, + ) + if 'password' in option.flags: + kw['action'] = 'store_true' + elif option.type is bool: + if option.default is True: + kw['action'] = 'store_false' + else: + kw['action'] = 'store_true' + else: + kw['metavar'] = metavar=option.__class__.__name__.upper() + o = optparse.make_option('--%s' % to_cli(option.cli_name), **kw) + parser.add_option(o) + return parser + + def get_usage(self, cmd): + return ' '.join(self.get_usage_iter(cmd)) + + def get_usage_iter(self, cmd): + yield 'Usage: %%prog [global-options] %s' % to_cli(cmd.name) + for arg in cmd.args(): + if 'password' in arg.flags: + continue + name = to_cli(arg.cli_name).upper() + if arg.multivalue: + name = '%s...' % name + if arg.required: + yield name + else: + yield '[%s]' % name + + def __get_mcl(self): + """ + Returns the Max Command Length. + """ + if self.__mcl is None: + if self.__d is None: + return None + self.__mcl = max(len(k) for k in self.__d) + return self.__mcl + mcl = property(__get_mcl) + + def isdone(self, name): + """ + Return True in method named ``name`` has already been called. + """ + return name in self.__done + + def __contains__(self, key): + assert self.__d is not None, 'you must call finalize() first' + return key in self.__d + + def __getitem__(self, key): + assert self.__d is not None, 'you must call finalize() first' + return self.__d[key] diff --git a/ipalib/config.py b/ipalib/config.py new file mode 100644 index 00000000..3544331d --- /dev/null +++ b/ipalib/config.py @@ -0,0 +1,529 @@ +# Authors: +# Martin Nagy <mnagy@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Process-wide static configuration and environment. + +The standard run-time instance of the `Env` class is initialized early in the +`ipalib` process and is then locked into a read-only state, after which no +further changes can be made to the environment throughout the remaining life +of the process. + +For the per-request thread-local information, see `ipalib.request`. +""" + +from ConfigParser import RawConfigParser, ParsingError +from types import NoneType +import os +from os import path +import sys + +from base import check_name +from constants import CONFIG_SECTION +from constants import TYPE_ERROR, OVERRIDE_ERROR, SET_ERROR, DEL_ERROR + + +class Env(object): + """ + Store and retrieve environment variables. + + First an foremost, the `Env` class provides a handy container for + environment variables. These variables can be both set *and* retrieved + either as attributes *or* as dictionary items. + + For example, you can set a variable as an attribute: + + >>> env = Env() + >>> env.attr = 'I was set as an attribute.' + >>> env.attr + 'I was set as an attribute.' + >>> env['attr'] # Also retrieve as a dictionary item + 'I was set as an attribute.' + + Or you can set a variable as a dictionary item: + + >>> env['item'] = 'I was set as a dictionary item.' + >>> env['item'] + 'I was set as a dictionary item.' + >>> env.item # Also retrieve as an attribute + 'I was set as a dictionary item.' + + The variable names must be valid lower-case Python identifiers that neither + start nor end with an underscore. If your variable name doesn't meet these + criteria, a ``ValueError`` will be raised when you try to set the variable + (compliments of the `base.check_name()` function). For example: + + >>> env.BadName = 'Wont work as an attribute' + Traceback (most recent call last): + ... + ValueError: name must match '^[a-z][_a-z0-9]*[a-z0-9]$'; got 'BadName' + >>> env['BadName'] = 'Also wont work as a dictionary item' + Traceback (most recent call last): + ... + ValueError: name must match '^[a-z][_a-z0-9]*[a-z0-9]$'; got 'BadName' + + The variable values can be ``str``, ``int``, or ``float`` instances, or the + ``True``, ``False``, or ``None`` constants. When the value provided is an + ``str`` instance, some limited automatic type conversion is performed, which + allows values of specific types to be set easily from configuration files or + command-line options. + + So in addition to their actual values, the ``True``, ``False``, and ``None`` + constants can be specified with an ``str`` equal to what ``repr()`` would + return. For example: + + >>> env.true = True + >>> env.also_true = 'True' # Equal to repr(True) + >>> env.true + True + >>> env.also_true + True + + Note that the automatic type conversion is case sensitive. For example: + + >>> env.not_false = 'false' # Not equal to repr(False)! + >>> env.not_false + 'false' + + If an ``str`` value looks like an integer, it's automatically converted to + the ``int`` type. Likewise, if an ``str`` value looks like a floating-point + number, it's automatically converted to the ``float`` type. For example: + + >>> env.lucky = '7' + >>> env.lucky + 7 + >>> env.three_halves = '1.5' + >>> env.three_halves + 1.5 + + Leading and trailing white-space is automatically stripped from ``str`` + values. For example: + + >>> env.message = ' Hello! ' # Surrounded by double spaces + >>> env.message + 'Hello!' + >>> env.number = ' 42 ' # Still converted to an int + >>> env.number + 42 + >>> env.false = ' False ' # Still equal to repr(False) + >>> env.false + False + + Also, empty ``str`` instances are converted to ``None``. For example: + + >>> env.empty = '' + >>> env.empty is None + True + + `Env` variables are all set-once (first-one-wins). Once a variable has been + set, trying to override it will raise an ``AttributeError``. For example: + + >>> env.date = 'First' + >>> env.date = 'Second' + Traceback (most recent call last): + ... + AttributeError: cannot override Env.date value 'First' with 'Second' + + An `Env` instance can be *locked*, after which no further variables can be + set. Trying to set variables on a locked `Env` instance will also raise + an ``AttributeError``. For example: + + >>> env = Env() + >>> env.okay = 'This will work.' + >>> env.__lock__() + >>> env.nope = 'This wont work!' + Traceback (most recent call last): + ... + AttributeError: locked: cannot set Env.nope to 'This wont work!' + + `Env` instances also provide standard container emulation for membership + testing, counting, and iteration. For example: + + >>> env = Env() + >>> 'key1' in env # Has key1 been set? + False + >>> env.key1 = 'value 1' + >>> 'key1' in env + True + >>> env.key2 = 'value 2' + >>> len(env) # How many variables have been set? + 2 + >>> list(env) # What variables have been set? + ['key1', 'key2'] + + Lastly, in addition to all the handy container functionality, the `Env` + class provides high-level methods for bootstraping a fresh `Env` instance + into one containing all the run-time and configuration information needed + by the built-in freeIPA plugins. + + These are the `Env` bootstraping methods, in the order they must be called: + + 1. `Env._bootstrap()` - initialize the run-time variables and then + merge-in variables specified on the command-line. + + 2. `Env._finalize_core()` - merge-in variables from the configuration + files and then merge-in variables from the internal defaults, after + which at least all the standard variables will be set. After this + method is called, the plugins will be loaded, during which + third-party plugins can merge-in defaults for additional variables + they use (likely using the `Env._merge()` method). + + 3. `Env._finalize()` - one last chance to merge-in variables and then + the instance is locked. After this method is called, no more + environment variables can be set during the remaining life of the + process. + + However, normally none of these three bootstraping methods are called + directly and instead only `plugable.API.bootstrap()` is called, which itself + takes care of correctly calling the `Env` bootstrapping methods. + """ + + __locked = False + + def __init__(self): + object.__setattr__(self, '_Env__d', {}) + object.__setattr__(self, '_Env__done', set()) + + def __lock__(self): + """ + Prevent further changes to environment. + """ + if self.__locked is True: + raise StandardError( + '%s.__lock__() already called' % self.__class__.__name__ + ) + object.__setattr__(self, '_Env__locked', True) + + def __islocked__(self): + """ + Return ``True`` if locked. + """ + return self.__locked + + def __setattr__(self, name, value): + """ + Set the attribute named ``name`` to ``value``. + + This just calls `Env.__setitem__()`. + """ + self[name] = value + + def __setitem__(self, key, value): + """ + Set ``key`` to ``value``. + """ + if self.__locked: + raise AttributeError( + SET_ERROR % (self.__class__.__name__, key, value) + ) + check_name(key) + if key in self.__d: + raise AttributeError(OVERRIDE_ERROR % + (self.__class__.__name__, key, self.__d[key], value) + ) + assert not hasattr(self, key) + if isinstance(value, basestring): + value = str(value.strip()) + m = { + 'True': True, + 'False': False, + 'None': None, + '': None, + } + if value in m: + value = m[value] + elif value.isdigit(): + value = int(value) + else: + try: + value = float(value) + except (TypeError, ValueError): + pass + assert type(value) in (str, int, float, bool, NoneType) + object.__setattr__(self, key, value) + self.__d[key] = value + + def __getitem__(self, key): + """ + Return the value corresponding to ``key``. + """ + return self.__d[key] + + def __delattr__(self, name): + """ + Raise an ``AttributeError`` (deletion is never allowed). + + For example: + + >>> env = Env() + >>> env.name = 'A value' + >>> del env.name + Traceback (most recent call last): + ... + AttributeError: locked: cannot delete Env.name + """ + raise AttributeError( + DEL_ERROR % (self.__class__.__name__, name) + ) + + def __contains__(self, key): + """ + Return True if instance contains ``key``; otherwise return False. + """ + return key in self.__d + + def __len__(self): + """ + Return number of variables currently set. + """ + return len(self.__d) + + def __iter__(self): + """ + Iterate through keys in ascending order. + """ + for key in sorted(self.__d): + yield key + + def _merge(self, **kw): + """ + Merge variables from ``kw`` into the environment. + + Any variables in ``kw`` that have already been set will be ignored + (meaning this method will *not* try to override them, which would raise + an exception). + + This method returns a ``(num_set, num_total)`` tuple containing first + the number of variables that were actually set, and second the total + number of variables that were provided. + + For example: + + >>> env = Env() + >>> env._merge(one=1, two=2) + (2, 2) + >>> env._merge(one=1, three=3) + (1, 2) + >>> env._merge(one=1, two=2, three=3) + (0, 3) + + Also see `Env._merge_from_file()`. + + :param kw: Variables provides as keyword arguments. + """ + i = 0 + for (key, value) in kw.iteritems(): + if key not in self: + self[key] = value + i += 1 + return (i, len(kw)) + + def _merge_from_file(self, config_file): + """ + Merge variables from ``config_file`` into the environment. + + Any variables in ``config_file`` that have already been set will be + ignored (meaning this method will *not* try to override them, which + would raise an exception). + + If ``config_file`` does not exist or is not a regular file, or if there + is an error parsing ``config_file``, ``None`` is returned. + + Otherwise this method returns a ``(num_set, num_total)`` tuple + containing first the number of variables that were actually set, and + second the total number of variables found in ``config_file``. + + This method will raise a ``ValueError`` if ``config_file`` is not an + absolute path. For example: + + >>> env = Env() + >>> env._merge_from_file('my/config.conf') + Traceback (most recent call last): + ... + ValueError: config_file must be an absolute path; got 'my/config.conf' + + Also see `Env._merge()`. + + :param config_file: Absolute path of the configuration file to load. + """ + if path.abspath(config_file) != config_file: + raise ValueError( + 'config_file must be an absolute path; got %r' % config_file + ) + if not path.isfile(config_file): + return + parser = RawConfigParser() + try: + parser.read(config_file) + except ParsingError: + return + if not parser.has_section(CONFIG_SECTION): + parser.add_section(CONFIG_SECTION) + items = parser.items(CONFIG_SECTION) + if len(items) == 0: + return (0, 0) + i = 0 + for (key, value) in items: + if key not in self: + self[key] = value + i += 1 + return (i, len(items)) + + def __doing(self, name): + if name in self.__done: + raise StandardError( + '%s.%s() already called' % (self.__class__.__name__, name) + ) + self.__done.add(name) + + def __do_if_not_done(self, name): + if name not in self.__done: + getattr(self, name)() + + def _isdone(self, name): + return name in self.__done + + def _bootstrap(self, **overrides): + """ + Initialize basic environment. + + This method will perform the following steps: + + 1. Initialize certain run-time variables. These run-time variables + are strictly determined by the external environment the process + is running in; they cannot be specified on the command-line nor + in the configuration files. + + 2. Merge-in the variables in ``overrides`` by calling + `Env._merge()`. The intended use of ``overrides`` is to merge-in + variables specified on the command-line. + + 3. Intelligently fill-in the *in_tree*, *context*, *conf*, and + *conf_default* variables if they haven't been set already. + + Also see `Env._finalize_core()`, the next method in the bootstrap + sequence. + + :param overrides: Variables specified via command-line options. + """ + self.__doing('_bootstrap') + + # Set run-time variables: + self.ipalib = path.dirname(path.abspath(__file__)) + self.site_packages = path.dirname(self.ipalib) + self.script = path.abspath(sys.argv[0]) + self.bin = path.dirname(self.script) + self.home = path.abspath(os.environ['HOME']) + self.dot_ipa = path.join(self.home, '.ipa') + self._merge(**overrides) + if 'in_tree' not in self: + if self.bin == self.site_packages and \ + path.isfile(path.join(self.bin, 'setup.py')): + self.in_tree = True + else: + self.in_tree = False + if 'context' not in self: + self.context = 'default' + if self.in_tree: + base = self.dot_ipa + else: + base = path.join('/', 'etc', 'ipa') + if 'conf' not in self: + self.conf = path.join(base, '%s.conf' % self.context) + if 'conf_default' not in self: + self.conf_default = path.join(base, 'default.conf') + if 'conf_dir' not in self: + self.conf_dir = base + + def _finalize_core(self, **defaults): + """ + Complete initialization of standard IPA environment. + + This method will perform the following steps: + + 1. Call `Env._bootstrap()` if it hasn't already been called. + + 2. Merge-in variables from the configuration file ``self.conf`` + (if it exists) by calling `Env._merge_from_file()`. + + 3. Merge-in variables from the defaults configuration file + ``self.conf_default`` (if it exists) by calling + `Env._merge_from_file()`. + + 4. Intelligently fill-in the *in_server* and *log* variables + if they haven't already been set. + + 5. Merge-in the variables in ``defaults`` by calling `Env._merge()`. + In normal circumstances ``defaults`` will simply be those + specified in `constants.DEFAULT_CONFIG`. + + After this method is called, all the environment variables used by all + the built-in plugins will be available. As such, this method should be + called *before* any plugins are loaded. + + After this method has finished, the `Env` instance is still writable + so that 3rd-party plugins can set variables they may require as the + plugins are registered. + + Also see `Env._finalize()`, the final method in the bootstrap sequence. + + :param defaults: Internal defaults for all built-in variables. + """ + self.__doing('_finalize_core') + self.__do_if_not_done('_bootstrap') + if self.__d.get('mode', None) != 'dummy': + self._merge_from_file(self.conf) + self._merge_from_file(self.conf_default) + if 'in_server' not in self: + self.in_server = (self.context == 'server') + if 'log' not in self: + name = '%s.log' % self.context + if self.in_tree or self.context == 'cli': + self.log = path.join(self.dot_ipa, 'log', name) + else: + self.log = path.join('/', 'var', 'log', 'ipa', name) + self._merge(**defaults) + + def _finalize(self, **lastchance): + """ + Finalize and lock environment. + + This method will perform the following steps: + + 1. Call `Env._finalize_core()` if it hasn't already been called. + + 2. Merge-in the variables in ``lastchance`` by calling + `Env._merge()`. + + 3. Lock this `Env` instance, after which no more environment + variables can be set on this instance. Aside from unit-tests + and example code, normally only one `Env` instance is created, + which means that after this step, no more variables can be set + during the remaining life of the process. + + This method should be called after all plugins have been loaded and + after `plugable.API.finalize()` has been called. + + :param lastchance: Any final variables to merge-in before locking. + """ + self.__doing('_finalize') + self.__do_if_not_done('_finalize_core') + self._merge(**lastchance) + self.__lock__() diff --git a/ipalib/constants.py b/ipalib/constants.py new file mode 100644 index 00000000..5687c53e --- /dev/null +++ b/ipalib/constants.py @@ -0,0 +1,153 @@ +# Authors: +# Martin Nagy <mnagy@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +All constants centralised in one file. +""" + +# The parameter system treats all these values as None: +NULLS = (None, '', u'', tuple(), []) + +# regular expression NameSpace member names must match: +NAME_REGEX = r'^[a-z][_a-z0-9]*[a-z0-9]$' + +# Format for ValueError raised when name does not match above regex: +NAME_ERROR = 'name must match %r; got %r' + +# Standard format for TypeError message: +TYPE_ERROR = '%s: need a %r; got %r (a %r)' + +# Stardard format for TypeError message when a callable is expected: +CALLABLE_ERROR = '%s: need a callable; got %r (which is a %r)' + +# Standard format for StandardError message when overriding an attribute: +OVERRIDE_ERROR = 'cannot override %s.%s value %r with %r' + +# Standard format for AttributeError message when a read-only attribute is +# already locked: +SET_ERROR = 'locked: cannot set %s.%s to %r' +DEL_ERROR = 'locked: cannot delete %s.%s' + +# Used for a tab (or indentation level) when formatting for CLI: +CLI_TAB = ' ' # Two spaces + +# The section to read in the config files, i.e. [global] +CONFIG_SECTION = 'global' + + +# Log format for console output +LOG_FORMAT_STDERR = ': '.join([ + '%(name)s', + '%(levelname)s', + '%(message)s', +]) + + +# Log format for console output when env.dubug is True: +LOG_FORMAT_STDERR_DEBUG = ' '.join([ + '%(levelname)s', + '%(message)r', + '%(lineno)d', + '%(filename)s', +]) + + +# Tab-delimited log format for file (easy to opened in a spreadsheet): +LOG_FORMAT_FILE = '\t'.join([ + '%(asctime)s', + '%(levelname)s', + '%(message)r', # Using %r for repr() so message is a single line + '%(lineno)d', + '%(pathname)s', +]) + + +# The default configuration for api.env +# This is a tuple instead of a dict so that it is immutable. +# To create a dict with this config, just "d = dict(DEFAULT_CONFIG)". +DEFAULT_CONFIG = ( + # Domain, realm, basedn: + ('domain', 'example.com'), + ('realm', 'EXAMPLE.COM'), + ('basedn', 'dc=example,dc=com'), + + # LDAP containers: + ('container_accounts', 'cn=accounts'), + ('container_user', 'cn=users,cn=accounts'), + ('container_group', 'cn=groups,cn=accounts'), + ('container_service', 'cn=services,cn=accounts'), + ('container_host', 'cn=computers,cn=accounts'), + ('container_hostgroup', 'cn=hostgroups,cn=accounts'), + ('container_automount', 'cn=automount'), + + # Ports, hosts, and URIs: + ('lite_xmlrpc_port', 8888), + ('lite_webui_port', 9999), + ('xmlrpc_uri', 'http://localhost:8888'), + ('ldap_uri', 'ldap://localhost:389'), + ('ldap_host', 'localhost'), + ('ldap_port', 389), + + # Debugging: + ('verbose', False), + ('debug', False), + ('mode', 'production'), + + # Logging: + ('log_format_stderr', LOG_FORMAT_STDERR), + ('log_format_stderr_debug', LOG_FORMAT_STDERR_DEBUG), + ('log_format_file', LOG_FORMAT_FILE), + + # ******************************************************** + # The remaining keys are never set from the values here! + # ******************************************************** + # + # Env.__init__() or Env._bootstrap() or Env._finalize_core() + # will have filled in all the keys below by the time DEFAULT_CONFIG + # is merged in, so the values below are never actually used. They are + # listed both to provide a big picture and also so DEFAULT_CONFIG contains + # at least all the keys that should be present after Env._finalize_core() + # is called. + # + # Each environment variable below is sent to ``object``, which just happens + # to be an invalid value for an environment variable, so if for some reason + # any of these keys were set from the values here, an exception will be + # raised. + + # Set in Env.__init__(): + ('ipalib', object), # The directory containing ipalib/__init__.py + ('site_packages', object), # The directory contaning ipalib + ('script', object), # sys.argv[0] + ('bin', object), # The directory containing script + ('home', object), # The home directory of user underwhich process is running + ('dot_ipa', object), # ~/.ipa directory + + # Set in Env._bootstrap(): + ('in_tree', object), # Whether or not running in-tree (bool) + ('context', object), # Name of context, default is 'default' + ('conf', object), # Path to config file + ('conf_default', object), # Path to common default config file + ('conf_dir', object), # Directory containing config files + + # Set in Env._finalize_core(): + ('in_server', object), # Whether or not running in-server (bool) + ('log', object), # Path to log file + +) diff --git a/ipalib/crud.py b/ipalib/crud.py new file mode 100644 index 00000000..345fc270 --- /dev/null +++ b/ipalib/crud.py @@ -0,0 +1,149 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Base classes for standard CRUD operations. +""" + +import backend, frontend, errors + + +class Add(frontend.Method): + def get_args(self): + yield self.obj.primary_key + for arg in self.takes_args: + yield arg + + def get_options(self): + for param in self.obj.params_minus_pk(): + yield param + for option in self.takes_options: + yield option + + +class Get(frontend.Method): + def get_args(self): + yield self.obj.primary_key + + +class Del(frontend.Method): + def get_args(self): + yield self.obj.primary_key + + def get_options(self): + for option in self.takes_options: + yield option + + +class Mod(frontend.Method): + def get_args(self): + yield self.obj.primary_key + + def get_options(self): + for param in self.obj.params_minus_pk(): + yield param.clone(required=False, query=True) + for option in self.takes_options: + yield option + + +class Find(frontend.Method): + def get_args(self): + yield self.obj.primary_key + + def get_options(self): + for param in self.obj.params_minus_pk(): + yield param.clone(required=False, query=True) + for option in self.takes_options: + yield option + + +class CrudBackend(backend.Backend): + """ + Base class defining generic CRUD backend API. + """ + + def create(self, **kw): + """ + Create a new entry. + + This method should take key word arguments representing the + attributes the created entry will have. + + If this methods constructs the primary_key internally, it should raise + an exception if the primary_key was passed. Likewise, if this method + requires the primary_key to be passed in from the caller, it should + raise an exception if the primary key was *not* passed. + + This method should return a dict of the exact entry as it was created + in the backing store, including any automatically created attributes. + """ + raise NotImplementedError('%s.create()' % self.name) + + def retrieve(self, primary_key, attributes): + """ + Retrieve an existing entry. + + This method should take a two arguments: the primary_key of the + entry in question and a list of the attributes to be retrieved. + If the list of attributes is None then all non-operational + attributes will be returned. + + If such an entry exists, this method should return a dict + representing that entry. If no such entry exists, this method + should return None. + """ + raise NotImplementedError('%s.retrieve()' % self.name) + + def update(self, primary_key, **kw): + """ + Update an existing entry. + + This method should take one required argument, the primary_key of the + entry to modify, plus optional keyword arguments for each of the + attributes being updated. + + This method should return a dict representing the entry as it now + exists in the backing store. If no such entry exists, this method + should return None. + """ + raise NotImplementedError('%s.update()' % self.name) + + def delete(self, primary_key): + """ + Delete an existing entry. + + This method should take one required argument, the primary_key of the + entry to delete. + """ + raise NotImplementedError('%s.delete()' % self.name) + + def search(self, **kw): + """ + Return entries matching specific criteria. + + This method should take keyword arguments representing the search + criteria. If a key is the name of an entry attribute, the value + should be treated as a filter on that attribute. The meaning of + keys outside this namespace is left to the implementation. + + This method should return and iterable containing the matched + entries, where each entry is a dict. If no entries are matched, + this method should return an empty iterable. + """ + raise NotImplementedError('%s.search()' % self.name) diff --git a/ipalib/errors.py b/ipalib/errors.py new file mode 100644 index 00000000..beb6342d --- /dev/null +++ b/ipalib/errors.py @@ -0,0 +1,465 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty inmsgion +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +All custom errors raised by `ipalib` package. + +Also includes a few utility functions for raising exceptions. +""" + +IPA_ERROR_BASE = 1000 + +TYPE_FORMAT = '%s: need a %r; got %r' + +def raise_TypeError(value, type_, name): + """ + Raises a TypeError with a nicely formatted message and helpful attributes. + + The TypeError raised will have three custom attributes: + + ``value`` - The value (of incorrect type) passed as argument. + + ``type`` - The type expected for the argument. + + ``name`` - The name (identifier) of the argument in question. + + There is no edict that all TypeError should be raised with raise_TypeError, + but when it fits, use it... it makes the unit tests faster to write and + the debugging easier to read. + + Here is an example: + + >>> raise_TypeError(u'Hello, world!', str, 'message') + Traceback (most recent call last): + File "<stdin>", line 1, in <module> + File "ipalib/errors.py", line 65, in raise_TypeError + raise e + TypeError: message: need a <type 'str'>; got u'Hello, world!' + + :param value: The value (of incorrect type) passed as argument. + :param type_: The type expected for the argument. + :param name: The name (identifier) of the argument in question. + """ + + assert type(type_) is type, TYPE_FORMAT % ('type_', type, type_) + assert type(value) is not type_, 'value: %r is a %r' % (value, type_) + assert type(name) is str, TYPE_FORMAT % ('name', str, name) + e = TypeError(TYPE_FORMAT % (name, type_, value)) + setattr(e, 'value', value) + setattr(e, 'type', type_) + setattr(e, 'name', name) + raise e + + +def check_type(value, type_, name, allow_none=False): + assert type(name) is str, TYPE_FORMAT % ('name', str, name) + assert type(type_) is type, TYPE_FORMAT % ('type_', type, type_) + assert type(allow_none) is bool, TYPE_FORMAT % ('allow_none', bool, allow_none) + if value is None and allow_none: + return + if type(value) is not type_: + raise_TypeError(value, type_, name) + return value + + +def check_isinstance(value, type_, name, allow_none=False): + assert type(type_) is type, TYPE_FORMAT % ('type_', type, type_) + assert type(name) is str, TYPE_FORMAT % ('name', str, name) + assert type(allow_none) is bool, TYPE_FORMAT % ('allow_none', bool, allow_none) + if value is None and allow_none: + return + if not isinstance(value, type_): + raise_TypeError(value, type_, name) + return value + + +class IPAError(StandardError): + """ + Base class for all custom IPA errors. + + Use this base class for your custom IPA errors unless there is a + specific reason to subclass from AttributeError, KeyError, etc. + """ + + format = None + faultCode = 1 + + def __init__(self, *args): + self.args = args + + def __str__(self): + """ + Returns the string representation of this exception. + """ + return self.format % self.args + + +class InvocationError(IPAError): + pass + + +class UnknownCommandError(InvocationError): + format = 'unknown command "%s"' + +class NoSuchNamespaceError(InvocationError): + format = 'api has no such namespace: %s' + +def _(text): + return text + + +class SubprocessError(StandardError): + def __init__(self, returncode, argv): + self.returncode = returncode + self.argv = argv + StandardError.__init__(self, + 'return code %d from %r' % (returncode, argv) + ) + +class HandledError(StandardError): + """ + Base class for errors that can be raised across a remote procedure call. + """ + + code = 1 + + def __init__(self, message=None, **kw): + self.kw = kw + if message is None: + message = self.format % kw + StandardError.__init__(self, message) + + +class UnknownError(HandledError): + """ + Raised when the true error is not a handled error. + """ + + format = _('An unknown internal error has occurred') + + +class CommandError(HandledError): + """ + Raised when an unknown command is called client-side. + """ + format = _('Unknown command %(name)r') + + +class RemoteCommandError(HandledError): + format = 'Server at %(uri)r has no command %(name)r' + + +class UnknownHelpError(InvocationError): + format = 'no command nor topic "%s"' + + +class ArgumentError(IPAError): + """ + Raised when a command is called with wrong number of arguments. + """ + + format = '%s %s' + + def __init__(self, command, error): + self.command = command + self.error = error + IPAError.__init__(self, command.name, error) + + +class ValidationError(IPAError): + """ + Base class for all types of validation errors. + """ + + format = 'invalid %r value %r: %s' + + def __init__(self, name, value, error, index=None): + """ + :param name: The name of the value that failed validation. + :param value: The value that failed validation. + :param error: The error message describing the failure. + :param index: If multivalue, index of value in multivalue tuple + """ + assert type(name) is str + assert index is None or (type(index) is int and index >= 0) + self.name = name + self.value = value + self.error = error + self.index = index + IPAError.__init__(self, name, value, error) + + +class ConversionError(ValidationError): + """ + Raised when a value cannot be converted to the correct type. + """ + + def __init__(self, name, value, type_, index=None): + self.type = type_ + ValidationError.__init__(self, name, value, type_.conversion_error, + index=index, + ) + + +class RuleError(ValidationError): + """ + Raised when a value fails a validation rule. + """ + def __init__(self, name, value, error, rule, index=None): + assert callable(rule) + self.rule = rule + ValidationError.__init__(self, name, value, error, index=index) + + +class RequirementError(ValidationError): + """ + Raised when a required option was not provided. + """ + def __init__(self, name): + ValidationError.__init__(self, name, None, 'Required') + + +class RegistrationError(IPAError): + """ + Base class for errors that occur during plugin registration. + """ + + +class SubclassError(RegistrationError): + """ + Raised when registering a plugin that is not a subclass of one of the + allowed bases. + """ + msg = 'plugin %r not subclass of any base in %r' + + def __init__(self, cls, allowed): + self.cls = cls + self.allowed = allowed + + def __str__(self): + return self.msg % (self.cls, self.allowed) + + +class DuplicateError(RegistrationError): + """ + Raised when registering a plugin whose exact class has already been + registered. + """ + msg = '%r at %d was already registered' + + def __init__(self, cls): + self.cls = cls + + def __str__(self): + return self.msg % (self.cls, id(self.cls)) + + +class OverrideError(RegistrationError): + """ + Raised when override=False yet registering a plugin that overrides an + existing plugin in the same namespace. + """ + msg = 'unexpected override of %s.%s with %r (use override=True if intended)' + + def __init__(self, base, cls): + self.base = base + self.cls = cls + + def __str__(self): + return self.msg % (self.base.__name__, self.cls.__name__, self.cls) + + +class MissingOverrideError(RegistrationError): + """ + Raised when override=True yet no preexisting plugin with the same name + and base has been registered. + """ + msg = '%s.%s has not been registered, cannot override with %r' + + def __init__(self, base, cls): + self.base = base + self.cls = cls + + def __str__(self): + return self.msg % (self.base.__name__, self.cls.__name__, self.cls) + +class GenericError(IPAError): + """Base class for our custom exceptions""" + faultCode = 1000 + fromFault = False + def __str__(self): + try: + return str(self.args[0]['args'][0]) + except: + try: + return str(self.args[0]) + except: + return str(self.__dict__) + +class DatabaseError(GenericError): + """A database error has occurred""" + faultCode = 1001 + +class MidairCollision(GenericError): + """Change collided with another change""" + faultCode = 1002 + +class NotFound(GenericError): + """Entry not found""" + faultCode = 1003 + +class DuplicateEntry(GenericError): + """This entry already exists""" + faultCode = 1004 + +class MissingDN(GenericError): + """The distinguished name (DN) is missing""" + faultCode = 1005 + +class EmptyModlist(GenericError): + """No modifications to be performed""" + faultCode = 1006 + +class InputError(GenericError): + """Error on input""" + faultCode = 1007 + +class SameGroupError(InputError): + """You can't add a group to itself""" + faultCode = 1008 + +class NotGroupMember(InputError): + """This entry is not a member of the group""" + faultCode = 1009 + +class AdminsImmutable(InputError): + """The admins group cannot be renamed""" + faultCode = 1010 + +class UsernameTooLong(InputError): + """The requested username is too long""" + faultCode = 1011 + +class PrincipalError(GenericError): + """There is a problem with the kerberos principal""" + faultCode = 1012 + +class MalformedServicePrincipal(PrincipalError): + """The requested service principal is not of the form: service/fully-qualified host name""" + faultCode = 1013 + +class RealmMismatch(PrincipalError): + """The realm for the principal does not match the realm for this IPA server""" + faultCode = 1014 + +class PrincipalRequired(PrincipalError): + """You cannot remove IPA server service principals""" + faultCode = 1015 + +class InactivationError(GenericError): + """This entry cannot be inactivated""" + faultCode = 1016 + +class AlreadyActiveError(InactivationError): + """This entry is already locked""" + faultCode = 1017 + +class AlreadyInactiveError(InactivationError): + """This entry is already unlocked""" + faultCode = 1018 + +class HasNSAccountLock(InactivationError): + """This entry appears to have the nsAccountLock attribute in it so the Class of Service activation/inactivation will not work. You will need to remove the attribute nsAccountLock for this to work.""" + faultCode = 1019 + +class ConnectionError(GenericError): + """Connection to database failed""" + faultCode = 1020 + +class NoCCacheError(GenericError): + """No Kerberos credentials cache is available. Connection cannot be made""" + faultCode = 1021 + +class GSSAPIError(GenericError): + """GSSAPI Authorization error""" + faultCode = 1022 + +class ServerUnwilling(GenericError): + """Account inactivated. Server is unwilling to perform""" + faultCode = 1023 + +class ConfigurationError(GenericError): + """A configuration error occurred""" + faultCode = 1024 + +class DefaultGroup(ConfigurationError): + """You cannot remove the default users group""" + faultCode = 1025 + +class HostService(ConfigurationError): + """You must enroll a host in order to create a host service""" + faultCode = 1026 + +class InsufficientAccess(GenericError): + """You do not have permission to perform this task""" + faultCode = 1027 + +class InvalidUserPrincipal(GenericError): + """Invalid user principal""" + faultCode = 1028 + +class FunctionDeprecated(GenericError): + """Raised by a deprecated function""" + faultCode = 2000 + +def convertFault(fault): + """Convert a fault to the corresponding Exception type, if possible""" + code = getattr(fault,'faultCode',None) + if code is None: + return fault + for v in globals().values(): + if type(v) == type(Exception) and issubclass(v,GenericError) and \ + code == getattr(v,'faultCode',None): + ret = v(fault.faultString) + ret.fromFault = True + return ret + #otherwise... + return fault + +def listFaults(): + """Return a list of faults + + Returns a list of dictionaries whose keys are: + faultCode: the numeric code used in fault conversion + name: the name of the exception + desc: the description of the exception (docstring) + """ + ret = [] + for n,v in globals().items(): + if type(v) == type(Exception) and issubclass(v,GenericError): + code = getattr(v,'faultCode',None) + if code is None: + continue + info = {} + info['faultCode'] = code + info['name'] = n + info['desc'] = getattr(v,'__doc__',None) + ret.append(info) + ret.sort(lambda a,b: cmp(a['faultCode'],b['faultCode'])) + return ret diff --git a/ipalib/errors2.py b/ipalib/errors2.py new file mode 100644 index 00000000..7e2eea05 --- /dev/null +++ b/ipalib/errors2.py @@ -0,0 +1,580 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty inmsgion +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Custom exception classes (some which are RPC transparent). + +`PrivateError` and its subclasses are custom IPA excetions that will *never* be +forwarded in a Remote Procedure Call (RPC) response. + +On the other hand, `PublicError` and its subclasses can be forwarded in an RPC +response. These public errors each carry a unique integer error code as well as +a gettext translated error message (translated at the time the exception is +raised). The purpose of the public errors is to relay information about +*expected* user errors, service availability errors, and so on. They should +*never* be used for *unexpected* programmatic or run-time errors. + +For security reasons it is *extremely* important that arbitrary exceptions *not* +be forwarded in an RPC response. Unexpected exceptions can easily contain +compromising information in their error messages. Any time the server catches +any exception that isn't a `PublicError` subclass, it should raise an +`InternalError`, which itself always has the same, static error message (and +therefore cannot be populated with information about the true exception). + +The public errors are arranging into five main blocks of error code ranges: + + ============= ======================================== + Error codes Exceptions + ============= ======================================== + 1000 - 1999 `AuthenticationError` and its subclasses + 2000 - 2999 `AuthorizationError` and its subclasses + 3000 - 3999 `InvocationError` and its subclasses + 4000 - 4999 `ExecutionError` and its subclasses + 5000 - 5999 `GenericError` and its subclasses + ============= ======================================== + +Within these five blocks some sub-ranges are already allocated for certain types +of error messages, while others are reserved for future use. Here are the +current block assignments: + + - **900-5999** `PublicError` and its subclasses + + - **901 - 907** Assigned to special top-level public errors + + - **908 - 999** *Reserved for future use* + + - **1000 - 1999** `AuthenticationError` and its subclasses + + - **1001 - 1099** Open for general authentication errors + + - **1100 - 1199** `KerberosError` and its subclasses + + - **1200 - 1999** *Reserved for future use* + + - **2000 - 2999** `AuthorizationError` and its subclasses + + - **2001 - 2099** Open for general authorization errors + + - **2100 - 2199** `ACIError` and its subclasses + + - **2200 - 2999** *Reserved for future use* + + - **3000 - 3999** `InvocationError` and its subclasses + + - **3001 - 3099** Open for general invocation errors + + - **3100 - 3199** *Reserved for future use* + + - **4000 - 4999** `ExecutionError` and its subclasses + + - **4001 - 4099** Open for general execution errors + + - **4100 - 4199** `LDAPError` and its subclasses + + - **4300 - 4999** *Reserved for future use* + + - **5000 - 5999** `GenericError` and its subclasses + + - **5001 - 5099** Open for generic errors + + - **5100 - 5999** *Reserved for future use* +""" + +from inspect import isclass +from request import ugettext, ungettext +from constants import TYPE_ERROR + + +class PrivateError(StandardError): + """ + Base class for exceptions that are *never* forwarded in an RPC response. + """ + + format = '' + + def __init__(self, **kw): + self.message = self.format % kw + for (key, value) in kw.iteritems(): + assert not hasattr(self, key), 'conflicting kwarg %s.%s = %r' % ( + self.__class__.__name__, key, value, + ) + setattr(self, key, value) + StandardError.__init__(self, self.message) + + +class SubprocessError(PrivateError): + """ + Raised when ``subprocess.call()`` returns a non-zero exit status. + + This custom exception is needed because Python 2.4 doesn't have the + ``subprocess.CalledProcessError`` exception (which was added in Python 2.5). + + For example: + + >>> raise SubprocessError(returncode=2, argv=('ls', '-lh', '/no-foo/')) + Traceback (most recent call last): + ... + SubprocessError: return code 2 from ('ls', '-lh', '/no-foo/') + + The exit code of the sub-process is available via the ``returncode`` + instance attribute. For example: + + >>> e = SubprocessError(returncode=1, argv=('/bin/false',)) + >>> e.returncode + 1 + >>> e.argv # argv is also available + ('/bin/false',) + """ + + format = 'return code %(returncode)d from %(argv)r' + + +class PluginSubclassError(PrivateError): + """ + Raised when a plugin doesn't subclass from an allowed base. + + For example: + + >>> raise PluginSubclassError(plugin='bad', bases=('base1', 'base2')) + Traceback (most recent call last): + ... + PluginSubclassError: 'bad' not subclass of any base in ('base1', 'base2') + + """ + + format = '%(plugin)r not subclass of any base in %(bases)r' + + +class PluginDuplicateError(PrivateError): + """ + Raised when the same plugin class is registered more than once. + + For example: + + >>> raise PluginDuplicateError(plugin='my_plugin') + Traceback (most recent call last): + ... + PluginDuplicateError: 'my_plugin' was already registered + """ + + format = '%(plugin)r was already registered' + + +class PluginOverrideError(PrivateError): + """ + Raised when a plugin overrides another without using ``override=True``. + + For example: + + >>> raise PluginOverrideError(base='Command', name='env', plugin='my_env') + Traceback (most recent call last): + ... + PluginOverrideError: unexpected override of Command.env with 'my_env' + """ + + format = 'unexpected override of %(base)s.%(name)s with %(plugin)r' + + +class PluginMissingOverrideError(PrivateError): + """ + Raised when a plugin overrides another that has not been registered. + + For example: + + >>> raise PluginMissingOverrideError(base='Command', name='env', plugin='my_env') + Traceback (most recent call last): + ... + PluginMissingOverrideError: Command.env not registered, cannot override with 'my_env' + """ + + format = '%(base)s.%(name)s not registered, cannot override with %(plugin)r' + + + +############################################################################## +# Public errors: + +__messages = [] + +def _(message): + __messages.append(message) + return message + + +class PublicError(StandardError): + """ + **900** Base class for exceptions that can be forwarded in an RPC response. + """ + + errno = 900 + format = None + + def __init__(self, format=None, message=None, **kw): + name = self.__class__.__name__ + if self.format is not None and format is not None: + raise ValueError( + 'non-generic %r needs format=None; got format=%r' % ( + name, format) + ) + if message is None: + if self.format is None: + if format is None: + raise ValueError( + '%s.format is None yet format=None, message=None' % name + ) + self.format = format + self.forwarded = False + self.message = self.format % kw + self.strerror = ugettext(self.format) % kw + else: + if type(message) is not unicode: + raise TypeError( + TYPE_ERROR % ('message', unicode, message, type(message)) + ) + self.forwarded = True + self.message = message + self.strerror = message + for (key, value) in kw.iteritems(): + assert not hasattr(self, key), 'conflicting kwarg %s.%s = %r' % ( + name, key, value, + ) + setattr(self, key, value) + StandardError.__init__(self, self.message) + + +class VersionError(PublicError): + """ + **901** Raised when client and server versions are incompatible. + + For example: + + >>> raise VersionError(cver='2.0', sver='2.1', server='https://localhost') + Traceback (most recent call last): + ... + VersionError: 2.0 client incompatible with 2.1 server at 'https://localhost' + + """ + + errno = 901 + format = _('%(cver)s client incompatible with %(sver)s server at %(server)r') + + +class UnknownError(PublicError): + """ + **902** Raised when client does not know error it caught from server. + + For example: + + >>> raise UnknownError(code=57, server='localhost', error=u'a new error') + ... + Traceback (most recent call last): + ... + UnknownError: unknown error 57 from localhost: a new error + + """ + + errno = 902 + format = _('unknown error %(code)d from %(server)s: %(error)s') + + +class InternalError(PublicError): + """ + **903** Raised to conceal a non-public exception. + + For example: + + >>> raise InternalError() + Traceback (most recent call last): + ... + InternalError: an internal error has occured + """ + + errno = 903 + format = _('an internal error has occured') + + def __init__(self, message=None): + """ + Security issue: ignore any information given to constructor. + """ + PublicError.__init__(self) + + +class ServerInternalError(PublicError): + """ + **904** Raised when client catches an `InternalError` from server. + + For example: + + >>> raise ServerInternalError(server='https://localhost') + Traceback (most recent call last): + ... + ServerInternalError: an internal error has occured on server at 'https://localhost' + """ + + errno = 904 + format = _('an internal error has occured on server at %(server)r') + + +class CommandError(PublicError): + """ + **905** Raised when an unknown command is called. + + For example: + + >>> raise CommandError(name='foobar') + Traceback (most recent call last): + ... + CommandError: unknown command 'foobar' + """ + + errno = 905 + format = _('unknown command %(name)r') + + +class ServerCommandError(PublicError): + """ + **906** Raised when client catches a `CommandError` from server. + + For example: + + >>> e = CommandError(name='foobar') + >>> raise ServerCommandError(error=e.message, server='https://localhost') + Traceback (most recent call last): + ... + ServerCommandError: error on server 'https://localhost': unknown command 'foobar' + """ + + errno = 906 + format = _('error on server %(server)r: %(error)s') + + +class NetworkError(PublicError): + """ + **907** Raised when a network connection cannot be created. + + For example: + + >>> raise NetworkError(uri='ldap://localhost:389') + Traceback (most recent call last): + ... + NetworkError: cannot connect to 'ldap://localhost:389' + """ + + errno = 907 + format = _('cannot connect to %(uri)r') + + +class ServerNetworkError(PublicError): + """ + **908** Raised when client catches a `NetworkError` from server. + + For example: + + >>> e = NetworkError(uri='ldap://localhost:389') + >>> raise ServerNetworkError(error=e.message, server='https://localhost') + Traceback (most recent call last): + ... + ServerNetworkError: error on server 'https://localhost': cannot connect to 'ldap://localhost:389' + """ + + errno = 908 + format = _('error on server %(server)r: %(error)s') + + + +############################################################################## +# 1000 - 1999: Authentication errors +class AuthenticationError(PublicError): + """ + **1000** Base class for authentication errors (*1000 - 1999*). + """ + + errno = 1000 + + +class KerberosError(AuthenticationError): + """ + **1100** Base class for Kerberos authentication errors (*1100 - 1199*). + """ + + errno = 1100 + + + +############################################################################## +# 2000 - 2999: Authorization errors +class AuthorizationError(PublicError): + """ + **2000** Base class for authorization errors (*2000 - 2999*). + """ + + errno = 2000 + + +class ACIError(AuthorizationError): + """ + **2100** Base class for ACI authorization errors (*2100 - 2199*). + """ + + errno = 2100 + + + +############################################################################## +# 3000 - 3999: Invocation errors + +class InvocationError(PublicError): + """ + **3000** Base class for command invocation errors (*3000 - 3999*). + """ + + errno = 3000 + + +class EncodingError(InvocationError): + """ + **3001** Raised when received text is incorrectly encoded. + """ + + errno = 3001 + + +class BinaryEncodingError(InvocationError): + """ + **3002** Raised when received binary data is incorrectly encoded. + """ + + errno = 3002 + + +class ArgumentError(InvocationError): + """ + **3003** Raised when a command is called with wrong number of arguments. + """ + + errno = 3003 + + +class OptionError(InvocationError): + """ + **3004** Raised when a command is called with unknown options. + """ + + errno = 3004 + + +class RequirementError(InvocationError): + """ + **3005** Raised when a required parameter is not provided. + + For example: + + >>> raise RequirementError(name='givenname') + Traceback (most recent call last): + ... + RequirementError: 'givenname' is required + """ + + errno = 3005 + format = _('%(name)r is required') + + +class ConversionError(InvocationError): + """ + **3006** Raised when parameter value can't be converted to correct type. + + For example: + + >>> raise ConversionError(name='age', error='must be an integer') + Traceback (most recent call last): + ... + ConversionError: invalid 'age': must be an integer + """ + + errno = 3006 + format = _('invalid %(name)r: %(error)s') + + +class ValidationError(InvocationError): + """ + **3007** Raised when a parameter value fails a validation rule. + + For example: + + >>> raise ValidationError(name='sn', error='can be at most 128 characters') + Traceback (most recent call last): + ... + ValidationError: invalid 'sn': can be at most 128 characters + """ + + errno = 3007 + format = _('invalid %(name)r: %(error)s') + + + +############################################################################## +# 4000 - 4999: Execution errors + +class ExecutionError(PublicError): + """ + **4000** Base class for execution errors (*4000 - 4999*). + """ + + errno = 4000 + + +class LDAPError(ExecutionError): + """ + **4100** Base class for LDAP execution errors (*4100 - 4199*). + """ + + errno = 4100 + + + +############################################################################## +# 5000 - 5999: Generic errors + +class GenericError(PublicError): + """ + **5000** Base class for errors that don't fit elsewhere (*5000 - 5999*). + """ + + errno = 5000 + + + +def __errors_iter(): + """ + Iterate through all the `PublicError` subclasses. + """ + for (key, value) in globals().items(): + if key.startswith('_') or not isclass(value): + continue + if issubclass(value, PublicError): + yield value + +public_errors = tuple( + sorted(__errors_iter(), key=lambda E: E.errno) +) + +if __name__ == '__main__': + for klass in public_errors: + print '%d\t%s' % (klass.code, klass.__name__) + print '(%d public errors)' % len(public_errors) diff --git a/ipalib/frontend.py b/ipalib/frontend.py new file mode 100644 index 00000000..b30205fe --- /dev/null +++ b/ipalib/frontend.py @@ -0,0 +1,696 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Base classes for all front-end plugins. +""" + +import re +import inspect +import plugable +from plugable import lock, check_name +import errors +from errors import check_type, check_isinstance, raise_TypeError +from parameters import create_param, Param, Str, Flag +from util import make_repr + + +RULE_FLAG = 'validation_rule' + +def rule(obj): + assert not hasattr(obj, RULE_FLAG) + setattr(obj, RULE_FLAG, True) + return obj + +def is_rule(obj): + return callable(obj) and getattr(obj, RULE_FLAG, False) is True + + +class Command(plugable.Plugin): + """ + A public IPA atomic operation. + + All plugins that subclass from `Command` will be automatically available + as a CLI command and as an XML-RPC method. + + Plugins that subclass from Command are registered in the ``api.Command`` + namespace. For example: + + >>> from ipalib import create_api + >>> api = create_api() + >>> class my_command(Command): + ... pass + ... + >>> api.register(my_command) + >>> api.finalize() + >>> list(api.Command) + ['my_command'] + >>> api.Command.my_command # doctest:+ELLIPSIS + PluginProxy(Command, ...my_command()) + """ + + __public__ = frozenset(( + 'get_default', + 'convert', + 'normalize', + 'validate', + 'execute', + '__call__', + 'args', + 'options', + 'params', + 'args_to_kw', + 'params_2_args_options', + 'output_for_cli', + )) + takes_options = tuple() + takes_args = tuple() + args = None + options = None + params = None + output_for_cli = None + + def __call__(self, *args, **kw): + """ + Perform validation and then execute the command. + + If not in a server context, the call will be forwarded over + XML-RPC and the executed an the nearest IPA server. + """ + self.debug(make_repr(self.name, *args, **kw)) + if len(args) > 0: + arg_kw = self.args_to_kw(*args) + assert set(arg_kw).intersection(kw) == set() + kw.update(arg_kw) + kw = self.normalize(**kw) + kw = self.convert(**kw) + kw.update(self.get_default(**kw)) + self.validate(**kw) + (args, options) = self.params_2_args_options(kw) + result = self.run(*args, **options) + self.debug('%s result: %r', self.name, result) + return result + + def args_to_kw(self, *values): + """ + Map positional into keyword arguments. + """ + if self.max_args is not None and len(values) > self.max_args: + if self.max_args == 0: + raise errors.ArgumentError(self, 'takes no arguments') + if self.max_args == 1: + raise errors.ArgumentError(self, 'takes at most 1 argument') + raise errors.ArgumentError(self, + 'takes at most %d arguments' % len(self.args) + ) + return dict(self.__args_to_kw_iter(values)) + + def __args_to_kw_iter(self, values): + """ + Generator used by `Command.args_to_kw` method. + """ + multivalue = False + for (i, arg) in enumerate(self.args()): + assert not multivalue + if len(values) > i: + if arg.multivalue: + multivalue = True + if len(values) == i + 1 and type(values[i]) in (list, tuple): + yield (arg.name, values[i]) + else: + yield (arg.name, values[i:]) + else: + yield (arg.name, values[i]) + else: + break + + def args_options_2_params(self, args, options): + pass + + def params_2_args_options(self, params): + """ + Split params into (args, kw). + """ + args = tuple(params.get(name, None) for name in self.args) + options = dict( + (name, params.get(name, None)) for name in self.options + ) + return (args, options) + + def normalize(self, **kw): + """ + Return a dictionary of normalized values. + + For example: + + >>> class my_command(Command): + ... takes_options = ( + ... Param('first', normalizer=lambda value: value.lower()), + ... Param('last'), + ... ) + ... + >>> c = my_command() + >>> c.finalize() + >>> c.normalize(first=u'JOHN', last=u'DOE') + {'last': u'DOE', 'first': u'john'} + """ + return dict( + (k, self.params[k].normalize(v)) for (k, v) in kw.iteritems() + ) + + def convert(self, **kw): + """ + Return a dictionary of values converted to correct type. + + >>> from ipalib import Int + >>> class my_command(Command): + ... takes_args = ( + ... Int('one'), + ... 'two', + ... ) + ... + >>> c = my_command() + >>> c.finalize() + >>> c.convert(one=1, two=2) + {'two': u'2', 'one': 1} + """ + return dict( + (k, self.params[k].convert(v)) for (k, v) in kw.iteritems() + ) + + def get_default(self, **kw): + """ + Return a dictionary of defaults for all missing required values. + + For example: + + >>> from ipalib import Str + >>> class my_command(Command): + ... takes_args = [Str('color', default=u'Red')] + ... + >>> c = my_command() + >>> c.finalize() + >>> c.get_default() + {'color': u'Red'} + >>> c.get_default(color=u'Yellow') + {} + """ + return dict(self.__get_default_iter(kw)) + + def __get_default_iter(self, kw): + """ + Generator method used by `Command.get_default`. + """ + for param in self.params(): + if param.name in kw: + continue + if param.required or param.autofill: + default = param.get_default(**kw) + if default is not None: + yield (param.name, default) + + def validate(self, **kw): + """ + Validate all values. + + If any value fails the validation, `ipalib.errors.ValidationError` + (or a subclass thereof) will be raised. + """ + for param in self.params(): + value = kw.get(param.name, None) + if value is not None: + param.validate(value) + elif param.required: + raise errors.RequirementError(param.name) + + def run(self, *args, **kw): + """ + Dispatch to `Command.execute` or `Command.forward`. + + If running in a server context, `Command.execute` is called and the + actually work this command performs is executed locally. + + If running in a non-server context, `Command.forward` is called, + which forwards this call over XML-RPC to the exact same command + on the nearest IPA server and the actual work this command + performs is executed remotely. + """ + if self.api.env.in_server: + target = self.execute + else: + target = self.forward + object.__setattr__(self, 'run', target) + return target(*args, **kw) + + def execute(self, *args, **kw): + """ + Perform the actual work this command does. + + This method should be implemented only against functionality + in self.api.Backend. For example, a hypothetical + user_add.execute() might be implemented like this: + + >>> class user_add(Command): + ... def execute(self, **kw): + ... return self.api.Backend.ldap.add(**kw) + ... + """ + raise NotImplementedError('%s.execute()' % self.name) + + def forward(self, *args, **kw): + """ + Forward call over XML-RPC to this same command on server. + """ + return self.Backend.xmlrpc.forward_call(self.name, *args, **kw) + + def finalize(self): + """ + Finalize plugin initialization. + + This method creates the ``args``, ``options``, and ``params`` + namespaces. This is not done in `Command.__init__` because + subclasses (like `crud.Add`) might need to access other plugins + loaded in self.api to determine what their custom `Command.get_args` + and `Command.get_options` methods should yield. + """ + self.args = plugable.NameSpace(self.__create_args(), sort=False) + if len(self.args) == 0 or not self.args[-1].multivalue: + self.max_args = len(self.args) + else: + self.max_args = None + self.options = plugable.NameSpace( + (create_param(spec) for spec in self.get_options()), + sort=False + ) + def get_key(p): + if p.required: + if p.default_from is None: + return 0 + return 1 + return 2 + self.params = plugable.NameSpace( + sorted(tuple(self.args()) + tuple(self.options()), key=get_key), + sort=False + ) + super(Command, self).finalize() + + def get_args(self): + """ + Return iterable with arguments for Command.args namespace. + + Subclasses can override this to customize how the arguments + are determined. For an example of why this can be useful, + see `ipalib.crud.Mod`. + """ + return self.takes_args + + def get_options(self): + """ + Return iterable with options for Command.options namespace. + + Subclasses can override this to customize how the options + are determined. For an example of why this can be useful, + see `ipalib.crud.Mod`. + """ + return self.takes_options + + def __create_args(self): + """ + Generator used to create args namespace. + """ + optional = False + multivalue = False + for arg in self.get_args(): + arg = create_param(arg) + if optional and arg.required: + raise ValueError( + '%s: required argument after optional' % arg.name + ) + if multivalue: + raise ValueError( + '%s: only final argument can be multivalue' % arg.name + ) + if not arg.required: + optional = True + if arg.multivalue: + multivalue = True + yield arg + + +class LocalOrRemote(Command): + """ + A command that is explicitly executed locally or remotely. + + This is for commands that makes sense to execute either locally or + remotely to return a perhaps different result. The best example of + this is the `ipalib.plugins.f_misc.env` plugin which returns the + key/value pairs describing the configuration state: it can be + """ + + takes_options = ( + Flag('server?', + doc='Forward to server instead of running locally', + ), + ) + + def run(self, *args, **options): + """ + Dispatch to forward() or execute() based on ``server`` option. + + When running in a client context, this command is executed remotely if + ``options['server']`` is true; otherwise it is executed locally. + + When running in a server context, this command is always executed + locally and the value of ``options['server']`` is ignored. + """ + if options['server'] and not self.env.in_server: + return self.forward(*args, **options) + return self.execute(*args, **options) + + +class Object(plugable.Plugin): + __public__ = frozenset(( + 'backend', + 'methods', + 'properties', + 'params', + 'primary_key', + 'params_minus_pk', + 'get_dn', + )) + backend = None + methods = None + properties = None + params = None + primary_key = None + params_minus_pk = None + + # Can override in subclasses: + backend_name = None + takes_params = tuple() + + def set_api(self, api): + super(Object, self).set_api(api) + self.methods = plugable.NameSpace( + self.__get_attrs('Method'), sort=False + ) + self.properties = plugable.NameSpace( + self.__get_attrs('Property'), sort=False + ) + self.params = plugable.NameSpace( + self.__get_params(), sort=False + ) + pkeys = filter(lambda p: p.primary_key, self.params()) + if len(pkeys) > 1: + raise ValueError( + '%s (Object) has multiple primary keys: %s' % ( + self.name, + ', '.join(p.name for p in pkeys), + ) + ) + if len(pkeys) == 1: + self.primary_key = pkeys[0] + self.params_minus_pk = plugable.NameSpace( + filter(lambda p: not p.primary_key, self.params()), sort=False + ) + + if 'Backend' in self.api and self.backend_name in self.api.Backend: + self.backend = self.api.Backend[self.backend_name] + + def get_dn(self, primary_key): + """ + Construct an LDAP DN from a primary_key. + """ + raise NotImplementedError('%s.get_dn()' % self.name) + + def __get_attrs(self, name): + if name not in self.api: + return + namespace = self.api[name] + assert type(namespace) is plugable.NameSpace + for proxy in namespace(): # Equivalent to dict.itervalues() + if proxy.obj_name == self.name: + yield proxy.__clone__('attr_name') + + def __get_params(self): + props = self.properties.__todict__() + for spec in self.takes_params: + if type(spec) is str: + key = spec.rstrip('?*+') + else: + assert isinstance(spec, Param) + key = spec.name + if key in props: + yield props.pop(key).param + else: + yield create_param(spec) + def get_key(p): + if p.param.required: + if p.param.default_from is None: + return 0 + return 1 + return 2 + for prop in sorted(props.itervalues(), key=get_key): + yield prop.param + + +class Attribute(plugable.Plugin): + """ + Base class implementing the attribute-to-object association. + + `Attribute` plugins are associated with an `Object` plugin to group + a common set of commands that operate on a common set of parameters. + + The association between attribute and object is done using a simple + naming convention: the first part of the plugin class name (up to the + first underscore) is the object name, and rest is the attribute name, + as this table shows: + + =============== =========== ============== + Class name Object name Attribute name + =============== =========== ============== + noun_verb noun verb + user_add user add + user_first_name user first_name + =============== =========== ============== + + For example: + + >>> class user_add(Attribute): + ... pass + ... + >>> instance = user_add() + >>> instance.obj_name + 'user' + >>> instance.attr_name + 'add' + + In practice the `Attribute` class is not used directly, but rather is + only the base class for the `Method` and `Property` classes. Also see + the `Object` class. + """ + __public__ = frozenset(( + 'obj', + 'obj_name', + )) + __obj = None + + def __init__(self): + m = re.match( + '^([a-z][a-z0-9]+)_([a-z][a-z0-9]+)$', + self.__class__.__name__ + ) + assert m + self.__obj_name = m.group(1) + self.__attr_name = m.group(2) + super(Attribute, self).__init__() + + def __get_obj_name(self): + return self.__obj_name + obj_name = property(__get_obj_name) + + def __get_attr_name(self): + return self.__attr_name + attr_name = property(__get_attr_name) + + def __get_obj(self): + """ + Returns the obj instance this attribute is associated with, or None + if no association has been set. + """ + return self.__obj + obj = property(__get_obj) + + def set_api(self, api): + self.__obj = api.Object[self.obj_name] + super(Attribute, self).set_api(api) + + +class Method(Attribute, Command): + """ + A command with an associated object. + + A `Method` plugin must have a corresponding `Object` plugin. The + association between object and method is done through a simple naming + convention: the first part of the method name (up to the first under + score) is the object name, as the examples in this table show: + + ============= =========== ============== + Method name Object name Attribute name + ============= =========== ============== + user_add user add + noun_verb noun verb + door_open_now door open_now + ============= =========== ============== + + There are three different places a method can be accessed. For example, + say you created a `Method` plugin and its corresponding `Object` plugin + like this: + + >>> from ipalib import create_api + >>> api = create_api() + >>> class user_add(Method): + ... def run(self): + ... return 'Added the user!' + ... + >>> class user(Object): + ... pass + ... + >>> api.register(user_add) + >>> api.register(user) + >>> api.finalize() + + First, the ``user_add`` plugin can be accessed through the ``api.Method`` + namespace: + + >>> list(api.Method) + ['user_add'] + >>> api.Method.user_add() # Will call user_add.run() + 'Added the user!' + + Second, because `Method` is a subclass of `Command`, the ``user_add`` + plugin can also be accessed through the ``api.Command`` namespace: + + >>> list(api.Command) + ['user_add'] + >>> api.Command.user_add() # Will call user_add.run() + 'Added the user!' + + And third, ``user_add`` can be accessed as an attribute on the ``user`` + `Object`: + + >>> list(api.Object) + ['user'] + >>> list(api.Object.user.methods) + ['add'] + >>> api.Object.user.methods.add() # Will call user_add.run() + 'Added the user!' + + The `Attribute` base class implements the naming convention for the + attribute-to-object association. Also see the `Object` and the + `Property` classes. + """ + __public__ = Attribute.__public__.union(Command.__public__) + + def __init__(self): + super(Method, self).__init__() + + +class Property(Attribute): + __public__ = frozenset(( + 'rules', + 'param', + 'type', + )).union(Attribute.__public__) + + klass = Str + default = None + default_from = None + normalizer = None + + def __init__(self): + super(Property, self).__init__() + self.rules = tuple( + sorted(self.__rules_iter(), key=lambda f: getattr(f, '__name__')) + ) + self.kwargs = tuple( + sorted(self.__kw_iter(), key=lambda keyvalue: keyvalue[0]) + ) + kw = dict(self.kwargs) + self.param = self.klass(self.attr_name, *self.rules, **kw) + + def __kw_iter(self): + for (key, kind, default) in self.klass.kwargs: + if getattr(self, key, None) is not None: + yield (key, getattr(self, key)) + + def __rules_iter(self): + """ + Iterates through the attributes in this instance to retrieve the + methods implementing validation rules. + """ + for name in dir(self.__class__): + if name.startswith('_'): + continue + base_attr = getattr(self.__class__, name) + if is_rule(base_attr): + attr = getattr(self, name) + if is_rule(attr): + yield attr + + +class Application(Command): + """ + Base class for commands register by an external application. + + Special commands that only apply to a particular application built atop + `ipalib` should subclass from ``Application``. + + Because ``Application`` subclasses from `Command`, plugins that subclass + from ``Application`` with be available in both the ``api.Command`` and + ``api.Application`` namespaces. + """ + + __public__ = frozenset(( + 'application', + 'set_application' + )).union(Command.__public__) + __application = None + + def __get_application(self): + """ + Returns external ``application`` object. + """ + return self.__application + application = property(__get_application) + + def set_application(self, application): + """ + Sets the external application object to ``application``. + """ + if self.__application is not None: + raise AttributeError( + '%s.application can only be set once' % self.name + ) + if application is None: + raise TypeError( + '%s.application cannot be None' % self.name + ) + object.__setattr__(self, '_Application__application', application) + assert self.application is application diff --git a/ipalib/ipauuid.py b/ipalib/ipauuid.py new file mode 100644 index 00000000..9923dc7a --- /dev/null +++ b/ipalib/ipauuid.py @@ -0,0 +1,556 @@ +# This is a backport of the Python2.5 uuid module. + +r"""UUID objects (universally unique identifiers) according to RFC 4122. + +This module provides immutable UUID objects (class UUID) and the functions +uuid1(), uuid3(), uuid4(), uuid5() for generating version 1, 3, 4, and 5 +UUIDs as specified in RFC 4122. + +If all you want is a unique ID, you should probably call uuid1() or uuid4(). +Note that uuid1() may compromise privacy since it creates a UUID containing +the computer's network address. uuid4() creates a random UUID. + +Typical usage: + + **Important:** So that the freeIPA Python 2.4 ``uuid`` backport can be + automatically loaded when needed, import the ``uuid`` module like this: + + >>> from ipalib import uuid + + Make a UUID based on the host ID and current time: + + >>> uuid.uuid1() #doctest: +ELLIPSIS + UUID('...') + + Make a UUID using an MD5 hash of a namespace UUID and a name: + + >>> uuid.uuid3(uuid.NAMESPACE_DNS, 'python.org') + UUID('6fa459ea-ee8a-3ca4-894e-db77e160355e') + + Make a random UUID: + + >>> uuid.uuid4() #doctest: +ELLIPSIS + UUID('...') + + Make a UUID using a SHA-1 hash of a namespace UUID and a name: + + >>> uuid.uuid5(uuid.NAMESPACE_DNS, 'python.org') + UUID('886313e1-3b8a-5372-9b90-0c9aee199e5d') + + Make a UUID from a string of hex digits (braces and hyphens ignored): + + >>> x = uuid.UUID('{00010203-0405-0607-0809-0a0b0c0d0e0f}') + >>> x + UUID('00010203-0405-0607-0809-0a0b0c0d0e0f') + + Convert a UUID to a string of hex digits in standard form: + + >>> str(x) + '00010203-0405-0607-0809-0a0b0c0d0e0f' + + Get the raw 16 bytes of the UUID: + + >>> x.bytes + '\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f' + + Make a UUID from a 16-byte string: + + >>> uuid.UUID(bytes=x.bytes) + UUID('00010203-0405-0607-0809-0a0b0c0d0e0f') +""" + +__author__ = 'Ka-Ping Yee <ping@zesty.ca>' + +RESERVED_NCS, RFC_4122, RESERVED_MICROSOFT, RESERVED_FUTURE = [ + 'reserved for NCS compatibility', 'specified in RFC 4122', + 'reserved for Microsoft compatibility', 'reserved for future definition'] + +class UUID(object): + """Instances of the UUID class represent UUIDs as specified in RFC 4122. + UUID objects are immutable, hashable, and usable as dictionary keys. + Converting a UUID to a string with str() yields something in the form + '12345678-1234-1234-1234-123456789abc'. The UUID constructor accepts + five possible forms: a similar string of hexadecimal digits, or a tuple + of six integer fields (with 32-bit, 16-bit, 16-bit, 8-bit, 8-bit, and + 48-bit values respectively) as an argument named 'fields', or a string + of 16 bytes (with all the integer fields in big-endian order) as an + argument named 'bytes', or a string of 16 bytes (with the first three + fields in little-endian order) as an argument named 'bytes_le', or a + single 128-bit integer as an argument named 'int'. + + UUIDs have these read-only attributes: + + bytes the UUID as a 16-byte string (containing the six + integer fields in big-endian byte order) + + bytes_le the UUID as a 16-byte string (with time_low, time_mid, + and time_hi_version in little-endian byte order) + + fields a tuple of the six integer fields of the UUID, + which are also available as six individual attributes + and two derived attributes: + + time_low the first 32 bits of the UUID + time_mid the next 16 bits of the UUID + time_hi_version the next 16 bits of the UUID + clock_seq_hi_variant the next 8 bits of the UUID + clock_seq_low the next 8 bits of the UUID + node the last 48 bits of the UUID + + time the 60-bit timestamp + clock_seq the 14-bit sequence number + + hex the UUID as a 32-character hexadecimal string + + int the UUID as a 128-bit integer + + urn the UUID as a URN as specified in RFC 4122 + + variant the UUID variant (one of the constants RESERVED_NCS, + RFC_4122, RESERVED_MICROSOFT, or RESERVED_FUTURE) + + version the UUID version number (1 through 5, meaningful only + when the variant is RFC_4122) + """ + + def __init__(self, hex=None, bytes=None, bytes_le=None, fields=None, + int=None, version=None): + r"""Create a UUID from either a string of 32 hexadecimal digits, + a string of 16 bytes as the 'bytes' argument, a string of 16 bytes + in little-endian order as the 'bytes_le' argument, a tuple of six + integers (32-bit time_low, 16-bit time_mid, 16-bit time_hi_version, + 8-bit clock_seq_hi_variant, 8-bit clock_seq_low, 48-bit node) as + the 'fields' argument, or a single 128-bit integer as the 'int' + argument. When a string of hex digits is given, curly braces, + hyphens, and a URN prefix are all optional. For example, these + expressions all yield the same UUID: + + UUID('{12345678-1234-5678-1234-567812345678}') + UUID('12345678123456781234567812345678') + UUID('urn:uuid:12345678-1234-5678-1234-567812345678') + UUID(bytes='\x12\x34\x56\x78'*4) + UUID(bytes_le='\x78\x56\x34\x12\x34\x12\x78\x56' + + '\x12\x34\x56\x78\x12\x34\x56\x78') + UUID(fields=(0x12345678, 0x1234, 0x5678, 0x12, 0x34, 0x567812345678)) + UUID(int=0x12345678123456781234567812345678) + + Exactly one of 'hex', 'bytes', 'bytes_le', 'fields', or 'int' must + be given. The 'version' argument is optional; if given, the resulting + UUID will have its variant and version set according to RFC 4122, + overriding the given 'hex', 'bytes', 'bytes_le', 'fields', or 'int'. + """ + + if [hex, bytes, bytes_le, fields, int].count(None) != 4: + raise TypeError('need one of hex, bytes, bytes_le, fields, or int') + if hex is not None: + hex = hex.replace('urn:', '').replace('uuid:', '') + hex = hex.strip('{}').replace('-', '') + if len(hex) != 32: + raise ValueError('badly formed hexadecimal UUID string') + int = long(hex, 16) + if bytes_le is not None: + if len(bytes_le) != 16: + raise ValueError('bytes_le is not a 16-char string') + bytes = (bytes_le[3] + bytes_le[2] + bytes_le[1] + bytes_le[0] + + bytes_le[5] + bytes_le[4] + bytes_le[7] + bytes_le[6] + + bytes_le[8:]) + if bytes is not None: + if len(bytes) != 16: + raise ValueError('bytes is not a 16-char string') + int = long(('%02x'*16) % tuple(map(ord, bytes)), 16) + if fields is not None: + if len(fields) != 6: + raise ValueError('fields is not a 6-tuple') + (time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node) = fields + if not 0 <= time_low < 1<<32L: + raise ValueError('field 1 out of range (need a 32-bit value)') + if not 0 <= time_mid < 1<<16L: + raise ValueError('field 2 out of range (need a 16-bit value)') + if not 0 <= time_hi_version < 1<<16L: + raise ValueError('field 3 out of range (need a 16-bit value)') + if not 0 <= clock_seq_hi_variant < 1<<8L: + raise ValueError('field 4 out of range (need an 8-bit value)') + if not 0 <= clock_seq_low < 1<<8L: + raise ValueError('field 5 out of range (need an 8-bit value)') + if not 0 <= node < 1<<48L: + raise ValueError('field 6 out of range (need a 48-bit value)') + clock_seq = (clock_seq_hi_variant << 8L) | clock_seq_low + int = ((time_low << 96L) | (time_mid << 80L) | + (time_hi_version << 64L) | (clock_seq << 48L) | node) + if int is not None: + if not 0 <= int < 1<<128L: + raise ValueError('int is out of range (need a 128-bit value)') + if version is not None: + if not 1 <= version <= 5: + raise ValueError('illegal version number') + # Set the variant to RFC 4122. + int &= ~(0xc000 << 48L) + int |= 0x8000 << 48L + # Set the version number. + int &= ~(0xf000 << 64L) + int |= version << 76L + self.__dict__['int'] = int + + def __cmp__(self, other): + if isinstance(other, UUID): + return cmp(self.int, other.int) + return NotImplemented + + def __hash__(self): + return hash(self.int) + + def __int__(self): + return self.int + + def __repr__(self): + return 'UUID(%r)' % str(self) + + def __setattr__(self, name, value): + raise TypeError('UUID objects are immutable') + + def __str__(self): + hex = '%032x' % self.int + return '%s-%s-%s-%s-%s' % ( + hex[:8], hex[8:12], hex[12:16], hex[16:20], hex[20:]) + + def get_bytes(self): + bytes = '' + for shift in range(0, 128, 8): + bytes = chr((self.int >> shift) & 0xff) + bytes + return bytes + + bytes = property(get_bytes) + + def get_bytes_le(self): + bytes = self.bytes + return (bytes[3] + bytes[2] + bytes[1] + bytes[0] + + bytes[5] + bytes[4] + bytes[7] + bytes[6] + bytes[8:]) + + bytes_le = property(get_bytes_le) + + def get_fields(self): + return (self.time_low, self.time_mid, self.time_hi_version, + self.clock_seq_hi_variant, self.clock_seq_low, self.node) + + fields = property(get_fields) + + def get_time_low(self): + return self.int >> 96L + + time_low = property(get_time_low) + + def get_time_mid(self): + return (self.int >> 80L) & 0xffff + + time_mid = property(get_time_mid) + + def get_time_hi_version(self): + return (self.int >> 64L) & 0xffff + + time_hi_version = property(get_time_hi_version) + + def get_clock_seq_hi_variant(self): + return (self.int >> 56L) & 0xff + + clock_seq_hi_variant = property(get_clock_seq_hi_variant) + + def get_clock_seq_low(self): + return (self.int >> 48L) & 0xff + + clock_seq_low = property(get_clock_seq_low) + + def get_time(self): + return (((self.time_hi_version & 0x0fffL) << 48L) | + (self.time_mid << 32L) | self.time_low) + + time = property(get_time) + + def get_clock_seq(self): + return (((self.clock_seq_hi_variant & 0x3fL) << 8L) | + self.clock_seq_low) + + clock_seq = property(get_clock_seq) + + def get_node(self): + return self.int & 0xffffffffffff + + node = property(get_node) + + def get_hex(self): + return '%032x' % self.int + + hex = property(get_hex) + + def get_urn(self): + return 'urn:uuid:' + str(self) + + urn = property(get_urn) + + def get_variant(self): + if not self.int & (0x8000 << 48L): + return RESERVED_NCS + elif not self.int & (0x4000 << 48L): + return RFC_4122 + elif not self.int & (0x2000 << 48L): + return RESERVED_MICROSOFT + else: + return RESERVED_FUTURE + + variant = property(get_variant) + + def get_version(self): + # The version bits are only meaningful for RFC 4122 UUIDs. + if self.variant == RFC_4122: + return int((self.int >> 76L) & 0xf) + + version = property(get_version) + +def _find_mac(command, args, hw_identifiers, get_index): + import os + for dir in ['', '/sbin/', '/usr/sbin']: + executable = os.path.join(dir, command) + if not os.path.exists(executable): + continue + + try: + # LC_ALL to get English output, 2>/dev/null to + # prevent output on stderr + cmd = 'LC_ALL=C %s %s 2>/dev/null' % (executable, args) + pipe = os.popen(cmd) + except IOError: + continue + + for line in pipe: + words = line.lower().split() + for i in range(len(words)): + if words[i] in hw_identifiers: + return int(words[get_index(i)].replace(':', ''), 16) + return None + +def _ifconfig_getnode(): + """Get the hardware address on Unix by running ifconfig.""" + + # This works on Linux ('' or '-a'), Tru64 ('-av'), but not all Unixes. + for args in ('', '-a', '-av'): + mac = _find_mac('ifconfig', args, ['hwaddr', 'ether'], lambda i: i+1) + if mac: + return mac + + import socket + ip_addr = socket.gethostbyname(socket.gethostname()) + + # Try getting the MAC addr from arp based on our IP address (Solaris). + mac = _find_mac('arp', '-an', [ip_addr], lambda i: -1) + if mac: + return mac + + # This might work on HP-UX. + mac = _find_mac('lanscan', '-ai', ['lan0'], lambda i: 0) + if mac: + return mac + + return None + +def _ipconfig_getnode(): + """Get the hardware address on Windows by running ipconfig.exe.""" + import os, re + dirs = ['', r'c:\windows\system32', r'c:\winnt\system32'] + try: + import ctypes + buffer = ctypes.create_string_buffer(300) + ctypes.windll.kernel32.GetSystemDirectoryA(buffer, 300) + dirs.insert(0, buffer.value.decode('mbcs')) + except: + pass + for dir in dirs: + try: + pipe = os.popen(os.path.join(dir, 'ipconfig') + ' /all') + except IOError: + continue + for line in pipe: + value = line.split(':')[-1].strip().lower() + if re.match('([0-9a-f][0-9a-f]-){5}[0-9a-f][0-9a-f]', value): + return int(value.replace('-', ''), 16) + +def _netbios_getnode(): + """Get the hardware address on Windows using NetBIOS calls. + See http://support.microsoft.com/kb/118623 for details.""" + import win32wnet, netbios + ncb = netbios.NCB() + ncb.Command = netbios.NCBENUM + ncb.Buffer = adapters = netbios.LANA_ENUM() + adapters._pack() + if win32wnet.Netbios(ncb) != 0: + return + adapters._unpack() + for i in range(adapters.length): + ncb.Reset() + ncb.Command = netbios.NCBRESET + ncb.Lana_num = ord(adapters.lana[i]) + if win32wnet.Netbios(ncb) != 0: + continue + ncb.Reset() + ncb.Command = netbios.NCBASTAT + ncb.Lana_num = ord(adapters.lana[i]) + ncb.Callname = '*'.ljust(16) + ncb.Buffer = status = netbios.ADAPTER_STATUS() + if win32wnet.Netbios(ncb) != 0: + continue + status._unpack() + bytes = map(ord, status.adapter_address) + return ((bytes[0]<<40L) + (bytes[1]<<32L) + (bytes[2]<<24L) + + (bytes[3]<<16L) + (bytes[4]<<8L) + bytes[5]) + +# Thanks to Thomas Heller for ctypes and for his help with its use here. + +# If ctypes is available, use it to find system routines for UUID generation. +_uuid_generate_random = _uuid_generate_time = _UuidCreate = None +try: + import ctypes, ctypes.util + _buffer = ctypes.create_string_buffer(16) + + # The uuid_generate_* routines are provided by libuuid on at least + # Linux and FreeBSD, and provided by libc on Mac OS X. + for libname in ['uuid', 'c']: + try: + lib = ctypes.CDLL(ctypes.util.find_library(libname)) + except: + continue + if hasattr(lib, 'uuid_generate_random'): + _uuid_generate_random = lib.uuid_generate_random + if hasattr(lib, 'uuid_generate_time'): + _uuid_generate_time = lib.uuid_generate_time + + # On Windows prior to 2000, UuidCreate gives a UUID containing the + # hardware address. On Windows 2000 and later, UuidCreate makes a + # random UUID and UuidCreateSequential gives a UUID containing the + # hardware address. These routines are provided by the RPC runtime. + # NOTE: at least on Tim's WinXP Pro SP2 desktop box, while the last + # 6 bytes returned by UuidCreateSequential are fixed, they don't appear + # to bear any relationship to the MAC address of any network device + # on the box. + try: + lib = ctypes.windll.rpcrt4 + except: + lib = None + _UuidCreate = getattr(lib, 'UuidCreateSequential', + getattr(lib, 'UuidCreate', None)) +except: + pass + +def _unixdll_getnode(): + """Get the hardware address on Unix using ctypes.""" + _uuid_generate_time(_buffer) + return UUID(bytes=_buffer.raw).node + +def _windll_getnode(): + """Get the hardware address on Windows using ctypes.""" + if _UuidCreate(_buffer) == 0: + return UUID(bytes=_buffer.raw).node + +def _random_getnode(): + """Get a random node ID, with eighth bit set as suggested by RFC 4122.""" + import random + return random.randrange(0, 1<<48L) | 0x010000000000L + +_node = None + +def getnode(): + """Get the hardware address as a 48-bit positive integer. + + The first time this runs, it may launch a separate program, which could + be quite slow. If all attempts to obtain the hardware address fail, we + choose a random 48-bit number with its eighth bit set to 1 as recommended + in RFC 4122. + """ + + global _node + if _node is not None: + return _node + + import sys + if sys.platform == 'win32': + getters = [_windll_getnode, _netbios_getnode, _ipconfig_getnode] + else: + getters = [_unixdll_getnode, _ifconfig_getnode] + + for getter in getters + [_random_getnode]: + try: + _node = getter() + except: + continue + if _node is not None: + return _node + +_last_timestamp = None + +def uuid1(node=None, clock_seq=None): + """Generate a UUID from a host ID, sequence number, and the current time. + If 'node' is not given, getnode() is used to obtain the hardware + address. If 'clock_seq' is given, it is used as the sequence number; + otherwise a random 14-bit sequence number is chosen.""" + + # When the system provides a version-1 UUID generator, use it (but don't + # use UuidCreate here because its UUIDs don't conform to RFC 4122). + if _uuid_generate_time and node is clock_seq is None: + _uuid_generate_time(_buffer) + return UUID(bytes=_buffer.raw) + + global _last_timestamp + import time + nanoseconds = int(time.time() * 1e9) + # 0x01b21dd213814000 is the number of 100-ns intervals between the + # UUID epoch 1582-10-15 00:00:00 and the Unix epoch 1970-01-01 00:00:00. + timestamp = int(nanoseconds/100) + 0x01b21dd213814000L + if timestamp <= _last_timestamp: + timestamp = _last_timestamp + 1 + _last_timestamp = timestamp + if clock_seq is None: + import random + clock_seq = random.randrange(1<<14L) # instead of stable storage + time_low = timestamp & 0xffffffffL + time_mid = (timestamp >> 32L) & 0xffffL + time_hi_version = (timestamp >> 48L) & 0x0fffL + clock_seq_low = clock_seq & 0xffL + clock_seq_hi_variant = (clock_seq >> 8L) & 0x3fL + if node is None: + node = getnode() + return UUID(fields=(time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node), version=1) + +def uuid3(namespace, name): + """Generate a UUID from the MD5 hash of a namespace UUID and a name.""" + import md5 + hash = md5.md5(namespace.bytes + name).digest() + return UUID(bytes=hash[:16], version=3) + +def uuid4(): + """Generate a random UUID.""" + + # When the system provides a version-4 UUID generator, use it. + if _uuid_generate_random: + _uuid_generate_random(_buffer) + return UUID(bytes=_buffer.raw) + + # Otherwise, get randomness from urandom or the 'random' module. + try: + import os + return UUID(bytes=os.urandom(16), version=4) + except: + import random + bytes = [chr(random.randrange(256)) for i in range(16)] + return UUID(bytes=bytes, version=4) + +def uuid5(namespace, name): + """Generate a UUID from the SHA-1 hash of a namespace UUID and a name.""" + import sha + hash = sha.sha(namespace.bytes + name).digest() + return UUID(bytes=hash[:16], version=5) + +# The following standard UUIDs are for use with uuid3() or uuid5(). + +NAMESPACE_DNS = UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_URL = UUID('6ba7b811-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_OID = UUID('6ba7b812-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_X500 = UUID('6ba7b814-9dad-11d1-80b4-00c04fd430c8') diff --git a/ipalib/parameters.py b/ipalib/parameters.py new file mode 100644 index 00000000..76d88347 --- /dev/null +++ b/ipalib/parameters.py @@ -0,0 +1,990 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Parameter system for command plugins. + +TODO: + + * Change rule call signature to rule(_, value, **kw) so that rules can also + validate relative to other parameter values (e.g., login name as it relates + to first name and last name) + + * Add the _rule_pattern() methods to `Bytes` and `Str` + + * Add maxvalue, minvalue kwargs and rules to `Int` and `Float` +""" + +from types import NoneType +from util import make_repr +from request import ugettext +from plugable import ReadOnly, lock, check_name +from errors2 import ConversionError, RequirementError, ValidationError +from constants import NULLS, TYPE_ERROR, CALLABLE_ERROR + +class DefaultFrom(ReadOnly): + """ + Derive a default value from other supplied values. + + For example, say you wanted to create a default for the user's login from + the user's first and last names. It could be implemented like this: + + >>> login = DefaultFrom(lambda first, last: first[0] + last) + >>> login(first='John', last='Doe') + 'JDoe' + + If you do not explicitly provide keys when you create a `DefaultFrom` + instance, the keys are implicitly derived from your callback by + inspecting ``callback.func_code.co_varnames``. The keys are available + through the ``DefaultFrom.keys`` instance attribute, like this: + + >>> login.keys + ('first', 'last') + + The callback is available through the ``DefaultFrom.callback`` instance + attribute, like this: + + >>> login.callback # doctest:+ELLIPSIS + <function <lambda> at 0x...> + >>> login.callback.func_code.co_varnames # The keys + ('first', 'last') + + The keys can be explicitly provided as optional positional arguments after + the callback. For example, this is equivalent to the ``login`` instance + above: + + >>> login2 = DefaultFrom(lambda a, b: a[0] + b, 'first', 'last') + >>> login2.keys + ('first', 'last') + >>> login2.callback.func_code.co_varnames # Not the keys + ('a', 'b') + >>> login2(first='John', last='Doe') + 'JDoe' + + If any keys are missing when calling your `DefaultFrom` instance, your + callback is not called and ``None`` is returned. For example: + + >>> login(first='John', lastname='Doe') is None + True + >>> login() is None + True + + Any additional keys are simply ignored, like this: + + >>> login(last='Doe', first='John', middle='Whatever') + 'JDoe' + + As above, because `DefaultFrom.__call__` takes only pure keyword + arguments, they can be supplied in any order. + + Of course, the callback need not be a ``lambda`` expression. This third + example is equivalent to both the ``login`` and ``login2`` instances + above: + + >>> def get_login(first, last): + ... return first[0] + last + ... + >>> login3 = DefaultFrom(get_login) + >>> login3.keys + ('first', 'last') + >>> login3.callback.func_code.co_varnames + ('first', 'last') + >>> login3(first='John', last='Doe') + 'JDoe' + """ + + def __init__(self, callback, *keys): + """ + :param callback: The callable to call when all keys are present. + :param keys: Optional keys used for source values. + """ + if not callable(callback): + raise TypeError( + CALLABLE_ERROR % ('callback', callback, type(callback)) + ) + self.callback = callback + if len(keys) == 0: + fc = callback.func_code + self.keys = fc.co_varnames[:fc.co_argcount] + else: + self.keys = keys + for key in self.keys: + if type(key) is not str: + raise TypeError( + TYPE_ERROR % ('keys', str, key, type(key)) + ) + lock(self) + + def __call__(self, **kw): + """ + Call the callback if all keys are present. + + If all keys are present, the callback is called and its return value is + returned. If any keys are missing, ``None`` is returned. + + :param kw: The keyword arguments. + """ + vals = tuple(kw.get(k, None) for k in self.keys) + if None in vals: + return + try: + return self.callback(*vals) + except StandardError: + pass + + +def parse_param_spec(spec): + """ + Parse shorthand ``spec`` into to ``(name, kw)``. + + The ``spec`` string determines the parameter name, whether the parameter is + required, and whether the parameter is multivalue according the following + syntax: + + ====== ===== ======== ========== + Spec Name Required Multivalue + ====== ===== ======== ========== + 'var' 'var' True False + 'var?' 'var' False False + 'var*' 'var' False True + 'var+' 'var' True True + ====== ===== ======== ========== + + For example, + + >>> parse_param_spec('login') + ('login', {'required': True, 'multivalue': False}) + >>> parse_param_spec('gecos?') + ('gecos', {'required': False, 'multivalue': False}) + >>> parse_param_spec('telephone_numbers*') + ('telephone_numbers', {'required': False, 'multivalue': True}) + >>> parse_param_spec('group+') + ('group', {'required': True, 'multivalue': True}) + + :param spec: A spec string. + """ + if type(spec) is not str: + raise TypeError( + TYPE_ERROR % ('spec', str, spec, type(spec)) + ) + if len(spec) < 2: + raise ValueError( + 'spec must be at least 2 characters; got %r' % spec + ) + _map = { + '?': dict(required=False, multivalue=False), + '*': dict(required=False, multivalue=True), + '+': dict(required=True, multivalue=True), + } + end = spec[-1] + if end in _map: + return (spec[:-1], _map[end]) + return (spec, dict(required=True, multivalue=False)) + + +__messages = set() + +def _(message): + __messages.add(message) + return message + + +class Param(ReadOnly): + """ + Base class for all parameters. + """ + + # This is a dummy type so that most of the functionality of Param can be + # unit tested directly without always creating a subclass; however, a real + # (direct) subclass must *always* override this class attribute: + type = NoneType # Ouch, this wont be very useful in the real world! + + # Subclasses should override this with something more specific: + type_error = _('incorrect type') + + kwargs = ( + ('cli_name', str, None), + ('label', callable, None), + ('doc', str, ''), + ('required', bool, True), + ('multivalue', bool, False), + ('primary_key', bool, False), + ('normalizer', callable, None), + ('default_from', DefaultFrom, None), + ('create_default', callable, None), + ('autofill', bool, False), + ('query', bool, False), + ('flags', frozenset, frozenset()), + + # The 'default' kwarg gets appended in Param.__init__(): + # ('default', self.type, None), + ) + + def __init__(self, name, *rules, **kw): + # We keep these values to use in __repr__(): + self.param_spec = name + self.__kw = dict(kw) + + # Merge in kw from parse_param_spec(): + if not ('required' in kw or 'multivalue' in kw): + (name, kw_from_spec) = parse_param_spec(name) + kw.update(kw_from_spec) + self.name = check_name(name) + self.nice = '%s(%r)' % (self.__class__.__name__, self.param_spec) + + # Add 'default' to self.kwargs and makes sure no unknown kw were given: + assert type(self.type) is type + self.kwargs += (('default', self.type, None),) + if not set(t[0] for t in self.kwargs).issuperset(self.__kw): + extra = set(kw) - set(t[0] for t in self.kwargs) + raise TypeError( + '%s: takes no such kwargs: %s' % (self.nice, + ', '.join(repr(k) for k in sorted(extra)) + ) + ) + + # Merge in default for 'cli_name' if not given: + if kw.get('cli_name', None) is None: + kw['cli_name'] = self.name + + # Wrap 'default_from' in a DefaultFrom if not already: + df = kw.get('default_from', None) + if callable(df) and not isinstance(df, DefaultFrom): + kw['default_from'] = DefaultFrom(df) + + # We keep this copy with merged values also to use when cloning: + self.__clonekw = kw + + # Perform type validation on kw, add in class rules: + class_rules = [] + for (key, kind, default) in self.kwargs: + value = kw.get(key, default) + if value is not None: + if kind is frozenset: + if type(value) in (list, tuple): + value = frozenset(value) + elif type(value) is str: + value = frozenset([value]) + if ( + type(kind) is type and type(value) is not kind + or + type(kind) is tuple and not isinstance(value, kind) + ): + raise TypeError( + TYPE_ERROR % (key, kind, value, type(value)) + ) + elif kind is callable and not callable(value): + raise TypeError( + CALLABLE_ERROR % (key, value, type(value)) + ) + if hasattr(self, key): + raise ValueError('kwarg %r conflicts with attribute on %s' % ( + key, self.__class__.__name__) + ) + setattr(self, key, value) + rule_name = '_rule_%s' % key + if value is not None and hasattr(self, rule_name): + class_rules.append(getattr(self, rule_name)) + check_name(self.cli_name) + + # Check that only default_from or create_default was provided: + assert not hasattr(self, '_get_default'), self.nice + if callable(self.default_from): + if callable(self.create_default): + raise ValueError( + '%s: cannot have both %r and %r' % ( + self.nice, 'default_from', 'create_default') + ) + self._get_default = self.default_from + elif callable(self.create_default): + self._get_default = self.create_default + else: + self._get_default = None + + # Check that all the rules are callable + self.class_rules = tuple(class_rules) + self.rules = rules + self.all_rules = self.class_rules + self.rules + for rule in self.all_rules: + if not callable(rule): + raise TypeError( + '%s: rules must be callable; got %r' % (self.nice, rule) + ) + + # And we're done. + lock(self) + + def __repr__(self): + """ + Return an expresion that could construct this `Param` instance. + """ + return make_repr( + self.__class__.__name__, + self.param_spec, + **self.__kw + ) + + def __call__(self, value, **kw): + """ + One stop shopping. + """ + if value in NULLS: + value = self.get_default(**kw) + else: + value = self.convert(self.normalize(value)) + self.validate(value) + return value + + def clone(self, **overrides): + """ + Return a new `Param` instance similar to this one. + """ + kw = dict(self.__clonekw) + kw.update(overrides) + return self.__class__(self.name, **kw) + + def get_label(self): + """ + Return translated label using `request.ugettext`. + """ + if self.label is None: + return self.cli_name.decode('UTF-8') + return self.label(ugettext) + + def normalize(self, value): + """ + Normalize ``value`` using normalizer callback. + + For example: + + >>> param = Param('telephone', + ... normalizer=lambda value: value.replace('.', '-') + ... ) + >>> param.normalize(u'800.123.4567') + u'800-123-4567' + + If this `Param` instance was created with a normalizer callback and + ``value`` is a unicode instance, the normalizer callback is called and + *its* return value is returned. + + On the other hand, if this `Param` instance was *not* created with a + normalizer callback, if ``value`` is *not* a unicode instance, or if an + exception is caught when calling the normalizer callback, ``value`` is + returned unchanged. + + :param value: A proposed value for this parameter. + """ + if self.normalizer is None: + return value + if self.multivalue: + if type(value) in (tuple, list): + return tuple( + self._normalize_scalar(v) for v in value + ) + return (self._normalize_scalar(value),) # Return a tuple + return self._normalize_scalar(value) + + def _normalize_scalar(self, value): + """ + Normalize a scalar value. + + This method is called once for each value in a multivalue. + """ + if type(value) is not unicode: + return value + try: + return self.normalizer(value) + except StandardError: + return value + + def convert(self, value): + """ + Convert ``value`` to the Python type required by this parameter. + + For example: + + >>> scalar = Str('my_scalar') + >>> scalar.type + <type 'unicode'> + >>> scalar.convert(43.2) + u'43.2' + + (Note that `Str` is a subclass of `Param`.) + + All values in `constants.NULLS` will be converted to ``None``. For + example: + + >>> scalar.convert(u'') is None # An empty string + True + >>> scalar.convert([]) is None # An empty list + True + + Likewise, values in `constants.NULLS` will be filtered out of a + multivalue parameter. For example: + + >>> multi = Str('my_multi', multivalue=True) + >>> multi.convert([1.5, '', 17, None, u'Hello']) + (u'1.5', u'17', u'Hello') + >>> multi.convert([None, u'']) is None # Filters to an empty list + True + + Lastly, multivalue parameters will always return a ``tuple`` (assuming + they don't return ``None`` as in the last example above). For example: + + >>> multi.convert(42) # Called with a scalar value + (u'42',) + >>> multi.convert([0, 1]) # Called with a list value + (u'0', u'1') + + Note that how values are converted (and from what types they will be + converted) completely depends upon how a subclass implements its + `Param._convert_scalar()` method. For example, see + `Str._convert_scalar()`. + + :param value: A proposed value for this parameter. + """ + if value in NULLS: + return + if self.multivalue: + if type(value) not in (tuple, list): + value = (value,) + values = tuple( + self._convert_scalar(v, i) for (i, v) in filter( + lambda iv: iv[1] not in NULLS, enumerate(value) + ) + ) + if len(values) == 0: + return + return values + return self._convert_scalar(value) + + def _convert_scalar(self, value, index=None): + """ + Convert a single scalar value. + """ + if type(value) is self.type: + return value + raise ConversionError(name=self.name, index=index, + error=ugettext(self.type_error), + ) + + def validate(self, value): + """ + Check validity of ``value``. + + :param value: A proposed value for this parameter. + """ + if value is None: + if self.required: + raise RequirementError(name=self.name) + return + if self.query: + return + if self.multivalue: + if type(value) is not tuple: + raise TypeError( + TYPE_ERROR % ('value', tuple, value, type(value)) + ) + if len(value) < 1: + raise ValueError('value: empty tuple must be converted to None') + for (i, v) in enumerate(value): + self._validate_scalar(v, i) + else: + self._validate_scalar(value) + + def _validate_scalar(self, value, index=None): + if type(value) is not self.type: + if index is None: + name = 'value' + else: + name = 'value[%d]' % index + raise TypeError( + TYPE_ERROR % (name, self.type, value, type(value)) + ) + if index is not None and type(index) is not int: + raise TypeError( + TYPE_ERROR % ('index', int, index, type(index)) + ) + for rule in self.all_rules: + error = rule(ugettext, value) + if error is not None: + raise ValidationError( + name=self.name, + value=value, + index=index, + error=error, + rule=rule, + ) + + def get_default(self, **kw): + """ + Return the static default or construct and return a dynamic default. + + (In these examples, we will use the `Str` and `Bytes` classes, which + both subclass from `Param`.) + + The *default* static default is ``None``. For example: + + >>> s = Str('my_str') + >>> s.default is None + True + >>> s.get_default() is None + True + + However, you can provide your own static default via the ``default`` + keyword argument when you create your `Param` instance. For example: + + >>> s = Str('my_str', default=u'My Static Default') + >>> s.default + u'My Static Default' + >>> s.get_default() + u'My Static Default' + + If you need to generate a dynamic default from other supplied parameter + values, provide a callback via the ``default_from`` keyword argument. + This callback will be automatically wrapped in a `DefaultFrom` instance + if it isn't one already (see the `DefaultFrom` class for all the gory + details). For example: + + >>> login = Str('login', default=u'my-static-login-default', + ... default_from=lambda first, last: (first[0] + last).lower(), + ... ) + >>> isinstance(login.default_from, DefaultFrom) + True + >>> login.default_from.keys + ('first', 'last') + + Then when all the keys needed by the `DefaultFrom` instance are present, + the dynamic default is constructed and returned. For example: + + >>> kw = dict(last=u'Doe', first=u'John') + >>> login.get_default(**kw) + u'jdoe' + + Or if any keys are missing, your *static* default is returned. + For example: + + >>> kw = dict(first=u'John', department=u'Engineering') + >>> login.get_default(**kw) + u'my-static-login-default' + + The second, less common way to construct a dynamic default is to provide + a callback via the ``create_default`` keyword argument. Unlike a + ``default_from`` callback, your ``create_default`` callback will not get + wrapped in any dispatcher. Instead, it will be called directly, which + means your callback must accept arbitrary keyword arguments, although + whether your callback utilises these values is up to your + implementation. For example: + + >>> def make_csr(**kw): + ... print ' make_csr(%r)' % (kw,) # Note output below + ... return 'Certificate Signing Request' + ... + >>> csr = Bytes('csr', create_default=make_csr) + + Your ``create_default`` callback will be called with whatever keyword + arguments are passed to `Param.get_default()`. For example: + + >>> kw = dict(arbitrary='Keyword', arguments='Here') + >>> csr.get_default(**kw) + make_csr({'arguments': 'Here', 'arbitrary': 'Keyword'}) + 'Certificate Signing Request' + + And your ``create_default`` callback is called even if + `Param.get_default()` is called with *zero* keyword arguments. + For example: + + >>> csr.get_default() + make_csr({}) + 'Certificate Signing Request' + + The ``create_default`` callback will most likely be used as a + pre-execute hook to perform some special client-side operation. For + example, the ``csr`` parameter above might make a call to + ``/usr/bin/openssl``. However, often a ``create_default`` callback + could also be implemented as a ``default_from`` callback. When this is + the case, a ``default_from`` callback should be used as they are more + structured and therefore less error-prone. + + The ``default_from`` and ``create_default`` keyword arguments are + mutually exclusive. If you provide both, a ``ValueError`` will be + raised. For example: + + >>> homedir = Str('home', + ... default_from=lambda login: '/home/%s' % login, + ... create_default=lambda **kw: '/lets/use/this', + ... ) + Traceback (most recent call last): + ... + ValueError: Str('home'): cannot have both 'default_from' and 'create_default' + """ + if self._get_default is not None: + default = self._get_default(**kw) + if default is not None: + try: + return self.convert(self.normalize(default)) + except StandardError: + pass + return self.default + + +class Bool(Param): + """ + A parameter for boolean values (stored in the ``bool`` type). + """ + + type = bool + type_error = _('must be True or False') + + +class Flag(Bool): + """ + A boolean parameter that always gets filled in with a default value. + + This `Bool` subclass forces ``autofill=True`` in `Flag.__init__()`. If no + default is provided, it also fills in a default value of ``False``. + Lastly, unlike the `Bool` class, the default must be either ``True`` or + ``False`` and cannot be ``None``. + + For example: + + >>> flag = Flag('my_flag') + >>> (flag.autofill, flag.default) + (True, False) + + To have a default value of ``True``, create your `Flag` intance with + ``default=True``. For example: + + >>> flag = Flag('my_flag', default=True) + >>> (flag.autofill, flag.default) + (True, True) + + Also note that creating a `Flag` instance with ``autofill=False`` will have + no effect. For example: + + >>> flag = Flag('my_flag', autofill=False) + >>> flag.autofill + True + """ + + def __init__(self, name, *rules, **kw): + kw['autofill'] = True + if 'default' not in kw: + kw['default'] = False + if type(kw['default']) is not bool: + default = kw['default'] + raise TypeError( + TYPE_ERROR % ('default', bool, default, type(default)) + ) + super(Flag, self).__init__(name, *rules, **kw) + + +class Number(Param): + """ + Base class for the `Int` and `Float` parameters. + """ + + def _convert_scalar(self, value, index=None): + """ + Convert a single scalar value. + """ + if type(value) is self.type: + return value + if type(value) in (unicode, int, float): + try: + return self.type(value) + except ValueError: + pass + raise ConversionError(name=self.name, index=index, + error=ugettext(self.type_error), + ) + + +class Int(Number): + """ + A parameter for integer values (stored in the ``int`` type). + """ + + type = int + type_error = _('must be an integer') + + +class Float(Number): + """ + A parameter for floating-point values (stored in the ``float`` type). + """ + + type = float + type_error = _('must be a decimal number') + + +class Data(Param): + """ + Base class for the `Bytes` and `Str` parameters. + + Previously `Str` was as subclass of `Bytes`. Now the common functionality + has been split into this base class so that ``isinstance(foo, Bytes)`` wont + be ``True`` when ``foo`` is actually an `Str` instance (which is confusing). + """ + + kwargs = Param.kwargs + ( + ('minlength', int, None), + ('maxlength', int, None), + ('length', int, None), + ) + + def __init__(self, name, *rules, **kw): + super(Data, self).__init__(name, *rules, **kw) + + if not ( + self.length is None or + (self.minlength is None and self.maxlength is None) + ): + raise ValueError( + '%s: cannot mix length with minlength or maxlength' % self.nice + ) + + if self.minlength is not None and self.minlength < 1: + raise ValueError( + '%s: minlength must be >= 1; got %r' % (self.nice, self.minlength) + ) + + if self.maxlength is not None and self.maxlength < 1: + raise ValueError( + '%s: maxlength must be >= 1; got %r' % (self.nice, self.maxlength) + ) + + if None not in (self.minlength, self.maxlength): + if self.minlength > self.maxlength: + raise ValueError( + '%s: minlength > maxlength (minlength=%r, maxlength=%r)' % ( + self.nice, self.minlength, self.maxlength) + ) + elif self.minlength == self.maxlength: + raise ValueError( + '%s: minlength == maxlength; use length=%d instead' % ( + self.nice, self.minlength) + ) + + +class Bytes(Data): + """ + A parameter for binary data (stored in the ``str`` type). + + This class is named *Bytes* instead of *Str* so it's aligned with the + Python v3 ``(str, unicode) => (bytes, str)`` clean-up. See: + + http://docs.python.org/3.0/whatsnew/3.0.html + """ + + type = str + type_error = _('must be binary data') + + kwargs = Data.kwargs + ( + ('pattern', str, None), + ) + + def _rule_minlength(self, _, value): + """ + Check minlength constraint. + """ + assert type(value) is str + if len(value) < self.minlength: + return _('must be at least %(minlength)d bytes') % dict( + minlength=self.minlength, + ) + + def _rule_maxlength(self, _, value): + """ + Check maxlength constraint. + """ + assert type(value) is str + if len(value) > self.maxlength: + return _('can be at most %(maxlength)d bytes') % dict( + maxlength=self.maxlength, + ) + + def _rule_length(self, _, value): + """ + Check length constraint. + """ + assert type(value) is str + if len(value) != self.length: + return _('must be exactly %(length)d bytes') % dict( + length=self.length, + ) + + +class Str(Data): + """ + A parameter for Unicode text (stored in the ``unicode`` type). + + This class is named *Str* instead of *Unicode* so it's aligned with the + Python v3 ``(str, unicode) => (bytes, str)`` clean-up. See: + + http://docs.python.org/3.0/whatsnew/3.0.html + """ + + type = unicode + type_error = _('must be Unicode text') + + kwargs = Data.kwargs + ( + ('pattern', unicode, None), + ) + + def _convert_scalar(self, value, index=None): + """ + Convert a single scalar value. + """ + if type(value) is self.type: + return value + if type(value) in (int, float): + return self.type(value) + raise ConversionError(name=self.name, index=index, + error=ugettext(self.type_error), + ) + + def _rule_minlength(self, _, value): + """ + Check minlength constraint. + """ + assert type(value) is unicode + if len(value) < self.minlength: + return _('must be at least %(minlength)d characters') % dict( + minlength=self.minlength, + ) + + def _rule_maxlength(self, _, value): + """ + Check maxlength constraint. + """ + assert type(value) is unicode + if len(value) > self.maxlength: + return _('can be at most %(maxlength)d characters') % dict( + maxlength=self.maxlength, + ) + + def _rule_length(self, _, value): + """ + Check length constraint. + """ + assert type(value) is unicode + if len(value) != self.length: + return _('must be exactly %(length)d characters') % dict( + length=self.length, + ) + + +class Password(Str): + """ + A parameter for passwords (stored in the ``unicode`` type). + """ + + +class Enum(Param): + """ + Base class for parameters with enumerable values. + """ + + kwargs = Param.kwargs + ( + ('values', tuple, tuple()), + ) + + def __init__(self, name, *rules, **kw): + super(Enum, self).__init__(name, *rules, **kw) + for (i, v) in enumerate(self.values): + if type(v) is not self.type: + n = '%s values[%d]' % (self.nice, i) + raise TypeError( + TYPE_ERROR % (n, self.type, v, type(v)) + ) + + def _rule_values(self, _, value, **kw): + if value not in self.values: + return _('must be one of %(values)r') % dict( + values=self.values, + ) + + +class BytesEnum(Enum): + """ + Enumerable for binary data (stored in the ``str`` type). + """ + + type = unicode + + +class StrEnum(Enum): + """ + Enumerable for Unicode text (stored in the ``unicode`` type). + + For example: + + >>> enum = StrEnum('my_enum', values=(u'One', u'Two', u'Three')) + >>> enum.validate(u'Two') is None + True + >>> enum.validate(u'Four') + Traceback (most recent call last): + ... + ValidationError: invalid 'my_enum': must be one of (u'One', u'Two', u'Three') + """ + + type = unicode + + +def create_param(spec): + """ + Create an `Str` instance from the shorthand ``spec``. + + This function allows you to create `Str` parameters (the most common) from + a convenient shorthand that defines the parameter name, whether it is + required, and whether it is multivalue. (For the definition of the + shorthand syntax, see the `parse_param_spec()` function.) + + If ``spec`` is an ``str`` instance, it will be used to create a new `Str` + parameter, which will be returned. For example: + + >>> s = create_param('hometown?') + >>> s + Str('hometown?') + >>> (s.name, s.required, s.multivalue) + ('hometown', False, False) + + On the other hand, if ``spec`` is already a `Param` instance, it is + returned unchanged. For example: + + >>> b = Bytes('cert') + >>> create_param(b) is b + True + + As a plugin author, you will not call this function directly (which would + be no more convenient than simply creating the `Str` instance). Instead, + `frontend.Command` will call it for you when it evaluates the + ``takes_args`` and ``takes_options`` attributes, and `frontend.Object` + will call it for you when it evaluates the ``takes_params`` attribute. + + :param spec: A spec string or a `Param` instance. + """ + if isinstance(spec, Param): + return spec + if type(spec) is not str: + raise TypeError( + TYPE_ERROR % ('spec', (str, Param), spec, type(spec)) + ) + return Str(spec) diff --git a/ipalib/plugable.py b/ipalib/plugable.py new file mode 100644 index 00000000..b52db900 --- /dev/null +++ b/ipalib/plugable.py @@ -0,0 +1,718 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Plugin framework. + +The classes in this module make heavy use of Python container emulation. If +you are unfamiliar with this Python feature, see +http://docs.python.org/ref/sequence-types.html +""" + +import re +import sys +import inspect +import threading +import logging +import os +from os import path +import subprocess +import errors2 +from config import Env +import util +from base import ReadOnly, NameSpace, lock, islocked, check_name +from constants import DEFAULT_CONFIG + + +class SetProxy(ReadOnly): + """ + A read-only container with set/sequence behaviour. + + This container acts as a proxy to an actual set-like object (a set, + frozenset, or dict) that is passed to the constructor. To the extent + possible in Python, this underlying set-like object cannot be modified + through the SetProxy... which just means you wont do it accidentally. + """ + def __init__(self, s): + """ + :param s: The target set-like object (a set, frozenset, or dict) + """ + allowed = (set, frozenset, dict) + if type(s) not in allowed: + raise TypeError('%r not in %r' % (type(s), allowed)) + self.__s = s + lock(self) + + def __len__(self): + """ + Return the number of items in this container. + """ + return len(self.__s) + + def __iter__(self): + """ + Iterate (in ascending order) through keys. + """ + for key in sorted(self.__s): + yield key + + def __contains__(self, key): + """ + Return True if this container contains ``key``. + + :param key: The key to test for membership. + """ + return key in self.__s + + +class DictProxy(SetProxy): + """ + A read-only container with mapping behaviour. + + This container acts as a proxy to an actual mapping object (a dict) that + is passed to the constructor. To the extent possible in Python, this + underlying mapping object cannot be modified through the DictProxy... + which just means you wont do it accidentally. + + Also see `SetProxy`. + """ + def __init__(self, d): + """ + :param d: The target mapping object (a dict) + """ + if type(d) is not dict: + raise TypeError('%r is not %r' % (type(d), dict)) + self.__d = d + super(DictProxy, self).__init__(d) + + def __getitem__(self, key): + """ + Return the value corresponding to ``key``. + + :param key: The key of the value you wish to retrieve. + """ + return self.__d[key] + + def __call__(self): + """ + Iterate (in ascending order by key) through values. + """ + for key in self: + yield self.__d[key] + + +class MagicDict(DictProxy): + """ + A mapping container whose values can be accessed as attributes. + + For example: + + >>> magic = MagicDict({'the_key': 'the value'}) + >>> magic['the_key'] + 'the value' + >>> magic.the_key + 'the value' + + This container acts as a proxy to an actual mapping object (a dict) that + is passed to the constructor. To the extent possible in Python, this + underlying mapping object cannot be modified through the MagicDict... + which just means you wont do it accidentally. + + Also see `DictProxy` and `SetProxy`. + """ + + def __getattr__(self, name): + """ + Return the value corresponding to ``name``. + + :param name: The name of the attribute you wish to retrieve. + """ + try: + return self[name] + except KeyError: + raise AttributeError('no magic attribute %r' % name) + + +class Plugin(ReadOnly): + """ + Base class for all plugins. + """ + __public__ = frozenset() + __proxy__ = True + __api = None + + def __init__(self): + cls = self.__class__ + self.name = cls.__name__ + self.module = cls.__module__ + self.fullname = '%s.%s' % (self.module, self.name) + self.doc = inspect.getdoc(cls) + if self.doc is None: + self.summary = '<%s>' % self.fullname + else: + self.summary = self.doc.split('\n\n', 1)[0] + log = logging.getLogger(self.fullname) + for name in ('debug', 'info', 'warning', 'error', 'critical', 'exception'): + if hasattr(self, name): + raise StandardError( + '%s.%s attribute (%r) conflicts with Plugin logger' % ( + self.name, name, getattr(self, name)) + ) + setattr(self, name, getattr(log, name)) + + def __get_api(self): + """ + Return `API` instance passed to `finalize()`. + + If `finalize()` has not yet been called, None is returned. + """ + return self.__api + api = property(__get_api) + + @classmethod + def implements(cls, arg): + """ + Return True if this class implements ``arg``. + + There are three different ways this method can be called: + + With a <type 'str'> argument, e.g.: + + >>> class base(Plugin): + ... __public__ = frozenset(['attr1', 'attr2']) + ... + >>> base.implements('attr1') + True + >>> base.implements('attr2') + True + >>> base.implements('attr3') + False + + With a <type 'frozenset'> argument, e.g.: + + With any object that has a `__public__` attribute that is + <type 'frozenset'>, e.g.: + + Unlike ProxyTarget.implemented_by(), this returns an abstract answer + because only the __public__ frozenset is checked... a ProxyTarget + need not itself have attributes for all names in __public__ + (subclasses might provide them). + """ + assert type(cls.__public__) is frozenset + if isinstance(arg, str): + return arg in cls.__public__ + if type(getattr(arg, '__public__', None)) is frozenset: + return cls.__public__.issuperset(arg.__public__) + if type(arg) is frozenset: + return cls.__public__.issuperset(arg) + raise TypeError( + "must be str, frozenset, or have frozenset '__public__' attribute" + ) + + @classmethod + def implemented_by(cls, arg): + """ + Return True if ``arg`` implements public interface of this class. + + This classmethod returns True if: + + 1. ``arg`` is an instance of or subclass of this class, and + + 2. ``arg`` (or ``arg.__class__`` if instance) has an attribute for + each name in this class's ``__public__`` frozenset. + + Otherwise, returns False. + + Unlike `Plugin.implements`, this returns a concrete answer because + the attributes of the subclass are checked. + + :param arg: An instance of or subclass of this class. + """ + if inspect.isclass(arg): + subclass = arg + else: + subclass = arg.__class__ + assert issubclass(subclass, cls), 'must be subclass of %r' % cls + for name in cls.__public__: + if not hasattr(subclass, name): + return False + return True + + def finalize(self): + """ + """ + lock(self) + + def set_api(self, api): + """ + Set reference to `API` instance. + """ + assert self.__api is None, 'set_api() can only be called once' + assert api is not None, 'set_api() argument cannot be None' + self.__api = api + if not isinstance(api, API): + return + for name in api: + assert not hasattr(self, name) + setattr(self, name, api[name]) + # FIXME: the 'log' attribute is depreciated. See Plugin.__init__() + for name in ('env', 'context', 'log'): + if hasattr(api, name): + assert not hasattr(self, name) + setattr(self, name, getattr(api, name)) + + def call(self, executable, *args): + """ + Call ``executable`` with ``args`` using subprocess.call(). + + If the call exits with a non-zero exit status, + `ipalib.errors2.SubprocessError` is raised, from which you can retrieve + the exit code by checking the SubprocessError.returncode attribute. + + This method does *not* return what ``executable`` sent to stdout... for + that, use `Plugin.callread()`. + """ + argv = (executable,) + args + self.debug('Calling %r', argv) + code = subprocess.call(argv) + if code != 0: + raise errors2.SubprocessError(returncode=code, argv=argv) + + def __repr__(self): + """ + Return 'module_name.class_name()' representation. + + This representation could be used to instantiate this Plugin + instance given the appropriate environment. + """ + return '%s.%s()' % ( + self.__class__.__module__, + self.__class__.__name__ + ) + + +class PluginProxy(SetProxy): + """ + Allow access to only certain attributes on a `Plugin`. + + Think of a proxy as an agreement that "I will have at most these + attributes". This is different from (although similar to) an interface, + which can be thought of as an agreement that "I will have at least these + attributes". + """ + + __slots__ = ( + '__base', + '__target', + '__name_attr', + '__public__', + 'name', + 'doc', + ) + + def __init__(self, base, target, name_attr='name'): + """ + :param base: A subclass of `Plugin`. + :param target: An instance ``base`` or a subclass of ``base``. + :param name_attr: The name of the attribute on ``target`` from which + to derive ``self.name``. + """ + if not inspect.isclass(base): + raise TypeError( + '`base` must be a class, got %r' % base + ) + if not isinstance(target, base): + raise ValueError( + '`target` must be an instance of `base`, got %r' % target + ) + self.__base = base + self.__target = target + self.__name_attr = name_attr + self.__public__ = base.__public__ + self.name = getattr(target, name_attr) + self.doc = target.doc + assert type(self.__public__) is frozenset + super(PluginProxy, self).__init__(self.__public__) + + def implements(self, arg): + """ + Return True if plugin being proxied implements ``arg``. + + This method simply calls the corresponding `Plugin.implements` + classmethod. + + Unlike `Plugin.implements`, this is not a classmethod as a + `PluginProxy` can only implement anything as an instance. + """ + return self.__base.implements(arg) + + def __clone__(self, name_attr): + """ + Return a `PluginProxy` instance similar to this one. + + The new `PluginProxy` returned will be identical to this one except + the proxy name might be derived from a different attribute on the + target `Plugin`. The same base and target will be used. + """ + return self.__class__(self.__base, self.__target, name_attr) + + def __getitem__(self, key): + """ + Return attribute named ``key`` on target `Plugin`. + + If this proxy allows access to an attribute named ``key``, that + attribute will be returned. If access is not allowed, + KeyError will be raised. + """ + if key in self.__public__: + return getattr(self.__target, key) + raise KeyError('no public attribute %s.%s' % (self.name, key)) + + def __getattr__(self, name): + """ + Return attribute named ``name`` on target `Plugin`. + + If this proxy allows access to an attribute named ``name``, that + attribute will be returned. If access is not allowed, + AttributeError will be raised. + """ + if name in self.__public__: + return getattr(self.__target, name) + raise AttributeError('no public attribute %s.%s' % (self.name, name)) + + def __call__(self, *args, **kw): + """ + Call target `Plugin` and return its return value. + + If `__call__` is not an attribute this proxy allows access to, + KeyError is raised. + """ + return self['__call__'](*args, **kw) + + def __repr__(self): + """ + Return a Python expression that could create this instance. + """ + return '%s(%s, %r)' % ( + self.__class__.__name__, + self.__base.__name__, + self.__target, + ) + + +class Registrar(DictProxy): + """ + Collects plugin classes as they are registered. + + The Registrar does not instantiate plugins... it only implements the + override logic and stores the plugins in a namespace per allowed base + class. + + The plugins are instantiated when `API.finalize()` is called. + """ + def __init__(self, *allowed): + """ + :param allowed: Base classes from which plugins accepted by this + Registrar must subclass. + """ + self.__allowed = dict((base, {}) for base in allowed) + self.__registered = set() + super(Registrar, self).__init__( + dict(self.__base_iter()) + ) + + def __base_iter(self): + for (base, sub_d) in self.__allowed.iteritems(): + assert inspect.isclass(base) + name = base.__name__ + assert not hasattr(self, name) + setattr(self, name, MagicDict(sub_d)) + yield (name, base) + + def __findbases(self, klass): + """ + Iterates through allowed bases that ``klass`` is a subclass of. + + Raises `errors2.PluginSubclassError` if ``klass`` is not a subclass of + any allowed base. + + :param klass: The plugin class to find bases for. + """ + assert inspect.isclass(klass) + found = False + for (base, sub_d) in self.__allowed.iteritems(): + if issubclass(klass, base): + found = True + yield (base, sub_d) + if not found: + raise errors2.PluginSubclassError( + plugin=klass, bases=self.__allowed.keys() + ) + + def __call__(self, klass, override=False): + """ + Register the plugin ``klass``. + + :param klass: A subclass of `Plugin` to attempt to register. + :param override: If true, override an already registered plugin. + """ + if not inspect.isclass(klass): + raise TypeError('plugin must be a class; got %r' % klass) + + # Raise DuplicateError if this exact class was already registered: + if klass in self.__registered: + raise errors2.PluginDuplicateError(plugin=klass) + + # Find the base class or raise SubclassError: + for (base, sub_d) in self.__findbases(klass): + # Check override: + if klass.__name__ in sub_d: + if not override: + # Must use override=True to override: + raise errors2.PluginOverrideError( + base=base.__name__, + name=klass.__name__, + plugin=klass, + ) + else: + if override: + # There was nothing already registered to override: + raise errors2.PluginMissingOverrideError( + base=base.__name__, + name=klass.__name__, + plugin=klass, + ) + + # The plugin is okay, add to sub_d: + sub_d[klass.__name__] = klass + + # The plugin is okay, add to __registered: + self.__registered.add(klass) + + +class LazyContext(object): + """ + On-demand creation of thread-local context attributes. + """ + + def __init__(self, api): + self.__api = api + self.__context = threading.local() + + def __getattr__(self, name): + if name not in self.__context.__dict__: + if name not in self.__api.Context: + raise AttributeError('no Context plugin for %r' % name) + value = self.__api.Context[name].get_value() + self.__context.__dict__[name] = value + return self.__context.__dict__[name] + + def __getitem__(self, key): + return self.__getattr__(key) + + + +class API(DictProxy): + """ + Dynamic API object through which `Plugin` instances are accessed. + """ + + def __init__(self, *allowed): + self.__d = dict() + self.__done = set() + self.register = Registrar(*allowed) + self.env = Env() + self.context = LazyContext(self) + super(API, self).__init__(self.__d) + + def __doing(self, name): + if name in self.__done: + raise StandardError( + '%s.%s() already called' % (self.__class__.__name__, name) + ) + self.__done.add(name) + + def __do_if_not_done(self, name): + if name not in self.__done: + getattr(self, name)() + + def isdone(self, name): + return name in self.__done + + def bootstrap(self, **overrides): + """ + Initialize environment variables and logging. + """ + self.__doing('bootstrap') + self.env._bootstrap(**overrides) + self.env._finalize_core(**dict(DEFAULT_CONFIG)) + log = logging.getLogger('ipa') + object.__setattr__(self, 'log', log) + if self.env.debug: + log.setLevel(logging.DEBUG) + else: + log.setLevel(logging.INFO) + + # Add stderr handler: + stderr = logging.StreamHandler() + format = self.env.log_format_stderr + if self.env.debug: + format = self.env.log_format_stderr_debug + stderr.setLevel(logging.DEBUG) + elif self.env.verbose: + stderr.setLevel(logging.INFO) + else: + stderr.setLevel(logging.WARNING) + stderr.setFormatter(util.LogFormatter(format)) + log.addHandler(stderr) + + # Add file handler: + if self.env.mode in ('dummy', 'unit_test'): + return # But not if in unit-test mode + log_dir = path.dirname(self.env.log) + if not path.isdir(log_dir): + try: + os.makedirs(log_dir) + except OSError: + log.warn('Could not create log_dir %r', log_dir) + return + handler = logging.FileHandler(self.env.log) + handler.setFormatter(util.LogFormatter(self.env.log_format_file)) + if self.env.debug: + handler.setLevel(logging.DEBUG) + else: + handler.setLevel(logging.INFO) + log.addHandler(handler) + + def bootstrap_with_global_options(self, options=None, context=None): + if options is None: + parser = util.add_global_options() + (options, args) = parser.parse_args( + list(s.decode('utf-8') for s in sys.argv[1:]) + ) + overrides = {} + if options.env is not None: + assert type(options.env) is list + for item in options.env: + try: + (key, value) = item.split('=', 1) + except ValueError: + # FIXME: this should raise an IPA exception with an + # error code. + # --Jason, 2008-10-31 + pass + overrides[str(key.strip())] = value.strip() + for key in ('conf', 'debug', 'verbose'): + value = getattr(options, key, None) + if value is not None: + overrides[key] = value + if context is not None: + overrides['context'] = context + self.bootstrap(**overrides) + + def load_plugins(self): + """ + Load plugins from all standard locations. + + `API.bootstrap` will automatically be called if it hasn't been + already. + """ + self.__doing('load_plugins') + self.__do_if_not_done('bootstrap') + if self.env.mode in ('dummy', 'unit_test'): + return + util.import_plugins_subpackage('ipalib') + if self.env.in_server: + util.import_plugins_subpackage('ipaserver') + + def finalize(self): + """ + Finalize the registration, instantiate the plugins. + + `API.bootstrap` will automatically be called if it hasn't been + already. + """ + self.__doing('finalize') + self.__do_if_not_done('load_plugins') + + class PluginInstance(object): + """ + Represents a plugin instance. + """ + + i = 0 + + def __init__(self, klass): + self.created = self.next() + self.klass = klass + self.instance = klass() + self.bases = [] + + @classmethod + def next(cls): + cls.i += 1 + return cls.i + + class PluginInfo(ReadOnly): + def __init__(self, p): + assert isinstance(p, PluginInstance) + self.created = p.created + self.name = p.klass.__name__ + self.module = str(p.klass.__module__) + self.plugin = '%s.%s' % (self.module, self.name) + self.bases = tuple(b.__name__ for b in p.bases) + lock(self) + + plugins = {} + def plugin_iter(base, subclasses): + for klass in subclasses: + assert issubclass(klass, base) + if klass not in plugins: + plugins[klass] = PluginInstance(klass) + p = plugins[klass] + assert base not in p.bases + p.bases.append(base) + if base.__proxy__: + yield PluginProxy(base, p.instance) + else: + yield p.instance + + for name in self.register: + base = self.register[name] + magic = getattr(self.register, name) + namespace = NameSpace( + plugin_iter(base, (magic[k] for k in magic)) + ) + assert not ( + name in self.__d or hasattr(self, name) + ) + self.__d[name] = namespace + object.__setattr__(self, name, namespace) + + for p in plugins.itervalues(): + p.instance.set_api(self) + assert p.instance.api is self + + for p in plugins.itervalues(): + p.instance.finalize() + object.__setattr__(self, '_API__finalized', True) + tuple(PluginInfo(p) for p in plugins.itervalues()) + object.__setattr__(self, 'plugins', + tuple(PluginInfo(p) for p in plugins.itervalues()) + ) diff --git a/ipalib/plugins/__init__.py b/ipalib/plugins/__init__.py new file mode 100644 index 00000000..544429ef --- /dev/null +++ b/ipalib/plugins/__init__.py @@ -0,0 +1,25 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Sub-package containing all core plugins. + +By convention, modules with frontend plugins are named f_*.py and modules +with backend plugins are named b_*.py. +""" diff --git a/ipalib/plugins/b_kerberos.py b/ipalib/plugins/b_kerberos.py new file mode 100644 index 00000000..cc820497 --- /dev/null +++ b/ipalib/plugins/b_kerberos.py @@ -0,0 +1,34 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Backend plugin for Kerberos. + +This wraps the python-kerberos and python-krbV bindings. +""" + +from ipalib import api +from ipalib.backend import Backend + +class krb(Backend): + """ + Kerberos backend plugin. + """ + +api.register(krb) diff --git a/ipalib/plugins/b_xmlrpc.py b/ipalib/plugins/b_xmlrpc.py new file mode 100644 index 00000000..14f2a9be --- /dev/null +++ b/ipalib/plugins/b_xmlrpc.py @@ -0,0 +1,102 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Backend plugin for XML-RPC client. + +This provides a lightwieght XML-RPC client using Python standard library +``xmlrpclib`` module. +""" + +import xmlrpclib +import socket +import httplib +import kerberos +from ipalib.backend import Backend +from ipalib.util import xmlrpc_marshal +from ipalib import api +from ipalib import errors + +class xmlrpc(Backend): + """ + XML-RPC client backend plugin. + """ + + def get_client(self): + """ + Return an xmlrpclib.ServerProxy instance (the client). + """ + # FIXME: Rob, is there any reason we can't use allow_none=True here? + # Are there any reasonably common XML-RPC client implementations + # that don't support the <nil/> extension? + # See: http://docs.python.org/library/xmlrpclib.html + uri = self.env.xmlrpc_uri + if uri.startswith('https://'): + return xmlrpclib.ServerProxy(uri, + transport=KerbTransport(), + ) + return xmlrpclib.ServerProxy(uri) + + def forward_call(self, name, *args, **kw): + """ + Forward a call over XML-RPC to an IPA server. + """ + self.info('Forwarding %r call to %r' % (name, self.env.xmlrpc_uri)) + client = self.get_client() + command = getattr(client, name) + params = xmlrpc_marshal(*args, **kw) + try: + return command(*params) + except socket.error, e: + raise + except xmlrpclib.Fault, e: + err = errors.convertFault(e) + raise err + return + +api.register(xmlrpc) + +class KerbTransport(xmlrpclib.SafeTransport): + """Handles Kerberos Negotiation authentication to an XML-RPC server.""" + + def get_host_info(self, host): + + host, extra_headers, x509 = xmlrpclib.Transport.get_host_info(self, host) + + # Set the remote host principal + h = host + hostinfo = h.split(':') + service = "HTTP@" + hostinfo[0] + + try: + rc, vc = kerberos.authGSSClientInit(service); + except kerberos.GSSError, e: + raise kerberos.GSSError(e) + + try: + kerberos.authGSSClientStep(vc, ""); + except kerberos.GSSError, e: + raise kerberos.GSSError(e) + + extra_headers = [ + ("Authorization", "negotiate %s" % kerberos.authGSSClientResponse(vc) ) + ] + + return host, extra_headers, x509 diff --git a/ipalib/plugins/f_automount.py b/ipalib/plugins/f_automount.py new file mode 100644 index 00000000..2365ce22 --- /dev/null +++ b/ipalib/plugins/f_automount.py @@ -0,0 +1,563 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for automount. + +RFC 2707bis http://www.padl.com/~lukeh/rfc2307bis.txt +""" + +from ldap import explode_dn +from ipalib import crud, errors +from ipalib import api, Str, Flag, Object, Command + +map_attributes = ['automountMapName', 'description', ] +key_attributes = ['description', 'automountKey', 'automountInformation'] + +def display_entry(textui, entry): + # FIXME: for now delete dn here. In the future pass in the kw to + # output_for_cli() + attr = sorted(entry.keys()) + + for a in attr: + if a != 'dn': + textui.print_plain("%s: %s" % (a, entry[a])) + +def make_automount_dn(mapname): + """ + Construct automount dn from map name. + """ + # FIXME, should this be in b_ldap? + # Experimenting to see what a plugin looks like for a 3rd party who can't + # modify the backend. + import ldap + return 'automountmapname=%s,%s,%s' % ( + ldap.dn.escape_dn_chars(mapname), + api.env.container_automount, + api.env.basedn, + ) + +class automount(Object): + """ + Automount object. + """ + takes_params = ( + Str('automountmapname', + cli_name='mapname', + primary_key=True, + doc='A group of related automount objects', + ), + ) +api.register(automount) + + +class automount_addmap(crud.Add): + 'Add a new automount map.' + + takes_options = ( + Str('description?', + doc='A description of the automount map'), + ) + + def execute(self, mapname, **kw): + """ + Execute the automount-addmap operation. + + Returns the entry as it will be created in LDAP. + + :param mapname: The map name being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'automountmapname' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + kw['automountmapname'] = mapname + kw['dn'] = make_automount_dn(mapname) + + kw['objectClass'] = ['automountMap'] + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, map, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Automount map %s added" % map) + +api.register(automount_addmap) + + +class automount_addkey(crud.Add): + 'Add a new automount key.' + takes_options = ( + Str('automountkey', + cli_name='key', + doc='An entry in an automount map'), + Str('automountinformation', + cli_name='info', + doc='Mount information for this key'), + Str('description?', + doc='A description of the mount'), + ) + + def execute(self, mapname, **kw): + """ + Execute the automount-addkey operation. + + Returns the entry as it will be created in LDAP. + + :param mapname: The map name being added to. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'automountmapname' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + # use find_entry_dn instead of make_automap_dn so we can confirm that + # the map exists + map_dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + kw['dn'] = "automountkey=%s,%s" % (kw['automountkey'], map_dn) + + kw['objectClass'] = ['automount'] + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Automount key added") + +api.register(automount_addkey) + + +class automount_delmap(crud.Del): + 'Delete an automount map.' + def execute(self, mapname, **kw): + """Delete an automount map. This will also remove all of the keys + associated with this map. + + mapname is the automount map to remove + + :param mapname: The map to be removed + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + keys = api.Command['automount_getkeys'](mapname) + if keys: + for k in keys: + ldap.delete(k.get('dn')) + return ldap.delete(dn) + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + print "Automount map and associated keys deleted" + +api.register(automount_delmap) + + +class automount_delkey(crud.Del): + 'Delete an automount key.' + takes_options = ( + Str('automountkey', + cli_name='key', + doc='The automount key to remove'), + ) + def execute(self, mapname, **kw): + """Delete an automount key. + + key is the automount key to remove + + :param mapname: The automount map containing the key to be removed + :param kw: "key" the key to be removed + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + keys = api.Command['automount_getkeys'](mapname) + keydn = None + keyname = kw.get('automountkey').lower() + if keys: + for k in keys: + if k.get('automountkey').lower() == keyname: + keydn = k.get('dn') + break + if not keydn: + raise errors.NotFound + return ldap.delete(keydn) + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + print "Automount key deleted" + +api.register(automount_delkey) + +class automount_modmap(crud.Mod): + 'Edit an existing automount map.' + takes_options = ( + Str('description?', + doc='A description of the automount map'), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-modmap operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param mapname: The map name to update. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'automountmapname' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + print "Automount map updated" + +api.register(automount_modmap) + + +class automount_modkey(crud.Mod): + 'Edit an existing automount key.' + takes_options = ( + Str('automountkey', + cli_name='key', + doc='An entry in an automount map'), + Str('automountinformation?', + cli_name='info', + doc='Mount information for this key'), + Str('description?', + doc='A description of the automount map'), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-modkey operation. + + Returns the entry + + :param mapname: The map name to update. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'automountmapname' not in kw + assert 'dn' not in kw + keyname = kw.get('automountkey').lower() + del kw['automountkey'] + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + keys = api.Command['automount_getkeys'](mapname) + keydn = None + if keys: + for k in keys: + if k.get('automountkey').lower() == keyname: + keydn = k.get('dn') + break + if not keydn: + raise errors.NotFound + return ldap.update(keydn, **kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + print "Automount key updated" + +api.register(automount_modkey) + + +class automount_findmap(crud.Find): + 'Search automount maps.' + takes_options = ( + Flag('all', doc='Retrieve all attributes'), + ) + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + search_fields = map_attributes + + for s in search_fields: + kw[s] = term + + kw['objectclass'] = 'automountMap' + kw['base'] = api.env.container_automount + if kw.get('all', False): + kw['attributes'] = ['*'] + else: + kw['attributes'] = map_attributes + return ldap.search(**kw) + + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + entries = result[1:] + if counter == 0: + textui.print_plain("No entries found") + return + elif counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") + + for e in entries: + display_entry(textui, e) + textui.print_plain("") + +api.register(automount_findmap) + + +class automount_findkey(crud.Find): + 'Search automount keys.' + takes_options = ( + Flag('all?', doc='Retrieve all attributes'), + ) + def get_args(self): + return (Str('automountkey', + cli_name='key', + doc='An entry in an automount map'),) + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + search_fields = key_attributes + + for s in search_fields: + kw[s] = term + + kw['objectclass'] = 'automount' + kw['base'] = api.env.container_automount + if kw.get('all', False): + kw['attributes'] = ['*'] + else: + kw['attributes'] = key_attributes + return ldap.search(**kw) + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + entries = result[1:] + if counter == 0: + textui.print_plain("No entries found") + return + elif counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") + + for e in entries: + display_entry(textui, e) + textui.print_plain("") + +api.register(automount_findkey) + + +class automount_showmap(crud.Get): + 'Examine an existing automount map.' + takes_options = ( + Flag('all?', doc='Retrieve all attributes'), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-showmap operation. + + Returns the entry + + :param mapname: The automount map to retrieve + :param kw: "all" set to True = return all attributes + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + # FIXME: should kw contain the list of attributes to display? + if kw.get('all', False): + return ldap.retrieve(dn) + else: + return ldap.retrieve(dn, map_attributes) + def output_for_cli(self, textui, result, *args, **options): + if result: + display_entry(textui, result) + +api.register(automount_showmap) + + +class automount_showkey(crud.Get): + 'Examine an existing automount key.' + takes_options = ( + Str('automountkey', + cli_name='key', + doc='The automount key to display'), + Flag('all?', doc='Retrieve all attributes'), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-showkey operation. + + Returns the entry + + :param mapname: The mapname to examine + :param kw: "automountkey" the key to retrieve + :param kw: "all" set to True = return all attributes + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + keys = api.Command['automount_getkeys'](mapname) + keyname = kw.get('automountkey').lower() + keydn = None + if keys: + for k in keys: + if k.get('automountkey').lower() == keyname: + keydn = k.get('dn') + break + if not keydn: + raise errors.NotFound + # FIXME: should kw contain the list of attributes to display? + if kw.get('all', False): + return ldap.retrieve(keydn) + else: + return ldap.retrieve(keydn, key_attributes) + def output_for_cli(self, textui, result, *args, **options): + # The automount map name associated with this key is available only + # in the dn. Add it as an attribute to display instead. + if result and not result.get('automountmapname'): + elements = explode_dn(result.get('dn').lower()) + for e in elements: + (attr, value) = e.split('=',1) + if attr == 'automountmapname': + result['automountmapname'] = value + display_entry(textui, result) + +api.register(automount_showkey) + + +class automount_getkeys(Command): + 'Retrieve all keys for an automount map.' + takes_args = ( + Str('automountmapname', + cli_name='mapname', + primary_key=True, + doc='A group of related automount objects', + ), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-getkeys operation. + + Return a list of all automount keys for this mapname + + :param mapname: Retrieve all keys for this mapname + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + try: + keys = ldap.get_one_entry(dn, 'objectclass=*', ['automountkey']) + except errors.NotFound: + keys = [] + + return keys + def output_for_cli(self, textui, result, *args, **options): + for k in result: + textui.print_plain('%s' % k.get('automountkey')) + +api.register(automount_getkeys) + + +class automount_getmaps(Command): + 'Retrieve all automount maps' + takes_args = ( + Str('automountmapname?', + cli_name='mapname', + primary_key=True, + doc='A group of related automount objects', + ), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-getmaps operation. + + Return a list of all automount maps. + """ + + ldap = self.api.Backend.ldap + base = api.env.container_automount + "," + api.env.basedn + + if not mapname: + mapname = "auto.master" + search_base = "automountmapname=%s,%s" % (mapname, base) + maps = ldap.get_one_entry(search_base, "objectClass=*", ["*"]) + + return maps + def output_for_cli(self, textui, result, *args, **options): + for k in result: + textui.print_plain('%s: %s' % (k.get('automountinformation'), k.get('automountkey'))) + +api.register(automount_getmaps) + +class automount_addindirectmap(crud.Add): + """ + Add a new automap indirect mount point. + """ + + takes_options = ( + Str('parentmap?', + cli_name='parentmap', + default=u'auto.master', + doc='The parent map to connect this to.', + ), + Str('automountkey', + cli_name='key', + doc='An entry in an automount map', + ), + Str('description?', + doc='A description of the automount map', + ), + ) + + def execute(self, mapname, **kw): + """ + Execute the automount-addindirectmap operation. + + Returns the key entry as it will be created in LDAP. + + This function creates 2 LDAP entries. It creates an + automountmapname entry and an automountkey entry. + + :param mapname: The map name being added. + :param kw['parentmap'] is the top-level map to add this to. + defaulting to auto.master + :param kw['automountkey'] is the mount point + :param kw['description'] is a textual description of this map + """ + mapkw = {} + if kw.get('description'): + mapkw['description'] = kw.get('description') + newmap = api.Command['automount_addmap'](mapname, **mapkw) + + keykw = {'automountkey': kw['automountkey'], 'automountinformation': mapname} + if kw.get('description'): + keykw['description'] = kw.get('description') + newkey = api.Command['automount_addkey'](kw['parentmap'], **keykw) + + return newkey + def output_for_cli(self, textui, result, map, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Indirect automount map %s added" % map) + +api.register(automount_addindirectmap) diff --git a/ipalib/plugins/f_delegation.py b/ipalib/plugins/f_delegation.py new file mode 100644 index 00000000..fbf8cfbf --- /dev/null +++ b/ipalib/plugins/f_delegation.py @@ -0,0 +1,65 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for delegations. +""" + +from ipalib import frontend +from ipalib import crud +from ipalib.frontend import Param +from ipalib import api +from ipalib import errors + +class delegation(frontend.Object): + """ + Delegation object. + """ + takes_params = ( + 'attributes', + 'source', + 'target', + Param('name', primary_key=True) + ) +api.register(delegation) + + +class delegation_add(crud.Add): + 'Add a new delegation.' +api.register(delegation_add) + + +class delegation_del(crud.Del): + 'Delete an existing delegation.' +api.register(delegation_del) + + +class delegation_mod(crud.Mod): + 'Edit an existing delegation.' +api.register(delegation_mod) + + +class delegation_find(crud.Find): + 'Search for a delegation.' +api.register(delegation_find) + + +class delegation_show(crud.Get): + 'Examine an existing delegation.' +api.register(delegation_show) diff --git a/ipalib/plugins/f_group.py b/ipalib/plugins/f_group.py new file mode 100644 index 00000000..740b32f8 --- /dev/null +++ b/ipalib/plugins/f_group.py @@ -0,0 +1,384 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for group (Identity). +""" + +from ipalib import api, crud, errors +from ipalib import Object, Command # Plugin base classes +from ipalib import Str, Int # Parameter types + + +def get_members(members): + """ + Return a list of members. + + It is possible that the value passed in is None. + """ + if members: + members = members.split(',') + else: + members = [] + + return members + +class group(Object): + """ + Group object. + """ + takes_params = ( + Str('description', + doc='A description of this group', + ), + Int('gidnumber?', + cli_name='gid', + doc='The gid to use for this group. If not included one is automatically set.', + ), + Str('cn', + cli_name='name', + primary_key=True, + normalizer=lambda value: value.lower(), + ), + ) +api.register(group) + + +class group_add(crud.Add): + 'Add a new group.' + + def execute(self, cn, **kw): + """ + Execute the group-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry as it will be created in LDAP. + + No need to explicitly set gidNumber. The dna_plugin will do this + for us if the value isn't provided by the caller. + + :param cn: The name of the group being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + kw['cn'] = cn + kw['dn'] = ldap.make_group_dn(cn) + + # Get our configuration + config = ldap.get_ipa_config() + + # some required objectclasses + kw['objectClass'] = config.get('ipagroupobjectclasses') + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_name(self.name) + textui.print_entry(result) + textui.print_dashed('Added group "%s"' % result['cn']) + +api.register(group_add) + + +class group_del(crud.Del): + 'Delete an existing group.' + def execute(self, cn, **kw): + """ + Delete a group + + The memberOf plugin handles removing the group from any other + groups. + + :param cn: The name of the group being removed + :param kw: Unused + """ + # We have 2 special groups, don't allow them to be removed +# if "admins" == cn.lower() or "editors" == cn.lower(): +# raise ipaerror.gen_exception(ipaerror.CONFIG_REQUIRED_GROUPS) + + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, "posixGroup") + self.log.info("IPA: group-del '%s'" % dn) + + # Don't allow the default user group to be removed + config=ldap.get_ipa_config() + default_group = ldap.find_entry_dn("cn", config.get('ipadefaultprimarygroup'), "posixGroup") + if dn == default_group: + raise errors.DefaultGroup + + return ldap.delete(dn) + + def output_for_cli(self, textui, result, cn): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Deleted group %s" % cn) + +api.register(group_del) + + +class group_mod(crud.Mod): + 'Edit an existing group.' + def execute(self, cn, **kw): + """ + Execute the group-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The name of the group to update. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, "posixGroup") + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, cn, **options): + """ + Output result of this command to command line interface. + """ + if result: + textui.print_plain("Group updated") + +api.register(group_mod) + + +class group_find(crud.Find): + 'Search the groups.' + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + # Pull the list of searchable attributes out of the configuration. + config = ldap.get_ipa_config() + search_fields_conf_str = config.get('ipagroupsearchfields') + search_fields = search_fields_conf_str.split(",") + + search_kw = {} + for s in search_fields: + search_kw[s] = term + + object_type = ldap.get_object_type("cn") + if object_type and not kw.get('objectclass'): + search_kw['objectclass'] = object_type + return ldap.search(**search_kw) + + def output_for_cli(self, textui, result, uid, **options): + counter = result[0] + groups = result[1:] + if counter == 0 or len(groups) == 0: + textui.print_plain("No entries found") + return + if len(groups) == 1: + textui.print_entry(groups[0]) + return + textui.print_name(self.name) + + for g in groups: + textui.print_entry(g) + textui.print_plain('') + if counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") + textui.print_count(groups, '%d groups matched') + +api.register(group_find) + + +class group_show(crud.Get): + 'Examine an existing group.' + def execute(self, cn, **kw): + """ + Execute the group-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The group name to retrieve. + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, "posixGroup") + # FIXME: should kw contain the list of attributes to display? + return ldap.retrieve(dn) + + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + groups = result[1:] + if counter == 0 or len(groups) == 0: + textui.print_plain("No entries found") + return + if len(groups) == 1: + textui.print_entry(groups[0]) + return + textui.print_name(self.name) + for u in groups: + textui.print_plain('%(givenname)s %(sn)s:' % u) + textui.print_entry(u) + textui.print_plain('') + if counter == -1: + textui.print_plain('These results are truncated.') + textui.print_plain('Please refine your search and try again.') + textui.print_count(groups, '%d groups matched') + +api.register(group_show) + + +class group_add_member(Command): + 'Add a member to a group.' + takes_args = ( + Str('group', primary_key=True), + ) + takes_options = ( + Str('users?', doc='comma-separated list of users to add'), + Str('groups?', doc='comma-separated list of groups to add'), + ) + def execute(self, cn, **kw): + """ + Execute the group-add-member operation. + + Returns the updated group entry + + :param cn: The group name to add new members to. + :param kw: groups is a comma-separated list of groups to add + :parem kw: users is a comma-separated list of users to add + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn) + add_failed = [] + to_add = [] + completed = 0 + + members = get_members(kw.get('groups', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m) + to_add.append(member_dn) + except errors.NotFound: + add_failed.append(m) + continue + + members = get_members(kw.get('users', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("uid", m) + to_add.append(member_dn) + except errors.NotFound: + add_failed.append(m) + continue + + for member_dn in to_add: + try: + ldap.add_member_to_group(member_dn, dn) + completed+=1 + except: + add_failed.append(member_dn) + + return add_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + print "These entries failed to add to the group:" + for a in add_failed: + print "\t'%s'" % a + + +api.register(group_add_member) + + +class group_remove_member(Command): + 'Remove a member from a group.' + takes_args = ( + Str('group', primary_key=True), + ) + takes_options = ( + Str('users?', doc='comma-separated list of users to remove'), + Str('groups?', doc='comma-separated list of groups to remove'), + ) + def execute(self, cn, **kw): + """ + Execute the group-remove-member operation. + + Returns the members that could not be added + + :param cn: The group name to add new members to. + :param kw: groups is a comma-separated list of groups to remove + :parem kw: users is a comma-separated list of users to remove + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn) + to_remove = [] + remove_failed = [] + completed = 0 + + members = get_members(kw.get('groups', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m) + to_remove.append(member_dn) + except errors.NotFound: + remove_failed.append(m) + continue + + members = get_members(kw.get('users', '')) + for m in members: + try: + member_dn = ldap.find_entry_dn("uid", m,) + to_remove.append(member_dn) + except errors.NotFound: + remove_failed.append(m) + continue + + for member_dn in to_remove: + try: + ldap.remove_member_from_group(member_dn, dn) + completed+=1 + except: + remove_failed.append(member_dn) + + return remove_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + print "These entries failed to be removed from the group:" + for a in remove_failed: + print "\t'%s'" % a + +api.register(group_remove_member) diff --git a/ipalib/plugins/f_host.py b/ipalib/plugins/f_host.py new file mode 100644 index 00000000..ea819a77 --- /dev/null +++ b/ipalib/plugins/f_host.py @@ -0,0 +1,286 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for host/machine Identity. +""" + +from ipalib import api, crud, errors, util +from ipalib import Object # Plugin base class +from ipalib import Str, Flag # Parameter types + + +def get_host(hostname): + """ + Try to get the hostname as fully-qualified first, then fall back to + just a host name search. + """ + ldap = api.Backend.ldap + + # Strip off trailing dot + if hostname.endswith('.'): + hostname = hostname[:-1] + try: + dn = ldap.find_entry_dn("cn", hostname, "ipaHost") + except errors.NotFound: + dn = ldap.find_entry_dn("serverhostname", hostname, "ipaHost") + return dn + +def validate_host(ugettext, cn): + """ + Require at least one dot in the hostname (to support localhost.localdomain) + """ + dots = len(cn.split('.')) + if dots < 2: + return 'Fully-qualified hostname required' + return None + +default_attributes = ['cn','description','localityname','nshostlocation','nshardwareplatform','nsosversion'] + +class host(Object): + """ + Host object. + """ + takes_params = ( + Str('cn', validate_host, + cli_name='hostname', + primary_key=True, + normalizer=lambda value: value.lower(), + ), + Str('description?', + doc='Description of the host', + ), + Str('localityname?', + cli_name='locality', + doc='Locality of this host (Baltimore, MD)', + ), + Str('nshostlocation?', + cli_name='location', + doc='Location of this host (e.g. Lab 2)', + ), + Str('nshardwareplatform?', + cli_name='platform', + doc='Hardware platform of this host (e.g. Lenovo T61)', + ), + Str('nsosversion?', + cli_name='os', + doc='Operating System and version on this host (e.g. Fedora 9)', + ), + Str('userpassword?', + cli_name='password', + doc='Set a password to be used in bulk enrollment', + ), + ) +api.register(host) + + +class host_add(crud.Add): + 'Add a new host.' + def execute(self, hostname, **kw): + """ + Execute the host-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + If password is set then this is considered a 'bulk' host so we + do not create a kerberos service principal. + + Returns the entry as it will be created in LDAP. + + :param hostname: The name of the host being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + assert 'krbprincipalname' not in kw + ldap = self.api.Backend.ldap + + kw['cn'] = hostname + kw['serverhostname'] = hostname.split('.',1)[0] + kw['dn'] = ldap.make_host_dn(hostname) + + # FIXME: do a DNS lookup to ensure host exists + + current = util.get_current_principal() + if not current: + raise errors.NotFound('Unable to determine current user') + kw['enrolledby'] = ldap.find_entry_dn("krbPrincipalName", current, "posixAccount") + + # Get our configuration + config = ldap.get_ipa_config() + + # some required objectclasses + # FIXME: add this attribute to cn=ipaconfig + #kw['objectclass'] = config.get('ipahostobjectclasses') + kw['objectclass'] = ['nsHost', 'ipaHost', 'pkiUser'] + + # Ensure the list of objectclasses is lower-case + kw['objectclass'] = map(lambda z: z.lower(), kw.get('objectclass')) + + if not kw.get('userpassword', False): + kw['krbprincipalname'] = "host/%s@%s" % (hostname, self.api.env.realm) + + if 'krbprincipalaux' not in kw.get('objectclass'): + kw['objectclass'].append('krbprincipalaux') + else: + if 'krbprincipalaux' in kw.get('objectclass'): + kw['objectclass'].remove('krbprincipalaux') + + return ldap.create(**kw) + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Host added") + +api.register(host_add) + + +class host_del(crud.Del): + 'Delete an existing host.' + def execute(self, hostname, **kw): + """Delete a host. + + hostname is the name of the host to delete + + :param hostname: The name of the host being removed. + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + dn = get_host(hostname) + return ldap.delete(dn) + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Host deleted") + +api.register(host_del) + + +class host_mod(crud.Mod): + 'Edit an existing host.' + def execute(self, hostname, **kw): + """ + Execute the host-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param hostname: The name of the host to retrieve. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = get_host(hostname) + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Host updated") + +api.register(host_mod) + + +class host_find(crud.Find): + 'Search the hosts.' + + takes_options = ( + Flag('all', doc='Retrieve all attributes'), + ) + + # FIXME: This should no longer be needed with the Param.query kwarg. +# def get_args(self): +# """ +# Override Find.get_args() so we can exclude the validation rules +# """ +# yield self.obj.primary_key.__clone__(rules=tuple()) + + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + # Pull the list of searchable attributes out of the configuration. + #config = ldap.get_ipa_config() + # FIXME: add this attribute to cn=ipaconfig + #search_fields_conf_str = config.get('ipahostsearchfields') + #search_fields = search_fields_conf_str.split(",") + search_fields = ['cn','serverhostname','description','localityname','nshostlocation','nshardwareplatform','nsosversion'] + + search_kw = {} + for s in search_fields: + search_kw[s] = term + + # Can't use ldap.get_object_type() since cn is also used for group dns + search_kw['objectclass'] = "ipaHost" + if kw.get('all', False): + search_kw['attributes'] = ['*'] + else: + search_kw['attributes'] = default_attributes + return ldap.search(**search_kw) + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + hosts = result[1:] + if counter == 0: + textui.print_plain("No entries found") + return + + for h in hosts: + textui.print_entry(h) + if counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") +api.register(host_find) + + +class host_show(crud.Get): + 'Examine an existing host.' + takes_options = ( + Flag('all', doc='Display all host attributes'), + ) + def execute(self, hostname, **kw): + """ + Execute the host-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param hostname: The login name of the host to retrieve. + :param kw: "all" set to True = return all attributes + """ + ldap = self.api.Backend.ldap + dn = get_host(hostname) + # FIXME: should kw contain the list of attributes to display? + if kw.get('all', False): + return ldap.retrieve(dn) + else: + value = ldap.retrieve(dn, default_attributes) + del value['dn'] + return value + def output_for_cli(self, textui, result, *args, **options): + textui.print_entry(result) + +api.register(host_show) diff --git a/ipalib/plugins/f_hostgroup.py b/ipalib/plugins/f_hostgroup.py new file mode 100644 index 00000000..706712c9 --- /dev/null +++ b/ipalib/plugins/f_hostgroup.py @@ -0,0 +1,354 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for groups of hosts +""" + +from ipalib import api, crud, errors +from ipalib import Object, Command # Plugin base classes +from ipalib import Str # Parameter types + + +hostgroup_filter = "groupofnames)(!(objectclass=posixGroup)" + +def get_members(members): + """ + Return a list of members. + + It is possible that the value passed in is None. + """ + if members: + members = members.split(',') + else: + members = [] + + return members + +class hostgroup(Object): + """ + Host Group object. + """ + takes_params = ( + Str('description', + doc='A description of this group', + ), + Str('cn', + cli_name='name', + primary_key=True, + normalizer=lambda value: value.lower(), + ) + ) +api.register(hostgroup) + + +class hostgroup_add(crud.Add): + 'Add a new group of hosts.' + + def execute(self, cn, **kw): + """ + Execute the hostgroup-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry as it will be created in LDAP. + + No need to explicitly set gidNumber. The dna_plugin will do this + for us if the value isn't provided by the caller. + + :param cn: The name of the host group being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + kw['cn'] = cn + kw['dn'] = ldap.make_hostgroup_dn(cn) + + # Get our configuration + #config = ldap.get_ipa_config() + + # some required objectclasses + # FIXME: get this out of config + kw['objectClass'] = ['groupofnames'] + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Group added") + +api.register(hostgroup_add) + + +class hostgroup_del(crud.Del): + 'Delete an existing group of hosts.' + def execute(self, cn, **kw): + """ + Delete a group of hosts + + The memberOf plugin handles removing the group from any other + groups. + + :param cn: The name of the group being removed + :param kw: Unused + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, hostgroup_filter) + + return ldap.delete(dn) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Group deleted") + +api.register(hostgroup_del) + + +class hostgroup_mod(crud.Mod): + 'Edit an existing group of hosts.' + def execute(self, cn, **kw): + """ + Execute the hostgroup-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The name of the group to update. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, hostgroup_filter) + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + texui.print_plain("Group updated") + +api.register(hostgroup_mod) + + +class hostgroup_find(crud.Find): + 'Search the groups of hosts.' + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + # Pull the list of searchable attributes out of the configuration. + config = ldap.get_ipa_config() + + # FIXME: for now use same search fields as user groups + search_fields_conf_str = config.get('ipagroupsearchfields') + search_fields = search_fields_conf_str.split(",") + + search_kw = {} + for s in search_fields: + search_kw[s] = term + + search_kw['objectclass'] = hostgroup_filter + return ldap.search(**search_kw) + + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + groups = result[1:] + if counter == 0: + textui.print_plain("No entries found") + return + + for g in groups: + textui.print_entry(g) + + if counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") + +api.register(hostgroup_find) + + +class hostgroup_show(crud.Get): + 'Examine an existing group of hosts.' + def execute(self, cn, **kw): + """ + Execute the hostgroup-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The group name to retrieve. + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + # FIXME: this works for now but the plan is to add a new objectclass + # type. + dn = ldap.find_entry_dn("cn", cn, hostgroup_filter) + # FIXME: should kw contain the list of attributes to display? + return ldap.retrieve(dn) + + def output_for_cli(self, textui, result, *args, **options): + textui.print_entry(result) + +api.register(hostgroup_show) + + +class hostgroup_add_member(Command): + 'Add a member to a group.' + takes_args = ( + Str('group', primary_key=True), + ) + takes_options = ( + Str('groups?', doc='comma-separated list of host groups to add'), + Str('hosts?', doc='comma-separated list of hosts to add'), + ) + def execute(self, cn, **kw): + """ + Execute the hostgroup-add-member operation. + + Returns the updated group entry + + :param cn: The group name to add new members to. + :param kw: groups is a comma-separated list of host groups to add + :param kw: hosts is a comma-separated list of hosts to add + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, hostgroup_filter) + add_failed = [] + to_add = [] + completed = 0 + + members = get_members(kw.get('groups', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m, hostgroup_filter) + to_add.append(member_dn) + except errors.NotFound: + add_failed.append(m) + continue + + members = get_members(kw.get('hosts', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m, "ipaHost") + to_add.append(member_dn) + except errors.NotFound: + add_failed.append(m) + continue + + for member_dn in to_add: + try: + ldap.add_member_to_group(member_dn, dn) + completed+=1 + except: + add_failed.append(member_dn) + + return add_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + textui.print_plain("These entries failed to add to the group:") + for a in result: + print "\t'%s'" % a + else: + textui.print_plain("Group membership updated.") + +api.register(hostgroup_add_member) + + +class hostgroup_remove_member(Command): + 'Remove a member from a group.' + takes_args = ( + Str('group', primary_key=True), + ) + takes_options = ( + Str('hosts?', doc='comma-separated list of hosts to add'), + Str('groups?', doc='comma-separated list of groups to remove'), + ) + def execute(self, cn, **kw): + """ + Execute the group-remove-member operation. + + Returns the members that could not be added + + :param cn: The group name to add new members to. + :param kw: groups is a comma-separated list of groups to remove + :param kw: hosts is a comma-separated list of hosts to add + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, hostgroup_filter) + to_remove = [] + remove_failed = [] + completed = 0 + + members = get_members(kw.get('groups', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m, hostgroup_filter) + to_remove.append(member_dn) + except errors.NotFound: + remove_failed.append(m) + continue + + members = get_members(kw.get('hosts', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m, "ipaHost") + to_remove.append(member_dn) + except errors.NotFound: + remove_failed.append(m) + continue + + for member_dn in to_remove: + try: + ldap.remove_member_from_group(member_dn, dn) + completed+=1 + except: + remove_failed.append(member_dn) + + return remove_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + textui.print_plain("These entries failed to be removed from the group:") + for a in result: + print "\t'%s'" % a + else: + textui.print_plain("Group membership updated.") + +api.register(hostgroup_remove_member) diff --git a/ipalib/plugins/f_misc.py b/ipalib/plugins/f_misc.py new file mode 100644 index 00000000..a2f0fa4e --- /dev/null +++ b/ipalib/plugins/f_misc.py @@ -0,0 +1,89 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Misc frontend plugins. +""" + +import re +from ipalib import api, LocalOrRemote + + + +# FIXME: We should not let env return anything in_server +# when mode == 'production'. This would allow an attacker to see the +# configuration of the server, potentially revealing compromising +# information. However, it's damn handy for testing/debugging. +class env(LocalOrRemote): + """Show environment variables""" + + takes_args = ('variables*',) + + def __find_keys(self, variables): + keys = set() + for query in variables: + if '*' in query: + pat = re.compile(query.replace('*', '.*') + '$') + for key in self.env: + if pat.match(key): + keys.add(key) + elif query in self.env: + keys.add(query) + return sorted(keys) + + def execute(self, variables, **options): + if variables is None: + keys = self.env + else: + keys = self.__find_keys(variables) + return tuple( + (key, self.env[key]) for key in keys + ) + + def output_for_cli(self, textui, result, variables, **options): + if len(result) == 0: + return + if len(result) == 1: + textui.print_keyval(result) + return + textui.print_name(self.name) + textui.print_keyval(result) + textui.print_count(result, '%d variables') + +api.register(env) + + +class plugins(LocalOrRemote): + """Show all loaded plugins""" + + def execute(self, **options): + plugins = sorted(self.api.plugins, key=lambda o: o.plugin) + return tuple( + (p.plugin, p.bases) for p in plugins + ) + + def output_for_cli(self, textui, result, **options): + textui.print_name(self.name) + for (plugin, bases) in result: + textui.print_indented( + '%s: %s' % (plugin, ', '.join(bases)) + ) + textui.print_count(result, '%d plugin loaded', '%s plugins loaded') + +api.register(plugins) diff --git a/ipalib/plugins/f_netgroup.py b/ipalib/plugins/f_netgroup.py new file mode 100644 index 00000000..6ee55b0d --- /dev/null +++ b/ipalib/plugins/f_netgroup.py @@ -0,0 +1,461 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugin for netgroups. +""" + +from ipalib import api, crud, errors +from ipalib import Object, Command # Plugin base classes +from ipalib import Str # Parameter types +from ipalib import uuid + +netgroup_base = "cn=ng, cn=alt" +netgroup_filter = "ipaNISNetgroup" +hostgroup_filter = "groupofnames)(!(objectclass=posixGroup)" + +def get_members(members): + """ + Return a list of members. + + It is possible that the value passed in is None. + """ + if members: + members = members.split(',') + else: + members = [] + + return members + +def find_members(ldap, failed, members, attribute, filter=None): + """ + Return 2 lists: one a list of DNs found, one a list of errors + """ + found = [] + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn(attribute, m, filter) + found.append(member_dn) + except errors.NotFound: + failed.append(m) + continue + + return found, failed + +def add_members(ldap, completed, members, dn, memberattr): + add_failed = [] + for member_dn in members: + try: + ldap.add_member_to_group(member_dn, dn, memberattr) + completed+=1 + except: + add_failed.append(member_dn) + + return completed, add_failed + +def add_external(ldap, completed, members, cn): + failed = [] + netgroup = api.Command['netgroup_show'](cn) + external = netgroup.get('externalhost', []) + if not isinstance(external, list): + external = [external] + external_len = len(external) + for m in members: + if not m in external: + external.append(m) + completed+=1 + else: + failed.append(m) + if len(external) > external_len: + kw = {'externalhost': external} + ldap.update(netgroup['dn'], **kw) + + return completed, failed + +def remove_members(ldap, completed, members, dn, memberattr): + remove_failed = [] + for member_dn in members: + try: + ldap.remove_member_from_group(member_dn, dn, memberattr) + completed+=1 + except: + remove_failed.append(member_dn) + + return completed, remove_failed + +def remove_external(ldap, completed, members, cn): + failed = [] + netgroup = api.Command['netgroup_show'](cn) + external = netgroup.get('externalhost', []) + if not isinstance(external, list): + external = [external] + external_len = len(external) + for m in members: + try: + external.remove(m) + completed+=1 + except ValueError: + failed.append(m) + if len(external) < external_len: + kw = {'externalhost': external} + ldap.update(netgroup['dn'], **kw) + + return completed, failed + +class netgroup(Object): + """ + netgroups object. + """ + takes_params = ( + Str('cn', + cli_name='name', + primary_key=True + ), + Str('description', + doc='Description', + ), + Str('nisdomainname?', + cli_name='domainname', + doc='Domain name', + ), + ) +api.register(netgroup) + + +class netgroup_add(crud.Add): + 'Add a new netgroup.' + + def execute(self, cn, **kw): + """ + Execute the netgroup-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry as it will be created in LDAP. + + :param cn: The name of the netgroup + :param kw: Keyword arguments for the other LDAP attributes. + """ + self.log.info("IPA: netgroup-add '%s'" % cn) + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + kw['cn'] = cn +# kw['dn'] = ldap.make_netgroup_dn() + kw['ipauniqueid'] = str(uuid.uuid1()) + kw['dn'] = "ipauniqueid=%s,%s,%s" % (kw['ipauniqueid'], netgroup_base, api.env.basedn) + + if not kw.get('nisdomainname', False): + kw['nisdomainname'] = api.env.domain + + # some required objectclasses + kw['objectClass'] = ['top', 'ipaAssociation', 'ipaNISNetgroup'] + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_name(self.name) + textui.print_entry(result) + textui.print_dashed('Added netgroup "%s"' % result.get('cn')) + +api.register(netgroup_add) + + +class netgroup_del(crud.Del): + 'Delete an existing netgroup.' + + def execute(self, cn, **kw): + """Delete a netgroup. + + cn is the cn of the netgroup to delete + + The memberOf plugin handles removing the netgroup from any other + groups. + + :param cn: The name of the netgroup being removed. + :param kw: Not used. + """ + self.log.info("IPA: netgroup-del '%s'" % cn) + + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, netgroup_filter, netgroup_base) + return ldap.delete(dn) + + def output_for_cli(self, textui, result, cn): + """ + Output result of this command to command line interface. + """ + textui.print_plain('Deleted net group "%s"' % cn) + +api.register(netgroup_del) + + +class netgroup_mod(crud.Mod): + 'Edit an existing netgroup.' + def execute(self, cn, **kw): + """ + Execute the netgroup-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The name of the netgroup to retrieve. + :param kw: Keyword arguments for the other LDAP attributes. + """ + self.log.info("IPA: netgroup-mod '%s'" % cn) + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, netgroup_filter, netgroup_base) + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, cn, **options): + """ + Output result of this command to command line interface. + """ + textui.print_name(self.name) + textui.print_entry(result) + textui.print_dashed('Updated netgroup "%s"' % result['cn']) + +api.register(netgroup_mod) + + +class netgroup_find(crud.Find): + 'Search the netgroups.' + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + search_fields = ['ipauniqueid','description','nisdomainname','cn'] + + search_kw = {} + for s in search_fields: + search_kw[s] = term + + search_kw['objectclass'] = netgroup_filter + search_kw['base'] = netgroup_base + return ldap.search(**search_kw) + + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + groups = result[1:] + if counter == 0 or len(groups) == 0: + textui.print_plain("No entries found") + return + if len(groups) == 1: + textui.print_entry(groups[0]) + return + textui.print_name(self.name) + for g in groups: + textui.print_entry(g) + textui.print_plain('') + if counter == -1: + textui.print_plain('These results are truncated.') + textui.print_plain('Please refine your search and try again.') + textui.print_count(groups, '%d netgroups matched') + +api.register(netgroup_find) + + +class netgroup_show(crud.Get): + 'Examine an existing netgroup.' + def execute(self, cn, **kw): + """ + Execute the netgroup-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The name of the netgroup to retrieve. + :param kw: Unused + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, netgroup_filter, netgroup_base) + return ldap.retrieve(dn) + + def output_for_cli(self, textui, result, *args, **options): + textui.print_entry(result) + +api.register(netgroup_show) + +class netgroup_add_member(Command): + 'Add a member to a group.' + takes_args = ( + Str('cn', + cli_name='name', + primary_key=True + ), + ) + takes_options = ( + Str('hosts?', doc='comma-separated list of hosts to add'), + Str('hostgroups?', doc='comma-separated list of host groups to add'), + Str('users?', doc='comma-separated list of users to add'), + Str('groups?', doc='comma-separated list of groups to add'), + ) + + def execute(self, cn, **kw): + """ + Execute the netgroup-add-member operation. + + Returns the updated group entry + + :param cn: The netgroup name to add new members to. + :param kw: hosts is a comma-separated list of hosts to add + :param kw: hostgroups is a comma-separated list of host groups to add + :param kw: users is a comma-separated list of users to add + :param kw: groups is a comma-separated list of host to add + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, netgroup_filter, netgroup_base) + add_failed = [] + to_add = [] + completed = 0 + + # Hosts + members = get_members(kw.get('hosts', '')) + (to_add, add_failed) = find_members(ldap, add_failed, members, "cn", "ipaHost") + + # If a host is not found we'll consider it an externalHost. It will + # be up to the user to handle typos + if add_failed: + (completed, failed) = add_external(ldap, completed, add_failed, cn) + add_failed = failed + + (completed, failed) = add_members(ldap, completed, to_add, dn, 'memberhost') + add_failed+=failed + + # Host groups + members = get_members(kw.get('hostgroups', '')) + (to_add, add_failed) = find_members(ldap, add_failed, members, "cn", hostgroup_filter) + (completed, failed) = add_members(ldap, completed, to_add, dn, 'memberhost') + add_failed+=failed + + # User + members = get_members(kw.get('users', '')) + (to_add, add_failed) = find_members(ldap, add_failed, members, "uid") + (completed, failed) = add_members(ldap, completed, to_add, dn, 'memberuser') + add_failed+=failed + + # Groups + members = get_members(kw.get('groups', '')) + (to_add, add_failed) = find_members(ldap, add_failed, members, "cn", "posixGroup") + (completed, failed) = add_members(ldap, completed, to_add, dn, 'memberuser') + add_failed+=failed + + return add_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + textui.print_plain("These entries failed to add to the group:") + for a in result: + print "\t'%s'" % a + else: + textui.print_plain("netgroup membership updated.") + +api.register(netgroup_add_member) + + +class netgroup_remove_member(Command): + 'Remove a member from a group.' + takes_args = ( + Str('cn', + cli_name='name', + primary_key=True + ), + ) + takes_options = ( + Str('hosts?', doc='comma-separated list of hosts to remove'), + Str('hostgroups?', doc='comma-separated list of groups to remove'), + Str('users?', doc='comma-separated list of users to remove'), + Str('groups?', doc='comma-separated list of groups to remove'), + ) + def execute(self, cn, **kw): + """ + Execute the group-remove-member operation. + + Returns the members that could not be added + + :param cn: The group name to add new members to. + :param kw: hosts is a comma-separated list of hosts to remove + :param kw: hostgroups is a comma-separated list of host groups to remove + :param kw: users is a comma-separated list of users to remove + :param kw: groups is a comma-separated list of host to remove + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, netgroup_filter, netgroup_base) + remove_failed = [] + to_remove = [] + completed = 0 + + # Hosts + members = get_members(kw.get('hosts', '')) + (to_remove, remove_failed) = find_members(ldap, remove_failed, members, "cn", "ipaHost") + + # If a host is not found we'll consider it an externalHost. It will + # be up to the user to handle typos + if remove_failed: + (completed, failed) = remove_external(ldap, completed, remove_failed, cn) + remove_failed = failed + + (completed, failed) = remove_members(ldap, completed, to_remove, dn, 'memberhost') + remove_failed+=failed + + # Host groups + members = get_members(kw.get('hostgroups', '')) + (to_remove, remove_failed) = find_members(ldap, remove_failed, members, "cn", hostgroup_filter) + (completed, failed) = remove_members(ldap, completed, to_remove, dn, 'memberhost') + remove_failed+=failed + + # User + members = get_members(kw.get('users', '')) + (to_remove, remove_failed) = find_members(ldap, remove_failed, members, "uid") + (completed, failed) = remove_members(ldap, completed, to_remove, dn, 'memberuser') + remove_failed+=failed + + # Groups + members = get_members(kw.get('groups', '')) + (to_remove, remove_failed) = find_members(ldap, remove_failed, members, "cn", "posixGroup") + (completed, failed) = remove_members(ldap, completed, to_remove, dn, 'memberuser') + remove_failed+=failed + + return remove_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + textui.print_plain("These entries failed to be removed from the group:") + for a in result: + print "\t'%s'" % a + else: + textui.print_plain("netgroup membership updated.") + +api.register(netgroup_remove_member) diff --git a/ipalib/plugins/f_passwd.py b/ipalib/plugins/f_passwd.py new file mode 100644 index 00000000..ea78c4c1 --- /dev/null +++ b/ipalib/plugins/f_passwd.py @@ -0,0 +1,70 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for password changes. +""" + +from ipalib import api, errors, util +from ipalib import Command # Plugin base classes +from ipalib import Str, Password # Parameter types + + +class passwd(Command): + 'Edit existing password policy.' + + takes_args = ( + Str('principal', + cli_name='user', + primary_key=True, + default_from=util.get_current_principal, + ), + Password('password'), + ) + + def execute(self, principal, password): + """ + Execute the passwd operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param param uid: The login name of the user being updated. + :param kw: Not used. + """ + if principal.find('@') > 0: + u = principal.split('@') + if len(u) > 2: + raise errors.InvalidUserPrincipal, principal + else: + principal = principal+"@"+self.api.env.realm + dn = self.Backend.ldap.find_entry_dn( + "krbprincipalname", + principal, + "posixAccount" + ) + return self.Backend.ldap.modify_password(dn, newpass=password) + + def output_for_cli(self, textui, result, principal, password): + assert password is None + textui.print_plain('Changed password for "%s"' % principal) + +api.register(passwd) diff --git a/ipalib/plugins/f_pwpolicy.py b/ipalib/plugins/f_pwpolicy.py new file mode 100644 index 00000000..d914ce72 --- /dev/null +++ b/ipalib/plugins/f_pwpolicy.py @@ -0,0 +1,122 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for password policy. +""" + +from ipalib import api +from ipalib import Command # Plugin base classes +from ipalib import Int # Parameter types + + +class pwpolicy_mod(Command): + 'Edit existing password policy.' + takes_options = ( + Int('krbmaxpwdlife?', + cli_name='maxlife', + doc='Max. Password Lifetime (days)' + ), + Int('krbminpwdlife?', + cli_name='minlife', + doc='Min. Password Lifetime (hours)' + ), + Int('krbpwdhistorylength?', + cli_name='history', + doc='Password History Size' + ), + Int('krbpwdmindiffchars?', + cli_name='minclasses', + doc='Min. Number of Character Classes' + ), + Int('krbpwdminlength?', + cli_name='minlength', + doc='Min. Length of Password' + ), + ) + def execute(self, *args, **kw): + """ + Execute the pwpolicy-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param args: This function takes no positional arguments + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", "accounts", "krbPwdPolicy") + + # The LDAP routines want strings, not ints, so convert a few + # things. Otherwise it sees a string -> int conversion as a change. + for k in kw.iterkeys(): + if k.startswith("krb", 0, 3): + kw[k] = str(kw[k]) + + # Convert hours and days to seconds + if kw.get('krbmaxpwdlife'): + kw['krbmaxpwdlife'] = str(int(kw.get('krbmaxpwdlife')) * 86400) + if kw.get('krbminpwdlife'): + kw['krbminpwdlife'] = str(int(kw.get('krbminpwdlife')) * 3600) + + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, *args, **options): + textui.print_plain("Policy modified") + +api.register(pwpolicy_mod) + + +class pwpolicy_show(Command): + 'Retrieve current password policy' + def execute(self, *args, **kw): + """ + Execute the pwpolicy-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param args: Not used. + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", "accounts", "krbPwdPolicy") + + policy = ldap.retrieve(dn) + + # convert some values for display purposes + policy['krbmaxpwdlife'] = str(int(policy.get('krbmaxpwdlife')) / 86400) + policy['krbminpwdlife'] = str(int(policy.get('krbminpwdlife')) / 3600) + + return policy + + def output_for_cli(self, textui, result, *args, **options): + textui.print_plain("Password Policy") + textui.print_plain("Min. Password Lifetime (hours): %s" % result.get('krbminpwdlife')) + textui.print_plain("Max. Password Lifetime (days): %s" % result.get('krbmaxpwdlife')) + textui.print_plain("Min. Number of Character Classes: %s" % result.get('krbpwdmindiffchars')) + textui.print_plain("Min. Length of Password: %s" % result.get('krbpwdminlength')) + textui.print_plain("Password History Size: %s" % result.get('krbpwdhistorylength')) + +api.register(pwpolicy_show) diff --git a/ipalib/plugins/f_ra.py b/ipalib/plugins/f_ra.py new file mode 100644 index 00000000..7ac84e65 --- /dev/null +++ b/ipalib/plugins/f_ra.py @@ -0,0 +1,117 @@ +# Authors: +# Andrew Wnuk <awnuk@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for IPA-RA PKI operations. +""" + +from ipalib import api, Command, Str, Int + + +class request_certificate(Command): + """ Submit a certificate request. """ + + takes_args = ['csr'] + + takes_options = [Str('request_type?', default=u'pkcs10')] + + def execute(self, csr, **options): + return self.Backend.ra.request_certificate(csr, **options) + + def output_for_cli(self, textui, result, *args, **options): + if isinstance(result, dict) and len(result) > 0: + textui.print_entry(result, 0) + else: + textui.print_plain('Failed to submit a certificate request.') + +api.register(request_certificate) + + +class get_certificate(Command): + """ Retrieve an existing certificate. """ + + takes_args = ['serial_number'] + + def execute(self, serial_number, **options): + return self.Backend.ra.get_certificate(serial_number) + + def output_for_cli(self, textui, result, *args, **options): + if isinstance(result, dict) and len(result) > 0: + textui.print_entry(result, 0) + else: + textui.print_plain('Failed to obtain a certificate.') + +api.register(get_certificate) + + +class check_request_status(Command): + """ Check a request status. """ + + takes_args = ['request_id'] + + + def execute(self, request_id, **options): + return self.Backend.ra.check_request_status(request_id) + + def output_for_cli(self, textui, result, *args, **options): + if isinstance(result, dict) and len(result) > 0: + textui.print_entry(result, 0) + else: + textui.print_plain('Failed to retrieve a request status.') + +api.register(check_request_status) + + +class revoke_certificate(Command): + """ Revoke a certificate. """ + + takes_args = ['serial_number'] + + # FIXME: The default is 0. Is this really an Int param? + takes_options = [Int('revocation_reason?', default=0)] + + + def execute(self, serial_number, **options): + return self.Backend.ra.revoke_certificate(serial_number, **options) + + def output_for_cli(self, textui, result, *args, **options): + if isinstance(result, dict) and len(result) > 0: + textui.print_entry(result, 0) + else: + textui.print_plain('Failed to revoke a certificate.') + +api.register(revoke_certificate) + + +class take_certificate_off_hold(Command): + """ Take a revoked certificate off hold. """ + + takes_args = ['serial_number'] + + def execute(self, serial_number, **options): + return self.Backend.ra.take_certificate_off_hold(serial_number) + + def output_for_cli(self, textui, result, *args, **options): + if isinstance(result, dict) and len(result) > 0: + textui.print_entry(result, 0) + else: + textui.print_plain('Failed to take a revoked certificate off hold.') + +api.register(take_certificate_off_hold) diff --git a/ipalib/plugins/f_service.py b/ipalib/plugins/f_service.py new file mode 100644 index 00000000..06d6a5d0 --- /dev/null +++ b/ipalib/plugins/f_service.py @@ -0,0 +1,204 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for service (Identity). +""" + +from ipalib import api, crud, errors +from ipalib import Object # Plugin base classes +from ipalib import Str, Flag # Parameter types + + +class service(Object): + """ + Service object. + """ + takes_params = ( + Str('principal', primary_key=True), + ) +api.register(service) + + +class service_add(crud.Add): + """ + Add a new service. + """ + + takes_options = ( + Flag('force', + doc='Force a service principal name', + ), + ) + def execute(self, principal, **kw): + """ + Execute the service-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry as it will be created in LDAP. + + :param principal: The service to be added in the form: service/hostname + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'krbprincipalname' not in kw + ldap = self.api.Backend.ldap + + force = kw.get('force', False) + try: + del kw['force'] + except: + pass + + # Break down the principal into its component parts, which may or + # may not include the realm. + sp = principal.split('/') + if len(sp) != 2: + raise errors.MalformedServicePrincipal + service = sp[0] + + if service.lower() == "host": + raise errors.HostService + + sr = sp[1].split('@') + if len(sr) == 1: + hostname = sr[0].lower() + realm = self.api.env.realm + elif len(sr) == 2: + hostname = sr[0].lower() + realm = sr[1] + else: + raise MalformedServicePrincipal + + """ + FIXME once DNS client is done + if not force: + fqdn = hostname + "." + rs = dnsclient.query(fqdn, dnsclient.DNS_C_IN, dnsclient.DNS_T_A) + if len(rs) == 0: + self.log.debug("IPA: DNS A record lookup failed for '%s'" % hostname) + raise ipaerror.gen_exception(ipaerror.INPUT_NOT_DNS_A_RECORD) + else: + self.log.debug("IPA: found %d records for '%s'" % (len(rs), hostname)) + """ + + # At some point we'll support multiple realms + if (realm != self.api.env.realm): + raise errors.RealmMismatch + + # Put the principal back together again + princ_name = service + "/" + hostname + "@" + realm + + dn = ldap.make_service_dn(princ_name) + + kw['dn'] = dn + kw['objectClass'] = ['krbPrincipal', 'krbPrincipalAux', 'krbTicketPolicyAux'] + + return ldap.create(**kw) + + def output_to_cli(self, ret): + if ret: + print "Service added" + +api.register(service_add) + + +class service_del(crud.Del): + 'Delete an existing service.' + def execute(self, principal, **kw): + """ + Delete a service principal. + + principal is the krbprincipalname of the entry to delete. + + This should be called with much care. + + :param principal: The service to be added in the form: service/hostname + :param kw: not used + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("krbprincipalname", principal) + return ldap.delete(dn) + + def output_to_cli(self, ret): + if ret: + print "Service removed" + +api.register(service_del) + +# There is no service-mod. The principal itself contains nothing that +# is user-changeable + +class service_find(crud.Find): + 'Search the existing services.' + def execute(self, principal, **kw): + ldap = self.api.Backend.ldap + + search_kw = {} + search_kw['filter'] = "&(objectclass=krbPrincipalAux)(!(objectClass=posixAccount))(!(|(krbprincipalname=kadmin/*)(krbprincipalname=K/M@*)(krbprincipalname=krbtgt/*)))" + search_kw['krbprincipalname'] = principal + + object_type = ldap.get_object_type("krbprincipalname") + if object_type and not kw.get('objectclass'): + search_kw['objectclass'] = object_type + + return ldap.search(**search_kw) + + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + services = result[1:] + if counter == 0: + textui.print_plain("No entries found") + return + + for s in services: + textui.print_entry(s) + + if counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") + textui.print_count(services, '%d services matched') + +api.register(service_find) + + +class service_show(crud.Get): + 'Examine an existing service.' + def execute(self, principal, **kw): + """ + Execute the service-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param principal: The service principal to retrieve + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("krbprincipalname", principal) + # FIXME: should kw contain the list of attributes to display? + return ldap.retrieve(dn) + def output_for_cli(self, textui, result, *args, **options): + textui.print_entry(result) + +api.register(service_show) diff --git a/ipalib/plugins/f_user.py b/ipalib/plugins/f_user.py new file mode 100644 index 00000000..506ad14d --- /dev/null +++ b/ipalib/plugins/f_user.py @@ -0,0 +1,367 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for user (Identity). +""" + +from ipalib import api, crud, errors +from ipalib import Object, Command # Plugin base classes +from ipalib import Str, Password, Flag, Int # Parameter types + + +def display_user(user): + # FIXME: for now delete dn here. In the future pass in the kw to + # output_for_cli() + attr = sorted(user.keys()) + # Always have sn following givenname + try: + l = attr.index('givenname') + attr.remove('sn') + attr.insert(l+1, 'sn') + except ValueError: + pass + + for a in attr: + if a != 'dn': + print "%s: %s" % (a, user[a]) + +default_attributes = ['uid','givenname','sn','homeDirectory','loginshell'] + + +class user(Object): + """ + User object. + """ + + takes_params = ( + Str('givenname', + cli_name='first', + doc="User's first name", + ), + Str('sn', + cli_name='last', + doc="User's last name", + ), + Str('uid', + cli_name='user', + primary_key=True, + default_from=lambda givenname, sn: givenname[0] + sn, + normalizer=lambda value: value.lower(), + ), + Str('gecos?', + doc='GECOS field', + default_from=lambda uid: uid, + ), + Str('homedirectory?', + cli_name='home', + doc="User's home directory", + default_from=lambda uid: '/home/%s' % uid, + ), + Str('loginshell?', + cli_name='shell', + default=u'/bin/sh', + doc="User's Login shell", + ), + Str('krbprincipalname?', + cli_name='principal', + doc="User's Kerberos Principal name", + default_from=lambda uid: '%s@%s' % (uid, api.env.realm), + ), + Str('mailaddress?', + cli_name='email', + doc="User's e-mail address", + ), + Password('userpassword?', + cli_name='password', + doc="Set user's password", + ), + Str('groups?', + doc='Add account to one or more groups (comma-separated)', + ), + Int('uidnumber?', + cli_name='uid', + doc='The uid to use for this user. If not included one is automatically set.', + ), + ) + +api.register(user) + + +class user_add(crud.Add): + 'Add a new user.' + + def execute(self, uid, **kw): + """ + Execute the user-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry as it will be created in LDAP. + + :param uid: The login name of the user being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'uid' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + kw['uid'] = uid + kw['dn'] = ldap.make_user_dn(uid) + + # FIXME: enforce this elsewhere +# if servercore.uid_too_long(kw['uid']): +# raise errors.UsernameTooLong + + # Get our configuration + config = ldap.get_ipa_config() + + # Let us add in some missing attributes + if kw.get('homedirectory') is None: + kw['homedirectory'] = '%s/%s' % (config.get('ipahomesrootdir'), kw.get('uid')) + kw['homedirectory'] = kw['homedirectory'].replace('//', '/') + kw['homedirectory'] = kw['homedirectory'].rstrip('/') + if kw.get('loginshell') is None: + kw['loginshell'] = config.get('ipadefaultloginshell') + if kw.get('gecos') is None: + kw['gecos'] = kw['uid'] + + # If uidnumber is blank the the FDS dna_plugin will automatically + # assign the next value. So we don't have to do anything with it. + + if not kw.get('gidnumber'): + try: + group_dn = ldap.find_entry_dn("cn", config.get('ipadefaultprimarygroup')) + default_group = ldap.retrieve(group_dn, ['dn','gidNumber']) + if default_group: + kw['gidnumber'] = default_group.get('gidnumber') + except errors.NotFound: + # Fake an LDAP error so we can return something useful to the kw + raise errors.NotFound, "The default group for new kws, '%s', cannot be found." % config.get('ipadefaultprimarygroup') + except Exception, e: + # catch everything else + raise e + + if kw.get('krbprincipalname') is None: + kw['krbprincipalname'] = "%s@%s" % (kw.get('uid'), self.api.env.realm) + + # FIXME. This is a hack so we can request separate First and Last + # name in the GUI. + if kw.get('cn') is None: + kw['cn'] = "%s %s" % (kw.get('givenname'), + kw.get('sn')) + + # some required objectclasses + kw['objectClass'] = config.get('ipauserobjectclasses') + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_name(self.name) + textui.print_entry(result) + textui.print_dashed('Added user "%s"' % result['uid']) + +api.register(user_add) + + +class user_del(crud.Del): + 'Delete an existing user.' + + def execute(self, uid, **kw): + """Delete a user. Not to be confused with inactivate_user. This + makes the entry go away completely. + + uid is the uid of the user to delete + + The memberOf plugin handles removing the user from any other + groups. + + :param uid: The login name of the user being added. + :param kw: Not used. + """ + if uid == "admin": + # FIXME: do we still want a "special" user? + raise SyntaxError("admin required") +# raise ipaerror.gen_exception(ipaerror.INPUT_ADMIN_REQUIRED) + self.log.info("IPA: user-del '%s'" % uid) + + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("uid", uid) + return ldap.delete(dn) + + def output_for_cli(self, textui, result, uid): + """ + Output result of this command to command line interface. + """ + textui.print_plain('Deleted user "%s"' % uid) + +api.register(user_del) + + +class user_mod(crud.Mod): + 'Edit an existing user.' + def execute(self, uid, **kw): + """ + Execute the user-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param uid: The login name of the user to retrieve. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'uid' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("uid", uid) + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, uid, **options): + """ + Output result of this command to command line interface. + """ + textui.print_name(self.name) + textui.print_entry(result) + textui.print_dashed('Updated user "%s"' % result['uid']) + +api.register(user_mod) + + +class user_find(crud.Find): + 'Search the users.' + takes_options = ( + Flag('all', doc='Retrieve all user attributes'), + ) + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + # Pull the list of searchable attributes out of the configuration. + config = ldap.get_ipa_config() + search_fields_conf_str = config.get('ipausersearchfields') + search_fields = search_fields_conf_str.split(",") + + search_kw = {} + for s in search_fields: + search_kw[s] = term + + object_type = ldap.get_object_type("uid") + if object_type and not kw.get('objectclass'): + search_kw['objectclass'] = object_type + if kw.get('all', False): + search_kw['attributes'] = ['*'] + else: + search_kw['attributes'] = default_attributes + return ldap.search(**search_kw) + + def output_for_cli(self, textui, result, uid, **options): + counter = result[0] + users = result[1:] + if counter == 0 or len(users) == 0: + textui.print_plain("No entries found") + return + if len(users) == 1: + textui.print_entry(users[0]) + return + textui.print_name(self.name) + for u in users: + gn = u.get('givenname', '') + sn= u.get('sn', '') + textui.print_plain('%s %s:' % (gn, sn)) + textui.print_entry(u) + textui.print_plain('') + if counter == -1: + textui.print_plain('These results are truncated.') + textui.print_plain('Please refine your search and try again.') + textui.print_count(users, '%d users matched') + +api.register(user_find) + + +class user_show(crud.Get): + 'Examine an existing user.' + takes_options = ( + Flag('all', doc='Retrieve all user attributes'), + ) + def execute(self, uid, **kw): + """ + Execute the user-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param uid: The login name of the user to retrieve. + :param kw: "all" set to True = return all attributes + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("uid", uid) + # FIXME: should kw contain the list of attributes to display? + if kw.get('all', False): + return ldap.retrieve(dn) + else: + return ldap.retrieve(dn, default_attributes) + + def output_for_cli(self, textui, result, uid, **options): + if result: + display_user(result) + +api.register(user_show) + +class user_lock(Command): + 'Lock a user account.' + + takes_args = ( + Str('uid', primary_key=True), + ) + + def execute(self, uid, **kw): + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("uid", uid) + return ldap.mark_entry_inactive(dn) + + def output_for_cli(self, textui, result, uid): + if result: + textui.print_plain('Locked user "%s"' % uid) + +api.register(user_lock) + + +class user_unlock(Command): + 'Unlock a user account.' + + takes_args = ( + Str('uid', primary_key=True), + ) + + def execute(self, uid, **kw): + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("uid", uid) + return ldap.mark_entry_active(dn) + + def output_for_cli(self, textui, result, uid): + if result: + textui.print_plain('Unlocked user "%s"' % uid) + +api.register(user_unlock) diff --git a/ipalib/request.py b/ipalib/request.py new file mode 100644 index 00000000..6ad7ad35 --- /dev/null +++ b/ipalib/request.py @@ -0,0 +1,71 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty contextrmation +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Per-request thread-local data. +""" + +import threading +import locale +import gettext +from constants import OVERRIDE_ERROR + + +# Thread-local storage of most per-request information +context = threading.local() + + +def ugettext(message): + if hasattr(context, 'ugettext'): + return context.ugettext(message) + return message.decode('UTF-8') + + +def ungettext(singular, plural, n): + if hasattr(context, 'ungettext'): + return context.ungettext(singular, plural, n) + if n == 1: + return singular.decode('UTF-8') + return plural.decode('UTF-8') + + +def set_languages(*languages): + if hasattr(context, 'languages'): + raise StandardError(OVERRIDE_ERROR % + ('context', 'languages', context.languages, languages) + ) + if len(languages) == 0: + languages = locale.getdefaultlocale()[:1] + context.languages = languages + assert type(context.languages) is tuple + + +def create_translation(domain, localedir, *languages): + if hasattr(context, 'ugettext') or hasattr(context, 'ungettext'): + raise StandardError( + 'create_translation() already called in thread %r' % + threading.currentThread().getName() + ) + set_languages(*languages) + translation = gettext.translation(domain, + localedir=localedir, languages=context.languages, fallback=True + ) + context.ugettext = translation.ugettext + context.ungettext = translation.ungettext diff --git a/ipalib/rpc.py b/ipalib/rpc.py new file mode 100644 index 00000000..e7823ef9 --- /dev/null +++ b/ipalib/rpc.py @@ -0,0 +1,207 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +RPC client and shared RPC client/server functionality. + +This module adds some additional functionality on top of the ``xmlrpclib`` +module in the Python standard library. For documentation on the +``xmlrpclib`` module, see: + + http://docs.python.org/library/xmlrpclib.html + +Also see the `ipaserver.rpcserver` module. +""" + +from types import NoneType +import threading +from xmlrpclib import Binary, Fault, dumps, loads +from ipalib.backend import Backend +from ipalib.errors2 import public_errors, PublicError, UnknownError +from ipalib.request import context + + +def xml_wrap(value): + """ + Wrap all ``str`` in ``xmlrpclib.Binary``. + + Because ``xmlrpclib.dumps()`` will itself convert all ``unicode`` instances + into UTF-8 encoded ``str`` instances, we don't do it here. + + So in total, when encoding data for an XML-RPC packet, the following + transformations occur: + + * All ``str`` instances are treated as binary data and are wrapped in + an ``xmlrpclib.Binary()`` instance. + + * Only ``unicode`` instances are treated as character data. They get + converted to UTF-8 encoded ``str`` instances (although as mentioned, + not by this function). + + Also see `xml_unwrap()`. + + :param value: The simple scalar or simple compound value to wrap. + """ + if type(value) in (list, tuple): + return tuple(xml_wrap(v) for v in value) + if type(value) is dict: + return dict( + (k, xml_wrap(v)) for (k, v) in value.iteritems() + ) + if type(value) is str: + return Binary(value) + assert type(value) in (unicode, int, float, bool, NoneType) + return value + + +def xml_unwrap(value, encoding='UTF-8'): + """ + Unwrap all ``xmlrpc.Binary``, decode all ``str`` into ``unicode``. + + When decoding data from an XML-RPC packet, the following transformations + occur: + + * The binary payloads of all ``xmlrpclib.Binary`` instances are + returned as ``str`` instances. + + * All ``str`` instances are treated as UTF-8 encoded Unicode strings. + They are decoded and the resulting ``unicode`` instance is returned. + + Also see `xml_wrap()`. + + :param value: The value to unwrap. + :param encoding: The Unicode encoding to use (defaults to ``'UTF-8'``). + """ + if type(value) in (list, tuple): + return tuple(xml_unwrap(v, encoding) for v in value) + if type(value) is dict: + return dict( + (k, xml_unwrap(v, encoding)) for (k, v) in value.iteritems() + ) + if type(value) is str: + return value.decode(encoding) + if isinstance(value, Binary): + assert type(value.data) is str + return value.data + assert type(value) in (unicode, int, float, bool, NoneType) + return value + + +def xml_dumps(params, methodname=None, methodresponse=False, encoding='UTF-8'): + """ + Encode an XML-RPC data packet, transparently wraping ``params``. + + This function will wrap ``params`` using `xml_wrap()` and will + then encode the XML-RPC data packet using ``xmlrpclib.dumps()`` (from the + Python standard library). + + For documentation on the ``xmlrpclib.dumps()`` function, see: + + http://docs.python.org/library/xmlrpclib.html#convenience-functions + + Also see `xml_loads()`. + + :param params: A ``tuple`` or an ``xmlrpclib.Fault`` instance. + :param methodname: The name of the method to call if this is a request. + :param methodresponse: Set this to ``True`` if this is a response. + :param encoding: The Unicode encoding to use (defaults to ``'UTF-8'``). + """ + if type(params) is tuple: + params = xml_wrap(params) + else: + assert isinstance(params, Fault) + return dumps(params, + methodname=methodname, + methodresponse=methodresponse, + encoding=encoding, + allow_none=True, + ) + + +def xml_loads(data): + """ + Decode the XML-RPC packet in ``data``, transparently unwrapping its params. + + This function will decode the XML-RPC packet in ``data`` using + ``xmlrpclib.loads()`` (from the Python standard library). If ``data`` + contains a fault, ``xmlrpclib.loads()`` will itself raise an + ``xmlrpclib.Fault`` exception. + + Assuming an exception is not raised, this function will then unwrap the + params in ``data`` using `xml_unwrap()`. Finally, a + ``(params, methodname)`` tuple is returned containing the unwrapped params + and the name of the method being called. If the packet contains no method + name, ``methodname`` will be ``None``. + + For documentation on the ``xmlrpclib.loads()`` function, see: + + http://docs.python.org/library/xmlrpclib.html#convenience-functions + + Also see `xml_dumps()`. + + :param data: The XML-RPC packet to decode. + """ + (params, method) = loads(data) + return (xml_unwrap(params), method) + + +class xmlclient(Backend): + """ + Forwarding backend for XML-RPC client. + """ + + def __init__(self): + super(xmlclient, self).__init__() + self.__errors = dict((e.errno, e) for e in public_errors) + + def forward(self, name, *args, **kw): + """ + Forward call to command named ``name`` over XML-RPC. + + This method will encode and forward an XML-RPC request, and will then + decode and return the corresponding XML-RPC response. + + :param command: The name of the command being forwarded. + :param args: Positional arguments to pass to remote command. + :param kw: Keyword arguments to pass to remote command. + """ + if name not in self.Command: + raise ValueError( + '%s.forward(): %r not in api.Command' % (self.name, name) + ) + if not hasattr(context, 'xmlconn'): + raise StandardError( + '%s.forward(%r): need context.xmlconn in thread %r' % ( + self.name, name, threading.currentThread().getName() + ) + ) + command = getattr(context.xmlconn, name) + params = args + (kw,) + try: + response = command(xml_wrap(params)) + return xml_unwrap(response) + except Fault, e: + if e.faultCode in self.__errors: + error = self.__errors[e.faultCode] + raise error(message=e.faultString) + raise UnknownError( + code=e.faultCode, + error=e.faultString, + server=self.env.xmlrpc_uri, + ) diff --git a/ipalib/util.py b/ipalib/util.py new file mode 100644 index 00000000..4a58d7fb --- /dev/null +++ b/ipalib/util.py @@ -0,0 +1,151 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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; version 2 only +# +# This program 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 Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Various utility functions. +""" + +import os +from os import path +import imp +import optparse +import logging +import time +from types import NoneType +from xmlrpclib import Binary +import krbV + + + +def xmlrpc_marshal(*args, **kw): + """ + Marshal (args, kw) into ((kw,) + args). + """ + kw = dict( + filter(lambda item: item[1] is not None, kw.iteritems()) + ) + args = tuple( + filter(lambda value: value is not None, args) + ) + return ((kw,) + args) + + +def xmlrpc_unmarshal(*params): + """ + Unmarshal (params) into (args, kw). + """ + if len(params) > 0: + kw = params[0] + if type(kw) is not dict: + raise TypeError('first xmlrpc argument must be dict') + else: + kw = {} + return (params[1:], kw) + + +def get_current_principal(): + try: + return krbV.default_context().default_ccache().principal().name + except krbV.Krb5Error: + #TODO: do a kinit + print "Unable to get kerberos principal" + return None + + +# FIXME: This function has no unit test +def find_modules_in_dir(src_dir): + """ + Iterate through module names found in ``src_dir``. + """ + if not (path.abspath(src_dir) == src_dir and path.isdir(src_dir)): + return + if path.islink(src_dir): + return + suffix = '.py' + for name in sorted(os.listdir(src_dir)): + if not name.endswith(suffix): + continue + py_file = path.join(src_dir, name) + if path.islink(py_file) or not path.isfile(py_file): + continue + module = name[:-len(suffix)] + if module == '__init__': + continue + yield module + + +# FIXME: This function has no unit test +def load_plugins_in_dir(src_dir): + """ + Import each Python module found in ``src_dir``. + """ + for module in find_modules_in_dir(src_dir): + imp.load_module(module, *imp.find_module(module, [src_dir])) + + +# FIXME: This function has no unit test +def import_plugins_subpackage(name): + """ + Import everythig in ``plugins`` sub-package of package named ``name``. + """ + try: + plugins = __import__(name + '.plugins').plugins + except ImportError: + return + src_dir = path.dirname(path.abspath(plugins.__file__)) + for name in find_modules_in_dir(src_dir): + full_name = '%s.%s' % (plugins.__name__, name) + __import__(full_name) + + +def add_global_options(parser=None): + """ + Add global options to an optparse.OptionParser instance. + """ + if parser is None: + parser = optparse.OptionParser() + parser.add_option('-e', dest='env', metavar='KEY=VAL', action='append', + help='Set environment variable KEY to VAL', + ) + parser.add_option('-c', dest='conf', metavar='FILE', + help='Load configuration from FILE', + ) + parser.add_option('-d', '--debug', action='store_true', + help='Produce full debuging output', + ) + parser.add_option('-v', '--verbose', action='store_true', + help='Produce more verbose output', + ) + return parser + + +class LogFormatter(logging.Formatter): + """ + Log formatter that uses UTC for all timestamps. + """ + converter = time.gmtime + + +def make_repr(name, *args, **kw): + """ + Construct a standard representation of a class instance. + """ + args = [repr(a) for a in args] + kw = ['%s=%r' % (k, kw[k]) for k in sorted(kw)] + return '%s(%s)' % (name, ', '.join(args + kw)) |