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