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