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