]> scripts.mit.edu Git - wizard.git/blob - wizard/install.py
Add version detection.
[wizard.git] / wizard / install.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 optparse
61 import os
62 import httplib
63 import urllib
64 import subprocess
65 import getpass
66
67 import wizard
68 from wizard import shell, util
69
70 def fetch(options, path, post=None):
71     """
72     Fetches a web page from the autoinstall, usually to perform database
73     installation.  ``path`` is the path of the file to retrieve, relative
74     to the autoinstall base (``options.web_path``), not the web root.
75     ``post`` is a dictionary to post.  ``options`` is the options
76     object generated by :class:`OptionParser`.
77     """
78     h = httplib.HTTPConnection(options.web_host)
79     fullpath = options.web_path + "/" + path
80     if post:
81         headers = {"Content-type": "application/x-www-form-urlencoded"}
82         h.request("POST", fullpath, urllib.urlencode(post), headers)
83     else:
84         h.request("GET", fullpath)
85     r = h.getresponse()
86     data = r.read()
87     h.close()
88     return data
89
90 def attr_to_option(variable):
91     """
92     Converts Python attribute names to command line options.
93
94     >>> attr_to_option("foo_bar")
95     '--foo-bar'
96     """
97     return '--' + variable.replace('_', '-')
98
99 def preloads():
100     """
101     Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
102     """
103     return {
104             'mysql': MysqlArgSet(),
105             'admin': AdminArgSet(),
106             'email': EmailArgSet(),
107             }
108
109 class Strategy(object):
110     """Represents a strategy for calculating arg values without user input."""
111     #: Whether or not this strategy has side effects.
112     side_effects = False
113     def execute(self, options):
114         """
115         Calculates values for the arguments that this strategy has been
116         associated with, and then mutates ``options`` to contain those new
117         values.  This function is atomic; when control leaves, all of the
118         options should either have values, **or** a :exc:`FailedStrategy` was
119         raised and none of the options should have been changed.
120         Execution is bypassed if all options are explicitly specified, even
121         in the case of strategies with side effects..
122         """
123         raise NotImplemented
124
125 class ScriptsWebStrategy(Strategy):
126     """Performs scripts specific guesses for web variables."""
127     # XXX: THIS CODE SUCKS
128     def execute(self, options):
129         """Guesses web path by splitting on web_scripts."""
130         _, _, web_path = os.getcwd().partition("/web_scripts")
131         if not web_path:
132             raise StrategyFailed
133         options.web_path = web_path
134         options.web_host = util.get_dir_owner() + ".scripts.mit.edu"
135
136 class ScriptsMysqlStrategy(Strategy):
137     """
138     Performs scripts specific guesses for MySQL variables.  This
139     may create an appropriate database for the user.
140     """
141     side_effects = True
142     def execute(self, options):
143         """Attempts to create a database using Scripts utilities."""
144         sh = shell.Shell()
145         try:
146             triplet = sh.eval("/mit/scripts/sql/bin/get-password").split()
147         except:
148             raise StrategyFailed
149         name = os.path.basename(os.getcwd())
150         username = os.getenv('USER')
151         options.mysql_host, options.mysql_user, options.mysql_password = triplet
152         # race condition
153         options.mysql_db = username + '+' + sh.eval("/mit/scripts/sql/bin/get-next-database", name)
154         sh.call("/mit/scripts/sql/bin/create-database", options.mysql_db)
155
156 class ScriptsEmailStrategy(Strategy):
157     """Performs script specific guess for email."""
158     def execute(self, options):
159         """Guesses email using username."""
160         # XXX: should double-check that you're on a scripts server
161         # and fail if you're not.
162         options.email = os.getenv("USER") + "@mit.edu"
163
164 class Arg(object):
165     """
166     Represent a required, named argument for installation.  These
167     cannot have strategies associated with them, so if you'd like
168     to have a strategy associated with a single argument, create
169     an :class:`ArgSet` with one item in it.
170     """
171     #: Attribute name of the argument
172     name = None
173     #: Help string
174     help = None
175     #: String "type" of the argument, used for metavar
176     type = None
177     #: If true, is a password
178     password = None
179     @property
180     def option(self):
181         """Full string of the option."""
182         return attr_to_option(self.name)
183     @property
184     def envname(self):
185         """Name of the environment variable containing this arg."""
186         return 'WIZARD_' + self.name.upper()
187     def prompt(self, options):
188         """Interactively prompts for a value and sets it to options."""
189         # XXX: put a sane default implementation; we'll probably need
190         # "big" descriptions for this, since 'help' is too sparse.
191         pass
192     def __init__(self, name, password=False, type=None, help=None):
193         self.name = name
194         self.password = password
195         self.help = help or "UNDOCUMENTED"
196         self.type = type
197
198 class ArgSet(object):
199     """
200     Represents a set of named installation arguments that are required
201     for an installation to complete successfully.  Arguments in a set
202     should share a common prefix and be related in functionality (the
203     litmus test is if you need one of these arguments, you should need
204     all of them).
205     """
206     #: The :class:`Arg` objects that compose this argument set.
207     args = None
208     #: The :class:`Strategy` objects for this option
209     strategy = None
210     def __init__(self):
211         self.args = []
212
213 class WebArgSet(ArgSet):
214     """Common arguments for any application that lives on the web."""
215     def __init__(self):
216         self.args = [
217                 Arg("web_host", type="HOST", help="Host that the application will live on"),
218                 Arg("web_path", type="PATH", help="Relative path to your application root"),
219                 ]
220         self.strategy = ScriptsWebStrategy()
221
222 class MysqlArgSet(ArgSet):
223     """Common arguments for applications that use a MySQL database."""
224     def __init__(self):
225         self.args = [
226                 Arg("mysql_host", type="HOST", help="Host that your MySQL server lives on"),
227                 Arg("mysql_db", type="DB", help="Name of the database to populate"),
228                 Arg("mysql_user", type="USER", help="Name of user to access database with"),
229                 Arg("mysql_password", type="PWD", password=True, help="Password of the database user"),
230                 ]
231         self.strategy = ScriptsMysqlStrategy()
232
233 class AdminArgSet(ArgSet):
234     """Common arguments when an admin account is to be created."""
235     def __init__(self):
236         self.args = [
237                 Arg("admin_name", type="NAME", help="Name of admin user to create"),
238                 Arg("admin_password", type="PWD", password=True, help="Password of admin user"),
239                 ]
240
241 class EmailArgSet(ArgSet):
242     """Common arguments when an administrative email is required."""
243     def __init__(self):
244         self.args = [
245                 Arg("email", help="Administrative email"),
246                 ]
247         self.strategy = ScriptsEmailStrategy()
248
249 class ArgHandler(object):
250     """
251     Generic controller which takes an argument specification of :class:`Arg`
252     and configures either a command line flags parser
253     (:class:`optparse.OptionParser`), environment variables,
254     an interactive user prompt
255     (:class:`OptionPrompt`) or possibly a web interface to request
256     these arguments appropriately.  This controller also
257     handles :class:`ArgSet`, which group related
258     functionality together and can be reused from installer to installer.
259
260     Valid identifiers for subclasses of :class:`ArgSet` are:
261
262     * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
263       ``mysql_user`` and ``mysql_password``.
264     * ``admin``, which populates the options ``admin_name`` and
265       ``admin_password``.
266     * ``email``, which populates the option ``email``.
267
268     The options ``web_path`` and ``web_host`` are automatically required.
269
270     Example::
271
272         parser = ArgHandler("sql", "admin", "email")
273         parser.add(Arg("title", help="Title of the new application"))
274     """
275     #: List of :class:`ArgSet` objects in schema.  The element at
276     #: index 0 will always be an anonymous :class:`ArgSet` that you
277     #: can add stray instances of :class:`Arg` to.
278     argsets = None
279     def __init__(self, *args):
280         self.argsets = [ArgSet(), WebArgSet()]
281         preload_dict = preloads()
282         for preload in args:
283             try:
284                 self.argsets.append(preload_dict[preload])
285             except KeyError:
286                 raise UnrecognizedPreloads(preload)
287     def add(self, arg):
288         """Adds an argument to our schema."""
289         self.argsets[0].args.append(arg)
290     def push(self, parser):
291         """Pushes arg schema to :class:`optparse.OptionParser`."""
292         for argset in self.argsets:
293             for arg in argset.args:
294                 parser.add_option(attr_to_option(arg.name), dest=arg.name, metavar=arg.type,
295                         default=None, help=arg.help)
296     def handle(self, options):
297         """
298         Takes the result of :meth:`optparse.OptionParser.parse_args`
299         and performs user interaction and/or calculations to complete
300         missing fields.
301         """
302         # categorize the argsets
303         argsets_nostrategy = []
304         argsets_strategy = []
305         argsets_strategy_with_side_effects = []
306         for argset in self.argsets:
307             # fill in environment variables
308             for arg in argset.args:
309                 if getattr(options, arg.name) is None:
310                     val = os.getenv(arg.envname)
311                     if val is not None:
312                         setattr(options, arg.name, val)
313             if not argset.strategy:
314                 argsets_nostrategy.append(argset)
315             elif argset.strategy.side_effects:
316                 argsets_strategy_with_side_effects.append(argset)
317             else:
318                 argsets_strategy.append(argset)
319         for argset in argsets_nostrategy:
320             for arg in argset.args:
321                 if getattr(options, arg.name) is None:
322                     # XXX: arg.prompt(options)
323                     raise MissingRequiredParam(arg)
324         def all_set(argset):
325             for arg in argset.args:
326                 if getattr(options, arg.name) is None:
327                     return False
328             return True
329         for sets in (argsets_strategy, argsets_strategy_with_side_effects):
330             for argset in sets:
331                 if all_set(argset): continue
332                 argset.strategy.execute(options)
333                 for arg in argset.args:
334                     if getattr(options, arg.name) is None:
335                         # XXX: arg.prompt(options)
336                         raise MissingRequiredParam(arg)
337
338 class Error(wizard.Error):
339     """Base error class for this module."""
340     pass
341
342 class Failure(Error):
343     """Web install process failed."""
344     # XXX: we can give better error messages
345     pass
346
347 class StrategyFailed(Error):
348     """Strategy couldn't figure out values."""
349     pass
350
351 class UnrecognizedPreloads(Error):
352     """You passed a preload that was not recognized."""
353     #: The preloads that were not recognized.
354     preloads = None
355     def __init__(self, preloads):
356         self.preloads = preloads
357     def __str__(self):
358         return "Did not recognize these preloads: " + ", ".join(self.preloads)
359
360 class MissingRequiredParam(Error):
361     """You missed a required argument, and we couldn't generate it."""
362     #: The :class:`Arg` that was not specified.
363     param = None
364     def __init__(self, arg):
365         self.arg = arg
366     def __str__(self):
367         return "Missing required parameter %s; try specifying option %s or environment variable %s" % (self.arg.name, self.arg.option, self.arg.envname)