]> scripts.mit.edu Git - wizard.git/blob - wizard/install/__init__.py
Convert ad hoc shell calls to singleton instance; fix upgrade bug.
[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 scripts, shell, 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     # perform some sanity checks on the database
35     database = options.dsn.database
36     options.dsn.database = None
37     engine = sqlalchemy.create_engine(options.dsn)
38     # generates warnings http://groups.google.com/group/sqlalchemy/browse_thread/thread/b7123fefb7dd83d5
39     with warnings.catch_warnings():
40         warnings.simplefilter("ignore")
41         engine.execute("CREATE DATABASE IF NOT EXISTS `%s`" % database)
42     options.dsn.database = database
43     # XXX: another good thing to check might be that the database is empty
44
45 # XXX: This is in the wrong place
46 def fetch(options, path, post=None):
47     """
48     Fetches a web page from the autoinstall, usually to perform database
49     installation.  ``path`` is the path of the file to retrieve, relative
50     to the autoinstall base (``options.web_path``), not the web root.
51     ``post`` is a dictionary to post.  ``options`` is the options
52     object generated by :class:`OptionParser`.
53     """
54     return util.fetch(options.web_host, options.web_path, path, post)
55
56 def preloads():
57     """
58     Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
59     """
60     return {
61             'web': WebArgSet(),
62             'db': DbArgSet(),
63             'admin': AdminArgSet(),
64             'email': EmailArgSet(),
65             'title': TitleArgSet(),
66             }
67
68 class Strategy(object):
69     """
70     Represents a strategy for calculating arg values without user input.
71
72     Under many circumstances, making some assumptions about the server
73     environment means that we don't actually have to ask the user for values
74     such as the host or the path: these tend to be side effect free strategies.
75     Furthermore, we may have utility scripts present that can automatically
76     configure a new database for a user when one is necessary: these are side
77     effectful computations.
78
79     Note for an implementor: it is perfectly acceptable to calculate preliminary
80     results in :meth:`prepare`, store them as underscore prefixed variables,
81     and refer to them from :meth:`execute`.
82     """
83     #: Arguments that this strategy provides
84     provides = frozenset()
85     #: Whether or not this strategy has side effects.
86     side_effects = False
87     def prepare(self):
88         """
89         Performs all side-effectless computation associated with this
90         strategy.  It also detects if computation is possible, and
91         raises :exc:`StrategyFailed` if it isn't.
92         """
93         raise NotImplementedError
94     def execute(self, options):
95         """
96         Performs effectful computations associated with this strategy,
97         and mutates ``options`` with the new values.  Behavior is
98         undefined if :meth:`prepare` was not called first.  If this
99         method throws an exception, it should be treated as fatal.
100         """
101         raise NotImplementedError
102
103 class EnvironmentStrategy(Strategy):
104     """
105     Fills in values from environment variables, based off of
106     :attr:`Arg.envname` from ``schema``.
107     """
108     def __init__(self, schema):
109         self.provides = set()
110         self.envlookup = {}
111         for arg in schema.args.values():
112             if os.getenv(arg.envname) is not None:
113                 self.provides.add(arg.name)
114                 self.envlookup[arg.name] = arg.envname
115     def prepare(self):
116         """This strategy is always available."""
117         return True
118     def execute(self, options):
119         """Sets undefined options to their environment variables."""
120         for name, envname in self.envlookup.items():
121             if getattr(options, name) is not None:
122                 continue
123             setattr(options, name, os.getenv(envname))
124
125 class ScriptsWebStrategy(Strategy):
126     """Performs scripts specific guesses for web variables."""
127     provides = frozenset(["web_host", "web_path"])
128     def __init__(self, dir):
129         self.dir = dir
130     def prepare(self):
131         """Uses :func:`wizard.scripts.get_web_host_and_path`."""
132         self._url = scripts.fill_url(self.dir, None)
133         if not self._url:
134             raise StrategyFailed
135     def execute(self, options):
136         """No-op."""
137         options.web_host = self._url.netloc # pylint: disable-msg=E1101
138         options.web_path = self._url.path   # pylint: disable-msg=E1101
139         options.web_inferred = True # hacky: needed to see if we need a .scripts/url file
140
141 class ScriptsMysqlStrategy(Strategy):
142     """
143     Performs scripts specific guesses for MySQL variables.  This
144     may create an appropriate database for the user.
145     """
146     side_effects = True
147     provides = frozenset(["dsn"])
148     def __init__(self, application, dir):
149         self.application = application
150         self.dir = dir
151     def prepare(self):
152         """Uses :func:`wizard.scripts.get_sql_credentials`"""
153         if self.application.database != "mysql":
154             raise StrategyFailed
155         try:
156             self._triplet = shell.eval("/mit/scripts/sql/bin/get-password").split()
157         except shell.CallError:
158             raise StrategyFailed
159         self._username = os.getenv('USER')
160         if self._username is None:
161             raise StrategyFailed
162     def execute(self, options):
163         """Creates a new database for the user using ``get-next-database`` and ``create-database``."""
164         host, username, password = self._triplet
165         # race condition
166         database = self._username + '+' + shell.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
167         shell.call("/mit/scripts/sql/bin/create-database", database)
168         options.dsn = sqlalchemy.engine.url.URL("mysql", username=username, password=password, host=host, database=database)
169
170 class ScriptsEmailStrategy(Strategy):
171     """Performs script specific guess for email."""
172     provides = frozenset(["email"])
173     def prepare(self):
174         """Uses :envvar:`USER` and assumes you are an MIT affiliate."""
175         # XXX: should double-check that you're on a scripts server
176         # and fail if you're not.
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 class ArgSchema(object):
268     """
269     Schema container for arguments.
270
271     Valid identifiers for subclasses of :class:`ArgSet` are:
272
273     * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
274       ``mysql_user`` and ``mysql_password``.
275     * ``admin``, which populates the options ``admin_name`` and
276       ``admin_password``.
277     * ``email``, which populates the option ``email``.
278     * ``title``, which populates the option ``title``.
279
280     The options ``web_path`` and ``web_host`` are automatically required.
281
282     Example::
283
284         parser = ArgHandler("mysql", "admin", "email")
285         parser.add(Arg("title", help="Title of the new application"))
286     """
287     #: Dictionary of argument names to :class:`Arg` objects in schema.
288     args = None
289     #: List of :class:`Strategy` objects in schema.
290     strategies = None
291     #: Set of arguments that are already provided by :attr:`strategies`.
292     provides = None
293     def __init__(self, *args):
294         self.args = {}
295         preload_dict = preloads()
296         args = list(args)
297         args.append("web")
298         for preload in args:
299             try:
300                 for arg in preload_dict[preload].args:
301                     self.args[arg.name] = arg
302             except KeyError:
303                 raise UnrecognizedPreloads(preload)
304     def add(self, arg):
305         """Adds an argument to our schema."""
306         self.args[arg.name] = arg
307     def commit(self, application, dir):
308         """Populates :attr:`strategies` and :attr:`provides`"""
309         self.strategies = []
310         self.provides = set()
311         # XXX: separate out soon
312         raw_strategies = [
313                 EnvironmentStrategy(self),
314                 ScriptsWebStrategy(dir),
315                 ScriptsMysqlStrategy(application, dir),
316                 ScriptsEmailStrategy(),
317                 ]
318         for arg in self.args.values():
319             if os.getenv(arg.envname) is not None:
320                 self.provides.add(arg.name)
321         for strategy in raw_strategies:
322             try:
323                 strategy.prepare()
324                 self.provides |= strategy.provides
325                 self.strategies.append(strategy)
326             except StrategyFailed:
327                 pass
328         # do non-effectful strategies first; this is a stable sort
329         self.strategies.sort(key=lambda x: x.side_effects)
330     def fill(self, options):
331         """
332         Fills an object with all arguments pre-set
333         to ``None``.
334         """
335         for i in self.args:
336             if not hasattr(options, i):
337                 setattr(options, i, None)
338     def load(self, options):
339         """
340         Load values from strategy.  Must be called after :meth:`commit`.  We
341         omit strategies whose provided variables are completely specified
342         already.  Will raise :exc:`MissingRequiredParam` if strategies aren't
343         sufficient to fill all options.  It will then run any callbacks on
344         arguments.
345         """
346         unfilled = set(name for name in self.args if getattr(options, name) is None)
347         missing = unfilled - self.provides
348         if missing:
349             raise MissingRequiredParam(missing)
350         for strategy in self.strategies:
351             if all(getattr(options, name) is not None for name in strategy.provides):
352                 continue
353             for name in strategy.provides:
354                 if getattr(options, name) is not None:
355                     logging.warning("Overriding pre-specified value for %s", name)
356             strategy.execute(options)
357         for arg in self.args.values():
358             if arg.callback is None:
359                 continue
360             arg.callback(options)
361
362 class Error(wizard.Error):
363     """Base error class for this module."""
364     pass
365
366 class StrategyFailed(Error):
367     """Strategy couldn't figure out values."""
368     pass
369
370 class UnrecognizedPreloads(Error):
371     """You passed a preload that was not recognized."""
372     #: The preloads that were not recognized.
373     preloads = None
374     def __init__(self, preloads):
375         self.preloads = preloads
376     def __str__(self):
377         return "Did not recognize these preloads: " + ", ".join(self.preloads)
378
379 class MissingRequiredParam(Error):
380     """You missed a required argument, and we couldn't generate it.
381     Controllers should catch this exception and provide better behavior."""
382     #: The names of the arguments that were not specified.
383     args = None
384     def __init__(self, args):
385         self.args = args
386     def __str__(self):
387         return "Missing required parameters: %s" % ', '.join(self.args)