]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
Set admin e-mail address properly on MediaWiki >= 1.18.0
[wizard.git] / wizard / deploy.py
1 """
2 Object model for querying information and manipulating deployments
3 of autoinstalls.  Every :class:`Deployment` has an :class:`app.ApplicationVersion`
4 which in turn has an :class:`app.Application`.
5 """
6
7 import os.path
8 import fileinput
9 import logging
10 import decorator
11 import datetime
12 import tempfile
13 import time
14 import traceback
15 import shutil
16 import errno
17 import pkg_resources
18 import urlparse
19
20 import wizard
21 from wizard import app, git, old_log, shell, sql, util
22
23 ## -- Global Functions --
24
25 def get_install_lines(versions_store, user=None):
26     """
27     Low level function that retrieves a list of lines from the
28     :term:`versions store` that can be passed to :meth:`Deployment.parse`.
29     """
30     if os.path.isfile(versions_store):
31         return fileinput.input([versions_store])
32     if user:
33         return fileinput.input([versions_store + "/" + user])
34     return fileinput.input([versions_store + "/" + f for f in sorted(os.listdir(versions_store))])
35
36 def parse_install_lines(show, versions_store, yield_errors = False, user = None):
37     """
38     Generator function for iterating through all autoinstalls.
39     Each item is an instance of :class:`Deployment`, or possibly
40     a :class:`wizard.deploy.Error` if ``yield_errors`` is ``True``.  You can
41     filter out applications and versions by specifying ``app``
42     or ``app-1.2.3`` in ``show``.  This function may generate
43     log output.
44     """
45     if not show:
46         show = app.applications()
47     elif isinstance(show, str):
48         # otherwise, frozenset will treat string as an iterable
49         show = frozenset([show])
50     else:
51         show = frozenset(show)
52     for line in get_install_lines(versions_store, user):
53         # construction
54         try:
55             d = Deployment.parse(line)
56             name = d.application.name
57         except app.NoSuchApplication as e:
58             if not e.location:
59                 try:
60                     e.location = line.split(':')[0]
61                 except IndexError:
62                     e.location = line
63             if yield_errors:
64                 yield e
65             continue
66         except Error:
67             # we consider this a worse error
68             logging.warning("Error with '%s'" % line.rstrip())
69             continue
70         # filter
71         if name + "-" + util.truncate(str(d.version)) in show or name in show:
72             pass
73         else:
74             continue
75         # yield
76         yield d
77
78 def web(dir, url=None):
79     """
80     Attempts to determine the URL a directory would be web-accessible at.
81     If ``url`` is specified, automatically use it.  Returns a generator which
82     produces a list of candidate urls.
83
84     This function implements a plugin interface named :ref:`wizard.deploy.web`.
85     """
86     if url:
87         if isinstance(url, str):
88             url = urlparse.urlparse(url)
89         logging.info("wizard.deploy.web: Using default URL %s", url)
90         yield url
91         return
92
93     for entry in pkg_resources.iter_entry_points("wizard.deploy.web"):
94         logging.debug("wizard.deploy.web: Processing %s", entry)
95         f = entry.load()
96         for r in f(dir):
97             if isinstance(r, str):
98                 r = urlparse.urlparse(r)
99             logging.info("wizard.deploy.web: Using plugin-supplied URL %s", r)
100             yield r
101
102     # try the environment
103     host = os.getenv("WIZARD_WEB_HOST")
104     path = os.getenv("WIZARD_WEB_PATH")
105     if host is not None and path is not None:
106         r = urlparse.ParseResult(
107                 "http",
108                 host,
109                 path.rstrip('/'),
110                 "", "", "")
111         logging.info("wizard.deploy.web: Using environment URL %s", r)
112         yield r
113
114     logging.info("wizard.deploy.web: Exhausted URLs")
115
116 ## -- Model Objects --
117
118 @decorator.decorator
119 def chdir_to_location(f, self, *args, **kwargs):
120     """
121     Decorator for making a function have working directory
122     :attr:`Deployment.location`.
123     """
124     with util.ChangeDirectory(self.location):
125         return f(self, *args, **kwargs)
126
127 class Deployment(object):
128     """
129     Represents a deployment of an autoinstall, e.g. directory that has a
130     ``.wizard`` directory in it.  Supply ``version`` with an
131     :class:`ApplicationVersion` only if you were reading from the
132     :term:`versions store` and care about speed (data from there can be
133     stale).
134
135     The Deployment interface is somewhat neutered, so you may
136     want to use :class:`WorkingCopy` or :class:`ProductionCopy` for
137     more powerful operations.
138
139     .. note::
140
141         For legacy purposes, deployments can also be marked by a
142         ``.scripts`` directory or a ``.scripts-version`` file.
143     """
144     #: Absolute path to the deployment
145     location = None
146     def __init__(self, location, version=None):
147         self.location = os.path.abspath(location)
148         self._app_version = version
149         # some cache variables
150         self._read_cache = {}
151         self._old_log = None
152         self._dsn = None
153         self._url = None
154         self._urlGen = None
155         self._wizard_dir = None
156     def invalidateCache(self):
157         """
158         Invalidates all cached variables.  This currently applies to
159         :attr:`app_version`, :attr:`old_log` and :meth:`read`.
160         """
161         self._app_version = None
162         self._read_cache = {}
163         self._old_log = None
164     def setAppVersion(self, app_version):
165         """
166         Manually resets the application version; useful if the working
167         copy is off in space (i.e. not anchored to something we can
168         git describe off of) or there is no metadata to be heard of.
169         """
170         self._app_version = app_version
171     def read(self, file, force = False):
172         """
173         Reads a file's contents, possibly from cache unless ``force``
174         is ``True``.
175         """
176         if force or file not in self._read_cache:
177             f = open(os.path.join(self.location, file))
178             self._read_cache[file] = f.read()
179             f.close()
180         return self._read_cache[file]
181     def extract(self):
182         """
183         Extracts all the values of all variables from deployment.
184         These variables may be used for parametrizing generic parent
185         commits and include things such as database access credentials
186         and local configuration.
187         """
188         return self.application.extract(self)
189
190     def verify(self, no_touch=False):
191         """
192         Checks if this is an autoinstall, throws an exception if there
193         are problems.  If ``no_touch`` is ``True``, it will not attempt
194         edit the installation.
195         """
196         with util.ChangeDirectory(self.location):
197             has_git = os.path.isdir(".git")
198             has_wizard = os.path.isdir(".wizard")
199             if not has_wizard and os.path.isdir(".scripts"):
200                 # LEGACY
201                 os.symlink(".scripts", ".wizard")
202                 has_wizard = True
203             if not has_git and has_wizard:
204                 raise CorruptedAutoinstallError(self.location)
205             elif has_git and not has_wizard:
206                 raise AlreadyVersionedError(self.location)
207             # LEGACY
208             elif not has_git and not has_wizard:
209                 if os.path.isfile(".scripts-version"):
210                     raise NotMigratedError(self.location)
211                 else:
212                     raise NotAutoinstallError(self.location)
213
214     def verifyTag(self, srv_path):
215         """
216         Checks if the purported version has a corresponding tag
217         in the upstream repository.
218         """
219         repo = self.application.repository(srv_path)
220         try:
221             shell.eval("git", "--git-dir", repo, "rev-parse", self.app_version.wizard_tag, '--')
222         except shell.CallError:
223             raise NoTagError(self.app_version.wizard_tag)
224
225     def verifyGit(self, srv_path):
226         """
227         Checks if the autoinstall's Git repository makes sense,
228         checking if the tag is parseable and corresponds to
229         a real application, and if the tag in this repository
230         corresponds to the one in the remote repository.
231         """
232         with util.ChangeDirectory(self.location):
233             repo = self.application.repository(srv_path)
234             def repo_rev_parse(tag):
235                 return shell.eval("git", "--git-dir", repo, "rev-parse", tag)
236             def self_rev_parse(tag):
237                 try:
238                     return shell.safeCall("git", "rev-parse", tag, strip=True)
239                 except shell.CallError:
240                     raise NoLocalTagError(tag)
241             def compare_tags(tag):
242                 return repo_rev_parse(tag) == self_rev_parse(tag)
243             if not compare_tags(self.app_version.pristine_tag):
244                 raise InconsistentPristineTagError(self.app_version.pristine_tag)
245             if not compare_tags(self.app_version.wizard_tag):
246                 # Causes remastering
247                 raise InconsistentWizardTagError(self.app_version.wizard_tag)
248             parent = repo_rev_parse(self.app_version.wizard_tag)
249             merge_base = shell.safeCall("git", "merge-base", parent, "HEAD", strip=True)
250             if merge_base != parent:
251                 # Causes remastering
252                 raise HeadNotDescendantError(self.app_version.wizard_tag)
253
254     def verifyConfigured(self):
255         """
256         Checks if the autoinstall is configured running.
257         """
258         if not self.configured:
259             raise NotConfiguredError(self.location)
260
261     @chdir_to_location
262     def verifyVersion(self):
263         """
264         Checks if our version and the version number recorded in a file
265         are consistent.
266         """
267         real = self.detectVersion()
268         if not str(real) == self.app_version.pristine_tag.partition('-')[2]:
269             raise VersionMismatchError(real, self.version)
270
271     @chdir_to_location
272     def detectVersion(self):
273         """
274         Returns the real version, based on filesystem, of install.
275
276         Throws a :class:`VersionDetectionError` if we couldn't figure out
277         what the real version was.
278         """
279         real = self.application.detectVersion(self)
280         if not real:
281             raise VersionDetectionError
282         return real
283
284     @property
285     @chdir_to_location
286     def configured(self):
287         """Whether or not an autoinstall has been configured/installed for use."""
288         return self.application.checkConfig(self)
289     @property
290     def migrated(self):
291         """Whether or not the autoinstalls has been migrated."""
292         return os.path.isdir(self.wizard_dir)
293     @property
294     def wizard_dir(self):
295         """The absolute path of the Wizard directory."""
296         return os.path.join(self.location, ".wizard")
297     @property
298     def backup_dir(self):
299         """The absolute path to ``.wizard/backups``."""
300         return os.path.join(self.wizard_dir, "backups")
301     # LEGACY
302     @property
303     def old_version_file(self):
304         """
305         The absolute path of either ``.scripts-version``.
306         """
307         return os.path.join(self.location, '.scripts-version')
308     @property
309     def blacklisted_file(self):
310         """The absolute path of the ``.wizard/blacklisted`` file."""
311         return os.path.join(self.wizard_dir, 'blacklisted')
312     @property
313     def pending_file(self):
314         """The absolute path of the ``.wizard/pending`` file."""
315         return os.path.join(self.wizard_dir, 'pending')
316     @property
317     def version_file(self):
318         """The absolute path of the ``.wizard/version`` file."""
319         return os.path.join(self.wizard_dir, 'version')
320     @property
321     def dsn_file(self):
322         """The absolute path of the :file:`.wizard/dsn` override file."""
323         return os.path.join(self.wizard_dir, 'dsn')
324     @property
325     def url_file(self):
326         """The absolute path of the :file:`.wizard/url` override file."""
327         return os.path.join(self.wizard_dir, 'url')
328     @property
329     def application(self):
330         """The :class:`app.Application` of this deployment."""
331         return self.app_version.application
332     @property
333     def old_log(self):
334         """
335         The :class:`wizard.old_log.Log` of this deployment.  This
336         is only applicable to un-migrated autoinstalls.
337         """
338         if not self._old_log:
339             self._old_log = old_log.DeployLog.load(self)
340         return self._old_log
341     @property
342     def version(self):
343         """
344         The :class:`distutils.version.LooseVersion` of this
345         deployment.
346         """
347         return self.app_version.version
348     @property
349     def app_version(self):
350         """The :class:`app.ApplicationVersion` of this deployment."""
351         if not self._app_version:
352             if os.path.isdir(os.path.join(self.location, ".git")):
353                 try:
354                     with util.ChangeDirectory(self.location):
355                         appname, _, version = git.describe().partition('-')
356                     self._app_version = app.ApplicationVersion.make(appname, version)
357                 except shell.CallError:
358                     pass
359         if not self._app_version:
360             # LEGACY
361             try:
362                 self._app_version = self.old_log[-1].version
363             except old_log.ScriptsVersionNoSuchFile:
364                 pass
365         if not self._app_version:
366             appname = shell.eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
367             self._app_version = app.ApplicationVersion.make(appname, "unknown")
368         return self._app_version
369     @property
370     def dsn(self):
371         """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
372         if not self._dsn:
373             self._dsn = sql.auth(self.application.dsn(self))
374         return self._dsn
375     @property
376     def url(self):
377         """The :class:`urlparse.ParseResult` for this deployment."""
378         if not self._url:
379             self.nextUrl()
380         return self._url
381     def nextUrl(self):
382         """
383         Initializes :attr:`url` with a possible URL the web application may
384         be located at.  It may be called again to switch to another possible
385         URL, usually in the event of a web access failure.
386         """
387         if not self._urlGen:
388             self._urlGen = web(self.location, self.application.url(self))
389         try:
390             self._url = self._urlGen.next() # pylint: disable-msg=E1101
391             return self._url
392         except StopIteration:
393             raise UnknownWebPath
394     @staticmethod
395     def parse(line):
396         """
397         Parses a line from the :term:`versions store`.
398
399         .. note::
400
401             Use this method only when speed is of the utmost
402             importance.  You should prefer to directly create a deployment
403             with only a ``location`` when possible.
404         """
405         line = line.rstrip()
406         try:
407             location, deploydir = line.split(":")
408         except ValueError:
409             return ProductionCopy(line) # lazy loaded version
410         try:
411             return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
412         except Error as e:
413             e.location = location
414             raise e
415
416 class ProductionCopy(Deployment):
417     """
418     Represents the production copy of a deployment.  This copy
419     is canonical, and is the only one guaranteed to be accessible
420     via web, have a database, etc.
421     """
422     @chdir_to_location
423     def upgrade(self, version, options):
424         """
425         Performs an upgrade of database schemas and other non-versioned data.
426         """
427         return self.application.upgrade(self, version, options)
428     @chdir_to_location
429     def backup(self, options):
430         """
431         Performs a backup of database schemas and other non-versioned data.
432         """
433         # There are retarded amounts of race-safety in this function,
434         # because we do NOT want to claim to have made a backup, when
435         # actually something weird happened to it.
436         if not os.path.exists(self.backup_dir):
437             try:
438                 os.mkdir(self.backup_dir)
439             except OSError as e:
440                 if e.errno == errno.EEXIST:
441                     pass
442                 else:
443                     raise
444         tmpdir = tempfile.mkdtemp() # actually will be kept around
445         try:
446             self.application.backup(self, tmpdir, options)
447         except app.BackupFailure:
448             # the backup is bogus, don't let it show up
449             shutil.rmtree(tmpdir)
450             raise
451         backup = None
452         with util.LockDirectory(os.path.join(self.backup_dir, "lock")):
453             while 1:
454                 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
455                 outdir = os.path.join(self.backup_dir, backup)
456                 if os.path.exists(outdir):
457                     logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
458                     time.sleep(1)
459                     continue
460                 try:
461                     shutil.move(tmpdir, outdir)
462                 except:
463                     # don't leave half-baked stuff lying around
464                     try:
465                         shutil.rmtree(outdir)
466                     except OSError:
467                         pass
468                     raise
469                 break
470         return backup
471     @chdir_to_location
472     def restore(self, backup, options):
473         """
474         Restores a backup. Destroys state, so be careful! Also, this does
475         NOT restore the file-level backup, which is what 'wizard restore'
476         does, so you probably do NOT want to call this elsewhere unless
477         you know what you're doing (call 'wizard restore' instead).
478         """
479         return self.application.restore(self, os.path.join(self.backup_dir, backup), options)
480     @chdir_to_location
481     def remove(self, options):
482         """
483         Deletes all non-local or non-filesystem data (such as databases) that
484         this application uses.
485         """
486         self.application.remove(self, options)
487     def verifyDatabase(self):
488         """
489         Checks if the autoinstall has a properly configured database.
490         """
491         if not self.application.checkDatabase(self):
492             raise DatabaseVerificationError
493     def verifyWeb(self):
494         """
495         Checks if the autoinstall is viewable from the web.  If you do not run
496         this, there is no guarantee that the url returned by this application
497         is the correct one.
498         """
499         while True:
500             if not self.application.checkWeb(self):
501                 try:
502                     self.nextUrl()
503                 except UnknownWebPath:
504                     raise WebVerificationError
505             else:
506                 break
507     def fetch(self, path, post=None):
508         """
509         Performs a HTTP request on the website.
510         """
511         return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
512
513 class WorkingCopy(Deployment):
514     """
515     Represents a temporary clone of a deployment that we can make
516     modifications to without fear of interfering with a production
517     deployment.  More operations are permitted on these copies.
518     """
519     @chdir_to_location
520     def parametrize(self, deployment):
521         """
522         Edits files in ``dir`` to replace WIZARD_* variables with literal
523         instances based on ``deployment``.  This is used for constructing
524         virtual merge bases, and as such ``deployment`` will generally not
525         equal ``self``.
526         """
527         return self.application.parametrize(self, deployment)
528     @chdir_to_location
529     def prepareConfig(self):
530         """
531         Edits files in the deployment such that any user-specific configuration
532         is replaced with generic WIZARD_* variables.
533         """
534         return self.application.prepareConfig(self)
535     @chdir_to_location
536     def resolveConflicts(self):
537         """
538         Resolves conflicted files in this working copy.  Returns whether or
539         not all conflicted files were resolved or not.  Fully resolved
540         files are added to the index, but no commit is made.
541         """
542         return self.application.resolveConflicts(self)
543     @chdir_to_location
544     def prepareMerge(self):
545         """
546         Performs various edits to files in the current working directory in
547         order to make a merge go more smoothly.  This is usually
548         used to fix botched line-endings.
549         """
550         return self.application.prepareMerge(self)
551
552 ## -- Exceptions --
553
554 class Error(wizard.Error):
555     """Base error class for this module"""
556     pass
557
558 # LEGACY
559 class NotMigratedError(Error):
560     """
561     The deployment contains a .scripts-version file, but no .git
562     or .wizard directory.
563     """
564     #: Directory of deployment
565     dir = None
566     def __init__(self, dir):
567         self.dir = dir
568     def __str__(self):
569         return """This installation was not migrated"""
570
571 class AlreadyVersionedError(Error):
572     """The deployment contained a .git directory but no .wizard directory."""
573     #: Directory of deployment
574     dir = None
575     def __init__(self, dir):
576         self.dir = dir
577     def __str__(self):
578         return """
579
580 ERROR: Directory contains a .git directory, but not
581 a .wizard directory.  If this is not a corrupt
582 migration, this means that the user was versioning their
583 install using Git."""
584
585 class NotConfiguredError(Error):
586     """The install was missing essential configuration."""
587     #: Directory of unconfigured install
588     dir = None
589     def __init__(self, dir):
590         self.dir = dir
591     def __str__(self):
592         return """
593
594 ERROR: The install was well-formed, but not configured
595 (essential configuration files were not found.)"""
596
597 class CorruptedAutoinstallError(Error):
598     """The install was missing a .git directory, but had a .wizard directory."""
599     #: Directory of the corrupted install
600     dir = None
601     def __init__(self, dir):
602         self.dir = dir
603     def __str__(self):
604         return """
605
606 ERROR: Directory contains a .wizard directory,
607 but not a .git directory."""
608
609 class NotAutoinstallError(Error):
610     """Application is not an autoinstall."""
611     #: Directory of the not autoinstall
612     dir = None
613     def __init__(self, dir):
614         self.dir = dir
615     def __str__(self):
616         return """
617
618 ERROR: The directory
619
620     %s
621
622 does not appear to be an autoinstall.  If you are in a
623 subdirectory of an autoinstall, you need to use the root
624 directory for the autoinstall.""" % self.dir
625
626 class NoTagError(Error):
627     """Deployment has a tag that does not have an equivalent in upstream repository."""
628     #: Missing tag
629     tag = None
630     def __init__(self, tag):
631         self.tag = tag
632     def __str__(self):
633         return """
634
635 ERROR: Could not find tag %s in repository.""" % self.tag
636
637 class NoLocalTagError(Error):
638     """Could not find tag in local repository."""
639     #: Missing tag
640     tag = None
641     def __init__(self, tag):
642         self.tag = tag
643     def __str__(self):
644         return """
645
646 ERROR: Could not find tag %s in local repository.""" % self.tag
647
648 class InconsistentPristineTagError(Error):
649     """Pristine tag commit ID does not match upstream pristine tag commit ID."""
650     #: Inconsistent tag
651     tag = None
652     def __init__(self, tag):
653         self.tag = tag
654     def __str__(self):
655         return """
656
657 ERROR: Local pristine tag %s did not match repository's.  This
658 probably means an upstream rebase occured.""" % self.tag
659
660 class InconsistentWizardTagError(Error):
661     """Wizard tag commit ID does not match upstream wizard tag commit ID."""
662     #: Inconsistent tag
663     tag = None
664     def __init__(self, tag):
665         self.tag = tag
666     def __str__(self):
667         return """
668
669 ERROR: Local wizard tag %s did not match repository's.  This
670 probably means an upstream rebase occurred.  Try
671 'git fetch --tags && wizard remaster'.""" % self.tag
672
673 class HeadNotDescendantError(Error):
674     """HEAD is not connected to tag."""
675     #: Tag that HEAD should have been descendant of.
676     tag = None
677     def __init__(self, tag):
678         self.tag = tag
679     def __str__(self):
680         return """
681
682 ERROR: HEAD is not a descendant of %s.  This probably
683 means that an upstream rebase occurred, and new tags were
684 pulled, but local user commits were never rebased.  Try
685 running 'wizard remaster'.""" % self.tag
686
687 class VersionDetectionError(Error):
688     """Could not detect real version of application."""
689     def __str__(self):
690         return """
691
692 ERROR: Could not detect the real version of the application."""
693
694 class VersionMismatchError(Error):
695     """Git version of application does not match detected version."""
696     #: Detected version
697     real_version = None
698     #: Version from Git
699     git_version = None
700     def __init__(self, real_version, git_version):
701         self.real_version = real_version
702         self.git_version = git_version
703     def __str__(self):
704         return """
705
706 ERROR: The detected version %s did not match the Git
707 version %s.""" % (self.real_version, self.git_version)
708
709 class WebVerificationError(Error):
710     """Could not access the application on the web"""
711     def __str__(self):
712         return """
713
714 ERROR: We were not able to access the application on the
715 web.  This may indicate that the website is behind
716 authentication on the htaccess level.  You can find
717 the contents of the page from the debug backtraces."""
718
719 class DatabaseVerificationError(Error):
720     """Could not access the database"""
721     def __str__(self):
722         return """
723
724 ERROR: We were not able to access the database for
725 this application; this probably means that your database
726 configuration is misconfigured."""
727
728 class UnknownWebPath(Error):
729     """Could not determine application's web path."""
730     def __str__(self):
731         return """
732
733 ERROR: We were not able to determine what the application's
734 host and path were in order to perform a web request
735 on the application.  You can specify this manually using
736 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment
737 variables."""