]> scripts.mit.edu Git - wizard.git/blob - wizard/install/__init__.py
Move install.py to install folder.
[wizard.git] / wizard / install / __init__.py
1 """
2 This module contains an object model for specifying "required options",
3 also known as "Args".  Whereas :class:`optparse.OptionParser` might
4 normally be configured by performing a bunch of function calls, we
5 generalize this configuration in order to support other types
6 of input methods (most notably interactive).
7
8 Briefly, a :class:`Arg` is the simplest unit of this
9 model, and merely represents some named argument that an installer
10 script needs in order to finish the installation (i.e., the password
11 to the database, or the name of the new application).  Instances
12 of :class:`Arg` can be registered to the :class:`ArgHandler`, which
13 manages marshalling these objects to whatever object
14 is actually managing user input.  An argument is any valid Python
15 variable name, usually categorized using underscores (i.e.
16 admin_user); the argument capitalized and with ``WIZARD_`` prepended
17 to it indicates a corresponding environment variable, i.e.
18 ``WIZARD_ADMIN_USER``.
19
20 Because autoinstallers will often have a number of themed
21 arguments (i.e. MySQL credentials) that are applicable across
22 autoinstallers, :class:`ArgSet` can be use to group :class:`Arg`
23 instances together, as well as promote reuse of these arguments.
24 There are a number of precanned :class:`ArgSet` subclasses
25 that serve this purpose, such as :class:`MysqlArgSet`.
26 :class:`ArgHandler` also contains some convenience syntax in its
27 constructor for loading predefined instances of :class:`ArgSet`.
28
29 Certain arguments will vary from install to install, but
30 can be automatically calculated if certain assumptions about the
31 server environment are made.  For example, an application might
32 request an email; if we are on an Athena machine, one would
33 reasonably expect the currently logged in user + @mit.edu to be
34 a valid email address.  :class:`Strategy` objects are responsible
35 for this sort of calculation, and may be attached to any
36 :class:`ArgSet` instance. (If you would like to attach a strategy
37 to a single arg, you should put the arg in a :class:`ArgSet` and
38 then set the strategy).
39
40 Finally, certain :class:`Strategy` objects may perform operations
41 with side effects (as marked by :attr:`Strategy.side_effects`).
42 The primary use case for this is automatic creation of databases
43 during an autoinstall.  Marking a :class:`Strategy` as having
44 side effects is important, so as to delay executing it until
45 absolutely necessary (at the end of options parsing, but before
46 the actual installation begins).
47
48 .. note:
49
50     Because Wizard is eventually intended for public use,
51     some hook mechanism for overloading the default strategies will
52     need to be created.  Setting up environment variables may act
53     as a vaguely reasonable workaround in the interim.
54
55 .. testsetup:: *
56
57     from wizard.install import *
58 """
59
60 import os
61
62 import wizard
63 from wizard import scripts, shell, util
64
65 def fetch(options, path, post=None):
66     """
67     Fetches a web page from the autoinstall, usually to perform database
68     installation.  ``path`` is the path of the file to retrieve, relative
69     to the autoinstall base (``options.web_path``), not the web root.
70     ``post`` is a dictionary to post.  ``options`` is the options
71     object generated by :class:`OptionParser`.
72     """
73     return util.fetch(options.web_host, options.web_path, path, post)
74
75 def attr_to_option(variable):
76     """
77     Converts Python attribute names to command line options.
78
79     >>> attr_to_option("foo_bar")
80     '--foo-bar'
81     """
82     return '--' + variable.replace('_', '-')
83
84 def preloads():
85     """
86     Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
87     """
88     return {
89             'mysql': MysqlArgSet(),
90             'admin': AdminArgSet(),
91             'email': EmailArgSet(),
92             }
93
94 class Strategy(object):
95     """Represents a strategy for calculating arg values without user input."""
96     #: Whether or not this strategy has side effects.
97     side_effects = False
98     def execute(self, options):
99         """
100         Calculates values for the arguments that this strategy has been
101         associated with, and then mutates ``options`` to contain those new
102         values.  This function is atomic; when control leaves, all of the
103         options should either have values, **or** a :exc:`FailedStrategy` was
104         raised and none of the options should have been changed.
105         Execution is bypassed if all options are explicitly specified, even
106         in the case of strategies with side effects..
107         """
108         raise NotImplemented
109
110 class ScriptsWebStrategy(Strategy):
111     """Performs scripts specific guesses for web variables."""
112     def execute(self, options):
113         """Guesses web path by splitting on web_scripts."""
114         tuple = scripts.get_web_host_and_path()
115         if not tuple:
116             raise StrategyFailed
117         options.web_host, options.web_path = tuple
118
119 class ScriptsMysqlStrategy(Strategy):
120     """
121     Performs scripts specific guesses for MySQL variables.  This
122     may create an appropriate database for the user.
123     """
124     side_effects = True
125     def execute(self, options):
126         """Attempts to create a database using Scripts utilities."""
127         sh = shell.Shell()
128         triplet = scripts.get_sql_credentials()
129         if not triplet:
130             raise StrategyFailed
131         name = os.path.basename(os.getcwd())
132         username = os.getenv('USER')
133         options.mysql_host, options.mysql_user, options.mysql_password = triplet
134         # race condition
135         options.mysql_db = username + '+' + sh.eval("/mit/scripts/sql/bin/get-next-database", name)
136         sh.call("/mit/scripts/sql/bin/create-database", options.mysql_db)
137
138 class ScriptsEmailStrategy(Strategy):
139     """Performs script specific guess for email."""
140     def execute(self, options):
141         """Guesses email using username."""
142         # XXX: should double-check that you're on a scripts server
143         # and fail if you're not.
144         options.email = os.getenv("USER") + "@mit.edu"
145
146 class Arg(object):
147     """
148     Represent a required, named argument for installation.  These
149     cannot have strategies associated with them, so if you'd like
150     to have a strategy associated with a single argument, create
151     an :class:`ArgSet` with one item in it.
152     """
153     #: Attribute name of the argument
154     name = None
155     #: Help string
156     help = None
157     #: String "type" of the argument, used for metavar
158     type = None
159     #: If true, is a password
160     password = None
161     @property
162     def option(self):
163         """Full string of the option."""
164         return attr_to_option(self.name)
165     @property
166     def envname(self):
167         """Name of the environment variable containing this arg."""
168         return 'WIZARD_' + self.name.upper()
169     def prompt(self, options):
170         """Interactively prompts for a value and sets it to options."""
171         # XXX: put a sane default implementation; we'll probably need
172         # "big" descriptions for this, since 'help' is too sparse.
173         pass
174     def __init__(self, name, password=False, type=None, help=None):
175         self.name = name
176         self.password = password
177         self.help = help or "UNDOCUMENTED"
178         self.type = type
179
180 class ArgSet(object):
181     """
182     Represents a set of named installation arguments that are required
183     for an installation to complete successfully.  Arguments in a set
184     should share a common prefix and be related in functionality (the
185     litmus test is if you need one of these arguments, you should need
186     all of them).
187     """
188     #: The :class:`Arg` objects that compose this argument set.
189     args = None
190     #: The :class:`Strategy` objects for this option
191     strategy = None
192     def __init__(self):
193         self.args = []
194
195 class WebArgSet(ArgSet):
196     """Common arguments for any application that lives on the web."""
197     def __init__(self):
198         self.args = [
199                 Arg("web_host", type="HOST", help="Host that the application will live on"),
200                 Arg("web_path", type="PATH", help="Relative path to your application root"),
201                 ]
202         self.strategy = ScriptsWebStrategy()
203
204 class MysqlArgSet(ArgSet):
205     """Common arguments for applications that use a MySQL database."""
206     def __init__(self):
207         self.args = [
208                 Arg("mysql_host", type="HOST", help="Host that your MySQL server lives on"),
209                 Arg("mysql_db", type="DB", help="Name of the database to populate"),
210                 Arg("mysql_user", type="USER", help="Name of user to access database with"),
211                 Arg("mysql_password", type="PWD", password=True, help="Password of the database user"),
212                 ]
213         self.strategy = ScriptsMysqlStrategy()
214
215 class AdminArgSet(ArgSet):
216     """Common arguments when an admin account is to be created."""
217     def __init__(self):
218         self.args = [
219                 Arg("admin_name", type="NAME", help="Name of admin user to create"),
220                 Arg("admin_password", type="PWD", password=True, help="Password of admin user"),
221                 ]
222
223 class EmailArgSet(ArgSet):
224     """Common arguments when an administrative email is required."""
225     def __init__(self):
226         self.args = [
227                 Arg("email", help="Administrative email"),
228                 ]
229         self.strategy = ScriptsEmailStrategy()
230
231 class ArgHandler(object):
232     """
233     Generic controller which takes an argument specification of :class:`Arg`
234     and configures either a command line flags parser
235     (:class:`optparse.OptionParser`), environment variables,
236     an interactive user prompt
237     (:class:`OptionPrompt`) or possibly a web interface to request
238     these arguments appropriately.  This controller also
239     handles :class:`ArgSet`, which group related
240     functionality together and can be reused from installer to installer.
241
242     Valid identifiers for subclasses of :class:`ArgSet` are:
243
244     * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
245       ``mysql_user`` and ``mysql_password``.
246     * ``admin``, which populates the options ``admin_name`` and
247       ``admin_password``.
248     * ``email``, which populates the option ``email``.
249
250     The options ``web_path`` and ``web_host`` are automatically required.
251
252     Example::
253
254         parser = ArgHandler("sql", "admin", "email")
255         parser.add(Arg("title", help="Title of the new application"))
256     """
257     #: List of :class:`ArgSet` objects in schema.  The element at
258     #: index 0 will always be an anonymous :class:`ArgSet` that you
259     #: can add stray instances of :class:`Arg` to.
260     argsets = None
261     def __init__(self, *args):
262         self.argsets = [ArgSet(), WebArgSet()]
263         preload_dict = preloads()
264         for preload in args:
265             try:
266                 self.argsets.append(preload_dict[preload])
267             except KeyError:
268                 raise UnrecognizedPreloads(preload)
269     def add(self, arg):
270         """Adds an argument to our schema."""
271         self.argsets[0].args.append(arg)
272     def push(self, parser):
273         """Pushes arg schema to :class:`optparse.OptionParser`."""
274         for argset in self.argsets:
275             for arg in argset.args:
276                 parser.add_option(attr_to_option(arg.name), dest=arg.name, metavar=arg.type,
277                         default=None, help=arg.help)
278     def handle(self, options):
279         """
280         Takes the result of :meth:`optparse.OptionParser.parse_args`
281         and performs user interaction and/or calculations to complete
282         missing fields.
283         """
284         # categorize the argsets
285         argsets_nostrategy = []
286         argsets_strategy = []
287         argsets_strategy_with_side_effects = []
288         for argset in self.argsets:
289             # fill in environment variables
290             for arg in argset.args:
291                 if getattr(options, arg.name) is None:
292                     val = os.getenv(arg.envname)
293                     if val is not None:
294                         setattr(options, arg.name, val)
295             if not argset.strategy:
296                 argsets_nostrategy.append(argset)
297             elif argset.strategy.side_effects:
298                 argsets_strategy_with_side_effects.append(argset)
299             else:
300                 argsets_strategy.append(argset)
301         for argset in argsets_nostrategy:
302             for arg in argset.args:
303                 if getattr(options, arg.name) is None:
304                     # XXX: arg.prompt(options)
305                     raise MissingRequiredParam(arg)
306         def all_set(argset):
307             for arg in argset.args:
308                 if getattr(options, arg.name) is None:
309                     return False
310             return True
311         for sets in (argsets_strategy, argsets_strategy_with_side_effects):
312             for argset in sets:
313                 if all_set(argset): continue
314                 try:
315                     argset.strategy.execute(options)
316                 except StrategyFailed:
317                     pass
318                 for arg in argset.args:
319                     if getattr(options, arg.name) is None:
320                         # XXX: arg.prompt(options)
321                         raise MissingRequiredParam(arg)
322
323 class Error(wizard.Error):
324     """Base error class for this module."""
325     pass
326
327 class Failure(Error):
328     """Web install process failed."""
329     # XXX: we can give better error messages
330     pass
331
332 class StrategyFailed(Error):
333     """Strategy couldn't figure out values."""
334     pass
335
336 class UnrecognizedPreloads(Error):
337     """You passed a preload that was not recognized."""
338     #: The preloads that were not recognized.
339     preloads = None
340     def __init__(self, preloads):
341         self.preloads = preloads
342     def __str__(self):
343         return "Did not recognize these preloads: " + ", ".join(self.preloads)
344
345 class MissingRequiredParam(Error):
346     """You missed a required argument, and we couldn't generate it."""
347     #: The :class:`Arg` that was not specified.
348     param = None
349     def __init__(self, arg):
350         self.arg = arg
351     def __str__(self):
352         return "Missing required parameter %s; try specifying option %s or environment variable %s" % (self.arg.name, self.arg.option, self.arg.envname)