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