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