]> scripts.mit.edu Git - wizard.git/blob - wizard/app/__init__.py
Expand documentation.
[wizard.git] / wizard / app / __init__.py
1 """
2 Plumbing object model for representing applications we want to
3 install.  This module does the heavy lifting, but you probably
4 want to use :class:`wizard.deploy.Deployment` which is more user-friendly.
5 You'll need to know how to overload the :class:`Application` class
6 and use some of the functions in this module in order to specify
7 new applications.
8
9 .. testsetup:: *
10
11     import re
12     import shutil
13     import os
14     from wizard import deploy, util
15     from wizard.app import *
16 """
17
18 import os.path
19 import re
20 import distutils.version
21 import decorator
22 import shlex
23 import logging
24 import shutil
25
26 import wizard
27 from wizard import resolve, scripts, shell, util
28
29 _application_list = [
30     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
31     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
32     # these are technically deprecated
33     "advancedpoll", "gallery",
34 ]
35 _applications = None
36
37 def applications():
38     """Hash table for looking up string application name to instance"""
39     global _applications
40     if not _applications:
41         _applications = dict([(n,Application.make(n)) for n in _application_list ])
42     return _applications
43
44
45 class Application(object):
46     """
47     Represents an application, i.e. mediawiki or phpbb.
48
49     .. note::
50         Many of these methods assume a specific working
51         directory; prefer using the corresponding methods
52         in :class:`wizard.deploy.Deployment` and its subclasses.
53     """
54     #: String name of the application
55     name = None
56     #: Dictionary of version strings to :class:`ApplicationVersion`.
57     #: See also :meth:`makeVersion`.
58     versions = None
59     #: List of files that need to be modified when parametrizing.
60     #: This is a class-wide constant, and should not normally be modified.
61     parametrized_files = []
62     #: Keys that are used in older versions of the application, but
63     #: not for the most recent version.
64     deprecated_keys = []
65     #: Dictionary of variable names to extractor functions.  These functions
66     #: take a :class:`wizard.deploy.Deployment` as an argument and return the value of
67     #: the variable, or ``None`` if it could not be found.
68     #: See also :func:`filename_regex_extractor`.
69     extractors = {}
70     #: Dictionary of variable names to substitution functions.  These functions
71     #: take a :class:`wizard.deploy.Deployment` as an argument and modify the deployment such
72     #: that an explicit instance of the variable is released with the generic
73     #: ``WIZARD_*`` constant.  See also :func:`filename_regex_substitution`.
74     substitutions = {}
75     #: Dictionary of file names to a list of resolutions, which are tuples of
76     #: a conflict marker string and a result list.  See :mod:`wizard.resolve`
77     #: for more information.
78     resolutions = {}
79     #: Instance of :class:`wizard.install.ArgSchema` that defines the arguments
80     #: this application requires.
81     install_schema = None
82     def __init__(self, name):
83         self.name = name
84         self.versions = {}
85         # cache variables
86         self._extractors = {}
87         self._substitutions = {}
88     def repository(self, srv_path):
89         """
90         Returns the Git repository that would contain this application.
91         ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
92         """
93         repo = os.path.join(srv_path, self.name + ".git")
94         if not os.path.isdir(repo):
95             repo = os.path.join(srv_path, self.name, ".git")
96             if not os.path.isdir(repo):
97                 raise NoRepositoryError(self.name)
98         return repo
99     def makeVersion(self, version):
100         """
101         Creates or retrieves the :class:`ApplicationVersion` singleton for the
102         specified version.
103         """
104         if version not in self.versions:
105             self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
106         return self.versions[version]
107     def extract(self, deployment):
108         """
109         Extracts wizard variables from a deployment.  Default implementation
110         uses :attr:`extractors`.
111         """
112         result = {}
113         for k,extractor in self.extractors.items():
114             result[k] = extractor(deployment)
115         return result
116     def parametrize(self, deployment, ref_deployment):
117         """
118         Takes a generic source checkout and parametrizes it according to the
119         values of ``deployment``.  This function operates on the current
120         working directory.  ``deployment`` should **not** be the same as the
121         current working directory.  Default implementation uses
122         :attr:`parametrized_files` and a simple search and replace on those
123         files.
124         """
125         variables = ref_deployment.extract()
126         for file in self.parametrized_files:
127             try:
128                 contents = open(file, "r").read()
129             except IOError:
130                 continue
131             for key, value in variables.items():
132                 if value is None: continue
133                 contents = contents.replace(key, value)
134             f = open(file, "w")
135             f.write(contents)
136     def resolveConflicts(self, deployment):
137         """
138         Resolves conflicted files in the current working directory.  Returns
139         whether or not all conflicted files were resolved or not.  Fully
140         resolved files are added to the index, but no commit is made.  The
141         default implementation uses :attr:`resolutions`.
142         """
143         resolved = True
144         sh = shell.Shell()
145         for status in sh.eval("git", "ls-files", "--unmerged").splitlines():
146             file = status.split()[-1]
147             if file in self.resolutions:
148                 contents = open(file, "r").read()
149                 for spec, result in self.resolutions[file]:
150                     old_contents = contents
151                     contents = resolve.resolve(contents, spec, result)
152                     if old_contents != contents:
153                         logging.info("Did resolution with spec:\n" + spec)
154                 open(file, "w").write(contents)
155                 if not resolve.is_conflict(contents):
156                     sh.call("git", "add", file)
157                 else:
158                     resolved = False
159             else:
160                 resolved = False
161         return resolved
162     def prepareMerge(self, deployment):
163         """
164         Performs various edits to files in the current working directory in
165         order to make a merge go more smoothly.  This is usually
166         used to fix botched line-endings.  If you add new files,
167         you have to 'git add' them; this is not necessary for edits.
168         By default this is a no-op; subclasses should replace this
169         with useful behavior.
170         """
171         pass
172     def prepareConfig(self, deployment):
173         """
174         Takes a deployment and replaces any explicit instances
175         of a configuration variable with generic ``WIZARD_*`` constants.
176         The default implementation uses :attr:`substitutions`, and
177         emits warnings when it encounters keys in :attr:`deprecated_keys`.
178         """
179         for key, subst in self.substitutions.items():
180             subs = subst(deployment)
181             if not subs and key not in self.deprecated_keys:
182                 logging.warning("No substitutions for %s" % key)
183     def install(self, version, options):
184         """
185         Run for 'wizard configure' (and, by proxy, 'wizard install') to
186         configure an application.  This assumes that the current working
187         directory is a deployment.  (Unlike its kin, this function does not
188         take a :class:`wizard.deploy.Deployment` as a parameter.)  Subclasses should
189         provide an implementation.
190         """
191         raise NotImplementedError
192     def upgrade(self, deployment, version, options):
193         """
194         Run for 'wizard upgrade' to upgrade database schemas and other
195         non-versioned data in an application after the filesystem has been
196         upgraded.  This assumes that the current working directory is the
197         deployment.  Subclasses should provide an implementation.
198         """
199         raise NotImplementedError
200     def backup(self, deployment, outdir, options):
201         """
202         Run for 'wizard backup' and upgrades to backup database schemas
203         and other non-versioned data in an application.  ``outdir`` is
204         the directory that backup files should be placed.  This assumes
205         that the current working directory is the deployment.  Subclasses
206         should provide an implementation, even if it is a no-op.
207
208         .. note::
209             Static user files may not need to be backed up, since in
210             many applications upgrades do not modify static files.
211         """
212         raise NotImplementedError
213     def restore(self, deployment, backup_dir, options):
214         """
215         Run for 'wizard restore' and failed upgrades to restore database
216         and other non-versioned data to a backed up version.  This assumes
217         that the current working directory is the deployment.  Subclasses
218         should provide an implementation.
219         """
220         raise NotImplementedError
221     def detectVersion(self, deployment):
222         """
223         Checks source files to determine the version manually.  This assumes
224         that the current working directory is the deployment.  Subclasses
225         should provide an implementation.
226         """
227         raise NotImplementedError
228     def download(self, version):
229         """
230         Returns a URL that can be used to download a tarball of ``version`` of
231         this application.
232         """
233         raise NotImplementedError
234     def checkWeb(self, deployment, output=None):
235         """
236         Checks if the autoinstall is viewable from the web.  To get
237         the HTML source that was retrieved, pass a variable containing
238         an empty list to ``output``; it will be mutated to have its
239         first element be the output.  Subclasses should provide an
240         implementation.
241
242         .. note::
243             Finding a reasonable heuristic that works across skinning
244             choices can be difficult.  We've had reasonable success
245             searching for metadata.  Be sure that the standard error
246             page does not contain the features you search for.  Try
247             not to depend on pages that are not the main page.
248         """
249         raise NotImplementedError
250     def checkConfig(self, deployment):
251         """
252         Checks whether or not an autoinstall has been configured/installed
253         for use.  Assumes that the current working directory is the deployment.
254         Subclasses should provide an implementation.
255         """
256         # XXX: Unfortunately, this doesn't quite work because we package
257         # bogus config files in the -scripts versions of installs.  Maybe
258         # we should check a hash or something?
259         raise NotImplementedError
260     @staticmethod
261     def make(name):
262         """Makes an application, but uses the correct subtype if available."""
263         try:
264             __import__("wizard.app." + name)
265             return getattr(wizard.app, name).Application(name)
266         except ImportError:
267             return Application(name)
268
269 class ApplicationVersion(object):
270     """Represents an abstract notion of a version for an application, where
271     ``version`` is a :class:`distutils.version.LooseVersion` and
272     ``application`` is a :class:`Application`."""
273     #: The :class:`distutils.version.LooseVersion` of this instance.
274     version = None
275     #: The :class:`Application` of this instance.
276     application = None
277     def __init__(self, version, application):
278         self.version = version
279         self.application = application
280     @property
281     def tag(self):
282         """
283         Returns the name of the git describe tag for the commit the user is
284         presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
285         """
286         return "%s-%s" % (self.application, self.version)
287     @property
288     def scripts_tag(self):
289         """
290         Returns the name of the Git tag for this version.
291         """
292         end = str(self.version).partition('-scripts')[2].partition('-')[0]
293         return "%s-scripts%s" % (self.pristine_tag, end)
294     @property
295     def pristine_tag(self):
296         """
297         Returns the name of the Git tag for the pristine version corresponding
298         to this version.
299         """
300         return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
301     def __cmp__(self, y):
302         return cmp(self.version, y.version)
303     @staticmethod
304     def parse(value):
305         """
306         Parses a line from the :term:`versions store` and return
307         :class:`ApplicationVersion`.
308
309         Use this only for cases when speed is of primary importance;
310         the data in version is unreliable and when possible, you should
311         prefer directly instantiating a :class:`wizard.deploy.Deployment` and having it query
312         the autoinstall itself for information.
313
314         The `value` to parse will vary.  For old style installs, it
315         will look like::
316
317            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
318
319         For new style installs, it will look like::
320
321            APP-x.y.z-scripts
322         """
323         name = value.split("/")[-1]
324         try:
325             if name.find("-") != -1:
326                 app, _, version = name.partition("-")
327             else:
328                 # kind of poor, maybe should error.  Generally this
329                 # will actually result in a not found error
330                 app = name
331                 version = "trunk"
332         except ValueError:
333             raise DeploymentParseError(value)
334         return ApplicationVersion.make(app, version)
335     @staticmethod
336     def make(app, version):
337         """
338         Makes/retrieves a singleton :class:`ApplicationVersion` from
339         a``app`` and ``version`` string.
340         """
341         try:
342             # defer to the application for version creation to enforce
343             # singletons
344             return applications()[app].makeVersion(version)
345         except KeyError:
346             raise NoSuchApplication(app)
347
348 def expand_re(val):
349     """
350     Takes a tree of values (implement using nested lists) and
351     transforms them into regular expressions.
352
353         >>> expand_re('*')
354         '\\\\*'
355         >>> expand_re(['a', 'b'])
356         '(?:a|b)'
357         >>> expand_re(['*', ['b', 'c']])
358         '(?:\\\\*|(?:b|c))'
359     """
360     if isinstance(val, str):
361         return re.escape(val)
362     else:
363         return '(?:' + '|'.join(map(expand_re, val)) + ')'
364
365 def make_extractors(seed):
366     """
367     Take a dictionary of ``key``s to ``(file, regex)`` tuples and convert them into
368     extractor functions (which take a :class:`wizard.deploy.Deployment`
369     and return the value of the second subpattern of ``regex`` when matched
370     with the contents of ``file``).
371     """
372     return util.dictmap(lambda a: filename_regex_extractor(*a), seed)
373
374 def make_substitutions(seed):
375     """
376     Take a dictionary of ``key``s to ``(file, regex)`` tuples and convert them into substitution
377     functions (which take a :class:`wizard.deploy.Deployment`, replace the second subpattern
378     of ``regex`` with ``key`` in ``file``, and returns the number of substitutions made.)
379     """
380     return util.dictkmap(lambda k, v: filename_regex_substitution(k, *v), seed)
381
382 # The following two functions are *highly* functional, and I recommend
383 # not touching them unless you know what you're doing.
384
385 def filename_regex_extractor(file, regex):
386     """
387     .. highlight:: haskell
388
389     Given a relative file name ``file``, a regular expression ``regex``, and a
390     :class:`wizard.deploy.Deployment` extracts a value out of the file in that
391     deployment.  This function is curried, so you pass just ``file`` and
392     ``regex``, and then pass ``deployment`` to the resulting function.
393
394     Its Haskell-style type signature would be::
395
396         Filename -> Regex -> (Deployment -> String)
397
398     The regular expression requires a very specific form, essentially ``()()()``
399     (with the second subgroup being the value to extract).  These enables
400     the regular expression to be used equivalently with filename
401
402     .. highlight:: python
403
404     For convenience purposes, we also accept ``[Filename]``, in which case
405     we use the first entry (index 0).  Passing an empty list is invalid.
406
407         >>> open("test-settings.extractor.ini", "w").write("config_var = 3\\n")
408         >>> f = filename_regex_extractor('test-settings.extractor.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
409         >>> f(deploy.Deployment("."))
410         '3'
411         >>> os.unlink("test-settings.extractor.ini")
412
413     .. note::
414         The first application of ``regex`` and ``file`` is normally performed
415         at compile-time inside a submodule; the second application is
416         performed at runtime.
417     """
418     if not isinstance(file, str):
419         file = file[0]
420     def h(deployment):
421         try:
422             contents = deployment.read(file) # cached
423         except IOError:
424             return None
425         match = regex.search(contents)
426         if not match: return None
427         # assumes that the second match is the one we want.
428         return match.group(2)
429     return h
430
431 def filename_regex_substitution(key, files, regex):
432     """
433     .. highlight:: haskell
434
435     Given a Wizard ``key`` (``WIZARD_*``), a list of ``files``, a
436     regular expression ``regex``, and a :class:`wizard.deploy.Deployment`
437     performs a substitution of the second subpattern of ``regex``
438     with ``key``.  Returns the number of replacements made.  This function
439     is curried, so you pass just ``key``, ``files`` and ``regex``, and
440     then pass ``deployment`` to the resulting function.
441
442     Its Haskell-style type signature would be::
443
444         Key -> ([File], Regex) -> (Deployment -> IO Int)
445
446     .. highlight:: python
447
448     For convenience purposes, we also accept ``Filename``, in which case it is treated
449     as a single item list.
450
451         >>> open("test-settings.substitution.ini", "w").write("config_var = 3")
452         >>> f = filename_regex_substitution('WIZARD_KEY', 'test-settings.substitution.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
453         >>> f(deploy.Deployment("."))
454         1
455         >>> print open("test-settings.substitution.ini", "r").read()
456         config_var = WIZARD_KEY
457         >>> os.unlink("test-settings.substitution.ini")
458     """
459     if isinstance(files, str):
460         files = (files,)
461     def h(deployment):
462         base = deployment.location
463         subs = 0
464         for file in files:
465             file = os.path.join(base, file)
466             try:
467                 contents = open(file, "r").read()
468                 contents, n = regex.subn("\\1" + key + "\\3", contents)
469                 subs += n
470                 open(file, "w").write(contents)
471             except IOError:
472                 pass
473         return subs
474     return h
475
476 # XXX: rename to show that it's mysql specific
477 def backup_database(outdir, deployment):
478     """
479     Generic database backup function for MySQL.  Assumes that ``WIZARD_DBNAME``
480     is extractable, and that :func:`wizard.scripts.get_sql_credentials`
481     works.
482     """
483     sh = shell.Shell()
484     outfile = os.path.join(outdir, "db.sql")
485     try:
486         sh.call("mysqldump", "--compress", "-r", outfile, *get_mysql_args(deployment))
487         sh.call("gzip", "--best", outfile)
488     except shell.CallError as e:
489         shutil.rmtree(outdir)
490         raise BackupFailure(e.stderr)
491
492 def restore_database(backup_dir, deployment):
493     """
494     Generic database restoration function for MySQL.  See :func:`backup_database`
495     for the assumptions that we make.
496     """
497     sh = shell.Shell()
498     if not os.path.exists(backup_dir):
499         raise RestoreFailure("Backup %s doesn't exist", backup_dir.rpartition("/")[2])
500     sql = open(os.path.join(backup_dir, "db.sql"), 'w+')
501     sh.call("gunzip", "-c", os.path.join(backup_dir, "db.sql.gz"), stdout=sql)
502     sql.seek(0)
503     sh.call("mysql", *get_mysql_args(deployment), stdin=sql)
504     sql.close()
505
506 def get_mysql_args(d):
507     """
508     Extracts arguments that would be passed to the command line mysql utility
509     from a deployment.
510     """
511     # XXX: add support for getting these out of options
512     vars = d.extract()
513     if 'WIZARD_DBNAME' not in vars:
514         raise app.BackupFailure("Could not determine database name")
515     triplet = scripts.get_sql_credentials(vars)
516     args = []
517     if triplet is not None:
518         server, user, password = triplet
519         args += ["-h", server, "-u", user, "-p" + password]
520     name = shlex.split(vars['WIZARD_DBNAME'])[0]
521     args.append(name)
522     return args
523
524 class Error(wizard.Error):
525     """Generic error class for this module."""
526     pass
527
528 class RecoverableFailure(Error):
529     """
530     The installer failed, but we were able to determine what the
531     error was, and should give the user a second chance if we were
532     running interactively.
533     """
534     #: List of the errors that were found.
535     errors = None
536     def __init__(self, errors):
537         self.errors = errors
538     def __str__(self):
539         return """Installation failed due to the following errors: %s""" % ", ".join(self.errors)
540
541 class NoRepositoryError(Error):
542     """
543     :class:`Application` does not appear to have a Git repository
544     in the normal location.
545     """
546     #: The name of the application that does not have a Git repository.
547     app = None
548     def __init__(self, app):
549         self.app = app
550     def __str__(self):
551         return """Could not find Git repository for '%s'.  If you would like to use a local version, try specifying --srv-path or WIZARD_SRV_PATH.""" % self.app
552
553 class DeploymentParseError(Error):
554     """
555     Could not parse ``value`` from :term:`versions store`.
556     """
557     #: The value that failed to parse.
558     value = None
559     #: The location of the autoinstall that threw this variable.
560     #: This should be set by error handling code when it is available.
561     location = None
562     def __init__(self, value):
563         self.value = value
564
565 class NoSuchApplication(Error):
566     """
567     You attempted to reference a :class:`Application` named
568     ``app``, which is not recognized by Wizard.
569     """
570     #: The name of the application that does not exist.
571     app = None
572     #: The location of the autoinstall that threw this variable.
573     #: This should be set by error handling code when it is availble.
574     location = None
575     def __init__(self, app):
576         self.app = app
577
578 class UpgradeFailure(Error):
579     """Upgrade script failed."""
580     #: String details of failure (possibly stdout or stderr output)
581     details = None
582     def __init__(self, details):
583         self.details = details
584     def __str__(self):
585         return """
586
587 ERROR: Upgrade script failed, details:
588
589 %s""" % self.details
590
591 class UpgradeVerificationFailure(Error):
592     """Upgrade script passed, but website wasn't accessible afterwards"""
593     #: String details of failure (possibly stdout or stderr output)
594     details = None
595     def __init__(self, details):
596         self.details = details
597     def __str__(self):
598         return """
599
600 ERROR: Upgrade script passed, but website wasn't accessible afterwards. Details:
601
602 %s""" % self.details
603
604 class BackupFailure(Error):
605     """Backup script failed."""
606     #: String details of failure
607     details = None
608     def __init__(self, details):
609         self.details = details
610     def __str__(self):
611         return """
612
613 ERROR: Backup script failed, details:
614
615 %s""" % self.details
616
617 class RestoreFailure(Error):
618     """Restore script failed."""
619     #: String details of failure
620     details = None
621     def __init__(self, details):
622         self.details = details
623     def __str__(self):
624         return """
625
626 ERROR: Restore script failed, details:
627
628 %s""" % self.details