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