]> scripts.mit.edu Git - wizard.git/blob - wizard/install.py
Rough draft of installation functionality.
[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     @property
166     def option(self):
167         """Full string of the option."""
168         return attr_to_option(self.name)
169     def __init__(self, name, password=False, help="XXX: UNDOCUMENTED"):
170         self.name = name
171         self.password = password
172         self.help = help
173
174 class ArgSet(object):
175     """
176     Represents a set of named installation arguments that are required
177     for an installation to complete successfully.  Arguments in a set
178     should share a common prefix and be related in functionality (the
179     litmus test is if you need one of these arguments, you should need
180     all of them).
181     """
182     #: The :class:`Arg` objects that compose this argument set.
183     args = None
184     #: The :class:`Strategy` objects for this option
185     strategy = None
186     def __init__(self):
187         self.args = []
188
189 class WebArgSet(ArgSet):
190     """Common arguments for any application that lives on the web."""
191     def __init__(self):
192         self.args = [
193                 Arg("web_host", help="Host that the application will live on"),
194                 Arg("web_path", help="Relative path to your application root"),
195                 ]
196         self.strategy = ScriptsWebStrategy()
197
198 class MysqlArgSet(ArgSet):
199     """Common arguments for applications that use a MySQL database."""
200     def __init__(self):
201         self.args = [
202                 Arg("mysql_host", help="Host that your MySQL server lives on"),
203                 Arg("mysql_db", help="Name of the database to populate"),
204                 Arg("mysql_user", help="Name of user to access database with"),
205                 Arg("mysql_password", password=True, help="Password of the database user"),
206                 ]
207         self.strategy = ScriptsMysqlStrategy()
208
209 class AdminArgSet(ArgSet):
210     """Common arguments when an admin account is to be created."""
211     def __init__(self):
212         self.args = [
213                 Arg("admin_name", help="Name of admin user to create"),
214                 Arg("admin_password", password=True, help="Password of admin user"),
215                 ]
216
217 class EmailArgSet(ArgSet):
218     """Common arguments when an administrative email is required."""
219     def __init__(self):
220         self.args = [
221                 Arg("email", help="Administrative email"),
222                 ]
223         self.strategy = ScriptsEmailStrategy()
224
225 class ArgHandler(object):
226     """
227     Generic controller which takes an argument specification of :class:`Arg`
228     and configures either a command line flags parser
229     (:class:`optparse.OptionParser`), an interactive user prompt
230     (:class:`OptionPrompt`) or possibly a web interface to request
231     these arguments appropriately.  This controller also
232     handles :class:`ArgSet`, which group related
233     functionality together and can be reused from installer to installer.
234
235     Valid identifiers for subclasses of :class:`ArgSet` are:
236
237     * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
238       ``mysql_user`` and ``mysql_password``.
239     * ``admin``, which populates the options ``admin_name`` and
240       ``admin_password``.
241     * ``email``, which populates the option ``email``.
242
243     The options ``web_path`` and ``web_host`` are automatically required.
244
245     Example::
246
247         parser = ArgHandler("sql", "admin", "email")
248         parser.add(Arg("title", help="Title of the new application"))
249     """
250     #: List of :class:`ArgSet` objects in schema.  The element at
251     #: index 0 will always be an anonymous :class:`ArgSet` that you
252     #: can add stray :class:`Arg`s to.
253     argsets = None
254     def __init__(self, *args):
255         self.argsets = [ArgSet(), WebArgSet()]
256         preload_dict = preloads()
257         for preload in args:
258             try:
259                 self.argsets.append(preload_dict[preload])
260             except KeyError:
261                 raise UnrecognizedPreloads(preload)
262     def add(self, arg):
263         """Adds an argument to our schema."""
264         self.argsets[0].args.append(arg)
265     def push(self, parser):
266         """Pushes arg schema to :class:`optparse.OptionParser`."""
267         for argset in self.argsets:
268             for arg in argset.args:
269                 parser.add_option(attr_to_option(arg.name), dest=arg.name,
270                         default=None, help=arg.help)
271     def handle(self, options):
272         """
273         Takes the result of :meth:`optparse.OptionParser.parse_args`
274         and performs user interaction and/or calculations to complete
275         missing fields.
276         """
277         # categorize the argsets
278         argsets_nostrategy = []
279         argsets_strategy = []
280         argsets_strategy_with_side_effects = []
281         for argset in self.argsets:
282             if not argset.strategy:
283                 argsets_nostrategy.append(argset)
284             elif argset.strategy.side_effects:
285                 argsets_strategy_with_side_effects.append(argset)
286             else:
287                 argsets_strategy.append(argset)
288         for argset in argsets_nostrategy:
289             for arg in argset.args:
290                 if getattr(options, arg.name) is None:
291                     # XXX: do something interactive
292                     raise MissingRequiredParam(arg)
293         def all_set(argset):
294             for arg in argset.args:
295                 if getattr(options, arg.name) is None:
296                     return False
297             return True
298         for argset in argsets_strategy:
299             if all_set(argset): continue
300             argset.strategy.execute(options)
301             # XXX: do something interactive
302         for argset in argsets_strategy_with_side_effects:
303             if all_set(argset): continue
304             argset.strategy.execute(options)
305             # XXX: refactor this
306
307 class Error(wizard.Error):
308     """Base error class for this module."""
309     pass
310
311 class Failure(Error):
312     """Web install process failed."""
313     # XXX: we can give better error messages
314     pass
315
316 class StrategyFailed(Error):
317     """Strategy couldn't figure out values."""
318     pass
319
320 class UnrecognizedPreloads(Error):
321     """You passed a preload that was not recognized."""
322     #: The preloads that were not recognized
323     preloads = None
324     def __init__(self, preloads):
325         self.preloads = preloads
326         self.message = str(self)
327     def __str__(self):
328         return "Did not recognize these preloads: " + ", ".join(self.preloads)
329
330 class MissingRequiredParam(Error):
331     """You missed a required argument, and we couldn't generate it."""
332     #: The :class:`Arg` that was not specified.
333     param = None
334     def __init__(self, arg):
335         self.arg = arg
336         self.message = str(self)
337     def __str__(self):
338         return "Missing required parameter %s; try specifying %s" % (self.arg.name, self.arg.option)