]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
Fix detectVersion bug, make unsupported nicer.
[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     @chdir_to_location
419     def parametrize(self, deployment):
420         """
421         Edits files in ``dir`` to replace WIZARD_* variables with literal
422         instances based on ``deployment``.  This is used for constructing
423         virtual merge bases, and as such ``deployment`` will generally not
424         equal ``self``.
425         """
426         return self.application.parametrize(self, deployment)
427     @chdir_to_location
428     def prepareConfig(self):
429         """
430         Edits files in the deployment such that any user-specific configuration
431         is replaced with generic WIZARD_* variables.
432         """
433         return self.application.prepareConfig(self)
434     @chdir_to_location
435     def resolveConflicts(self):
436         """
437         Resolves conflicted files in this working copy.  Returns whether or
438         not all conflicted files were resolved or not.  Fully resolved
439         files are added to the index, but no commit is made.
440         """
441         return self.application.resolveConflicts(self)
442     @chdir_to_location
443     def prepareMerge(self):
444         """
445         Performs various edits to files in the current working directory in
446         order to make a merge go more smoothly.  This is usually
447         used to fix botched line-endings.
448         """
449         return self.application.prepareMerge(self)
450
451 ## -- Exceptions --
452
453 class Error(wizard.Error):
454     """Base error class for this module"""
455     pass
456
457 class NotMigratedError(Error):
458     """
459     The deployment contains a .scripts-version file, but no .git
460     or .scripts directory.
461     """
462     #: Directory of deployment
463     dir = None
464     def __init__(self, dir):
465         self.dir = dir
466     def __str__(self):
467         return """This installation was not migrated"""
468
469 class AlreadyVersionedError(Error):
470     """The deployment contained a .git directory but no .scripts directory."""
471     #: Directory of deployment
472     dir = None
473     def __init__(self, dir):
474         self.dir = dir
475     def __str__(self):
476         return """
477
478 ERROR: Directory contains a .git directory, but not
479 a .scripts directory.  If this is not a corrupt
480 migration, this means that the user was versioning their
481 install using Git."""
482
483 class NotConfiguredError(Error):
484     """The install was missing essential configuration."""
485     #: Directory of unconfigured install
486     dir = None
487     def __init__(self, dir):
488         self.dir = dir
489     def __str__(self):
490         return """
491
492 ERROR: The install was well-formed, but not configured
493 (essential configuration files were not found.)"""
494
495 class CorruptedAutoinstallError(Error):
496     """The install was missing a .git directory, but had a .scripts directory."""
497     #: Directory of the corrupted install
498     dir = None
499     def __init__(self, dir):
500         self.dir = dir
501     def __str__(self):
502         return """
503
504 ERROR: Directory contains a .scripts directory,
505 but not a .git directory."""
506
507 class NotAutoinstallError(Error):
508     """The directory was not an autoinstall, due to missing .scripts-version file."""
509     #: Directory in question
510     dir = None
511     def __init__(self, dir):
512         self.dir = dir
513     def __str__(self):
514         return """
515
516 ERROR: Could not find .scripts-version file. Are you sure
517 this is an autoinstalled application?
518 """
519
520 class NoTagError(Error):
521     """Deployment has a tag that does not have an equivalent in upstream repository."""
522     #: Missing tag
523     tag = None
524     def __init__(self, tag):
525         self.tag = tag
526     def __str__(self):
527         return """
528
529 ERROR: Could not find tag %s in repository.""" % self.tag
530
531 class NoLocalTagError(Error):
532     """Could not find tag in local repository."""
533     #: Missing tag
534     tag = None
535     def __init__(self, tag):
536         self.tag = tag
537     def __str__(self):
538         return """
539
540 ERROR: Could not find tag %s in local repository.""" % self.tag
541
542 class InconsistentPristineTagError(Error):
543     """Pristine tag commit ID does not match upstream pristine tag commit ID."""
544     #: Inconsistent tag
545     tag = None
546     def __init__(self, tag):
547         self.tag = tag
548     def __str__(self):
549         return """
550
551 ERROR: Local pristine tag %s did not match repository's.  This
552 probably means an upstream rebase occured.""" % self.tag
553
554 class InconsistentScriptsTagError(Error):
555     """Scripts tag commit ID does not match upstream scripts tag commit ID."""
556     #: Inconsistent tag
557     tag = None
558     def __init__(self, tag):
559         self.tag = tag
560     def __str__(self):
561         return """
562
563 ERROR: Local scripts tag %s did not match repository's.  This
564 probably means an upstream rebase occurred.""" % self.tag
565
566 class HeadNotDescendantError(Error):
567     """HEAD is not connected to tag."""
568     #: Tag that HEAD should have been descendant of.
569     tag = None
570     def __init__(self, tag):
571         self.tag = tag
572     def __str__(self):
573         return """
574
575 ERROR: HEAD is not a descendant of %s.  This probably
576 means that an upstream rebase occurred, and new tags were
577 pulled, but local user commits were never rebased.""" % self.tag
578
579 class VersionDetectionError(Error):
580     """Could not detect real version of application."""
581     def __str__(self):
582         return """
583
584 ERROR: Could not detect the real version of the application."""
585
586 class VersionMismatchError(Error):
587     """Git version of application does not match detected version."""
588     #: Detected version
589     real_version = None
590     #: Version from Git
591     git_version = None
592     def __init__(self, real_version, git_version):
593         self.real_version = real_version
594         self.git_version = git_version
595     def __str__(self):
596         return """
597
598 ERROR: The detected version %s did not match the Git
599 version %s.""" % (self.real_version, self.git_version)
600
601 class WebVerificationError(Error):
602     """Could not access the application on the web"""
603     def __str__(self):
604         return """
605
606 ERROR: We were not able to access the application on the
607 web.  This may indicate that the website is behind
608 authentication on the htaccess level.  You can find
609 the contents of the page from the debug backtraces."""
610
611 class UnknownWebPath(Error):
612     """Could not determine application's web path."""
613     def __str__(self):
614         return """
615
616 ERROR: We were not able to determine what the application's
617 host and path were in order to perform a web request
618 on the application.  You can specify this manually using
619 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment
620 variables."""
621