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