]> scripts.mit.edu Git - wizard.git/blob - wizard/app/__init__.py
Show *which* directory does not exist
[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         cmd = ['git', 'describe', '--match', tagPattern, ]
417         p = subprocess.Popen(cmd, stdout=subprocess.PIPE, )
418         tag, _ = p.communicate()
419         tag = tag.strip()
420         if tag and len(tag) > len(preStrip) and tag[:len(preStrip)] == preStrip:
421             tag = tag[len(preStrip):]
422         if not tag: return None
423         return distutils.version.LooseVersion(tag)
424     def download(self, version):
425         """
426         Returns a URL that can be used to download a tarball of ``version`` of
427         this application.
428         """
429         raise NotImplementedError
430     def checkWeb(self, deployment):
431         """
432         Checks if the autoinstall is viewable from the web.  Subclasses should
433         provide an implementation.
434
435         .. note::
436             Finding a reasonable heuristic that works across skinning
437             choices can be difficult.  We've had reasonable success
438             searching for metadata.  Be sure that the standard error
439             page does not contain the features you search for.  Try
440             not to depend on pages that are not the main page.
441         """
442         raise NotImplementedError
443     def checkDatabase(self, deployment):
444         """
445         Checks if the database is accessible.
446         """
447         try:
448             sql.connect(deployment.dsn)
449             return True
450         except sqlalchemy.exc.DBAPIError:
451             return False
452     def checkWebPage(self, deployment, page, outputs=[], exclude=[]):
453         """
454         Checks if a given page of an autoinstall contains a particular string.
455         """
456         page = deployment.fetch(page)
457         for x in exclude:
458             if page.find(x) != -1:
459                 logging.info("checkWebPage (failed due to %s):\n\n%s", x, page)
460                 return False
461         votes = 0
462         for output in outputs:
463             votes += page.find(output) != -1
464         if votes > len(outputs) / 2:
465             logging.debug("checkWebPage (passed):\n\n" + page)
466             return True
467         else:
468             logging.info("checkWebPage (failed):\n\n" + page)
469             return False
470     def checkConfig(self, deployment):
471         """
472         Checks whether or not an autoinstall has been configured/installed
473         for use.  Assumes that the current working directory is the deployment.
474         Subclasses should provide an implementation.
475         """
476         # XXX: Unfortunately, this doesn't quite work because we package
477         # bogus config files in the -scripts versions of installs.  Maybe
478         # we should check a hash or something?
479         raise NotImplementedError
480     def researchFilter(self, filename, added, deleted):
481         """
482         Allows an application to selectively ignore certain diffstat signatures
483         during research; for example, configuration files will have a very
484         specific set of changes, so ignore them; certain installation files
485         may be removed, etc.  Return ``True`` if a diffstat signature should be
486         ignored,
487         """
488         return False
489     def researchVerbose(self, filename):
490         """
491         Allows an application to exclude certain dirty files from the output
492         report; usually this will just be parametrized files, since those are
493         guaranteed to have changes.  Return ``True`` if a file should only
494         be displayed in verbose mode.
495         """
496         return filename in self.parametrized_files
497
498 class ApplicationVersion(object):
499     """Represents an abstract notion of a version for an application, where
500     ``version`` is a :class:`distutils.version.LooseVersion` and
501     ``application`` is a :class:`Application`."""
502     #: The :class:`distutils.version.LooseVersion` of this instance.
503     version = None
504     #: The :class:`Application` of this instance.
505     application = None
506     def __init__(self, version, application):
507         self.version = version
508         self.application = application
509     @property
510     def tag(self):
511         """
512         Returns the name of the git describe tag for the commit the user is
513         presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
514         """
515         return "%s-%s" % (self.application, self.version)
516     @property
517     def scripts_tag(self):
518         """
519         Returns the name of the Git tag for this version.
520         """
521         end = str(self.version).partition('-scripts')[2].partition('-')[0]
522         return "%s-scripts%s" % (self.pristine_tag, end)
523     @property
524     def pristine_tag(self):
525         """
526         Returns the name of the Git tag for the pristine version corresponding
527         to this version.
528         """
529         return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
530     def __cmp__(self, y):
531         return cmp(self.version, y.version)
532     @staticmethod
533     def parse(value):
534         """
535         Parses a line from the :term:`versions store` and return
536         :class:`ApplicationVersion`.
537
538         Use this only for cases when speed is of primary importance;
539         the data in version is unreliable and when possible, you should
540         prefer directly instantiating a :class:`wizard.deploy.Deployment` and having it query
541         the autoinstall itself for information.
542
543         The `value` to parse will vary.  For old style installs, it
544         will look like::
545
546            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
547
548         For new style installs, it will look like::
549
550            APP-x.y.z-scripts
551         """
552         name = value.split("/")[-1]
553         try:
554             if name.find("-") != -1:
555                 app, _, version = name.partition("-")
556             else:
557                 # kind of poor, maybe should error.  Generally this
558                 # will actually result in a not found error
559                 app = name
560                 version = "trunk"
561         except ValueError:
562             raise DeploymentParseError(value)
563         return ApplicationVersion.make(app, version)
564     @staticmethod
565     def make(app, version):
566         """
567         Makes/retrieves a singleton :class:`ApplicationVersion` from
568         a``app`` and ``version`` string.
569         """
570         try:
571             # defer to the application for version creation to enforce
572             # singletons
573             return applications()[app].makeVersion(version)
574         except KeyError:
575             raise NoSuchApplication(app)
576
577 def expand_re(val):
578     """
579     Takes a tree of values (implement using nested lists) and
580     transforms them into regular expressions.
581
582         >>> expand_re('*')
583         '\\\\*'
584         >>> expand_re(['a', 'b'])
585         '(?:a|b)'
586         >>> expand_re(['*', ['b', 'c']])
587         '(?:\\\\*|(?:b|c))'
588     """
589     if isinstance(val, str):
590         return re.escape(val)
591     else:
592         return '(?:' + '|'.join(map(expand_re, val)) + ')'
593
594 def make_extractors(seed):
595     """
596     Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into
597     extractor functions (which take a :class:`wizard.deploy.Deployment`
598     and return the value of the second subpattern of ``regex`` when matched
599     with the contents of ``file``).
600     """
601     return util.dictmap(lambda a: filename_regex_extractor(*a), seed)
602
603 def make_substitutions(seed):
604     """
605     Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into substitution
606     functions (which take a :class:`wizard.deploy.Deployment`, replace the second subpattern
607     of ``regex`` with ``key`` in ``file``, and returns the number of substitutions made.)
608     """
609     return util.dictkmap(lambda k, v: filename_regex_substitution(k, *v), seed)
610
611 # The following two functions are *highly* functional, and I recommend
612 # not touching them unless you know what you're doing.
613
614 def filename_regex_extractor(file, regex):
615     """
616     .. highlight:: haskell
617
618     Given a relative file name ``file``, a regular expression ``regex``, and a
619     :class:`wizard.deploy.Deployment` extracts a value out of the file in that
620     deployment.  This function is curried, so you pass just ``file`` and
621     ``regex``, and then pass ``deployment`` to the resulting function.
622
623     Its Haskell-style type signature would be::
624
625         Filename -> Regex -> (Deployment -> String)
626
627     The regular expression requires a very specific form, essentially ``()()()``
628     (with the second subgroup being the value to extract).  These enables
629     the regular expression to be used equivalently with filename
630
631     .. highlight:: python
632
633     For convenience purposes, we also accept ``[Filename]``, in which case
634     we use the first entry (index 0).  Passing an empty list is invalid.
635
636         >>> open("test-settings.extractor.ini", "w").write("config_var = 3\\n")
637         >>> f = filename_regex_extractor('test-settings.extractor.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
638         >>> f(deploy.Deployment("."))
639         '3'
640         >>> os.unlink("test-settings.extractor.ini")
641
642     .. note::
643         The first application of ``regex`` and ``file`` is normally performed
644         at compile-time inside a submodule; the second application is
645         performed at runtime.
646     """
647     if not isinstance(file, str):
648         file = file[0]
649     def h(deployment):
650         try:
651             contents = deployment.read(file) # cached
652         except IOError:
653             return None
654         match = regex.search(contents)
655         if not match: return None
656         # assumes that the second match is the one we want.
657         return match.group(2)
658     return h
659
660 def filename_regex_substitution(key, files, regex):
661     """
662     .. highlight:: haskell
663
664     Given a Wizard ``key`` (``WIZARD_*``), a list of ``files``, a
665     regular expression ``regex``, and a :class:`wizard.deploy.Deployment`
666     performs a substitution of the second subpattern of ``regex``
667     with ``key``.  Returns the number of replacements made.  This function
668     is curried, so you pass just ``key``, ``files`` and ``regex``, and
669     then pass ``deployment`` to the resulting function.
670
671     Its Haskell-style type signature would be::
672
673         Key -> ([File], Regex) -> (Deployment -> IO Int)
674
675     .. highlight:: python
676
677     For convenience purposes, we also accept ``Filename``, in which case it is treated
678     as a single item list.
679
680         >>> open("test-settings.substitution.ini", "w").write("config_var = 3")
681         >>> f = filename_regex_substitution('WIZARD_KEY', 'test-settings.substitution.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
682         >>> f(deploy.Deployment("."))
683         1
684         >>> print open("test-settings.substitution.ini", "r").read()
685         config_var = WIZARD_KEY
686         >>> os.unlink("test-settings.substitution.ini")
687     """
688     if isinstance(files, str):
689         files = (files,)
690     def h(deployment):
691         base = deployment.location
692         subs = 0
693         for file in files:
694             file = os.path.join(base, file)
695             try:
696                 contents = open(file, "r").read()
697                 contents, n = regex.subn("\\1" + key + "\\3", contents)
698                 subs += n
699                 open(file, "w").write(contents)
700             except IOError:
701                 pass
702         return subs
703     return h
704
705 def backup_database(outdir, deployment):
706     """
707     Generic database backup function for MySQL.
708     """
709     # XXX: Change this once deployments support multiple dbs
710     if deployment.application.database == "mysql":
711         return backup_mysql_database(outdir, deployment)
712     else:
713         raise NotImplementedError
714
715 def backup_mysql_database(outdir, deployment):
716     """
717     Database backups for MySQL using the :command:`mysqldump` utility.
718     """
719     outfile = os.path.join(outdir, "db.sql")
720     try:
721         shell.call("mysqldump", "--compress", "-r", outfile, *get_mysql_args(deployment.dsn))
722         shell.call("gzip", "--best", outfile)
723     except shell.CallError as e:
724         raise BackupFailure(e.stderr)
725
726 def restore_database(backup_dir, deployment):
727     """
728     Generic database restoration function for MySQL.
729     """
730     # XXX: see backup_database
731     if deployment.application.database == "mysql":
732         return restore_mysql_database(backup_dir, deployment)
733     else:
734         raise NotImplementedError
735
736 def restore_mysql_database(backup_dir, deployment):
737     """
738     Database restoration for MySQL by piping SQL commands into :command:`mysql`.
739     """
740     if not os.path.exists(backup_dir):
741         raise RestoreFailure("Backup %s doesn't exist", backup_dir.rpartition("/")[2])
742     sql = open(os.path.join(backup_dir, "db.sql"), 'w+')
743     shell.call("gunzip", "-c", os.path.join(backup_dir, "db.sql.gz"), stdout=sql)
744     sql.seek(0)
745     shell.call("mysql", *get_mysql_args(deployment.dsn), stdin=sql)
746     sql.close()
747
748 def remove_database(deployment):
749     """
750     Generic database removal function.  Actually, not so generic because we
751     go and check if we're on scripts and if we are run a different command.
752     """
753     if deployment.dsn.host == "sql.mit.edu":
754         try:
755             shell.call("/mit/scripts/sql/bin/drop-database", deployment.dsn.database)
756             return
757         except shell.CallError:
758             pass
759     engine = sqlalchemy.create_engine(deployment.dsn)
760     engine.execute("DROP DATABASE `%s`" % deployment.dsn.database)
761
762 def get_mysql_args(dsn):
763     """
764     Extracts arguments that would be passed to the command line mysql utility
765     from a deployment.
766     """
767     args = []
768     if dsn.host:
769         args += ["-h", dsn.host]
770     if dsn.username:
771         args += ["-u", dsn.username]
772     if dsn.password:
773         args += ["-p" + dsn.password]
774     args += [dsn.database]
775     return args
776
777 class Error(wizard.Error):
778     """Generic error class for this module."""
779     pass
780
781 class NoRepositoryError(Error):
782     """
783     :class:`Application` does not appear to have a Git repository
784     in the normal location.
785     """
786     #: The name of the application that does not have a Git repository.
787     app = None
788     def __init__(self, app):
789         self.app = app
790     def __str__(self):
791         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
792
793 class DeploymentParseError(Error):
794     """
795     Could not parse ``value`` from :term:`versions store`.
796     """
797     #: The value that failed to parse.
798     value = None
799     #: The location of the autoinstall that threw this variable.
800     #: This should be set by error handling code when it is available.
801     location = None
802     def __init__(self, value):
803         self.value = value
804
805 class NoSuchApplication(Error):
806     """
807     You attempted to reference a :class:`Application` named
808     ``app``, which is not recognized by Wizard.
809     """
810     #: The name of the application that does not exist.
811     app = None
812     #: The location of the autoinstall that threw this variable.
813     #: This should be set by error handling code when it is availble.
814     location = None
815     def __init__(self, app):
816         self.app = app
817
818 class Failure(Error):
819     """
820     Represents a failure when performing some double-dispatched operation
821     such as an installation or an upgrade.  Failure classes are postfixed
822     with Failure, not Error.
823     """
824     pass
825
826 class InstallFailure(Error):
827     """Installation failed for unknown reason."""
828     def __str__(self):
829         return """
830
831 ERROR: Installation failed for unknown reason.  You can
832 retry the installation by appending --retry to the installation
833 command."""
834
835 class RecoverableInstallFailure(InstallFailure):
836     """
837     Installation failed, but we were able to determine what the
838     error was, and should give the user a second chance if we were
839     running interactively.
840     """
841     #: List of the errors that were found.
842     errors = None
843     def __init__(self, errors):
844         self.errors = errors
845     def __str__(self):
846         return """
847
848 ERROR: Installation failed due to the following errors:  %s
849
850 You can retry the installation by appending --retry to the
851 installation command.""" % ", ".join(self.errors)
852
853 class UpgradeFailure(Failure):
854     """Upgrade script failed."""
855     #: String details of failure (possibly stdout or stderr output)
856     details = None
857     def __init__(self, details):
858         self.details = details
859     def __str__(self):
860         return """
861
862 ERROR: Upgrade script failed, details:
863
864 %s""" % self.details
865
866 class UpgradeVerificationFailure(Failure):
867     """Upgrade script passed, but website wasn't accessible afterwards"""
868     def __str__(self):
869         return """
870
871 ERROR: Upgrade script passed, but website wasn't accessible afterwards.  Check
872 the debug logs for the contents of the page."""
873
874 class BackupFailure(Failure):
875     """Backup script failed."""
876     #: String details of failure
877     details = None
878     def __init__(self, details):
879         self.details = details
880     def __str__(self):
881         return """
882
883 ERROR: Backup script failed, details:
884
885 %s""" % self.details
886
887 class RestoreFailure(Failure):
888     """Restore script failed."""
889     #: String details of failure
890     details = None
891     def __init__(self, details):
892         self.details = details
893     def __str__(self):
894         return """
895
896 ERROR: Restore script failed, details:
897
898 %s""" % self.details
899
900 class RemoveFailure(Failure):
901     """Remove script failed."""
902     #: String details of failure
903     details = None
904     def __init__(self, details):
905         self.details = details
906     def __str__(self):
907         return """
908
909 ERROR: Remove script failed, details:
910
911 %s""" % self.details