]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
4fdff7d5dde202d63fcfbc4fa769ebd6f4425eb1
[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     def enableOldStyleUrls(self):
306         """
307         Switches to using http://user.scripts.mit.edu/~user/app URLs.
308         No effect if they have an explicit .scripts/url override.
309         """
310         self._url = scripts.fill_url(self.location, self.application.url(self), old_style = True)
311     @staticmethod
312     def parse(line):
313         """
314         Parses a line from the :term:`versions store`.
315
316         .. note::
317
318             Use this method only when speed is of the utmost
319             importance.  You should prefer to directly create a deployment
320             with only a ``location`` when possible.
321         """
322         line = line.rstrip()
323         try:
324             location, deploydir = line.split(":")
325         except ValueError:
326             return ProductionCopy(line) # lazy loaded version
327         try:
328             return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
329         except Error as e:
330             e.location = location
331             raise e
332
333 class ProductionCopy(Deployment):
334     """
335     Represents the production copy of a deployment.  This copy
336     is canonical, and is the only one guaranteed to be accessible
337     via web, have a database, etc.
338     """
339     @chdir_to_location
340     def upgrade(self, version, options):
341         """
342         Performs an upgrade of database schemas and other non-versioned data.
343         """
344         return self.application.upgrade(self, version, options)
345     @chdir_to_location
346     def backup(self, options):
347         """
348         Performs a backup of database schemas and other non-versioned data.
349         """
350         # There are retarded amounts of race-safety in this function,
351         # because we do NOT want to claim to have made a backup, when
352         # actually something weird happened to it.
353         backupdir = os.path.join(self.scripts_dir, "backups")
354         if not os.path.exists(backupdir):
355             try:
356                 os.mkdir(backupdir)
357             except OSError as e:
358                 if e.errno == errno.EEXIST:
359                     pass
360                 else:
361                     raise
362         tmpdir = tempfile.mkdtemp() # actually will be kept around
363         try:
364             self.application.backup(self, tmpdir, options)
365         except app.BackupFailure:
366             # the backup is bogus, don't let it show up
367             shutil.rmtree(tmpdir)
368             raise
369         backup = None
370         with util.LockDirectory(os.path.join(backupdir, "lock")):
371             while 1:
372                 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
373                 outdir = os.path.join(backupdir, backup)
374                 if os.path.exists(outdir):
375                     logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
376                     time.sleep(1)
377                     continue
378                 try:
379                     shutil.move(tmpdir, outdir)
380                 except:
381                     # don't leave half-baked stuff lying around
382                     try:
383                         shutil.rmtree(outdir)
384                     except OSError:
385                         pass
386                     raise
387                 break
388         return backup
389     @chdir_to_location
390     def restore(self, backup, options):
391         """
392         Restores a backup. Destroys state, so be careful! Also, this does
393         NOT restore the file-level backup, which is what 'wizard restore'
394         does, so you probably do NOT want to call this elsewhere unless
395         you know what you're doing (call 'wizard restore' instead).
396         """
397         backup_dir = os.path.join(".scripts", "backups", backup)
398         return self.application.restore(self, backup_dir, options)
399     @chdir_to_location
400     def remove(self, options):
401         """
402         Deletes all non-local or non-filesystem data (such as databases) that
403         this application uses.
404         """
405         self.application.remove(self, options)
406     def verifyWeb(self):
407         """
408         Checks if the autoinstall is viewable from the web.
409         """
410         if not self.application.checkWeb(self):
411             raise WebVerificationError
412     def fetch(self, path, post=None):
413         """
414         Performs a HTTP request on the website.
415         """
416         return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
417
418 class WorkingCopy(Deployment):
419     """
420     Represents a temporary clone of a deployment that we can make
421     modifications to without fear of interfering with a production
422     deployment.  More operations are permitted on these copies.
423     """
424     def setAppVersion(self, app_version):
425         """
426         Manually resets the application version; useful if the working
427         copy is off in space (i.e. not anchored to something we can
428         git describe off of.)
429         """
430         self._app_version = app_version
431     @chdir_to_location
432     def parametrize(self, deployment):
433         """
434         Edits files in ``dir`` to replace WIZARD_* variables with literal
435         instances based on ``deployment``.  This is used for constructing
436         virtual merge bases, and as such ``deployment`` will generally not
437         equal ``self``.
438         """
439         return self.application.parametrize(self, deployment)
440     @chdir_to_location
441     def prepareConfig(self):
442         """
443         Edits files in the deployment such that any user-specific configuration
444         is replaced with generic WIZARD_* variables.
445         """
446         return self.application.prepareConfig(self)
447     @chdir_to_location
448     def resolveConflicts(self):
449         """
450         Resolves conflicted files in this working copy.  Returns whether or
451         not all conflicted files were resolved or not.  Fully resolved
452         files are added to the index, but no commit is made.
453         """
454         return self.application.resolveConflicts(self)
455     @chdir_to_location
456     def prepareMerge(self):
457         """
458         Performs various edits to files in the current working directory in
459         order to make a merge go more smoothly.  This is usually
460         used to fix botched line-endings.
461         """
462         return self.application.prepareMerge(self)
463
464 ## -- Exceptions --
465
466 class Error(wizard.Error):
467     """Base error class for this module"""
468     pass
469
470 class NotMigratedError(Error):
471     """
472     The deployment contains a .scripts-version file, but no .git
473     or .scripts directory.
474     """
475     #: Directory of deployment
476     dir = None
477     def __init__(self, dir):
478         self.dir = dir
479     def __str__(self):
480         return """This installation was not migrated"""
481
482 class AlreadyVersionedError(Error):
483     """The deployment contained a .git directory but no .scripts directory."""
484     #: Directory of deployment
485     dir = None
486     def __init__(self, dir):
487         self.dir = dir
488     def __str__(self):
489         return """
490
491 ERROR: Directory contains a .git directory, but not
492 a .scripts directory.  If this is not a corrupt
493 migration, this means that the user was versioning their
494 install using Git."""
495
496 class NotConfiguredError(Error):
497     """The install was missing essential configuration."""
498     #: Directory of unconfigured install
499     dir = None
500     def __init__(self, dir):
501         self.dir = dir
502     def __str__(self):
503         return """
504
505 ERROR: The install was well-formed, but not configured
506 (essential configuration files were not found.)"""
507
508 class CorruptedAutoinstallError(Error):
509     """The install was missing a .git directory, but had a .scripts directory."""
510     #: Directory of the corrupted install
511     dir = None
512     def __init__(self, dir):
513         self.dir = dir
514     def __str__(self):
515         return """
516
517 ERROR: Directory contains a .scripts directory,
518 but not a .git directory."""
519
520 class NotAutoinstallError(Error):
521     """The directory was not an autoinstall, due to missing .scripts-version file."""
522     #: Directory in question
523     dir = None
524     def __init__(self, dir):
525         self.dir = dir
526     def __str__(self):
527         return """
528
529 ERROR: Could not find .scripts-version file. Are you sure
530 this is an autoinstalled application?
531 """
532
533 class NoTagError(Error):
534     """Deployment has a tag that does not have an equivalent in upstream repository."""
535     #: Missing tag
536     tag = None
537     def __init__(self, tag):
538         self.tag = tag
539     def __str__(self):
540         return """
541
542 ERROR: Could not find tag %s in repository.""" % self.tag
543
544 class NoLocalTagError(Error):
545     """Could not find tag in local repository."""
546     #: Missing tag
547     tag = None
548     def __init__(self, tag):
549         self.tag = tag
550     def __str__(self):
551         return """
552
553 ERROR: Could not find tag %s in local repository.""" % self.tag
554
555 class InconsistentPristineTagError(Error):
556     """Pristine tag commit ID does not match upstream pristine tag commit ID."""
557     #: Inconsistent tag
558     tag = None
559     def __init__(self, tag):
560         self.tag = tag
561     def __str__(self):
562         return """
563
564 ERROR: Local pristine tag %s did not match repository's.  This
565 probably means an upstream rebase occured.""" % self.tag
566
567 class InconsistentScriptsTagError(Error):
568     """Scripts tag commit ID does not match upstream scripts 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 scripts tag %s did not match repository's.  This
577 probably means an upstream rebase occurred.""" % self.tag
578
579 class HeadNotDescendantError(Error):
580     """HEAD is not connected to tag."""
581     #: Tag that HEAD should have been descendant of.
582     tag = None
583     def __init__(self, tag):
584         self.tag = tag
585     def __str__(self):
586         return """
587
588 ERROR: HEAD is not a descendant of %s.  This probably
589 means that an upstream rebase occurred, and new tags were
590 pulled, but local user commits were never rebased.""" % self.tag
591
592 class VersionDetectionError(Error):
593     """Could not detect real version of application."""
594     def __str__(self):
595         return """
596
597 ERROR: Could not detect the real version of the application."""
598
599 class VersionMismatchError(Error):
600     """Git version of application does not match detected version."""
601     #: Detected version
602     real_version = None
603     #: Version from Git
604     git_version = None
605     def __init__(self, real_version, git_version):
606         self.real_version = real_version
607         self.git_version = git_version
608     def __str__(self):
609         return """
610
611 ERROR: The detected version %s did not match the Git
612 version %s.""" % (self.real_version, self.git_version)
613
614 class WebVerificationError(Error):
615     """Could not access the application on the web"""
616     def __str__(self):
617         return """
618
619 ERROR: We were not able to access the application on the
620 web.  This may indicate that the website is behind
621 authentication on the htaccess level.  You can find
622 the contents of the page from the debug backtraces."""
623
624 class UnknownWebPath(Error):
625     """Could not determine application's web path."""
626     def __str__(self):
627         return """
628
629 ERROR: We were not able to determine what the application's
630 host and path were in order to perform a web request
631 on the application.  You can specify this manually using
632 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment
633 variables."""
634