]> scripts.mit.edu Git - wizard.git/blob - wizard/install/__init__.py
Move wizard.scripts module to plugins, added hooks accordingly.
[wizard.git] / wizard / install / __init__.py
1 """
2 This module deals with marshalling information from the user to the install
3 process for an application.  We divide this process into two parts: this module
4 addresses the specification of what fields the application is requesting, and
5 submodules implement controller logic for actually getting this information
6 from the user.  Common logic between controllers is stored in this module.
7
8 :class:`ArgSchema` is composed of two orthogonal components: a dictionary of
9 :class:`Arg` objects (which may be organized using :class:`ArgSet`) and a list
10 of :class:`Strategy` objects.  An :class:`Arg` contains information about a
11 given argument, and specified at compile-time by an application, while a
12 :class:`Strategy` contains a possible procedure for automatically determining
13 what the contents of some argument are, and is specified at run-time by the
14 user (this is not quite true yet, but will be soon).  Some arguments are most
15 commonly used together, so we group them together as an :class:`ArgSet` and
16 allow applications to refer to them as a single name.
17
18 .. testsetup:: *
19
20     from wizard.install import *
21 """
22
23 import os
24 import logging
25 import sqlalchemy
26 import warnings
27
28 import wizard
29 from wizard import deploy, shell, sql, util
30
31 def dsn_callback(options):
32     if not isinstance(options.dsn, sqlalchemy.engine.url.URL):
33         options.dsn = sqlalchemy.engine.url.make_url(options.dsn)
34     # do some guessing with sql
35     options.dsn = sql.fill_url(options.dsn)
36     # perform some sanity checks on the database
37     database = options.dsn.database
38     options.dsn.database = None
39     engine = sqlalchemy.create_engine(options.dsn)
40     # generates warnings http://groups.google.com/group/sqlalchemy/browse_thread/thread/b7123fefb7dd83d5
41     with warnings.catch_warnings():
42         warnings.simplefilter("ignore")
43         engine.execute("CREATE DATABASE IF NOT EXISTS `%s`" % database)
44     options.dsn.database = database
45     # XXX: another good thing to check might be that the database is empty
46
47 # XXX: This is in the wrong place
48 def fetch(options, path, post=None):
49     """
50     Fetches a web page from the autoinstall, usually to perform database
51     installation.  ``path`` is the path of the file to retrieve, relative
52     to the autoinstall base (``options.web_path``), not the web root.
53     ``post`` is a dictionary to post.  ``options`` is the options
54     object generated by :class:`OptionParser`.
55     """
56     return util.fetch(options.web_host, options.web_path, path, post)
57
58 class Strategy(object):
59     """
60     Represents a strategy for calculating arg values without user input.
61
62     Under many circumstances, making some assumptions about the server
63     environment means that we don't actually have to ask the user for values
64     such as the host or the path: these tend to be side effect free strategies.
65     Furthermore, we may have utility scripts present that can automatically
66     configure a new database for a user when one is necessary: these are side
67     effectful computations.
68
69     Note for an implementor: it is perfectly acceptable to calculate preliminary
70     results in :meth:`prepare`, store them as underscore prefixed variables,
71     and refer to them from :meth:`execute`.
72     """
73     #: Arguments that this strategy provides
74     provides = frozenset()
75     #: Whether or not this strategy has side effects.
76     side_effects = False
77     def prepare(self):
78         """
79         Performs all side-effectless computation associated with this
80         strategy.  It also detects if computation is possible, and
81         raises :exc:`StrategyFailed` if it isn't.
82         """
83         raise NotImplementedError
84     def execute(self, options):
85         """
86         Performs effectful computations associated with this strategy,
87         and mutates ``options`` with the new values.  Behavior is
88         undefined if :meth:`prepare` was not called first.  If this
89         method throws an exception, it should be treated as fatal.
90         """
91         raise NotImplementedError
92
93 class EnvironmentStrategy(Strategy):
94     """
95     Fills in values from environment variables, based off of
96     :attr:`Arg.envname` from ``schema``.
97     """
98     def __init__(self, schema):
99         self.provides = set()
100         self.envlookup = {}
101         for arg in schema.args.values():
102             if os.getenv(arg.envname) is not None:
103                 self.provides.add(arg.name)
104                 self.envlookup[arg.name] = arg.envname
105     def prepare(self):
106         """This strategy is always available."""
107         return True
108     def execute(self, options):
109         """Sets undefined options to their environment variables."""
110         for name, envname in self.envlookup.items():
111             if getattr(options, name) is not None:
112                 continue
113             setattr(options, name, os.getenv(envname))
114
115 class ScriptsWebStrategy(Strategy):
116     """Performs scripts specific guesses for web variables."""
117     # XXX: This actually isn't too scripts specific
118     provides = frozenset(["web_host", "web_path"])
119     def __init__(self, dir):
120         self.dir = dir
121     def prepare(self):
122         """Uses :func:`deploy.web`."""
123         if self.dir is None:
124             raise StrategyFailed
125         urls = deploy.web(self.dir, None)
126         if not urls:
127             raise StrategyFailed
128         try:
129             self._url = urls.next()
130         except StopIteration:
131             raise StrategyFailed
132     def execute(self, options):
133         """No-op."""
134         options.web_host = self._url.netloc # pylint: disable-msg=E1101
135         options.web_path = self._url.path   # pylint: disable-msg=E1101
136         options.web_inferred = True # hacky: needed to see if we need a .scripts/url file
137
138 class ScriptsMysqlStrategy(Strategy):
139     """
140     Performs scripts specific guesses for MySQL variables.  This
141     may create an appropriate database for the user.
142     """
143     side_effects = True
144     provides = frozenset(["dsn"])
145     def __init__(self, application, dir):
146         self.application = application
147         self.dir = dir
148     def prepare(self):
149         """Uses the SQL programs in the scripts locker"""
150         if self.application.database != "mysql":
151             raise StrategyFailed
152         try:
153             self._triplet = shell.eval("/mit/scripts/sql/bin/get-password").split()
154         except shell.CallError:
155             raise StrategyFailed
156         if len(self._triplet) != 3:
157             raise StrategyFailed
158         self._username = os.getenv('USER')
159         if self._username is None:
160             raise StrategyFailed
161     def execute(self, options):
162         """Creates a new database for the user using ``get-next-database`` and ``create-database``."""
163         host, username, password = self._triplet
164         # race condition
165         name = shell.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
166         database = shell.eval("/mit/scripts/sql/bin/create-database", name)
167         options.dsn = sqlalchemy.engine.url.URL("mysql", username=username, password=password, host=host, database=database)
168
169 class ScriptsEmailStrategy(Strategy):
170     """Performs script specific guess for email."""
171     provides = frozenset(["email"])
172     def prepare(self):
173         """Uses :envvar:`USER` and assumes you are an MIT affiliate."""
174         # XXX: should double-check that you're on a scripts server
175         # and fail if you're not.
176         # XXX: This might be buggy, because locker might be set to USER
177         self._user = os.getenv("USER")
178         if self._user is None:
179             raise StrategyFailed
180     def execute(self, options):
181         """No-op."""
182         options.email = self._user + "@mit.edu"
183
184 class Arg(object):
185     """
186     Represent a required, named argument for installation.
187     """
188     #: Attribute name of the argument
189     name = None
190     #: Help string
191     help = None
192     #: String to display if prompting a user for a value
193     prompt = None
194     #: String "type" of the argument, used for metavar
195     type = None
196     #: If true, is a password
197     password = False
198     #: Callback that this argument wants to get run on options after finished
199     callback = None
200     @property
201     def envname(self):
202         """Name of the environment variable containing this arg."""
203         return 'WIZARD_' + self.name.upper()
204     def __init__(self, name, **kwargs):
205         self.name = name
206         for k,v in kwargs.items(): # cuz I'm lazy
207             if not hasattr(self, k):
208                 raise TypeError("Arg() got unexpected keyword argument '%s'" % k)
209             setattr(self, k, v)
210         if self.prompt is None:
211             self.prompt = self.help
212
213 class ArgSet(object):
214     """
215     Represents a set of named installation arguments that are required
216     for an installation to complete successfully.  Arguments in a set
217     should share a common prefix and be related in functionality (the
218     litmus test is if you need one of these arguments, you should need
219     all of them).  Register them in :func:`preloads`.
220     """
221     #: The :class:`Arg` objects that compose this argument set.
222     args = None
223     # XXX: probably could also use a callback attribute
224     def __init__(self):
225         self.args = []
226
227 class WebArgSet(ArgSet):
228     """Common arguments for any application that lives on the web."""
229     def __init__(self):
230         self.args = [
231                 Arg("web_host", type="HOST", help="Host that the application will live on"),
232                 Arg("web_path", type="PATH", help="Relative path to your application root"),
233                 ]
234
235 class DbArgSet(ArgSet):
236     """Common arguments for applications that use a database."""
237     def __init__(self):
238         self.args = [
239                 Arg("dsn", type="DSN", help="Database Source Name, i.e. mysql://user:pass@host/dbname", callback=dsn_callback),
240                 ]
241
242 class AdminArgSet(ArgSet):
243     """Common arguments when an admin account is to be created."""
244     def __init__(self):
245         self.args = [
246                 Arg("admin_name", type="NAME", help="Name of admin user to create",
247                     prompt="You will be able to log in using a username of your choice.  Please decide on a username and enter it below."),
248                 Arg("admin_password", type="PWD", password=True, help="Password of admin user",
249                     prompt="Please decide on an admin password."),
250                 ]
251
252 class EmailArgSet(ArgSet):
253     """Common arguments when an administrative email is required."""
254     def __init__(self):
255         self.args = [
256                 Arg("email", help="Administrative email"),
257                 ]
258
259 class TitleArgSet(ArgSet):
260     """Common arguments when a title is required."""
261     def __init__(self):
262         self.args = [
263                 Arg("title", help="Title of your new site",
264                     prompt="Please decide on a title for your new website."),
265                 ]
266
267 def preloads():
268     """
269     Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
270     """
271     return {
272             'web': WebArgSet(),
273             'db': DbArgSet(),
274             'admin': AdminArgSet(),
275             'email': EmailArgSet(),
276             'title': TitleArgSet(),
277             }
278
279 class ArgSchema(object):
280     """
281     Schema container for arguments.
282
283     Valid identifiers for subclasses of :class:`ArgSet` are:
284
285     * ``db``, which populates the option ``dsn``, which is a SQLAlchemy
286       database source name, with properties for ``drivername``,
287       ``username``, ``password``, ``host``, ``port``, ``database`` and
288       ``query``.
289     * ``admin``, which populates the options ``admin_name`` and
290       ``admin_password``.
291     * ``email``, which populates the option ``email``.
292     * ``title``, which populates the option ``title``.
293
294     The options ``web_path`` and ``web_host`` are automatically required.
295
296     Example::
297
298         parser = ArgHandler("db", "admin", "email")
299         parser.add(Arg("title", help="Title of the new application"))
300     """
301     #: Dictionary of argument names to :class:`Arg` objects in schema.
302     args = None
303     #: List of :class:`Strategy` objects in schema.
304     strategies = None
305     #: Set of arguments that are already provided by :attr:`strategies`.
306     provides = None
307     def __init__(self, *args):
308         self.args = {}
309         preload_dict = preloads()
310         args = list(args)
311         args.append("web")
312         for preload in args:
313             try:
314                 for arg in preload_dict[preload].args:
315                     self.args[arg.name] = arg
316             except KeyError:
317                 raise UnrecognizedPreloads(preload)
318     def add(self, arg):
319         """Adds an argument to our schema."""
320         self.args[arg.name] = arg
321     def commit(self, application, dir, web_stub_path):
322         """Populates :attr:`strategies` and :attr:`provides`"""
323         self.strategies = []
324         self.provides = set()
325         # XXX: separate out soon
326         raw_strategies = [
327                 EnvironmentStrategy(self),
328                 ScriptsWebStrategy(dir),
329                 ScriptsWebStrategy(web_stub_path),
330                 ScriptsMysqlStrategy(application, dir),
331                 ScriptsEmailStrategy(),
332                 ]
333         for strategy in raw_strategies:
334             try:
335                 strategy.prepare()
336                 self.provides |= strategy.provides
337                 self.strategies.append(strategy)
338             except StrategyFailed:
339                 pass
340         # do non-effectful strategies first; this is a stable sort
341         self.strategies.sort(key=lambda x: x.side_effects)
342     def fill(self, options):
343         """
344         Fills an object with all arguments pre-set
345         to ``None``.
346         """
347         for i in self.args:
348             if not hasattr(options, i):
349                 setattr(options, i, None)
350     def load(self, options):
351         """
352         Load values from strategy.  Must be called after :meth:`commit`.  We
353         omit strategies whose provided variables are completely specified
354         already.  Will raise :exc:`MissingRequiredParam` if strategies aren't
355         sufficient to fill all options.  It will then run any callbacks on
356         arguments.
357         """
358         unfilled = set(name for name in self.args if getattr(options, name) is None)
359         missing = unfilled - self.provides
360         if missing:
361             raise MissingRequiredParam(missing)
362         for strategy in self.strategies:
363             # If the application being installed doesn't need all of the
364             # parameters a strategy could provide, we don't use it.
365             if any(not hasattr(options, name) for name in strategy.provides):
366                 if any(hasattr(options, name) for name in strategy.provides):
367                     logging.warning("Ignored partial strategy %s" % strategy)
368                 continue
369             if all(getattr(options, name) is not None for name in strategy.provides):
370                 continue
371             for name in strategy.provides:
372                 if getattr(options, name) is not None:
373                     logging.warning("Overriding pre-specified value for %s", name)
374             strategy.execute(options)
375         for arg in self.args.values():
376             if arg.callback is None:
377                 continue
378             arg.callback(options)
379
380 class Error(wizard.Error):
381     """Base error class for this module."""
382     pass
383
384 class StrategyFailed(Error):
385     """Strategy couldn't figure out values."""
386     pass
387
388 class UnrecognizedPreloads(Error):
389     """You passed a preload that was not recognized."""
390     #: The preloads that were not recognized.
391     preloads = None
392     def __init__(self, preloads):
393         self.preloads = preloads
394     def __str__(self):
395         return "Did not recognize these preloads: " + ", ".join(self.preloads)
396
397 class MissingRequiredParam(Error):
398     """You missed a required argument, and we couldn't generate it.
399     Controllers should catch this exception and provide better behavior."""
400     #: The names of the arguments that were not specified.
401     args = None
402     def __init__(self, args):
403         self.args = args
404     def __str__(self):
405         return "Missing required parameters: %s" % ', '.join(self.args)