]> scripts.mit.edu Git - wizard.git/blob - wizard/install/__init__.py
c40ee62b3d4d703d42c77cd8d5c038f49c9f7125
[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.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         sh = shell.Shell()
165         host, username, password = self._triplet
166         # race condition
167         database = self._username + '+' + sh.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
168         sh.call("/mit/scripts/sql/bin/create-database", database)
169         options.dsn = sqlalchemy.engine.url.URL("mysql", username=username, password=password, host=host, database=database)
170
171 class ScriptsEmailStrategy(Strategy):
172     """Performs script specific guess for email."""
173     provides = frozenset(["email"])
174     def prepare(self):
175         """Uses :envvar:`USER` and assumes you are an MIT affiliate."""
176         # XXX: should double-check that you're on a scripts server
177         # and fail if you're not.
178         self._user = os.getenv("USER")
179         if self._user is None:
180             raise StrategyFailed
181     def execute(self, options):
182         """No-op."""
183         options.email = self._user + "@mit.edu"
184
185 class Arg(object):
186     """
187     Represent a required, named argument for installation.
188     """
189     #: Attribute name of the argument
190     name = None
191     #: Help string
192     help = None
193     #: String to display if prompting a user for a value
194     prompt = None
195     #: String "type" of the argument, used for metavar
196     type = None
197     #: If true, is a password
198     password = False
199     #: Callback that this argument wants to get run on options after finished
200     callback = None
201     @property
202     def envname(self):
203         """Name of the environment variable containing this arg."""
204         return 'WIZARD_' + self.name.upper()
205     def __init__(self, name, **kwargs):
206         self.name = name
207         for k,v in kwargs.items(): # cuz I'm lazy
208             if not hasattr(self, k):
209                 raise TypeError("Arg() got unexpected keyword argument '%s'" % k)
210             setattr(self, k, v)
211         if self.prompt is None:
212             self.prompt = self.help
213
214 class ArgSet(object):
215     """
216     Represents a set of named installation arguments that are required
217     for an installation to complete successfully.  Arguments in a set
218     should share a common prefix and be related in functionality (the
219     litmus test is if you need one of these arguments, you should need
220     all of them).  Register them in :func:`preloads`.
221     """
222     #: The :class:`Arg` objects that compose this argument set.
223     args = None
224     # XXX: probably could also use a callback attribute
225     def __init__(self):
226         self.args = []
227
228 class WebArgSet(ArgSet):
229     """Common arguments for any application that lives on the web."""
230     def __init__(self):
231         self.args = [
232                 Arg("web_host", type="HOST", help="Host that the application will live on"),
233                 Arg("web_path", type="PATH", help="Relative path to your application root"),
234                 ]
235
236 class DbArgSet(ArgSet):
237     """Common arguments for applications that use a database."""
238     def __init__(self):
239         self.args = [
240                 Arg("dsn", type="DSN", help="Database Source Name, i.e. mysql://user:pass@host/dbname", callback=dsn_callback),
241                 ]
242
243 class AdminArgSet(ArgSet):
244     """Common arguments when an admin account is to be created."""
245     def __init__(self):
246         self.args = [
247                 Arg("admin_name", type="NAME", help="Name of admin user to create",
248                     prompt="You will be able to log in using a username of your choice.  Please decide on a username and enter it below."),
249                 Arg("admin_password", type="PWD", password=True, help="Password of admin user",
250                     prompt="Please decide on an admin password."),
251                 ]
252
253 class EmailArgSet(ArgSet):
254     """Common arguments when an administrative email is required."""
255     def __init__(self):
256         self.args = [
257                 Arg("email", help="Administrative email"),
258                 ]
259
260 class TitleArgSet(ArgSet):
261     """Common arguments when a title is required."""
262     def __init__(self):
263         self.args = [
264                 Arg("title", help="Title of your new site",
265                     prompt="Please decide on a title for your new website."),
266                 ]
267
268 class ArgSchema(object):
269     """
270     Schema container for arguments.
271
272     Valid identifiers for subclasses of :class:`ArgSet` are:
273
274     * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
275       ``mysql_user`` and ``mysql_password``.
276     * ``admin``, which populates the options ``admin_name`` and
277       ``admin_password``.
278     * ``email``, which populates the option ``email``.
279     * ``title``, which populates the option ``title``.
280
281     The options ``web_path`` and ``web_host`` are automatically required.
282
283     Example::
284
285         parser = ArgHandler("mysql", "admin", "email")
286         parser.add(Arg("title", help="Title of the new application"))
287     """
288     #: Dictionary of argument names to :class:`Arg` objects in schema.
289     args = None
290     #: List of :class:`Strategy` objects in schema.
291     strategies = None
292     #: Set of arguments that are already provided by :attr:`strategies`.
293     provides = None
294     def __init__(self, *args):
295         self.args = {}
296         preload_dict = preloads()
297         args = list(args)
298         args.append("web")
299         for preload in args:
300             try:
301                 for arg in preload_dict[preload].args:
302                     self.args[arg.name] = arg
303             except KeyError:
304                 raise UnrecognizedPreloads(preload)
305     def add(self, arg):
306         """Adds an argument to our schema."""
307         self.args[arg.name] = arg
308     def commit(self, application, dir):
309         """Populates :attr:`strategies` and :attr:`provides`"""
310         self.strategies = []
311         self.provides = set()
312         # XXX: separate out soon
313         raw_strategies = [
314                 EnvironmentStrategy(self),
315                 ScriptsWebStrategy(dir),
316                 ScriptsMysqlStrategy(application, dir),
317                 ScriptsEmailStrategy(),
318                 ]
319         for arg in self.args.values():
320             if os.getenv(arg.envname) is not None:
321                 self.provides.add(arg.name)
322         for strategy in raw_strategies:
323             try:
324                 strategy.prepare()
325                 self.provides |= strategy.provides
326                 self.strategies.append(strategy)
327             except StrategyFailed:
328                 pass
329         # do non-effectful strategies first; this is a stable sort
330         self.strategies.sort(key=lambda x: x.side_effects)
331     def fill(self, options):
332         """
333         Fills an object with all arguments pre-set
334         to ``None``.
335         """
336         for i in self.args:
337             if not hasattr(options, i):
338                 setattr(options, i, None)
339     def load(self, options):
340         """
341         Load values from strategy.  Must be called after :meth:`commit`.  We
342         omit strategies whose provided variables are completely specified
343         already.  Will raise :exc:`MissingRequiredParam` if strategies aren't
344         sufficient to fill all options.  It will then run any callbacks on
345         arguments.
346         """
347         unfilled = set(name for name in self.args if getattr(options, name) is None)
348         missing = unfilled - self.provides
349         if missing:
350             raise MissingRequiredParam(missing)
351         for strategy in self.strategies:
352             if all(getattr(options, name) is not None for name in strategy.provides):
353                 continue
354             for name in strategy.provides:
355                 if getattr(options, name) is not None:
356                     logging.warning("Overriding pre-specified value for %s", name)
357             strategy.execute(options)
358         for arg in self.args.values():
359             if arg.callback is None:
360                 continue
361             arg.callback(options)
362
363 class Error(wizard.Error):
364     """Base error class for this module."""
365     pass
366
367 class StrategyFailed(Error):
368     """Strategy couldn't figure out values."""
369     pass
370
371 class UnrecognizedPreloads(Error):
372     """You passed a preload that was not recognized."""
373     #: The preloads that were not recognized.
374     preloads = None
375     def __init__(self, preloads):
376         self.preloads = preloads
377     def __str__(self):
378         return "Did not recognize these preloads: " + ", ".join(self.preloads)
379
380 class MissingRequiredParam(Error):
381     """You missed a required argument, and we couldn't generate it.
382     Controllers should catch this exception and provide better behavior."""
383     #: The names of the arguments that were not specified.
384     args = None
385     def __init__(self, args):
386         self.args = args
387     def __str__(self):
388         return "Missing required parameters: %s" % ', '.join(self.args)