]> scripts.mit.edu Git - wizard.git/blob - wizard/app/__init__.py
4b64f8034eaf4aa99e4eb553d1b1c14aeaf7b2c8
[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 import sqlalchemy
31 import random
32 import string
33 import urlparse
34 import tempfile
35
36 import wizard
37 from wizard import resolve, scripts, shell, util
38
39 _application_list = [
40     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
41     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
42     # these are technically deprecated
43     "advancedpoll", "gallery",
44 ]
45 _applications = None
46
47 def applications():
48     """Hash table for looking up string application name to instance"""
49     global _applications
50     if not _applications:
51         _applications = dict([(n,Application.make(n)) for n in _application_list ])
52     return _applications
53
54
55 class Application(object):
56     """
57     Represents an application, i.e. mediawiki or phpbb.
58
59     .. note::
60         Many of these methods assume a specific working
61         directory; prefer using the corresponding methods
62         in :class:`wizard.deploy.Deployment` and its subclasses.
63     """
64     #: String name of the application
65     name = None
66     #: Dictionary of version strings to :class:`ApplicationVersion`.
67     #: See also :meth:`makeVersion`.
68     versions = None
69     #: List of files that need to be modified when parametrizing.
70     #: This is a class-wide constant, and should not normally be modified.
71     parametrized_files = []
72     #: Keys that are used in older versions of the application, but
73     #: not for the most recent version.
74     deprecated_keys = set()
75     #: Keys that we can simply generate random strings for if they're missing
76     random_keys = set()
77     #: Dictionary of variable names to extractor functions.  These functions
78     #: take a :class:`wizard.deploy.Deployment` as an argument and return the value of
79     #: the variable, or ``None`` if it could not be found.
80     #: See also :func:`filename_regex_extractor`.
81     extractors = {}
82     #: Dictionary of variable names to substitution functions.  These functions
83     #: take a :class:`wizard.deploy.Deployment` as an argument and modify the deployment such
84     #: that an explicit instance of the variable is released with the generic
85     #: ``WIZARD_*`` constant.  See also :func:`filename_regex_substitution`.
86     substitutions = {}
87     #: Dictionary of file names to a list of resolutions, which are tuples of
88     #: a conflict marker string and a result list.  See :mod:`wizard.resolve`
89     #: for more information.
90     resolutions = {}
91     #: Instance of :class:`wizard.install.ArgSchema` that defines the arguments
92     #: this application requires.
93     install_schema = None
94     #: Name of the database that this application uses, i.e. ``mysql`` or
95     #: ``postgres``.  If we end up supporting multiple databases for a single
96     #: application, there should also be a value for this in
97     #: :class:`wizard.deploy.Deployment`; the value here is merely the preferred
98     #: value.
99     database = None
100     def __init__(self, name):
101         self.name = name
102         self.versions = {}
103         # cache variables
104         self._extractors = {}
105         self._substitutions = {}
106     def repository(self, srv_path):
107         """
108         Returns the Git repository that would contain this application.
109         ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
110         """
111         repo = os.path.join(srv_path, self.name + ".git")
112         if not os.path.isdir(repo):
113             repo = os.path.join(srv_path, self.name, ".git")
114             if not os.path.isdir(repo):
115                 raise NoRepositoryError(self.name)
116         return repo
117     def makeVersion(self, version):
118         """
119         Creates or retrieves the :class:`ApplicationVersion` singleton for the
120         specified version.
121         """
122         if version not in self.versions:
123             self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
124         return self.versions[version]
125     def extract(self, deployment):
126         """
127         Extracts wizard variables from a deployment.  Default implementation
128         uses :attr:`extractors`.
129         """
130         result = {}
131         for k,extractor in self.extractors.items():
132             result[k] = extractor(deployment)
133         # XXX: ugh... we have to do quoting
134         for k in self.random_keys:
135             if result[k] is None:
136                 result[k] = "'%s'" % ''.join(random.choice(string.letters + string.digits) for i in xrange(30))
137         return result
138     def dsn(self, deployment):
139         """
140         Returns the deployment specific database URL.  Uses the override file
141         in :file:`.scripts` if it exists, and otherwise attempt to extract the
142         variables from the source files.
143
144         Under some cases, the database URL will contain only the database
145         property, and no other values.  This indicates that the actual DSN
146         should be determined from the environment.
147
148         This function might return ``None``.
149
150         .. note::
151
152             We are allowed to batch these two together, because the full precedence
153             chain for determining the database of an application combines these
154             two together.  If this was not the case, we would have to call
155             :meth:`databaseUrlFromOverride` and :meth:`databaseUrlFromExtract` manually.
156         """
157         url = self.dsnFromOverride(deployment)
158         if url:
159             return url
160         return self.dsnFromExtract(deployment)
161     def dsnFromOverride(self, deployment):
162         """
163         Extracts database URL from an explicit dsn override file.
164         """
165         try:
166             return sqlalchemy.engine.url.make_url(open(deployment.dsn_file).read().strip())
167         except IOError:
168             return None
169     def dsnFromExtract(self, deployment):
170         """
171         Extracts database URL from a deployment, and returns them as
172         a :class:`sqlalchemy.engine.url.URL`.  Returns ``None`` if we
173         can't figure it out: i.e. the conventional variables are not defined
174         for this application.
175         """
176         if not self.database:
177             return None
178         vars = self.extract(deployment)
179         names = ("WIZARD_DBSERVER", "WIZARD_DBUSER", "WIZARD_DBPASSWORD", "WIZARD_DBNAME")
180         host, user, password, database = (shlex.split(vars[x])[0] if vars[x] is not None else None for x in names)
181         # XXX: You'd have to put support for an explicit different database
182         # type here
183         return sqlalchemy.engine.url.URL(self.database, username=user, password=password, host=host, database=database)
184     def url(self, deployment):
185         """
186         Returns the deployment specific web URL.  Uses the override file
187         in :file:`.scripts` if it exists, and otherwise attempt to extract
188         the variables from the source files.
189
190         This function might return ``None``, which indicates we couldn't figure
191         it out.
192         """
193         url = self.urlFromOverride(deployment)
194         if url:
195             return url
196         return self.urlFromExtract(deployment)
197     def urlFromOverride(self, deployment):
198         """
199         Extracts URL from explicit url override file.
200         """
201         try:
202             return urlparse.urlparse(open(deployment.url_file).read().strip())
203         except IOError:
204             return None
205     def urlFromExtract(self, deployment):
206         """
207         Extracts URL from a deployment, and returns ``None`` if we can't
208         figure it out.  Default implementation is to fail; we might
209         do something clever with extractable variables in the future.
210         """
211         return None
212     def parametrize(self, deployment, ref_deployment):
213         """
214         Takes a generic source checkout and parametrizes it according to the
215         values of ``deployment``.  This function operates on the current
216         working directory.  ``deployment`` should **not** be the same as the
217         current working directory.  Default implementation uses
218         :attr:`parametrized_files` and a simple search and replace on those
219         files.
220         """
221         variables = ref_deployment.extract()
222         for file in self.parametrized_files:
223             try:
224                 contents = open(file, "r").read()
225             except IOError:
226                 continue
227             for key, value in variables.items():
228                 if value is None: continue
229                 contents = contents.replace(key, value)
230             f = open(file, "w")
231             f.write(contents)
232     def resolveConflicts(self, deployment):
233         """
234         Resolves conflicted files in the current working directory.  Returns
235         whether or not all conflicted files were resolved or not.  Fully
236         resolved files are added to the index, but no commit is made.  The
237         default implementation uses :attr:`resolutions`.
238         """
239         resolved = True
240         sh = shell.Shell()
241         files = set()
242         for status in sh.eval("git", "ls-files", "--unmerged").splitlines():
243             files.add(status.split()[-1])
244         for file in files:
245             # check for newline mismatch
246             # HACK: using git diff to tell if files are binary or not
247             if not len(sh.eval("git", "diff", file).splitlines()) == 1 and util.mixed_newlines(file):
248                 # this code only works on Unix
249                 def get_newline(filename):
250                     f = open(filename, "U")
251                     # for some reason I need two
252                     s = f.readline()
253                     if s != "" and f.newlines is None:
254                         f.readline()
255                     if not isinstance(f.newlines, str):
256                         raise Exception("Assert: expected newlines to be string, instead was %s in %s" % (repr(f.newlines), file))
257                     return f.newlines
258                 def create_reference(id):
259                     f = tempfile.NamedTemporaryFile(prefix="wizardResolve", delete=False)
260                     sh.call("git", "cat-file", "blob", ":%d:%s" % (id, file), stdout=f)
261                     f.close()
262                     return get_newline(f.name), f.name
263                 def convert(filename, dest_nl):
264                     contents = open(filename, "U").read().replace("\n", dest_nl)
265                     open(filename, "wb").write(contents)
266                 logging.info("Mixed newlines detected in %s", file)
267                 common_nl, common_file = create_reference(1)
268                 our_nl,    our_file    = create_reference(2)
269                 their_nl,  their_file  = create_reference(3)
270                 remerge = False
271                 if common_nl != their_nl:
272                     # upstream can't keep their newlines straight
273                     logging.info("Converting common file (1) from %s to %s newlines", repr(common_nl), repr(their_nl))
274                     convert(common_file, their_nl)
275                     remerge = True
276                 if our_nl != their_nl:
277                     # common case
278                     logging.info("Converting our file (2) from %s to %s newlines", repr(our_nl), repr(their_nl))
279                     convert(our_file, their_nl)
280                     remerge = True
281                 if remerge:
282                     logging.info("Remerging %s", file)
283                     with open(file, "wb") as f:
284                         try:
285                             sh.call("git", "merge-file", "--stdout", our_file, common_file, their_file, stdout=f)
286                             logging.info("New merge was clean")
287                             sh.call("git", "add", file)
288                             continue
289                         except shell.CallError:
290                             pass
291                     logging.info("Merge was still unclean")
292                 else:
293                     logging.warning("Mixed newlines detected in %s, but no remerge possible", file)
294             # manual resolutions
295             if file in self.resolutions:
296                 contents = open(file, "r").read()
297                 for spec, result in self.resolutions[file]:
298                     old_contents = contents
299                     contents = resolve.resolve(contents, spec, result)
300                     if old_contents != contents:
301                         logging.info("Did resolution with spec:\n" + spec)
302                 open(file, "w").write(contents)
303                 if not resolve.is_conflict(contents):
304                     sh.call("git", "add", file)
305                 else:
306                     resolved = False
307             else:
308                 resolved = False
309         return resolved
310     def prepareMerge(self, deployment):
311         """
312         Performs various edits to files in the current working directory in
313         order to make a merge go more smoothly.  This is usually
314         used to fix botched line-endings.  If you add new files,
315         you have to 'git add' them; this is not necessary for edits.
316         By default this is a no-op; subclasses should replace this
317         with useful behavior.
318         """
319         pass
320     def prepareConfig(self, deployment):
321         """
322         Takes a deployment and replaces any explicit instances
323         of a configuration variable with generic ``WIZARD_*`` constants.
324         The default implementation uses :attr:`substitutions`, and
325         emits warnings when it encounters keys in :attr:`deprecated_keys`.
326         """
327         for key, subst in self.substitutions.items():
328             subs = subst(deployment)
329             if not subs and key not in self.deprecated_keys:
330                 logging.warning("No substitutions for %s" % key)
331     def install(self, version, options):
332         """
333         Run for 'wizard configure' (and, by proxy, 'wizard install') to
334         configure an application.  This assumes that the current working
335         directory is a deployment.  (Unlike its kin, this function does not
336         take a :class:`wizard.deploy.Deployment` as a parameter.)  Subclasses should
337         provide an implementation.
338         """
339         raise NotImplementedError
340     def upgrade(self, deployment, version, options):
341         """
342         Run for 'wizard upgrade' to upgrade database schemas and other
343         non-versioned data in an application after the filesystem has been
344         upgraded.  This assumes that the current working directory is the
345         deployment.  Subclasses should provide an implementation.
346         """
347         raise NotImplementedError
348     def backup(self, deployment, outdir, options):
349         """
350         Run for 'wizard backup' and upgrades to backup database schemas
351         and other non-versioned data in an application.  ``outdir`` is
352         the directory that backup files should be placed.  This assumes
353         that the current working directory is the deployment.  Subclasses
354         should provide an implementation, even if it is a no-op.
355
356         .. note::
357             Static user files may not need to be backed up, since in
358             many applications upgrades do not modify static files.
359         """
360         raise NotImplementedError
361     def restore(self, deployment, backup_dir, options):
362         """
363         Run for 'wizard restore' and failed upgrades to restore database
364         and other non-versioned data to a backed up version.  This assumes
365         that the current working directory is the deployment.  Subclasses
366         should provide an implementation.
367         """
368         raise NotImplementedError
369     def remove(self, deployment, options):
370         """
371         Run for 'wizard remove' to delete all database and non-local
372         file data.  This assumes that the current working directory is
373         the deployment.  Subclasses should provide an implementation.
374         """
375         raise NotImplementedError
376     def detectVersion(self, deployment):
377         """
378         Checks source files to determine the version manually.  This assumes
379         that the current working directory is the deployment.  Subclasses
380         should provide an implementation.
381         """
382         raise NotImplementedError
383     def detectVersionFromFile(self, filename, regex):
384         """
385         Helper method that detects a version by using a regular expression
386         from a file.  The regexed value is passed through :mod:`shlex`.
387         This assumes that the current working directory is the deployment.
388         """
389         contents = open(filename).read()
390         match = regex.search(contents)
391         if not match: return None
392         return distutils.version.LooseVersion(shlex.split(match.group(2))[0])
393     def download(self, version):
394         """
395         Returns a URL that can be used to download a tarball of ``version`` of
396         this application.
397         """
398         raise NotImplementedError
399     def checkWeb(self, deployment):
400         """
401         Checks if the autoinstall is viewable from the web.  Subclasses should
402         provide an implementation.
403
404         .. note::
405             Finding a reasonable heuristic that works across skinning
406             choices can be difficult.  We've had reasonable success
407             searching for metadata.  Be sure that the standard error
408             page does not contain the features you search for.  Try
409             not to depend on pages that are not the main page.
410         """
411         raise NotImplementedError
412     def checkWebPage(self, deployment, page, output):
413         """
414         Checks if a given page of an autoinstall contains a particular string.
415         """
416         page = deployment.fetch(page)
417         result = page.find(output) != -1
418         if result:
419             logging.debug("checkWebPage (passed):\n\n" + page)
420         else:
421             logging.info("checkWebPage (failed):\n\n" + page)
422         return result
423     def checkConfig(self, deployment):
424         """
425         Checks whether or not an autoinstall has been configured/installed
426         for use.  Assumes that the current working directory is the deployment.
427         Subclasses should provide an implementation.
428         """
429         # XXX: Unfortunately, this doesn't quite work because we package
430         # bogus config files in the -scripts versions of installs.  Maybe
431         # we should check a hash or something?
432         raise NotImplementedError
433     def researchFilter(self, filename, added, deleted):
434         """
435         Allows an application to selectively ignore certain diffstat signatures
436         during research; for example, configuration files will have a very
437         specific set of changes, so ignore them; certain installation files
438         may be removed, etc.  Return ``True`` if a diffstat signature should be
439         ignored,
440         """
441         return False
442     def researchVerbose(self, filename):
443         """
444         Allows an application to exclude certain dirty files from the output
445         report; usually this will just be parametrized files, since those are
446         guaranteed to have changes.  Return ``True`` if a file should only
447         be displayed in verbose mode.
448         """
449         return filename in self.parametrized_files
450     @staticmethod
451     def make(name):
452         """Makes an application, but uses the correct subtype if available."""
453         try:
454             __import__("wizard.app." + name)
455             return getattr(wizard.app, name).Application(name)
456         except ImportError as error:
457             # XXX ugly hack to check if the import error is from the top level
458             # module we care about or a submodule. should be an archetectural change.
459             if error.args[0].split()[-1]==name:
460                 return Application(name)
461             else:
462                 raise
463
464 class ApplicationVersion(object):
465     """Represents an abstract notion of a version for an application, where
466     ``version`` is a :class:`distutils.version.LooseVersion` and
467     ``application`` is a :class:`Application`."""
468     #: The :class:`distutils.version.LooseVersion` of this instance.
469     version = None
470     #: The :class:`Application` of this instance.
471     application = None
472     def __init__(self, version, application):
473         self.version = version
474         self.application = application
475     @property
476     def tag(self):
477         """
478         Returns the name of the git describe tag for the commit the user is
479         presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
480         """
481         return "%s-%s" % (self.application, self.version)
482     @property
483     def scripts_tag(self):
484         """
485         Returns the name of the Git tag for this version.
486         """
487         end = str(self.version).partition('-scripts')[2].partition('-')[0]
488         return "%s-scripts%s" % (self.pristine_tag, end)
489     @property
490     def pristine_tag(self):
491         """
492         Returns the name of the Git tag for the pristine version corresponding
493         to this version.
494         """
495         return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
496     def __cmp__(self, y):
497         return cmp(self.version, y.version)
498     @staticmethod
499     def parse(value):
500         """
501         Parses a line from the :term:`versions store` and return
502         :class:`ApplicationVersion`.
503
504         Use this only for cases when speed is of primary importance;
505         the data in version is unreliable and when possible, you should
506         prefer directly instantiating a :class:`wizard.deploy.Deployment` and having it query
507         the autoinstall itself for information.
508
509         The `value` to parse will vary.  For old style installs, it
510         will look like::
511
512            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
513
514         For new style installs, it will look like::
515
516            APP-x.y.z-scripts
517         """
518         name = value.split("/")[-1]
519         try:
520             if name.find("-") != -1:
521                 app, _, version = name.partition("-")
522             else:
523                 # kind of poor, maybe should error.  Generally this
524                 # will actually result in a not found error
525                 app = name
526                 version = "trunk"
527         except ValueError:
528             raise DeploymentParseError(value)
529         return ApplicationVersion.make(app, version)
530     @staticmethod
531     def make(app, version):
532         """
533         Makes/retrieves a singleton :class:`ApplicationVersion` from
534         a``app`` and ``version`` string.
535         """
536         try:
537             # defer to the application for version creation to enforce
538             # singletons
539             return applications()[app].makeVersion(version)
540         except KeyError:
541             raise NoSuchApplication(app)
542
543 def expand_re(val):
544     """
545     Takes a tree of values (implement using nested lists) and
546     transforms them into regular expressions.
547
548         >>> expand_re('*')
549         '\\\\*'
550         >>> expand_re(['a', 'b'])
551         '(?:a|b)'
552         >>> expand_re(['*', ['b', 'c']])
553         '(?:\\\\*|(?:b|c))'
554     """
555     if isinstance(val, str):
556         return re.escape(val)
557     else:
558         return '(?:' + '|'.join(map(expand_re, val)) + ')'
559
560 def make_extractors(seed):
561     """
562     Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into
563     extractor functions (which take a :class:`wizard.deploy.Deployment`
564     and return the value of the second subpattern of ``regex`` when matched
565     with the contents of ``file``).
566     """
567     return util.dictmap(lambda a: filename_regex_extractor(*a), seed)
568
569 def make_substitutions(seed):
570     """
571     Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into substitution
572     functions (which take a :class:`wizard.deploy.Deployment`, replace the second subpattern
573     of ``regex`` with ``key`` in ``file``, and returns the number of substitutions made.)
574     """
575     return util.dictkmap(lambda k, v: filename_regex_substitution(k, *v), seed)
576
577 # The following two functions are *highly* functional, and I recommend
578 # not touching them unless you know what you're doing.
579
580 def filename_regex_extractor(file, regex):
581     """
582     .. highlight:: haskell
583
584     Given a relative file name ``file``, a regular expression ``regex``, and a
585     :class:`wizard.deploy.Deployment` extracts a value out of the file in that
586     deployment.  This function is curried, so you pass just ``file`` and
587     ``regex``, and then pass ``deployment`` to the resulting function.
588
589     Its Haskell-style type signature would be::
590
591         Filename -> Regex -> (Deployment -> String)
592
593     The regular expression requires a very specific form, essentially ``()()()``
594     (with the second subgroup being the value to extract).  These enables
595     the regular expression to be used equivalently with filename
596
597     .. highlight:: python
598
599     For convenience purposes, we also accept ``[Filename]``, in which case
600     we use the first entry (index 0).  Passing an empty list is invalid.
601
602         >>> open("test-settings.extractor.ini", "w").write("config_var = 3\\n")
603         >>> f = filename_regex_extractor('test-settings.extractor.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
604         >>> f(deploy.Deployment("."))
605         '3'
606         >>> os.unlink("test-settings.extractor.ini")
607
608     .. note::
609         The first application of ``regex`` and ``file`` is normally performed
610         at compile-time inside a submodule; the second application is
611         performed at runtime.
612     """
613     if not isinstance(file, str):
614         file = file[0]
615     def h(deployment):
616         try:
617             contents = deployment.read(file) # cached
618         except IOError:
619             return None
620         match = regex.search(contents)
621         if not match: return None
622         # assumes that the second match is the one we want.
623         return match.group(2)
624     return h
625
626 def filename_regex_substitution(key, files, regex):
627     """
628     .. highlight:: haskell
629
630     Given a Wizard ``key`` (``WIZARD_*``), a list of ``files``, a
631     regular expression ``regex``, and a :class:`wizard.deploy.Deployment`
632     performs a substitution of the second subpattern of ``regex``
633     with ``key``.  Returns the number of replacements made.  This function
634     is curried, so you pass just ``key``, ``files`` and ``regex``, and
635     then pass ``deployment`` to the resulting function.
636
637     Its Haskell-style type signature would be::
638
639         Key -> ([File], Regex) -> (Deployment -> IO Int)
640
641     .. highlight:: python
642
643     For convenience purposes, we also accept ``Filename``, in which case it is treated
644     as a single item list.
645
646         >>> open("test-settings.substitution.ini", "w").write("config_var = 3")
647         >>> f = filename_regex_substitution('WIZARD_KEY', 'test-settings.substitution.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
648         >>> f(deploy.Deployment("."))
649         1
650         >>> print open("test-settings.substitution.ini", "r").read()
651         config_var = WIZARD_KEY
652         >>> os.unlink("test-settings.substitution.ini")
653     """
654     if isinstance(files, str):
655         files = (files,)
656     def h(deployment):
657         base = deployment.location
658         subs = 0
659         for file in files:
660             file = os.path.join(base, file)
661             try:
662                 contents = open(file, "r").read()
663                 contents, n = regex.subn("\\1" + key + "\\3", contents)
664                 subs += n
665                 open(file, "w").write(contents)
666             except IOError:
667                 pass
668         return subs
669     return h
670
671 def backup_database(outdir, deployment):
672     """
673     Generic database backup function for MySQL.
674     """
675     # XXX: Change this once deployments support multiple dbs
676     if deployment.application.database == "mysql":
677         return backup_mysql_database(outdir, deployment)
678     else:
679         raise NotImplementedError
680
681 def backup_mysql_database(outdir, deployment):
682     """
683     Database backups for MySQL using the :command:`mysqldump` utility.
684     """
685     sh = shell.Shell()
686     outfile = os.path.join(outdir, "db.sql")
687     try:
688         sh.call("mysqldump", "--compress", "-r", outfile, *get_mysql_args(deployment.dsn))
689         sh.call("gzip", "--best", outfile)
690     except shell.CallError as e:
691         raise BackupFailure(e.stderr)
692
693 def restore_database(backup_dir, deployment):
694     """
695     Generic database restoration function for MySQL.
696     """
697     # XXX: see backup_database
698     if deployment.application.database == "mysql":
699         return restore_mysql_database(backup_dir, deployment)
700     else:
701         raise NotImplementedError
702
703 def restore_mysql_database(backup_dir, deployment):
704     """
705     Database restoration for MySQL by piping SQL commands into :command:`mysql`.
706     """
707     sh = shell.Shell()
708     if not os.path.exists(backup_dir):
709         raise RestoreFailure("Backup %s doesn't exist", backup_dir.rpartition("/")[2])
710     sql = open(os.path.join(backup_dir, "db.sql"), 'w+')
711     sh.call("gunzip", "-c", os.path.join(backup_dir, "db.sql.gz"), stdout=sql)
712     sql.seek(0)
713     sh.call("mysql", *get_mysql_args(deployment.dsn), stdin=sql)
714     sql.close()
715
716 def remove_database(deployment):
717     """
718     Generic database removal function.  Actually, not so generic because we
719     go and check if we're on scripts and if we are run a different command.
720     """
721     sh = shell.Shell()
722     if deployment.dsn.host == "sql.mit.edu":
723         try:
724             sh.call("/mit/scripts/sql/bin/drop-database", deployment.dsn.database)
725             return
726         except shell.CallError:
727             pass
728     engine = sqlalchemy.create_engine(deployment.dsn)
729     engine.execute("DROP DATABASE `%s`" % deployment.dsn.database)
730
731 def get_mysql_args(dsn):
732     """
733     Extracts arguments that would be passed to the command line mysql utility
734     from a deployment.
735     """
736     args = []
737     if dsn.host:
738         args += ["-h", dsn.host]
739     if dsn.username:
740         args += ["-u", dsn.username]
741     if dsn.password:
742         args += ["-p" + dsn.password]
743     args += [dsn.database]
744     return args
745
746 class Error(wizard.Error):
747     """Generic error class for this module."""
748     pass
749
750 class NoRepositoryError(Error):
751     """
752     :class:`Application` does not appear to have a Git repository
753     in the normal location.
754     """
755     #: The name of the application that does not have a Git repository.
756     app = None
757     def __init__(self, app):
758         self.app = app
759     def __str__(self):
760         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
761
762 class DeploymentParseError(Error):
763     """
764     Could not parse ``value`` from :term:`versions store`.
765     """
766     #: The value that failed to parse.
767     value = None
768     #: The location of the autoinstall that threw this variable.
769     #: This should be set by error handling code when it is available.
770     location = None
771     def __init__(self, value):
772         self.value = value
773
774 class NoSuchApplication(Error):
775     """
776     You attempted to reference a :class:`Application` named
777     ``app``, which is not recognized by Wizard.
778     """
779     #: The name of the application that does not exist.
780     app = None
781     #: The location of the autoinstall that threw this variable.
782     #: This should be set by error handling code when it is availble.
783     location = None
784     def __init__(self, app):
785         self.app = app
786
787 class Failure(Error):
788     """
789     Represents a failure when performing some double-dispatched operation
790     such as an installation or an upgrade.  Failure classes are postfixed
791     with Failure, not Error.
792     """
793     pass
794
795 class InstallFailure(Error):
796     """Installation failed for unknown reason."""
797     def __str__(self):
798         return """
799
800 ERROR: Installation failed for unknown reason.  You can
801 retry the installation by appending --retry to the installation
802 command."""
803
804 class RecoverableInstallFailure(InstallFailure):
805     """
806     Installation failed, but we were able to determine what the
807     error was, and should give the user a second chance if we were
808     running interactively.
809     """
810     #: List of the errors that were found.
811     errors = None
812     def __init__(self, errors):
813         self.errors = errors
814     def __str__(self):
815         return """
816
817 ERROR: Installation failed due to the following errors:  %s
818
819 You can retry the installation by appending --retry to the
820 installation command.""" % ", ".join(self.errors)
821
822 class UpgradeFailure(Failure):
823     """Upgrade script failed."""
824     #: String details of failure (possibly stdout or stderr output)
825     details = None
826     def __init__(self, details):
827         self.details = details
828     def __str__(self):
829         return """
830
831 ERROR: Upgrade script failed, details:
832
833 %s""" % self.details
834
835 class UpgradeVerificationFailure(Failure):
836     """Upgrade script passed, but website wasn't accessible afterwards"""
837     def __str__(self):
838         return """
839
840 ERROR: Upgrade script passed, but website wasn't accessible afterwards.  Check
841 the debug logs for the contents of the page."""
842
843 class BackupFailure(Failure):
844     """Backup script failed."""
845     #: String details of failure
846     details = None
847     def __init__(self, details):
848         self.details = details
849     def __str__(self):
850         return """
851
852 ERROR: Backup script failed, details:
853
854 %s""" % self.details
855
856 class RestoreFailure(Failure):
857     """Restore script failed."""
858     #: String details of failure
859     details = None
860     def __init__(self, details):
861         self.details = details
862     def __str__(self):
863         return """
864
865 ERROR: Restore script failed, details:
866
867 %s""" % self.details
868
869 class RemoveFailure(Failure):
870     """Remove script failed."""
871     #: String details of failure
872     details = None
873     def __init__(self, details):
874         self.details = details
875     def __str__(self):
876         return """
877
878 ERROR: Remove script failed, details:
879
880 %s""" % self.details