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