]> scripts.mit.edu Git - wizard.git/blob - wizard/app/__init__.py
More TODO notes, and update gitignore.
[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:`.wizard` 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:`.wizard` 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 and key not in self.random_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.  Maybe we should check a hash or
484         # 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 wizard_tag(self):
524         """
525         Returns the name of the Git tag for this version.
526         """
527         # XXX: Scripts specific
528         end = str(self.version).partition('-scripts')[2].partition('-')[0]
529         return "%s-scripts%s" % (self.pristine_tag, end)
530     @property
531     def pristine_tag(self):
532         """
533         Returns the name of the Git tag for the pristine version corresponding
534         to this version.
535         """
536         return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
537     def __cmp__(self, y):
538         return cmp(self.version, y.version)
539     @staticmethod
540     def parse(value):
541         """
542         Parses a line from the :term:`versions store` and return
543         :class:`ApplicationVersion`.
544
545         Use this only for cases when speed is of primary importance;
546         the data in version is unreliable and when possible, you should
547         prefer directly instantiating a :class:`wizard.deploy.Deployment` and having it query
548         the autoinstall itself for information.
549
550         The `value` to parse will vary.  For old style installs, it
551         will look like::
552
553            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
554
555         For new style installs, it will look like::
556
557            APP-x.y.z-scripts
558         """
559         name = value.split("/")[-1]
560         try:
561             if name.find("-") != -1:
562                 app, _, version = name.partition("-")
563             else:
564                 # kind of poor, maybe should error.  Generally this
565                 # will actually result in a not found error
566                 app = name
567                 version = "trunk"
568         except ValueError:
569             raise DeploymentParseError(value)
570         return ApplicationVersion.make(app, version)
571     @staticmethod
572     def make(app, version):
573         """
574         Makes/retrieves a singleton :class:`ApplicationVersion` from
575         a``app`` and ``version`` string.
576         """
577         try:
578             # defer to the application for version creation to enforce
579             # singletons
580             return applications()[app].makeVersion(version)
581         except KeyError:
582             raise NoSuchApplication(app)
583
584 def expand_re(val):
585     """
586     Takes a tree of values (implement using nested lists) and
587     transforms them into regular expressions.
588
589         >>> expand_re('*')
590         '\\\\*'
591         >>> expand_re(['a', 'b'])
592         '(?:a|b)'
593         >>> expand_re(['*', ['b', 'c']])
594         '(?:\\\\*|(?:b|c))'
595     """
596     if isinstance(val, str):
597         return re.escape(val)
598     else:
599         return '(?:' + '|'.join(map(expand_re, val)) + ')'
600
601 def make_extractors(seed):
602     """
603     Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into
604     extractor functions (which take a :class:`wizard.deploy.Deployment`
605     and return the value of the second subpattern of ``regex`` when matched
606     with the contents of ``file``).
607     """
608     return util.dictmap(lambda a: filename_regex_extractor(*a), seed)
609
610 def make_substitutions(seed):
611     """
612     Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into substitution
613     functions (which take a :class:`wizard.deploy.Deployment`, replace the second subpattern
614     of ``regex`` with ``key`` in ``file``, and returns the number of substitutions made.)
615     """
616     return util.dictkmap(lambda k, v: filename_regex_substitution(k, *v), seed)
617
618 # The following two functions are *highly* functional, and I recommend
619 # not touching them unless you know what you're doing.
620
621 def filename_regex_extractor(file, regex):
622     """
623     .. highlight:: haskell
624
625     Given a relative file name ``file``, a regular expression ``regex``, and a
626     :class:`wizard.deploy.Deployment` extracts a value out of the file in that
627     deployment.  This function is curried, so you pass just ``file`` and
628     ``regex``, and then pass ``deployment`` to the resulting function.
629
630     Its Haskell-style type signature would be::
631
632         Filename -> Regex -> (Deployment -> String)
633
634     The regular expression requires a very specific form, essentially ``()()()``
635     (with the second subgroup being the value to extract).  These enables
636     the regular expression to be used equivalently with filename
637
638     .. highlight:: python
639
640     For convenience purposes, we also accept ``[Filename]``, in which case
641     we use the first entry (index 0).  Passing an empty list is invalid.
642
643         >>> open("test-settings.extractor.ini", "w").write("config_var = 3\\n")
644         >>> f = filename_regex_extractor('test-settings.extractor.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
645         >>> f(deploy.Deployment("."))
646         '3'
647         >>> os.unlink("test-settings.extractor.ini")
648
649     .. note::
650         The first application of ``regex`` and ``file`` is normally performed
651         at compile-time inside a submodule; the second application is
652         performed at runtime.
653     """
654     if not isinstance(file, str):
655         file = file[0]
656     def h(deployment):
657         try:
658             contents = deployment.read(file) # cached
659         except IOError:
660             return None
661         match = regex.search(contents)
662         if not match: return None
663         # assumes that the second match is the one we want.
664         return match.group(2)
665     return h
666
667 def filename_regex_substitution(key, files, regex):
668     """
669     .. highlight:: haskell
670
671     Given a Wizard ``key`` (``WIZARD_*``), a list of ``files``, a
672     regular expression ``regex``, and a :class:`wizard.deploy.Deployment`
673     performs a substitution of the second subpattern of ``regex``
674     with ``key``.  Returns the number of replacements made.  This function
675     is curried, so you pass just ``key``, ``files`` and ``regex``, and
676     then pass ``deployment`` to the resulting function.
677
678     Its Haskell-style type signature would be::
679
680         Key -> ([File], Regex) -> (Deployment -> IO Int)
681
682     .. highlight:: python
683
684     For convenience purposes, we also accept ``Filename``, in which case it is treated
685     as a single item list.
686
687         >>> open("test-settings.substitution.ini", "w").write("config_var = 3")
688         >>> f = filename_regex_substitution('WIZARD_KEY', 'test-settings.substitution.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
689         >>> f(deploy.Deployment("."))
690         1
691         >>> print open("test-settings.substitution.ini", "r").read()
692         config_var = WIZARD_KEY
693         >>> os.unlink("test-settings.substitution.ini")
694     """
695     if isinstance(files, str):
696         files = (files,)
697     def h(deployment):
698         base = deployment.location
699         subs = 0
700         for file in files:
701             file = os.path.join(base, file)
702             try:
703                 contents = open(file, "r").read()
704                 contents, n = regex.subn("\\1" + key + "\\3", contents)
705                 subs += n
706                 open(file, "w").write(contents)
707             except IOError:
708                 pass
709         return subs
710     return h
711
712 def backup_database(outdir, deployment):
713     """
714     Generic database backup function for MySQL.
715     """
716     # XXX: Change this once deployments support multiple dbs
717     if deployment.application.database == "mysql":
718         return backup_mysql_database(outdir, deployment)
719     else:
720         raise NotImplementedError
721
722 def backup_mysql_database(outdir, deployment):
723     """
724     Database backups for MySQL using the :command:`mysqldump` utility.
725     """
726     outfile = os.path.join(outdir, "db.sql")
727     try:
728         shell.call("mysqldump", "--compress", "-r", outfile, *get_mysql_args(deployment.dsn))
729         shell.call("gzip", "--best", outfile)
730     except shell.CallError as e:
731         raise BackupFailure(e.stderr)
732
733 def restore_database(backup_dir, deployment):
734     """
735     Generic database restoration function for MySQL.
736     """
737     # XXX: see backup_database
738     if deployment.application.database == "mysql":
739         return restore_mysql_database(backup_dir, deployment)
740     else:
741         raise NotImplementedError
742
743 def restore_mysql_database(backup_dir, deployment):
744     """
745     Database restoration for MySQL by piping SQL commands into :command:`mysql`.
746     """
747     if not os.path.exists(backup_dir):
748         raise RestoreFailure("Backup %s doesn't exist", backup_dir.rpartition("/")[2])
749     sql = open(os.path.join(backup_dir, "db.sql"), 'w+')
750     shell.call("gunzip", "-c", os.path.join(backup_dir, "db.sql.gz"), stdout=sql)
751     sql.seek(0)
752     shell.call("mysql", *get_mysql_args(deployment.dsn), stdin=sql)
753     sql.close()
754
755 # XXX: SCRIPTS
756 def remove_database(deployment):
757     """
758     Generic database removal function.  Actually, not so generic because we
759     go and check if we're on scripts and if we are run a different command.
760     """
761     if deployment.dsn.host == "sql.mit.edu":
762         try:
763             shell.call("/mit/scripts/sql/bin/drop-database", deployment.dsn.database)
764             return
765         except shell.CallError:
766             pass
767     engine = sqlalchemy.create_engine(deployment.dsn)
768     engine.execute("DROP DATABASE `%s`" % deployment.dsn.database)
769
770 def get_mysql_args(dsn):
771     """
772     Extracts arguments that would be passed to the command line mysql utility
773     from a deployment.
774     """
775     args = []
776     if dsn.host:
777         args += ["-h", dsn.host]
778     if dsn.username:
779         args += ["-u", dsn.username]
780     if dsn.password:
781         args += ["-p" + dsn.password]
782     args += [dsn.database]
783     return args
784
785 class Error(wizard.Error):
786     """Generic error class for this module."""
787     pass
788
789 class NoRepositoryError(Error):
790     """
791     :class:`Application` does not appear to have a Git repository
792     in the normal location.
793     """
794     #: The name of the application that does not have a Git repository.
795     app = None
796     def __init__(self, app):
797         self.app = app
798     def __str__(self):
799         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
800
801 class DeploymentParseError(Error):
802     """
803     Could not parse ``value`` from :term:`versions store`.
804     """
805     #: The value that failed to parse.
806     value = None
807     #: The location of the autoinstall that threw this variable.
808     #: This should be set by error handling code when it is available.
809     location = None
810     def __init__(self, value):
811         self.value = value
812
813 class NoSuchApplication(Error):
814     """
815     You attempted to reference a :class:`Application` named
816     ``app``, which is not recognized by Wizard.
817     """
818     #: The name of the application that does not exist.
819     app = None
820     #: The location of the autoinstall that threw this variable.
821     #: This should be set by error handling code when it is availble.
822     location = None
823     def __init__(self, app):
824         self.app = app
825
826 class Failure(Error):
827     """
828     Represents a failure when performing some double-dispatched operation
829     such as an installation or an upgrade.  Failure classes are postfixed
830     with Failure, not Error.
831     """
832     pass
833
834 class InstallFailure(Error):
835     """Installation failed for unknown reason."""
836     def __str__(self):
837         return """
838
839 ERROR: Installation failed for unknown reason.  You can
840 retry the installation by appending --retry to the installation
841 command."""
842
843 class RecoverableInstallFailure(InstallFailure):
844     """
845     Installation failed, but we were able to determine what the
846     error was, and should give the user a second chance if we were
847     running interactively.
848     """
849     #: List of the errors that were found.
850     errors = None
851     def __init__(self, errors):
852         self.errors = errors
853     def __str__(self):
854         return """
855
856 ERROR: Installation failed due to the following errors:  %s
857
858 You can retry the installation by appending --retry to the
859 installation command.""" % ", ".join(self.errors)
860
861 class UpgradeFailure(Failure):
862     """Upgrade script failed."""
863     #: String details of failure (possibly stdout or stderr output)
864     details = None
865     def __init__(self, details):
866         self.details = details
867     def __str__(self):
868         return """
869
870 ERROR: Upgrade script failed, details:
871
872 %s""" % self.details
873
874 class UpgradeVerificationFailure(Failure):
875     """Upgrade script passed, but website wasn't accessible afterwards"""
876     def __str__(self):
877         return """
878
879 ERROR: Upgrade script passed, but website wasn't accessible afterwards.  Check
880 the debug logs for the contents of the page."""
881
882 class BackupFailure(Failure):
883     """Backup script failed."""
884     #: String details of failure
885     details = None
886     def __init__(self, details):
887         self.details = details
888     def __str__(self):
889         return """
890
891 ERROR: Backup script failed, details:
892
893 %s""" % self.details
894
895 class RestoreFailure(Failure):
896     """Restore script failed."""
897     #: String details of failure
898     details = None
899     def __init__(self, details):
900         self.details = details
901     def __str__(self):
902         return """
903
904 ERROR: Restore script failed, details:
905
906 %s""" % self.details
907
908 class RemoveFailure(Failure):
909     """Remove script failed."""
910     #: String details of failure
911     details = None
912     def __init__(self, details):
913         self.details = details
914     def __str__(self):
915         return """
916
917 ERROR: Remove script failed, details:
918
919 %s""" % self.details