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