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