2 import distutils.version
6 import logging.handlers
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.
15 from wizard import app, command, deploy, merge, shell, user, util
17 buffer = 1024 * 1024 * 30 # 30 MiB we will always leave available
18 errno_blacklisted = 64
20 def main(argv, baton):
21 options, args = parse_args(argv, baton)
22 dir = os.path.abspath(args[0]) if args else os.getcwd()
24 shell.drop_priviledges(dir, options.log_file)
26 upgrade = Upgrade(options)
28 if not options.non_interactive:
29 print "Upgrade complete"
31 class Upgrade(object):
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
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"
43 #: String commit ID of the latest, greatest wizard version; i.e. "theirs"
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.
50 #: The temporary directory containing our working copy for merging
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``.
56 #: Upstream repository to use. This does not need to be saved.
59 #: Instance of :class:`wizard.deploy.WorkingCopy` for this upgrade
61 #: Instance of :class:`wizard.deploy.ProductionCopy` for this upgrade
64 #: Options object that the installer was called with
67 def __init__(self, options):
69 self.user_commit = None
70 self.next_commit = None
72 self.temp_wc_dir = None
73 self.use_shm = False # False until proven otherwise.
76 self.options = options
78 def execute(self, dir):
80 Executes an upgrade. This is the entry-point. This expects
81 that it's current working directory is the same as ``dir``.
83 assert os.path.abspath(dir) == os.getcwd()
85 if self.options.continue_:
86 logging.info("Continuing upgrade...")
89 logging.info("Upgrading %s" % os.getcwd())
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!
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)
100 backup = self.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.
106 if self.use_shm and self.temp_dir and os.path.exists(self.temp_dir):
107 shutil.rmtree(self.temp_dir)
111 In the event of a ``--continue`` flag, we have to restore state and
112 perform some sanity checks.
118 def resumeChdir(self):
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.
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()
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(".")
140 self.user_commit, self.next_commit = open(".git/WIZARD_PARENTS", "r").read().split()
141 self.version = open(".git/WIZARD_UPGRADE_VERSION", "r").read()
143 if e.errno == errno.ENOENT:
144 raise CannotResumeError()
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."""
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()
162 raise LocalChangesError()
163 except shell.CallError:
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)
171 Make sure that a number of pre-upgrade invariants are met before
174 options = self.options
176 self.prod = deploy.ProductionCopy(".")
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()
183 self.prod.verifyDatabase()
184 self.prod.verifyTag(options.srv_path)
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()
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:
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)
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)
218 raise VersionRematchFailed
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
229 raise BlacklistedError(reason)
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")
240 def preflightQuota(self):
244 if limit is not None and (limit - usage) < buffer:
245 logging.info("preflightQuota: limit = %d, usage = %d, buffer = %d", limit, usage, buffer)
249 if not self.options.dry_run:
250 self.mergePreCommit()
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()
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()
274 message += "\nPre-commit-by: " + util.get_operator_git()
275 except util.NoOperatorInfo:
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
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
289 if not self.options.dry_run and not self.options.debug:
290 self.use_shm = os.path.exists("/dev/shm")
292 dir = "/dev/shm/wizard"
293 if not os.path.exists(dir):
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")
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")
326 merge.merge(self.wc.app_version.wizard_tag, self.version,
327 prepare_config, resolve_conflicts)
328 except merge.MergeError:
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)
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)
353 user_shell = os.getenv("SHELL")
354 if not user_shell: user_shell = "/bin/bash"
355 # XXX: scripts specific hack, since mbash doesn't respect the current working directory
356 # When the revolution comes (i.e. $ATHENA_HOMEDIR/Scripts is your Scripts home
357 # directory) this isn't strictly necessary, but we'll probably need to support
358 # web_scripts directories ad infinitum.
359 if user_shell == "/usr/local/bin/mbash": user_shell = "/bin/bash"
362 print "ERROR: The merge failed with %d conflicts in these files:" % conflicts
364 for file in sorted(files):
367 print "Please resolve these conflicts (edit and then `git add`), and"
368 print "then type 'exit'. You will now be dropped into a shell whose working"
369 print "directory is %s" % self.temp_wc_dir
371 print "NOTE: If you resolve these conflicts, and then the upgrade fails for"
372 print "an unrelated reason, you can run 'wizard upgrade --continue' from this"
373 print "directory to try again."
375 shell.call(user_shell, "-i", interactive=True)
376 except shell.CallError as e:
377 logging.warning("Shell returned non-zero exit code %d" % e.code)
378 if shell.eval("git", "ls-files", "--unmerged").strip():
380 print "WARNING: There are still unmerged files."
381 out = raw_input("Continue editing? [y/N]: ")
382 if out == "y" or out == "Y":
385 print "Aborting. The conflicted working copy can be found at:"
387 print " %s" % self.temp_wc_dir
389 print "and you can resume the upgrade process by running in that directory:"
391 print " wizard upgrade --continue"
395 def postflight(self):
396 with util.ChangeDirectory(self.temp_wc_dir):
397 if shell.eval("git", "ls-files", "-u").strip():
398 raise UnmergedChangesError
399 shell.call("git", "commit", "--allow-empty", "-am", "throw-away commit")
400 self.wc.parametrize(self.prod)
401 shell.call("git", "add", ".")
402 message = self.postflightCommitMessage()
403 new_tree = shell.eval("git", "write-tree")
404 final_commit = shell.eval("git", "commit-tree", new_tree,
405 "-p", self.user_commit, "-p", self.next_commit, input=message, log=True)
406 # a master branch may not necessarily exist if the user
407 # was manually installed to an earlier version
409 shell.call("git", "checkout", "-q", "-b", "master", "--")
410 except shell.CallError:
411 shell.call("git", "checkout", "-q", "master", "--")
412 shell.call("git", "reset", "-q", "--hard", final_commit)
413 # This is a quick sanity check to make sure we didn't completely
415 self.wc.invalidateCache()
416 self.wc.verifyVersion()
417 def postflightCommitMessage(self):
418 message = "Upgraded autoinstall to %s.\n\n%s" % (self.version, util.get_git_footer())
420 message += "\nUpgraded-by: " + util.get_operator_git()
421 except util.NoOperatorInfo:
426 # Ok, now we have to do a crazy complicated dance to see if we're
427 # going to have enough quota to finish what we need
428 pre_size = int(open(os.path.join(self.temp_wc_dir, ".git/WIZARD_SIZE"), "r").read())
429 post_size = util.disk_usage(self.temp_wc_dir)
430 backup = self.prod.backup(self.options)
434 if limit is not None and (limit - usage) - (post_size - pre_size) < buffer:
435 shutil.rmtree(os.path.join(self.prod.backup_dir, shell.eval("wizard", "restore").splitlines()[0]))
439 def upgrade(self, backup):
440 # XXX: frob .htaccess to make site inaccessible
441 # XXX: frob Git to disallow Git operations after the pull
442 with util.IgnoreKeyboardInterrupts():
443 with util.LockDirectory(".wizard-upgrade-lock"):
444 shell.call("git", "fetch", "--tags")
445 # git merge (which performs a fast forward)
446 shell.call("git", "pull", "-q", self.temp_wc_dir, "master")
447 version_obj = distutils.version.LooseVersion(self.version.partition('-')[2])
450 self.prod.upgrade(version_obj, self.options)
453 os.unlink(self.prod.pending_file)
455 if e.errno != errno.ENOENT:
457 except app.UpgradeFailure:
458 logging.warning("Upgrade failed: rolling back")
459 self.upgradeRollback(backup)
461 except deploy.WebVerificationError as e:
462 logging.warning("Web verification failed: rolling back")
463 self.upgradeRollback(backup)
464 raise app.UpgradeVerificationFailure()
465 # XXX: frob .htaccess to make site accessible
466 # to do this, check if .htaccess changed, first. Upgrade
467 # process might have frobbed it. Don't be
468 # particularly worried if the segment disappeared
469 def upgradeRollback(self, backup):
470 # You don't want d.restore() because it doesn't perform
471 # the file level backup
472 if not self.options.disable_rollback:
473 shell.call("wizard", "restore", backup)
476 except deploy.WebVerificationError:
477 logging.critical("Web verification failed after rollback")
479 logging.warning("Rollback was disabled; you can rollback with `wizard restore %s`", backup)
482 if not self.options.skip_verification:
483 self.prod.verifyWeb()
488 def mv_shm_to_tmp(curdir, use_shm):
489 if not use_shm: return curdir
490 # Keeping all of our autoinstalls in shared memory is
491 # a recipe for disaster, so let's move them to slightly
492 # less volatile storage (a temporary directory)
493 os.chdir(tempfile.gettempdir())
494 newdir = tempfile.mkdtemp(prefix="wizard")
495 # shutil, not os; at least on Ubuntu os.move fails
496 # with "[Errno 18] Invalid cross-device link"
497 shutil.move(curdir, newdir)
498 shutil.rmtree(os.path.dirname(curdir))
499 curdir = os.path.join(newdir, "repo")
502 def parse_args(argv, baton):
503 usage = """usage: %prog upgrade [ARGS] [DIR]
505 Upgrades an autoinstall to the latest version. This involves updating
506 files the upgrade script associated with this application. If the merge
507 fails, this program will write the number of conflicts and the directory
508 of the conflicted working tree to stdout, separated by a space."""
509 parser = command.WizardOptionParser(usage)
510 parser.add_option("--dry-run", dest="dry_run", action="store_true",
511 default=False, help="Prints would would be run without changing anything")
512 # notice trailing underscore
513 parser.add_option("--continue", dest="continue_", action="store_true",
514 default=False, help="Continues an upgrade that has had its merge manually "
515 "resolved using the current working directory as the resolved copy.")
516 parser.add_option("--force", dest="force", action="store_true",
517 default=False, help="Force running upgrade even if it's already at latest version.")
518 parser.add_option("--skip-verification", dest="skip_verification", action="store_true",
519 default=False, help="Skip running configuration and web verification checks.")
520 parser.add_option("--non-interactive", dest="non_interactive", action="store_true",
521 default=False, help="Don't drop to shell in event of conflict.")
522 parser.add_option("--rr-cache", dest="rr_cache", metavar="PATH",
523 default=None, help="Use this folder to reuse recorded merge resolutions. Defaults to"
524 "your production copy's rr-cache, if it exists.")
525 parser.add_option("--disable-rollback", dest="disable_rollback", action="store_true",
526 default=util.boolish(os.getenv("WIZARD_DISABLE_ROLLBACK")),
527 help="Skips rollback in the event of a failed upgrade. Envvar is WIZARD_DISABLE_ROLLBACK.")
528 baton.push(parser, "srv_path")
529 options, args = parser.parse_all(argv)
531 parser.error("too many arguments")
532 if options.skip_verification:
533 logging.warning("Verification is disabled; Wizard may break your application and will not tell you about it")
536 class Error(command.Error):
537 """Base exception for all exceptions raised by upgrade"""
540 class QuotaTooLow(Error):
544 ERROR: The locker quota was too low to complete the autoinstall
548 class AlreadyUpgraded(Error):
553 ERROR: This autoinstall is already at the latest version."""
555 class MergeFailed(Error):
560 ERROR: Merge failed. Above is the temporary directory that
561 the conflicted merge is in: resolve the merge by cd'ing to the
562 temporary directory, finding conflicted files with `git status`,
563 resolving the files, adding them using `git add` and then
564 running `wizard upgrade --continue`."""
566 class LocalChangesError(Error):
570 ERROR: Local changes occurred in the install while the merge was
571 being processed so that a pull would not result in a fast-forward.
572 The best way to resolve this is probably to attempt an upgrade again,
573 with git rerere to remember merge resolutions (XXX: not sure if
574 this actually works)."""
576 class UnmergedChangesError(Error):
580 ERROR: You attempted to continue an upgrade, but there were
581 still local unmerged changes in your working copy. Please resolve
582 them all and try again."""
584 class BlacklistedError(Error):
585 #: Reason why the autoinstall was blacklisted
587 exitcode = errno_blacklisted
588 def __init__(self, reason):
593 ERROR: This autoinstall was manually blacklisted against errors;
594 if the user has not been notified of this, please send them
595 mail. If you know that this application is blacklisted and
596 would like to attempt an upgrade anyway, run:
598 wizard blacklist --delete
600 The reason was: %s""" % self.reason
602 class CannotResumeError(Error):
606 ERROR: We cannot resume the upgrade process; either this working
607 copy is missing essential metadata, or you've attempt to continue
608 from a production copy that does not have any pending upgrades.
611 class VersionRematchFailed(Error):
615 ERROR: Your Git version information was not consistent with your
616 files on the system, and we were unable to create a fake merge
617 to make the two consistent."""
619 class UnknownVersionError(Error):
620 #: Version that we didn't have
622 def __init__(self, version):
623 self.version = version
627 ERROR: The version you are attempting to upgrade from (%s)
628 is unknown to the repository Wizard is using.""" % str(self.version)
630 class UpgradeInProgressError(Error):
631 #: Location of pending upgrade
633 #: Time of pending upgrade
635 def __init__(self, location, time):
636 self.location = location
641 ERROR: There is already an upgrade in progress at
645 which was last started at %s.
647 To ignore and start another upgrade anyway, remove the file
648 .wizard/pending and try again.""" % (self.location, time.ctime(self.time))