]> scripts.mit.edu Git - wizard.git/blob - wizard/install/__init__.py
Fix bugs in scripts-specific installation code.
[wizard.git] / wizard / install / __init__.py
1 """
2 This module contains an object model for specifying "required options",
3 also known as "Args".  While the format of this schema is inspired
4 by :mod:`optparse`, this is not a controller (that is the job
5 of :mod:`wizard.install` submodules); it merely is a schema
6 that controllers can consume in order to determine their desired
7 behavior.
8
9 An :class:`Arg` is the simplest unit of this
10 model, and merely represents some named argument that an installer
11 script needs in order to finish the installation (i.e., the password
12 to the database, or the name of the new application).  Instances
13 of :class:`Arg` can be registered to the :class:`ArgHandler`, which
14 manages marshalling these objects to whatever object
15 is actually managing user input.  An argument is any valid Python
16 variable name, usually categorized using underscores (i.e.
17 ``admin_user``); the argument capitalized and with ``WIZARD_`` prepended
18 to it indicates a corresponding environment variable, i.e.
19 ``WIZARD_ADMIN_USER``.  Arguments must be unique; applications
20 that define custom arguments are expected to namespace them.
21
22 Because autoinstallers will often have a number of themed
23 arguments (i.e. MySQL credentials) that are applicable across
24 autoinstallers, :class:`ArgSet` can be use to group :class:`Arg`
25 instances together, as well as promote reuse of these arguments.
26 There are a number of precanned :class:`ArgSet` subclasses
27 that serve this purpose, such as :class:`MysqlArgSet`.
28 :class:`ArgHandler` also contains some convenience syntax in its
29 constructor for loading predefined instances of :class:`ArgSet`.
30
31 Certain arguments will vary from install to install, but
32 can be automatically calculated if certain assumptions about the
33 server environment are made.  For example, an application might
34 request an email; if we are on an Athena machine, one would
35 reasonably expect the currently logged in user + @mit.edu to be
36 a valid email address.  :class:`Strategy` objects are responsible
37 for this sort of calculation, and may be attached to any
38 :class:`ArgSet` instance. (If you would like to attach a strategy
39 to a single arg, you should put the arg in a :class:`ArgSet` and
40 then set the strategy).
41
42 Finally, certain :class:`Strategy` objects may perform operations
43 with side effects (as marked by :attr:`Strategy.side_effects`).
44 The primary use case for this is automatic creation of databases
45 during an autoinstall.  Marking a :class:`Strategy` as having
46 side effects is important, so as to delay executing it until
47 absolutely necessary (at the end of options parsing, but before
48 the actual installation begins).
49
50 .. note:
51
52     Because Wizard is eventually intended for public use,
53     some hook mechanism for overloading the default strategies will
54     need to be created.  Setting up environment variables may act
55     as a vaguely reasonable workaround in the interim.
56
57 .. testsetup:: *
58
59     from wizard.install import *
60 """
61
62 import os
63 import logging
64
65 import wizard
66 from wizard import scripts, shell, util
67
68 def fetch(options, path, post=None):
69     """
70     Fetches a web page from the autoinstall, usually to perform database
71     installation.  ``path`` is the path of the file to retrieve, relative
72     to the autoinstall base (``options.web_path``), not the web root.
73     ``post`` is a dictionary to post.  ``options`` is the options
74     object generated by :class:`OptionParser`.
75     """
76     return util.fetch(options.web_host, options.web_path, path, post)
77
78 def preloads():
79     """
80     Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
81     """
82     return {
83             'web': WebArgSet(),
84             'mysql': MysqlArgSet(),
85             'admin': AdminArgSet(),
86             'email': EmailArgSet(),
87             }
88
89 class Strategy(object):
90     """Represents a strategy for calculating arg values without user input."""
91     #: Arguments that this strategy provides
92     provides = frozenset()
93     #: Whether or not this strategy has side effects.
94     side_effects = False
95     def prepare(self):
96         """
97         Performs all side-effectless computation associated with this
98         strategy.  It also detects if computation is possible, and
99         raises :exc:`StrategyFailed` if it isn't.
100         """
101         raise NotImplemented
102     def execute(self, options):
103         """
104         Performs effectful computations associated with this strategy,
105         and mutates ``options`` with the new values.  Behavior is
106         undefined if :meth:`prepare` was not called first.
107         """
108         raise NotImplemented
109
110 class EnvironmentStrategy(Strategy):
111     """Fills in values from environment variables."""
112     def __init__(self, schema):
113         self.provides = set()
114         self.envlookup = {}
115         for arg in schema.args.values():
116             if os.getenv(arg.envname) is not None:
117                 self.provides.add(arg.name)
118                 self.envlookup[arg.name] = arg.envname
119     def prepare(self):
120         """This strategy is always available."""
121         return True
122     def execute(self, options):
123         """Sets undefined options to their environment variables."""
124         for name, envname in self.envlookup.items():
125             if getattr(options, name) is not None:
126                 continue
127             setattr(options, name, os.getenv(envname))
128
129 class ScriptsWebStrategy(Strategy):
130     """Performs scripts specific guesses for web variables."""
131     provides = frozenset(["web_host", "web_path"])
132     def prepare(self):
133         """Uses :func:`wizard.scripts.get_web_host_and_path`."""
134         self._tuple = scripts.get_web_host_and_path()
135         if not self._tuple:
136             raise StrategyFailed
137     def execute(self, options):
138         """No-op."""
139         options.web_host, options.web_path = self._tuple
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(["mysql_host", "mysql_user", "mysql_password", "mysql_db"])
148     def prepare(self):
149         """Uses :func:`wizard.scripts.get_sql_credentials`"""
150         self._triplet = scripts.get_sql_credentials()
151         if not self._triplet:
152             raise StrategyFailed
153         self._username = os.getenv('USER')
154         if self._username is None:
155             raise StrategyFailed
156     def execute(self, options):
157         """Creates a new database for the user using ``get-next-database`` and ``create-database``."""
158         sh = shell.Shell()
159         name = os.path.basename(os.getcwd())
160         options.mysql_host, options.mysql_user, options.mysql_password = self._triplet
161         # race condition
162         options.mysql_db = self._username + '+' + sh.eval("/mit/scripts/sql/bin/get-next-database", name)
163         sh.call("/mit/scripts/sql/bin/create-database", options.mysql_db)
164
165 class ScriptsEmailStrategy(Strategy):
166     """Performs script specific guess for email."""
167     provides = frozenset(["email"])
168     def prepare(self):
169         """Uses :envvar:`USER` and assumes you are an MIT affiliate."""
170         # XXX: should double-check that you're on a scripts server
171         # and fail if you're not.
172         self._user = os.getenv("USER")
173         if self._user is None:
174             raise StrategyFailed
175     def execute(self, options):
176         """No-op."""
177         options.email = self._user + "@mit.edu"
178
179 class Arg(object):
180     """
181     Represent a required, named argument for installation.  These
182     cannot have strategies associated with them, so if you'd like
183     to have a strategy associated with a single argument, create
184     an :class:`ArgSet` with one item in it.
185     """
186     #: Attribute name of the argument
187     name = None
188     #: Help string
189     help = None
190     #: String "type" of the argument, used for metavar
191     type = None
192     #: If true, is a password
193     password = None
194     @property
195     def option(self):
196         """Full string of the option."""
197         return attr_to_option(self.name)
198     @property
199     def envname(self):
200         """Name of the environment variable containing this arg."""
201         return 'WIZARD_' + self.name.upper()
202     def prompt(self, options):
203         """Interactively prompts for a value and sets it to options."""
204         # XXX: put a sane default implementation; we'll probably need
205         # "big" descriptions for this, since 'help' is too sparse.
206         pass
207     def __init__(self, name, password=False, type=None, help=None):
208         self.name = name
209         self.password = password
210         self.help = help or "UNDOCUMENTED"
211         self.type = type
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).
220     """
221     #: The :class:`Arg` objects that compose this argument set.
222     args = None
223     def __init__(self):
224         self.args = []
225
226 class WebArgSet(ArgSet):
227     """Common arguments for any application that lives on the web."""
228     def __init__(self):
229         self.args = [
230                 Arg("web_host", type="HOST", help="Host that the application will live on"),
231                 Arg("web_path", type="PATH", help="Relative path to your application root"),
232                 ]
233
234 class MysqlArgSet(ArgSet):
235     """Common arguments for applications that use a MySQL database."""
236     def __init__(self):
237         self.args = [
238                 Arg("mysql_host", type="HOST", help="Host that your MySQL server lives on"),
239                 Arg("mysql_db", type="DB", help="Name of the database to populate"),
240                 Arg("mysql_user", type="USER", help="Name of user to access database with"),
241                 Arg("mysql_password", type="PWD", password=True, help="Password of the database user"),
242                 ]
243
244 class AdminArgSet(ArgSet):
245     """Common arguments when an admin account is to be created."""
246     def __init__(self):
247         self.args = [
248                 Arg("admin_name", type="NAME", help="Name of admin user to create"),
249                 Arg("admin_password", type="PWD", password=True, help="Password of admin user"),
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 ArgSchema(object):
260     """
261     Schema container for arguments.
262
263     Valid identifiers for subclasses of :class:`ArgSet` are:
264
265     * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
266       ``mysql_user`` and ``mysql_password``.
267     * ``admin``, which populates the options ``admin_name`` and
268       ``admin_password``.
269     * ``email``, which populates the option ``email``.
270
271     The options ``web_path`` and ``web_host`` are automatically required.
272
273     Example::
274
275         parser = ArgHandler("sql", "admin", "email")
276         parser.add(Arg("title", help="Title of the new application"))
277     """
278     #: Dictionary of argument names to :class:`Arg` objects in schema.
279     args = None
280     #: List of :class:`ArgStrategy` objects in schema.
281     strategies = None
282     #: Set of arguments that are already provided.  (This doesn't
283     #: say how to get them: probably running strategies or environment variables.)
284     provides = None
285     def __init__(self, *args):
286         self.args = {}
287         preload_dict = preloads()
288         args = list(args)
289         args.append("web")
290         for preload in args:
291             try:
292                 for arg in preload_dict[preload].args:
293                     self.args[arg.name] = arg
294             except KeyError:
295                 raise UnrecognizedPreloads(preload)
296     def add(self, arg):
297         """Adds an argument to our schema."""
298         self.args[arg.name] = arg
299     def commit(self):
300         """Populates :attr:`strategies` and :attr:`provides`"""
301         self.strategies = []
302         self.provides = set()
303         # XXX: separate out soon
304         raw_strategies = [
305                 EnvironmentStrategy(self),
306                 ScriptsWebStrategy(),
307                 ScriptsMysqlStrategy(),
308                 ScriptsEmailStrategy(),
309                 ]
310         for arg in self.args.values():
311             if os.getenv(arg.envname) is not None:
312                 self.provides.add(arg.name)
313         for strategy in raw_strategies:
314             try:
315                 strategy.prepare()
316                 self.provides |= strategy.provides
317                 self.strategies.append(strategy)
318             except StrategyFailed:
319                 pass
320         # do non-effectful strategies first; this is a stable sort
321         self.strategies.sort(key=lambda x: x.side_effects)
322     def load(self, options):
323         """
324         Load values from strategy.  Must be called after :meth:`commit`.  We
325         omit strategies whose provided variables are completely specified
326         already.  Will raise :exc:`MissingRequiredParam` if strategies aren't
327         sufficient to fill all options.
328         """
329         unfilled = set(name for name in self.args if getattr(options, name) is None)
330         missing = unfilled - self.provides
331         if missing:
332             raise MissingRequiredParam(missing)
333         for strategy in self.strategies:
334             if all(getattr(options, name) is not None for name in strategy.provides):
335                 continue
336             for name in strategy.provides:
337                 if getattr(options, name) is not None:
338                     logging.warning("Overriding pre-specified value for %s", name)
339             strategy.execute(options)
340
341 class Error(wizard.Error):
342     """Base error class for this module."""
343     pass
344
345 class Failure(Error):
346     """Installation failed."""
347     pass
348
349 class StrategyFailed(Error):
350     """Strategy couldn't figure out values."""
351     pass
352
353 class UnrecognizedPreloads(Error):
354     """You passed a preload that was not recognized."""
355     #: The preloads that were not recognized.
356     preloads = None
357     def __init__(self, preloads):
358         self.preloads = preloads
359     def __str__(self):
360         return "Did not recognize these preloads: " + ", ".join(self.preloads)
361
362 class MissingRequiredParam(Error):
363     """You missed a required argument, and we couldn't generate it.
364     Controllers should catch this exception and provide better behavior."""
365     #: The names of the arguments that were not specified.
366     args = None
367     def __init__(self, args):
368         self.args = args
369     def __str__(self):
370         return "Missing required parameters: %s" % ', '.join(self.args)