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