]> scripts.mit.edu Git - wizard.git/blob - wizard/command/upgrade.py
Refactor interactive shell and add addenv support.
[wizard.git] / wizard / command / upgrade.py
1 import sys
2 import distutils.version
3 import os
4 import os.path
5 import shutil
6 import logging.handlers
7 import tempfile
8 import itertools
9 import time
10 import errno
11
12 # XXX: read().strip() is an accident waiting to happen if anyone ever
13 # makes a directory with a trailing or leading space. Unlikely, but yeah.
14
15 from wizard import app, command, deploy, merge, shell, user, util
16
17 buffer = 1024 * 1024 * 30 # 30 MiB we will always leave available
18 errno_blacklisted = 64
19
20 def main(argv, baton):
21     options, args = parse_args(argv, baton)
22     dir = os.path.abspath(args[0]) if args else os.getcwd()
23     os.chdir(dir)
24     shell.drop_priviledges(dir, options.log_file)
25     util.set_git_env()
26     upgrade = Upgrade(options)
27     upgrade.execute(dir)
28     if not options.non_interactive:
29         print "Upgrade complete"
30
31 class Upgrade(object):
32     """
33     Represents the algorithm for upgrading an application.  This is in
34     a class and not a function because it's a multi-step process that
35     requires state betweens steps.  Steps are represented as methods
36     in this object.
37     """
38
39     #: Version of application we are upgrading to, i.e. the latest version.
40     version = None # XXX: This is a string... I'm not convinced it should be
41     #: String commit ID of the user's latest wc; i.e. "ours"
42     user_commit = None
43     #: String commit ID of the latest, greatest wizard version; i.e. "theirs"
44     next_commit = None
45     #: The temporary directory that the system gave us; may stay as ``None``
46     #: if we don't ever make ourselves a temporary directory (e.g. ``--continue``).
47     #: While we should clean this up if it is set to something, it may
48     #: not correspond to anything useful.
49     temp_dir = None
50     #: The temporary directory containing our working copy for merging
51     temp_wc_dir = None
52     #: We place the temporary repositories inside a tmpfs while merging;
53     #: this makes merges not disk-bound and affords a modest speed increase.
54     #: If you are running ``--continue``, this is guaranteed to be ``False``.
55     use_shm = None
56     #: Upstream repository to use.  This does not need to be saved.
57     repo = None
58
59     #: Instance of :class:`wizard.deploy.WorkingCopy` for this upgrade
60     wc = None
61     #: Instance of :class:`wizard.deploy.ProductionCopy` for this upgrade
62     prod = None
63
64     #: Options object that the installer was called with
65     options = None
66
67     def __init__(self, options):
68         self.version = None
69         self.user_commit = None
70         self.next_commit = None
71         self.temp_dir = None
72         self.temp_wc_dir = None
73         self.use_shm = False # False until proven otherwise.
74         self.wc = None
75         self.prod = None
76         self.options = options
77
78     def execute(self, dir):
79         """
80         Executes an upgrade.  This is the entry-point.  This expects
81         that it's current working directory is the same as ``dir``.
82         """
83         assert os.path.abspath(dir) == os.getcwd()
84         try:
85             if self.options.continue_:
86                 logging.info("Continuing upgrade...")
87                 self.resume()
88             else:
89                 logging.info("Upgrading %s" % os.getcwd())
90                 self.preflight()
91                 self.merge()
92             # Note invariant: we expect you to be in the production
93             # directory at this point, even if you --continue'd from
94             # the temporary directory!
95             self.postflight()
96             # Till now, all of our operations were in a tmp sandbox.
97             if self.options.dry_run:
98                 logging.info("Dry run, bailing.  See results at %s" % self.temp_wc_dir)
99                 return
100             backup = self.backup()
101             self.upgrade(backup)
102             # Note: disable_rollback assumes that upgrade is the last
103             # step, if you add another setp you may have to modify this
104             # to accomodate that.
105         finally:
106             if self.use_shm and self.temp_dir and os.path.exists(self.temp_dir):
107                 shutil.rmtree(self.temp_dir)
108
109     def resume(self):
110         """
111         In the event of a ``--continue`` flag, we have to restore state and
112         perform some sanity checks.
113         """
114         self.resumeChdir()
115         self.resumeState()
116         self.resumeLogging()
117         self.resumeProd()
118     def resumeChdir(self):
119         """
120         If we called ``--continue`` inside a working copy (the usual
121         situation),  check if :file:`.wizard/pending` exists and change
122         to that directory if so.
123         """
124         self.temp_wc_dir = os.getcwd()
125         didChdir = command.chdir_to_production()
126         self.prod = deploy.ProductionCopy(".")
127         pending_dir = open(self.prod.pending_file).read().strip()
128         if not didChdir:
129             logging.warning("Continued from a production copy; using working copy at %s", pending_dir)
130             self.temp_wc_dir = pending_dir
131         elif self.temp_wc_dir != pending_dir:
132             # prefer the original working copy, but warn that someone
133             # else someone a started an upgrade in the meantime (XXX:
134             # actually, that someone else should bug out, not clobber)
135             logging.warning("Someone else appears to have started an upgrade at %s", pending_dir)
136     def resumeState(self):
137         with util.ChangeDirectory(self.temp_wc_dir):
138             self.wc = deploy.WorkingCopy(".")
139             try:
140                 self.user_commit, self.next_commit = open(".git/WIZARD_PARENTS", "r").read().split()
141                 self.version = open(".git/WIZARD_UPGRADE_VERSION", "r").read()
142             except IOError as e:
143                 if e.errno == errno.ENOENT:
144                     raise CannotResumeError()
145                 else:
146                     raise
147     def resumeLogging(self):
148         with util.ChangeDirectory(self.temp_wc_dir):
149             options = self.options
150             if not options.log_file and os.path.exists(".git/WIZARD_LOG_FILE"):
151                 options.log_file = open(".git/WIZARD_LOG_FILE", "r").read()
152                 command.setup_file_logger(options.log_file, options.debug)
153     def resumeProd(self):
154         """Restore :attr:`prod` attribute, and check if the production copy has drifted."""
155         try:
156             # simulate the action of `git status`, based on cmd_status()'s call to
157             # refresh_cache() in builtin-commit.c
158             shell.call("git", "update-index", "-q", "--unmerged", "--refresh")
159             r1 = shell.eval("git", "diff-files", "--name-only").strip()
160             r2 = shell.eval("git", "diff-index", "--name-only", "HEAD").strip()
161             if r1 or r2:
162                 raise LocalChangesError()
163         except shell.CallError:
164             pass
165         # Working copy is not anchored anywhere useful for git describe,
166         # so we need to give it a hint.
167         self.wc.setAppVersion(self.prod.app_version)
168
169     def preflight(self):
170         """
171         Make sure that a number of pre-upgrade invariants are met before
172         attempting anything.
173         """
174         options = self.options
175         for i in range(0,2):
176             self.prod = deploy.ProductionCopy(".")
177             self.prod.verify()
178             self.repo = self.prod.application.repository(options.srv_path)
179             # XXX: put this in Application
180             self.version = shell.eval("git", "--git-dir="+self.repo, "describe", "--tags", "master")
181             self.preflightBlacklist()
182             self.prod.verify()
183             self.prod.verifyDatabase()
184             self.prod.verifyTag(options.srv_path)
185             try:
186                 self.prod.verifyGit(options.srv_path)
187             except deploy.InconsistentWizardTagError:
188                 shell.call("git", "fetch")
189                 shell.call("git", "fetch", "--tags")
190                 shell.call("wizard", "remaster")
191                 self.prod.verifyGit(options.srv_path)
192             except deploy.HeadNotDescendantError:
193                 shell.call("wizard", "remaster")
194                 self.prod.verifyGit(options.srv_path)
195             if not options.skip_verification:
196                 self.prod.verifyConfigured()
197             try:
198                 shell.call("git", "fetch", "--tags") # XXX: hack since some installs have stale tags
199             except shell.CallError as e:
200                 if "Disk quota exceeded" in e.stderr:
201                     raise QuotaTooLow
202                 raise
203             try:
204                 self.prod.verifyVersion()
205             except deploy.VersionMismatchError as e:
206                 # XXX: kind of hacky, mainly it does change the Git working copy
207                 # state (although /very/ non-destructively)
208                 try:
209                     shell.call("git", "merge", "--strategy=ours", self.prod.application.makeVersion(str(e.real_version)).wizard_tag)
210                 except shell.CallError as e2:
211                     if "does not point to a commit" in e2.stderr:
212                         raise UnknownVersionError(e.real_version)
213                     else:
214                         raise
215                 continue
216             break
217         else:
218             raise VersionRematchFailed
219         self.verifyWeb()
220         self.preflightAlreadyUpgraded()
221         self.preflightQuota()
222     def preflightBlacklist(self):
223         # XXX: should use deploy info
224         if os.path.exists(".wizard/blacklisted"):
225             reason = open(".wizard/blacklisted").read()
226             # ignore blank blacklisted files
227             if reason:
228                 print reason
229                 raise BlacklistedError(reason)
230             else:
231                 logging.warning("Application was blacklisted, but no reason was found");
232     def preflightAlreadyUpgraded(self):
233         if self.version == self.prod.app_version.wizard_tag and not self.options.force:
234             # don't log this error; we need to have the traceback line
235             # so that the parsing code can catch it
236             # XXX: maybe we should build this in as a flag to add
237             # to exceptions w/ our exception handler
238             sys.stderr.write("Traceback:\n  (n/a)\nAlreadyUpgraded\n")
239             sys.exit(2)
240     def preflightQuota(self):
241         r = user.quota()
242         if r is not None:
243             usage, limit = r
244             if limit is not None and (limit - usage) < buffer:
245                 logging.info("preflightQuota: limit = %d, usage = %d, buffer = %d", limit, usage, buffer)
246                 raise QuotaTooLow
247
248     def merge(self):
249         if not self.options.dry_run:
250             self.mergePreCommit()
251         self.mergeClone()
252         logging.debug("Temporary WC dir is %s", self.temp_wc_dir)
253         with util.ChangeDirectory(self.temp_wc_dir):
254             self.wc = deploy.WorkingCopy(".")
255             shell.call("git", "remote", "add", "wizard", self.repo)
256             shell.call("git", "fetch", "-q", "wizard")
257             self.user_commit = shell.eval("git", "rev-parse", "HEAD")
258             self.next_commit = shell.eval("git", "rev-parse", self.version)
259             self.mergeSaveState()
260             self.mergePerform()
261     def mergePreCommit(self):
262         def get_file_set(rev):
263             return set(shell.eval("git", "ls-tree", "-r", "--name-only", rev).split("\n"))
264         # add all files that are unversioned but would be replaced by the pull,
265         # and generate a new commit
266         old_files = get_file_set("HEAD")
267         new_files = get_file_set(self.version)
268         added_files = new_files - old_files
269         for f in added_files:
270             if os.path.lexists(f): # broken symbolic links count too!
271                 shell.call("git", "add", f)
272         message = "Pre-commit before autoinstall upgrade.\n\n%s" % util.get_git_footer()
273         try:
274             message += "\nPre-commit-by: " + util.get_operator_git()
275         except util.NoOperatorInfo:
276             pass
277         try:
278             shell.call("git", "commit", "-a", "-m", message)
279         except shell.CallError as e:
280             if "Permission denied" in e.stderr:
281                 raise util.PermissionsError
282             elif e.stderr:
283                 raise
284             logging.info("No changes detected")
285     def mergeClone(self):
286         # If /dev/shm exists, it's a tmpfs and we can use it
287         # to do a fast git merge. Don't forget to move it to
288         # /tmp if it fails.
289         if not self.options.dry_run and not self.options.debug:
290             self.use_shm = os.path.exists("/dev/shm")
291         if self.use_shm:
292             dir = "/dev/shm/wizard"
293             if not os.path.exists(dir):
294                 os.mkdir(dir)
295                 # XXX: race
296                 os.chmod(dir, 0o777)
297         else:
298             dir = None
299         self.temp_dir = tempfile.mkdtemp(prefix="wizard", dir=dir)
300         self.temp_wc_dir = os.path.join(self.temp_dir, "repo")
301         logging.info("Using temporary directory: " + self.temp_wc_dir)
302         shell.call("git", "clone", "-q", ".", self.temp_wc_dir)
303     def mergeSaveState(self):
304         """Save variables so that ``--continue`` will work."""
305         # yeah yeah no trailing newline whatever
306         open(".git/WIZARD_UPGRADE_VERSION", "w").write(self.version)
307         open(".git/WIZARD_PARENTS", "w").write("%s\n%s" % (self.user_commit, self.next_commit))
308         open(".git/WIZARD_SIZE", "w").write(str(util.disk_usage()))
309         if self.options.log_file:
310             open(".git/WIZARD_LOG_FILE", "w").write(self.options.log_file)
311     def mergePerform(self):
312         def prepare_config():
313             self.wc.prepareConfig()
314             shell.call("git", "add", ".")
315         def resolve_conflicts():
316             return self.wc.resolveConflicts()
317         shell.call("git", "config", "merge.conflictstyle", "diff3")
318         # setup rerere
319         if self.options.rr_cache is None:
320             self.options.rr_cache = os.path.join(self.prod.location, ".git", "rr-cache")
321         if not os.path.exists(self.options.rr_cache):
322             os.mkdir(self.options.rr_cache)
323         os.symlink(self.options.rr_cache, os.path.join(self.wc.location, ".git", "rr-cache"))
324         shell.call("git", "config", "rerere.enabled", "true")
325         try:
326             merge.merge(self.wc.app_version.wizard_tag, self.version,
327                         prepare_config, resolve_conflicts)
328         except merge.MergeError:
329             self.mergeFail()
330     def mergeFail(self):
331         files = set()
332         for line in shell.eval("git", "ls-files", "--unmerged").splitlines():
333             files.add(line.split(None, 3)[-1])
334         conflicts = len(files)
335         # XXX: this is kind of fiddly; note that temp_dir still points at the OLD
336         # location after this code.
337         self.temp_wc_dir = mv_shm_to_tmp(os.getcwd(), self.use_shm)
338         self.wc.location = self.temp_wc_dir
339         os.chdir(self.temp_wc_dir)
340         if os.path.exists(self.prod.pending_file):
341             mtime = os.path.getmtime(self.prod.pending_file)
342             pending_location = open(self.prod.pending_file).read().strip()
343             # don't complain if .wizard/pending is a day old
344             if mtime > (time.time() - 60 * 60 * 24):
345                 raise UpgradeInProgressError(pending_location, mtime)
346             else:
347                 logging.warning("Probably harmless old pending upgrade at %s from %s", pending_location, time.ctime(mtime))
348         open(self.prod.pending_file, "w").write(self.temp_wc_dir)
349         if self.options.non_interactive:
350             print "%d %s" % (conflicts, self.temp_wc_dir)
351             raise MergeFailed
352         else:
353             while 1:
354                 print
355                 print "ERROR: The merge failed with %d conflicts in these files:" % conflicts
356                 print
357                 for file in sorted(files):
358                     print "  * %s" % file
359                 print
360                 print "Please resolve these conflicts (edit and then `git add`), and"
361                 print "then type 'exit'.  You will now be dropped into a shell whose working"
362                 print "directory is %s" % self.temp_wc_dir
363                 print
364                 print "NOTE: If you resolve these conflicts, and then the upgrade fails for"
365                 print "an unrelated reason, you can run 'wizard upgrade --continue' from this"
366                 print "directory to try again."
367                 shell.interactive()
368                 if shell.eval("git", "ls-files", "--unmerged").strip():
369                     print
370                     print "WARNING: There are still unmerged files."
371                     out = raw_input("Continue editing? [y/N]: ")
372                     if out == "y" or out == "Y":
373                         continue
374                     else:
375                         print "Aborting.  The conflicted working copy can be found at:"
376                         print
377                         print "    %s" % self.temp_wc_dir
378                         print
379                         print "and you can resume the upgrade process by running in that directory:"
380                         print
381                         print "    wizard upgrade --continue"
382                         sys.exit(1)
383                 break
384
385     def postflight(self):
386         with util.ChangeDirectory(self.temp_wc_dir):
387             if shell.eval("git", "ls-files", "-u").strip():
388                 raise UnmergedChangesError
389             shell.call("git", "commit", "--allow-empty", "-am", "throw-away commit")
390             self.wc.parametrize(self.prod)
391             shell.call("git", "add", ".")
392             message = self.postflightCommitMessage()
393             new_tree = shell.eval("git", "write-tree")
394             final_commit = shell.eval("git", "commit-tree", new_tree,
395                     "-p", self.user_commit, "-p", self.next_commit, input=message, log=True)
396             # a master branch may not necessarily exist if the user
397             # was manually installed to an earlier version
398             try:
399                 shell.call("git", "checkout", "-q", "-b", "master", "--")
400             except shell.CallError:
401                 shell.call("git", "checkout", "-q", "master", "--")
402             shell.call("git", "reset", "-q", "--hard", final_commit)
403             # This is a quick sanity check to make sure we didn't completely
404             # mess up the merge
405             self.wc.invalidateCache()
406             self.wc.verifyVersion()
407     def postflightCommitMessage(self):
408         message = "Upgraded autoinstall to %s.\n\n%s" % (self.version, util.get_git_footer())
409         try:
410             message += "\nUpgraded-by: " + util.get_operator_git()
411         except util.NoOperatorInfo:
412             pass
413         return message
414
415     def backup(self):
416         # Ok, now we have to do a crazy complicated dance to see if we're
417         # going to have enough quota to finish what we need
418         pre_size = int(open(os.path.join(self.temp_wc_dir, ".git/WIZARD_SIZE"), "r").read())
419         post_size = util.disk_usage(self.temp_wc_dir)
420         backup = self.prod.backup(self.options)
421         r = user.quota()
422         if r is not None:
423             usage, limit = r
424             if limit is not None and (limit - usage) - (post_size - pre_size) < buffer:
425                 shutil.rmtree(os.path.join(self.prod.backup_dir, shell.eval("wizard", "restore").splitlines()[0]))
426                 raise QuotaTooLow
427         return backup
428
429     def upgrade(self, backup):
430         # XXX: frob .htaccess to make site inaccessible
431         # XXX: frob Git to disallow Git operations after the pull
432         with util.IgnoreKeyboardInterrupts():
433             with util.LockDirectory(".wizard-upgrade-lock"):
434                 shell.call("git", "fetch", "--tags")
435                 # git merge (which performs a fast forward)
436                 shell.call("git", "pull", "-q", self.temp_wc_dir, "master")
437                 version_obj = distutils.version.LooseVersion(self.version.partition('-')[2])
438                 try:
439                     # run update script
440                     self.prod.upgrade(version_obj, self.options)
441                     self.verifyWeb()
442                     try:
443                         os.unlink(self.prod.pending_file)
444                     except OSError as e:
445                         if e.errno != errno.ENOENT:
446                             raise
447                 except app.UpgradeFailure:
448                     logging.warning("Upgrade failed: rolling back")
449                     self.upgradeRollback(backup)
450                     raise
451                 except deploy.WebVerificationError as e:
452                     logging.warning("Web verification failed: rolling back")
453                     self.upgradeRollback(backup)
454                     raise app.UpgradeVerificationFailure()
455         # XXX: frob .htaccess to make site accessible
456         #       to do this, check if .htaccess changed, first.  Upgrade
457         #       process might have frobbed it.  Don't be
458         #       particularly worried if the segment disappeared
459     def upgradeRollback(self, backup):
460         # You don't want d.restore() because it doesn't perform
461         # the file level backup
462         if not self.options.disable_rollback:
463             shell.call("wizard", "restore", backup)
464             try:
465                 self.verifyWeb()
466             except deploy.WebVerificationError:
467                 logging.critical("Web verification failed after rollback")
468         else:
469             logging.warning("Rollback was disabled; you can rollback with `wizard restore %s`", backup)
470
471     def verifyWeb(self):
472         if not self.options.skip_verification:
473             self.prod.verifyWeb()
474
475
476 # utility functions
477
478 def mv_shm_to_tmp(curdir, use_shm):
479     if not use_shm: return curdir
480     # Keeping all of our autoinstalls in shared memory is
481     # a recipe for disaster, so let's move them to slightly
482     # less volatile storage (a temporary directory)
483     os.chdir(tempfile.gettempdir())
484     newdir = tempfile.mkdtemp(prefix="wizard")
485     # shutil, not os; at least on Ubuntu os.move fails
486     # with "[Errno 18] Invalid cross-device link"
487     shutil.move(curdir, newdir)
488     shutil.rmtree(os.path.dirname(curdir))
489     curdir = os.path.join(newdir, "repo")
490     return curdir
491
492 def parse_args(argv, baton):
493     usage = """usage: %prog upgrade [ARGS] [DIR]
494
495 Upgrades an autoinstall to the latest version.  This involves updating
496 files the upgrade script associated with this application.  If the merge
497 fails, this program will write the number of conflicts and the directory
498 of the conflicted working tree to stdout, separated by a space."""
499     parser = command.WizardOptionParser(usage)
500     parser.add_option("--dry-run", dest="dry_run", action="store_true",
501             default=False, help="Prints would would be run without changing anything")
502     # notice trailing underscore
503     parser.add_option("--continue", dest="continue_", action="store_true",
504             default=False, help="Continues an upgrade that has had its merge manually "
505             "resolved using the current working directory as the resolved copy.")
506     parser.add_option("--force", dest="force", action="store_true",
507             default=False, help="Force running upgrade even if it's already at latest version.")
508     parser.add_option("--skip-verification", dest="skip_verification", action="store_true",
509             default=False, help="Skip running configuration and web verification checks.")
510     parser.add_option("--non-interactive", dest="non_interactive", action="store_true",
511             default=False, help="Don't drop to shell in event of conflict.")
512     parser.add_option("--rr-cache", dest="rr_cache", metavar="PATH",
513             default=None, help="Use this folder to reuse recorded merge resolutions.  Defaults to"
514             "your production copy's rr-cache, if it exists.")
515     parser.add_option("--disable-rollback", dest="disable_rollback", action="store_true",
516             default=util.boolish(os.getenv("WIZARD_DISABLE_ROLLBACK")),
517             help="Skips rollback in the event of a failed upgrade. Envvar is WIZARD_DISABLE_ROLLBACK.")
518     baton.push(parser, "srv_path")
519     options, args = parser.parse_all(argv)
520     if len(args) > 1:
521         parser.error("too many arguments")
522     if options.skip_verification:
523         logging.warning("Verification is disabled; Wizard may break your application and will not tell you about it")
524     return options, args
525
526 class Error(command.Error):
527     """Base exception for all exceptions raised by upgrade"""
528     pass
529
530 class QuotaTooLow(Error):
531     def __str__(self):
532         return """
533
534 ERROR: The locker quota was too low to complete the autoinstall
535 upgrade.
536 """
537
538 class AlreadyUpgraded(Error):
539     quiet = True
540     def __str__(self):
541         return """
542
543 ERROR: This autoinstall is already at the latest version."""
544
545 class MergeFailed(Error):
546     quiet = True
547     def __str__(self):
548         return """
549
550 ERROR: Merge failed.  Above is the temporary directory that
551 the conflicted merge is in: resolve the merge by cd'ing to the
552 temporary directory, finding conflicted files with `git status`,
553 resolving the files, adding them using `git add` and then
554 running `wizard upgrade --continue`."""
555
556 class LocalChangesError(Error):
557     def __str__(self):
558         return """
559
560 ERROR: Local changes occurred in the install while the merge was
561 being processed so that a pull would not result in a fast-forward.
562 The best way to resolve this is probably to attempt an upgrade again,
563 with git rerere to remember merge resolutions (XXX: not sure if
564 this actually works)."""
565
566 class UnmergedChangesError(Error):
567     def __str__(self):
568         return """
569
570 ERROR: You attempted to continue an upgrade, but there were
571 still local unmerged changes in your working copy.  Please resolve
572 them all and try again."""
573
574 class BlacklistedError(Error):
575     #: Reason why the autoinstall was blacklisted
576     reason = None
577     exitcode = errno_blacklisted
578     def __init__(self, reason):
579         self.reason = reason
580     def __str__(self):
581         return """
582
583 ERROR: This autoinstall was manually blacklisted against errors;
584 if the user has not been notified of this, please send them
585 mail.  If you know that this application is blacklisted and
586 would like to attempt an upgrade anyway, run:
587
588     wizard blacklist --delete
589
590 The reason was: %s""" % self.reason
591
592 class CannotResumeError(Error):
593     def __str__(self):
594         return """
595
596 ERROR: We cannot resume the upgrade process; either this working
597 copy is missing essential metadata, or you've attempt to continue
598 from a production copy that does not have any pending upgrades.
599 """
600
601 class VersionRematchFailed(Error):
602     def __str__(self):
603         return """
604
605 ERROR: Your Git version information was not consistent with your
606 files on the system, and we were unable to create a fake merge
607 to make the two consistent."""
608
609 class UnknownVersionError(Error):
610     #: Version that we didn't have
611     version = None
612     def __init__(self, version):
613         self.version = version
614     def __str__(self):
615         return """
616
617 ERROR: The version you are attempting to upgrade from (%s)
618 is unknown to the repository Wizard is using.""" % str(self.version)
619
620 class UpgradeInProgressError(Error):
621     #: Location of pending upgrade
622     location = None
623     #: Time of pending upgrade
624     time = None
625     def __init__(self, location, time):
626         self.location = location
627         self.time = time
628     def __str__(self):
629         return """
630
631 ERROR: There is already an upgrade in progress at
632
633     %s
634
635 which was last started at %s.
636
637 To ignore and start another upgrade anyway, remove the file
638 .wizard/pending and try again.""" % (self.location, time.ctime(self.time))