]> scripts.mit.edu Git - wizard.git/blob - wizard/install/__init__.py
8a9401199d345521fd238c16a62d0c082b35c9c7
[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             'webstub': WebStubArgSet(),
63             'db': DbArgSet(),
64             'admin': AdminArgSet(),
65             'email': EmailArgSet(),
66             'title': TitleArgSet(),
67             }
68
69 class Strategy(object):
70     """
71     Represents a strategy for calculating arg values without user input.
72
73     Under many circumstances, making some assumptions about the server
74     environment means that we don't actually have to ask the user for values
75     such as the host or the path: these tend to be side effect free strategies.
76     Furthermore, we may have utility scripts present that can automatically
77     configure a new database for a user when one is necessary: these are side
78     effectful computations.
79
80     Note for an implementor: it is perfectly acceptable to calculate preliminary
81     results in :meth:`prepare`, store them as underscore prefixed variables,
82     and refer to them from :meth:`execute`.
83     """
84     #: Arguments that this strategy provides
85     provides = frozenset()
86     #: Whether or not this strategy has side effects.
87     side_effects = False
88     def prepare(self):
89         """
90         Performs all side-effectless computation associated with this
91         strategy.  It also detects if computation is possible, and
92         raises :exc:`StrategyFailed` if it isn't.
93         """
94         raise NotImplementedError
95     def execute(self, options):
96         """
97         Performs effectful computations associated with this strategy,
98         and mutates ``options`` with the new values.  Behavior is
99         undefined if :meth:`prepare` was not called first.  If this
100         method throws an exception, it should be treated as fatal.
101         """
102         raise NotImplementedError
103
104 class EnvironmentStrategy(Strategy):
105     """
106     Fills in values from environment variables, based off of
107     :attr:`Arg.envname` from ``schema``.
108     """
109     def __init__(self, schema):
110         self.provides = set()
111         self.envlookup = {}
112         for arg in schema.args.values():
113             if os.getenv(arg.envname) is not None:
114                 self.provides.add(arg.name)
115                 self.envlookup[arg.name] = arg.envname
116     def prepare(self):
117         """This strategy is always available."""
118         return True
119     def execute(self, options):
120         """Sets undefined options to their environment variables."""
121         for name, envname in self.envlookup.items():
122             if getattr(options, name) is not None:
123                 continue
124             setattr(options, name, os.getenv(envname))
125
126 class ScriptsWebStrategy(Strategy):
127     """Performs scripts specific guesses for web variables."""
128     provides = frozenset(["web_host", "web_path"])
129     def __init__(self, dir):
130         self.dir = dir
131     def prepare(self):
132         """Uses :func:`wizard.scripts.get_web_host_and_path`."""
133         self._url = scripts.fill_url(self.dir, None)
134         if not self._url:
135             raise StrategyFailed
136     def execute(self, options):
137         """No-op."""
138         options.web_host = self._url.netloc # pylint: disable-msg=E1101
139         options.web_path = self._url.path   # pylint: disable-msg=E1101
140         options.web_inferred = True # hacky: needed to see if we need a .scripts/url file
141
142 class ScriptsMysqlStrategy(Strategy):
143     """
144     Performs scripts specific guesses for MySQL variables.  This
145     may create an appropriate database for the user.
146     """
147     side_effects = True
148     provides = frozenset(["dsn"])
149     def __init__(self, application, dir):
150         self.application = application
151         self.dir = dir
152     def prepare(self):
153         """Uses :func:`wizard.scripts.get_sql_credentials`"""
154         if self.application.database != "mysql":
155             raise StrategyFailed
156         try:
157             self._triplet = shell.eval("/mit/scripts/sql/bin/get-password").split()
158         except shell.CallError:
159             raise StrategyFailed
160         self._username = os.getenv('USER')
161         if self._username is None:
162             raise StrategyFailed
163     def execute(self, options):
164         """Creates a new database for the user using ``get-next-database`` and ``create-database``."""
165         host, username, password = self._triplet
166         # race condition
167         name = shell.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
168         database = shell.eval("/mit/scripts/sql/bin/create-database", name)
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 WebStubArgSet(ArgSet):
237     """
238     Common arguments for any application that has an extra folder
239     necessary to place "stub" code for an application, i.e. a
240     FastCGI script.  Most Python applications will require this.
241     """
242     def __init__(self):
243         self.args = [
244                 Arg("web_stub_path", type="PATH", help="Absolute path to the directory containing the web stub"),
245                 ]
246
247 class DbArgSet(ArgSet):
248     """Common arguments for applications that use a database."""
249     def __init__(self):
250         self.args = [
251                 Arg("dsn", type="DSN", help="Database Source Name, i.e. mysql://user:pass@host/dbname", callback=dsn_callback),
252                 ]
253
254 class AdminArgSet(ArgSet):
255     """Common arguments when an admin account is to be created."""
256     def __init__(self):
257         self.args = [
258                 Arg("admin_name", type="NAME", help="Name of admin user to create",
259                     prompt="You will be able to log in using a username of your choice.  Please decide on a username and enter it below."),
260                 Arg("admin_password", type="PWD", password=True, help="Password of admin user",
261                     prompt="Please decide on an admin password."),
262                 ]
263
264 class EmailArgSet(ArgSet):
265     """Common arguments when an administrative email is required."""
266     def __init__(self):
267         self.args = [
268                 Arg("email", help="Administrative email"),
269                 ]
270
271 class TitleArgSet(ArgSet):
272     """Common arguments when a title is required."""
273     def __init__(self):
274         self.args = [
275                 Arg("title", help="Title of your new site",
276                     prompt="Please decide on a title for your new website."),
277                 ]
278
279 class ArgSchema(object):
280     """
281     Schema container for arguments.
282
283     Valid identifiers for subclasses of :class:`ArgSet` are:
284
285     * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
286       ``mysql_user`` and ``mysql_password``.
287     * ``admin``, which populates the options ``admin_name`` and
288       ``admin_password``.
289     * ``email``, which populates the option ``email``.
290     * ``title``, which populates the option ``title``.
291
292     The options ``web_path`` and ``web_host`` are automatically required.
293
294     Example::
295
296         parser = ArgHandler("mysql", "admin", "email")
297         parser.add(Arg("title", help="Title of the new application"))
298     """
299     #: Dictionary of argument names to :class:`Arg` objects in schema.
300     args = None
301     #: List of :class:`Strategy` objects in schema.
302     strategies = None
303     #: Set of arguments that are already provided by :attr:`strategies`.
304     provides = None
305     def __init__(self, *args):
306         self.args = {}
307         preload_dict = preloads()
308         args = list(args)
309         args.append("web")
310         for preload in args:
311             try:
312                 for arg in preload_dict[preload].args:
313                     self.args[arg.name] = arg
314             except KeyError:
315                 raise UnrecognizedPreloads(preload)
316     def add(self, arg):
317         """Adds an argument to our schema."""
318         self.args[arg.name] = arg
319     def commit(self, application, dir):
320         """Populates :attr:`strategies` and :attr:`provides`"""
321         self.strategies = []
322         self.provides = set()
323         # XXX: separate out soon
324         raw_strategies = [
325                 EnvironmentStrategy(self),
326                 ScriptsWebStrategy(dir),
327                 ScriptsMysqlStrategy(application, dir),
328                 ScriptsEmailStrategy(),
329                 ]
330         for arg in self.args.values():
331             if os.getenv(arg.envname) is not None:
332                 self.provides.add(arg.name)
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 any(not hasattr(options, name) for name in strategy.provides):
364                 if any(hasattr(options, name) for name in strategy.provides):
365                     logging.warning("Ignored partial strategy %s" % strategy)
366                 continue
367             if all(getattr(options, name) is not None for name in strategy.provides):
368                 continue
369             for name in strategy.provides:
370                 if getattr(options, name) is not None:
371                     logging.warning("Overriding pre-specified value for %s", name)
372             strategy.execute(options)
373         for arg in self.args.values():
374             if arg.callback is None:
375                 continue
376             arg.callback(options)
377
378 class Error(wizard.Error):
379     """Base error class for this module."""
380     pass
381
382 class StrategyFailed(Error):
383     """Strategy couldn't figure out values."""
384     pass
385
386 class UnrecognizedPreloads(Error):
387     """You passed a preload that was not recognized."""
388     #: The preloads that were not recognized.
389     preloads = None
390     def __init__(self, preloads):
391         self.preloads = preloads
392     def __str__(self):
393         return "Did not recognize these preloads: " + ", ".join(self.preloads)
394
395 class MissingRequiredParam(Error):
396     """You missed a required argument, and we couldn't generate it.
397     Controllers should catch this exception and provide better behavior."""
398     #: The names of the arguments that were not specified.
399     args = None
400     def __init__(self, args):
401         self.args = args
402     def __str__(self):
403         return "Missing required parameters: %s" % ', '.join(self.args)