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