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