]> scripts.mit.edu Git - wizard.git/blobdiff - wizard/install.py
Fix permissions problem when logging.
[wizard.git] / wizard / install.py
index 1cfef12696aef377656357fbb5f8f5cbc18fdc40..b6d5271e00a713feffa3c4356820d43d0a0f782c 100644 (file)
@@ -1,5 +1,56 @@
 """
-Common code for installation scripts that live in .scripts/install
+This module contains an object model for specifying "required options",
+also known as "Args".  Whereas :class:`optparse.OptionParser` might
+normally be configured by performing a bunch of function calls, we
+generalize this configuration in order to support other types
+of input methods (most notably interactive).
+
+Briefly, a :class:`Arg` is the simplest unit of this
+model, and merely represents some named argument that an installer
+script needs in order to finish the installation (i.e., the password
+to the database, or the name of the new application).  Instances
+of :class:`Arg` can be registered to the :class:`ArgHandler`, which
+manages marshalling these objects to whatever object
+is actually managing user input.  An argument is any valid Python
+variable name, usually categorized using underscores (i.e.
+admin_user); the argument capitalized and with ``WIZARD_`` prepended
+to it indicates a corresponding environment variable, i.e.
+``WIZARD_ADMIN_USER``.
+
+Because autoinstallers will often have a number of themed
+arguments (i.e. MySQL credentials) that are applicable across
+autoinstallers, :class:`ArgSet` can be use to group :class:`Arg`
+instances together, as well as promote reuse of these arguments.
+There are a number of precanned :class:`ArgSet` subclasses
+that serve this purpose, such as :class:`MysqlArgSet`.
+:class:`ArgHandler` also contains some convenience syntax in its
+constructor for loading predefined instances of :class:`ArgSet`.
+
+Certain arguments will vary from install to install, but
+can be automatically calculated if certain assumptions about the
+server environment are made.  For example, an application might
+request an email; if we are on an Athena machine, one would
+reasonably expect the currently logged in user + @mit.edu to be
+a valid email address.  :class:`Strategy` objects are responsible
+for this sort of calculation, and may be attached to any
+:class:`ArgSet` instance. (If you would like to attach a strategy
+to a single arg, you should put the arg in a :class:`ArgSet` and
+then set the strategy).
+
+Finally, certain :class:`Strategy` objects may perform operations
+with side effects (as marked by :attr:`Strategy.side_effects`).
+The primary use case for this is automatic creation of databases
+during an autoinstall.  Marking a :class:`Strategy` as having
+side effects is important, so as to delay executing it until
+absolutely necessary (at the end of options parsing, but before
+the actual installation begins).
+
+.. note:
+
+    Because Wizard is eventually intended for public use,
+    some hook mechanism for overloading the default strategies will
+    need to be created.  Setting up environment variables may act
+    as a vaguely reasonable workaround in the interim.
 
 .. testsetup:: *
 
@@ -11,9 +62,10 @@ import os
 import httplib
 import urllib
 import subprocess
+import getpass
 
 import wizard
-from wizard import util
+from wizard import shell, util
 
 def fetch(options, path, post=None):
     """
@@ -44,46 +96,168 @@ def attr_to_option(variable):
     """
     return '--' + variable.replace('_', '-')
 
-def calculate_web(options):
+def preloads():
+    """
+    Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
+    """
+    return {
+            'mysql': MysqlArgSet(),
+            'admin': AdminArgSet(),
+            'email': EmailArgSet(),
+            }
+
+class Strategy(object):
+    """Represents a strategy for calculating arg values without user input."""
+    #: Whether or not this strategy has side effects.
+    side_effects = False
+    def execute(self, options):
+        """
+        Calculates values for the arguments that this strategy has been
+        associated with, and then mutates ``options`` to contain those new
+        values.  This function is atomic; when control leaves, all of the
+        options should either have values, **or** a :exc:`FailedStrategy` was
+        raised and none of the options should have been changed.
+        Execution is bypassed if all options are explicitly specified, even
+        in the case of strategies with side effects..
+        """
+        raise NotImplemented
+
+class ScriptsWebStrategy(Strategy):
     """Performs scripts specific guesses for web variables."""
     # XXX: THIS CODE SUCKS
-    if options.web_path or options.web_host: return
-    _, _, web_path = os.getcwd().partition("/web_scripts")
-    if not web_path: return
-    options.web_path = web_path
-    options.web_host = util.get_dir_owner() + ".scripts.mit.edu"
+    def execute(self, options):
+        """Guesses web path by splitting on web_scripts."""
+        _, _, web_path = os.getcwd().partition("/web_scripts")
+        if not web_path:
+            raise StrategyFailed
+        options.web_path = web_path
+        options.web_host = util.get_dir_owner() + ".scripts.mit.edu"
 
-def calculate_mysql(options):
+class ScriptsMysqlStrategy(Strategy):
     """
-    Performs scripts specific guesses for mysql variables.
+    Performs scripts specific guesses for MySQL variables.  This
+    may create an appropriate database for the user.
+    """
+    side_effects = True
+    def execute(self, options):
+        """Attempts to create a database using Scripts utilities."""
+        sh = shell.Shell()
+        try:
+            triplet = sh.eval("/mit/scripts/sql/bin/get-password").split()
+        except:
+            raise StrategyFailed
+        name = os.path.basename(os.getcwd())
+        username = os.getenv('USER')
+        options.mysql_host, options.mysql_user, options.mysql_password = triplet
+        # race condition
+        options.mysql_db = username + '+' + sh.eval("/mit/scripts/sql/bin/get-next-database", name)
+        sh.call("/mit/scripts/sql/bin/create-database", options.mysql_db)
 
-    .. note::
+class ScriptsEmailStrategy(Strategy):
+    """Performs script specific guess for email."""
+    def execute(self, options):
+        """Guesses email using username."""
+        # XXX: should double-check that you're on a scripts server
+        # and fail if you're not.
+        options.email = os.getenv("USER") + "@mit.edu"
 
-        This might create the database using the sql signup service.
+class Arg(object):
     """
-    if options.mysql_host or options.mysql_db or options.mysql_user or options.mysql_password: return
-    try:
-        triplet = subprocess.Popen("/mit/scripts/sql/bin/get-password", stdout=subprocess.PIPE).communicate()[0].rstrip().split()
-    except:
-        raise
-    name = os.path.basename(os.getcwd())
-    username = os.getenv('USER')
-    options.mysql_host, options.mysql_user, options.mysql_password = triplet
-    options.mysql_db = username + '+' + subprocess.Popen(["/mit/scripts/sql/bin/get-next-database", name], stdout=subprocess.PIPE).communicate()[0].rstrip()
-    subprocess.Popen(["/mit/scripts/sql/bin/create-database", options.mysql_db], stdout=subprocess.PIPE).communicate()
-
-def calculate_email(options):
-    """Performs script specific guess for email."""
-    options.email = os.getenv("USER") + "@mit.edu"
+    Represent a required, named argument for installation.  These
+    cannot have strategies associated with them, so if you'd like
+    to have a strategy associated with a single argument, create
+    an :class:`ArgSet` with one item in it.
+    """
+    #: Attribute name of the argument
+    name = None
+    #: Help string
+    help = None
+    #: String "type" of the argument, used for metavar
+    type = None
+    #: If true, is a password
+    password = None
+    @property
+    def option(self):
+        """Full string of the option."""
+        return attr_to_option(self.name)
+    @property
+    def envname(self):
+        """Name of the environment variable containing this arg."""
+        return 'WIZARD_' + self.name.upper()
+    def prompt(self, options):
+        """Interactively prompts for a value and sets it to options."""
+        # XXX: put a sane default implementation; we'll probably need
+        # "big" descriptions for this, since 'help' is too sparse.
+        pass
+    def __init__(self, name, password=False, type=None, help=None):
+        self.name = name
+        self.password = password
+        self.help = help or "UNDOCUMENTED"
+        self.type = type
+
+class ArgSet(object):
+    """
+    Represents a set of named installation arguments that are required
+    for an installation to complete successfully.  Arguments in a set
+    should share a common prefix and be related in functionality (the
+    litmus test is if you need one of these arguments, you should need
+    all of them).
+    """
+    #: The :class:`Arg` objects that compose this argument set.
+    args = None
+    #: The :class:`Strategy` objects for this option
+    strategy = None
+    def __init__(self):
+        self.args = []
+
+class WebArgSet(ArgSet):
+    """Common arguments for any application that lives on the web."""
+    def __init__(self):
+        self.args = [
+                Arg("web_host", type="HOST", help="Host that the application will live on"),
+                Arg("web_path", type="PATH", help="Relative path to your application root"),
+                ]
+        self.strategy = ScriptsWebStrategy()
+
+class MysqlArgSet(ArgSet):
+    """Common arguments for applications that use a MySQL database."""
+    def __init__(self):
+        self.args = [
+                Arg("mysql_host", type="HOST", help="Host that your MySQL server lives on"),
+                Arg("mysql_db", type="DB", help="Name of the database to populate"),
+                Arg("mysql_user", type="USER", help="Name of user to access database with"),
+                Arg("mysql_password", type="PWD", password=True, help="Password of the database user"),
+                ]
+        self.strategy = ScriptsMysqlStrategy()
+
+class AdminArgSet(ArgSet):
+    """Common arguments when an admin account is to be created."""
+    def __init__(self):
+        self.args = [
+                Arg("admin_name", type="NAME", help="Name of admin user to create"),
+                Arg("admin_password", type="PWD", password=True, help="Password of admin user"),
+                ]
 
-class OptionParser(object):
+class EmailArgSet(ArgSet):
+    """Common arguments when an administrative email is required."""
+    def __init__(self):
+        self.args = [
+                Arg("email", help="Administrative email"),
+                ]
+        self.strategy = ScriptsEmailStrategy()
+
+class ArgHandler(object):
     """
-    Wrapper around :class:`optparse.OptionParser` which adds support
-    for required name parameters (i.e. "required options") and also
-    includes support for common parameters (from ``preloads``) that you'd want
-    when installing an application.
+    Generic controller which takes an argument specification of :class:`Arg`
+    and configures either a command line flags parser
+    (:class:`optparse.OptionParser`), environment variables,
+    an interactive user prompt
+    (:class:`OptionPrompt`) or possibly a web interface to request
+    these arguments appropriately.  This controller also
+    handles :class:`ArgSet`, which group related
+    functionality together and can be reused from installer to installer.
 
-    Valid preloads are:
+    Valid identifiers for subclasses of :class:`ArgSet` are:
 
     * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
       ``mysql_user`` and ``mysql_password``.
@@ -95,76 +269,71 @@ class OptionParser(object):
 
     Example::
 
-        parser = OptionParser("sql", "admin", "email")
+        parser = ArgHandler("sql", "admin", "email")
+        parser.add(Arg("title", help="Title of the new application"))
     """
-    #: Instance of :class:`optparse.OptionParser`, for specifying
-    #: non-interactive options.
-    parser = None
-    #: Parameters that are required by this parser.
-    params = None
-    def __init__(self, *preloads):
-        self.parser = optparse.OptionParser()
-        self.params = set()
-        self._user_params = set()
-        self._calculators = []
-        preloads = set(preloads)
-        self.add_param("web_path", auto=True, help="Path to your install, e.g. /myapp")
-        self.add_param("web_host", auto=True, help="Host of your install, e.g. example.com")
-        self._calculators.append(calculate_web)
-        if "mysql" in preloads:
-            preloads.remove("mysql")
-            self.add_param("mysql_host", auto=True, help="Host that your MySQL server lives on")
-            self.add_param("mysql_db", auto=True, help="Name of the database to populate")
-            self.add_param("mysql_user", auto=True, help="Name of user to access database with")
-            self.add_param("mysql_password", auto=True, help="Password of the database user")
-            self._calculators.append(calculate_mysql)
-        if "admin" in preloads:
-            preloads.remove("admin")
-            self.add_param("admin_name", help="Name of admin user to create")
-            self.add_param("admin_password", help="Password of admin user")
-        if "email" in preloads:
-            preloads.remove("email")
-            self.add_param("email", auto=True, help="Administrative email")
-            self._calculators.append(calculate_email)
-        if preloads:
-            raise UnrecognizedPreloads(preloads)
-    def add_param(self, name, auto=False, help=None):
-        """
-        Adds a required parameter ``name``.  This parameter can be asked
-        for interactively or specified command line with ``--name`` (with
-        underscores replaced with dashes).  The ``help`` shows up both
-        in ``--help`` output as well as interactive operation.  If
-        ``auto`` is ``True``, that means that we might be able to
-        automatically calculate a sensible value.
-
-        .. note:
-
-            This API is most certainly going to change.
+    #: List of :class:`ArgSet` objects in schema.  The element at
+    #: index 0 will always be an anonymous :class:`ArgSet` that you
+    #: can add stray instances of :class:`Arg` to.
+    argsets = None
+    def __init__(self, *args):
+        self.argsets = [ArgSet(), WebArgSet()]
+        preload_dict = preloads()
+        for preload in args:
+            try:
+                self.argsets.append(preload_dict[preload])
+            except KeyError:
+                raise UnrecognizedPreloads(preload)
+    def add(self, arg):
+        """Adds an argument to our schema."""
+        self.argsets[0].args.append(arg)
+    def push(self, parser):
+        """Pushes arg schema to :class:`optparse.OptionParser`."""
+        for argset in self.argsets:
+            for arg in argset.args:
+                parser.add_option(attr_to_option(arg.name), dest=arg.name, metavar=arg.type,
+                        default=None, help=arg.help)
+    def handle(self, options):
         """
-        self.parser.add_option(attr_to_option(name), dest=name,
-                default=None, help=help)
-        self.params.add(name)
-        if not auto: self._user_params.add(name)
-    def parse_args(self):
+        Takes the result of :meth:`optparse.OptionParser.parse_args`
+        and performs user interaction and/or calculations to complete
+        missing fields.
         """
-        Performs option parsing and required option validation. Returns
-        a tuple of ``(options, args)``, like
-        :meth:`optparse.OptionParser.parse_args`.
-        """
-        options, args = self.parser.parse_args()
-        # XXX: Something more robust, as in "here are the parameters
-        # that need to be set at a certain point in time, and incrementally
-        # increase validation as things go on".  These means we need
-        # an actual object for preloads
-        for param in self._user_params:
-            if getattr(options, param) is None:
-                raise MissingRequiredParam(param)
-        for calculator in self._calculators:
-            calculator(options)
-        for param in self.params:
-            if getattr(options, param) is None:
-                raise MissingRequiredParam(param)
-        return options, param
+        # categorize the argsets
+        argsets_nostrategy = []
+        argsets_strategy = []
+        argsets_strategy_with_side_effects = []
+        for argset in self.argsets:
+            # fill in environment variables
+            for arg in argset.args:
+                if getattr(options, arg.name) is None:
+                    val = os.getenv(arg.envname)
+                    if val is not None:
+                        setattr(options, arg.name, val)
+            if not argset.strategy:
+                argsets_nostrategy.append(argset)
+            elif argset.strategy.side_effects:
+                argsets_strategy_with_side_effects.append(argset)
+            else:
+                argsets_strategy.append(argset)
+        for argset in argsets_nostrategy:
+            for arg in argset.args:
+                if getattr(options, arg.name) is None:
+                    # XXX: arg.prompt(options)
+                    raise MissingRequiredParam(arg)
+        def all_set(argset):
+            for arg in argset.args:
+                if getattr(options, arg.name) is None:
+                    return False
+            return True
+        for sets in (argsets_strategy, argsets_strategy_with_side_effects):
+            for argset in sets:
+                if all_set(argset): continue
+                argset.strategy.execute(options)
+                for arg in argset.args:
+                    if getattr(options, arg.name) is None:
+                        # XXX: arg.prompt(options)
+                        raise MissingRequiredParam(arg)
 
 class Error(wizard.Error):
     """Base error class for this module."""
@@ -175,22 +344,24 @@ class Failure(Error):
     # XXX: we can give better error messages
     pass
 
+class StrategyFailed(Error):
+    """Strategy couldn't figure out values."""
+    pass
+
 class UnrecognizedPreloads(Error):
     """You passed a preload that was not recognized."""
-    #: The preloads that were not recognized
+    #: The preloads that were not recognized.
     preloads = None
     def __init__(self, preloads):
         self.preloads = preloads
-        self.message = str(self)
     def __str__(self):
         return "Did not recognize these preloads: " + ", ".join(self.preloads)
 
 class MissingRequiredParam(Error):
-    """You missed a required parameter, and we couldn't generate it."""
-    #: The param variable name that was not recognized
+    """You missed a required argument, and we couldn't generate it."""
+    #: The :class:`Arg` that was not specified.
     param = None
-    def __init__(self, param):
-        self.param = param
-        self.message = str(self)
+    def __init__(self, arg):
+        self.arg = arg
     def __str__(self):
-        return "Missing required parameter %s; try specifying %s" % (self.param, attr_to_option(self.param))
+        return "Missing required parameter %s; try specifying option %s or environment variable %s" % (self.arg.name, self.arg.option, self.arg.envname)