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