2 import distutils.version
6 import logging.handlers
12 from wizard import app, command, deploy, merge, shell, user, util
14 buffer = 1024 * 1024 * 30 # 30 MiB we will always leave available
15 errno_blacklisted = 64
17 def main(argv, baton):
18 options, args = parse_args(argv, baton)
19 dir = os.path.abspath(args[0]) if args else os.getcwd()
21 shell.drop_priviledges(dir, options.log_file)
23 upgrade = Upgrade(options)
25 if not options.non_interactive:
26 print "Upgrade complete"
28 class Upgrade(object):
30 Represents the algorithm for upgrading an application. This is in
31 a class and not a function because it's a multi-step process that
32 requires state betweens steps. Steps are represented as methods
36 #: Version of application we are upgrading to, i.e. the latest version.
37 version = None # XXX: This is a string... I'm not convinced it should be
38 #: String commit ID of the user's latest wc; i.e. "ours"
40 #: String commit ID of the latest, greatest wizard version; i.e. "theirs"
42 #: The temporary directory that the system gave us; may stay as ``None``
43 #: if we don't ever make ourselves a temporary directory (e.g. ``--continue``).
44 #: While we should clean this up if it is set to something, it may
45 #: not correspond to anything useful.
47 #: The temporary directory containing our working copy for merging
49 #: We place the temporary repositories inside a tmpfs while merging;
50 #: this makes merges not disk-bound and affords a modest speed increase.
51 #: If you are running ``--continue``, this is guaranteed to be ``False``.
53 #: Upstream repository to use. This does not need to be saved.
56 #: Instance of :class:`wizard.deploy.WorkingCopy` for this upgrade
58 #: Instance of :class:`wizard.deploy.ProductionCopy` for this upgrade
61 #: Options object that the installer was called with
64 def __init__(self, options):
66 self.user_commit = None
67 self.next_commit = None
69 self.temp_wc_dir = None
70 self.use_shm = False # False until proven otherwise.
73 self.options = options
75 def execute(self, dir):
77 Executes an upgrade. This is the entry-point. This expects
78 that it's current working directory is the same as ``dir``.
80 assert os.path.abspath(dir) == os.getcwd()
82 if self.options.continue_:
83 logging.info("Continuing upgrade...")
86 logging.info("Upgrading %s" % os.getcwd())
90 # Till now, all of our operations were in a tmp sandbox.
91 if self.options.dry_run:
92 logging.info("Dry run, bailing. See results at %s" % self.temp_wc_dir)
94 backup = self.backup()
96 # Note: disable_rollback assumes that upgrade is the last
97 # step, if you add another setp you may have to modify this
100 if self.use_shm and self.temp_dir and os.path.exists(self.temp_dir):
101 shutil.rmtree(self.temp_dir)
105 In the event of a ``--continue`` flag, we have to restore state and
106 perform some sanity checks.
111 util.chdir(shell.eval("git", "config", "remote.origin.url"))
113 def resumeChdir(self):
115 If we called ``--continue`` inside a production copy, check if
116 :file:`.wizard/pending` exists and change to that directory if so.
118 util.chdir_to_production()
119 def resumeState(self):
120 self.temp_wc_dir = os.getcwd()
121 self.wc = deploy.WorkingCopy(".")
123 self.user_commit, self.next_commit = open(".git/WIZARD_PARENTS", "r").read().split()
124 self.version = open(".git/WIZARD_UPGRADE_VERSION", "r").read()
126 if e.errno == errno.ENOENT:
127 raise CannotResumeError()
130 def resumeLogging(self):
131 options = self.options
132 if not options.log_file and os.path.exists(".git/WIZARD_LOG_FILE"):
133 options.log_file = open(".git/WIZARD_LOG_FILE", "r").read()
134 command.setup_file_logger(options.log_file, options.debug)
135 def resumeProd(self):
136 """Restore :attr:`prod` attribute, and check if the production copy has drifted."""
137 self.prod = deploy.ProductionCopy(".")
139 # simulate the action of `git status`, based on cmd_status()'s call to
140 # refresh_cache() in builtin-commit.c
141 shell.call("git", "update-index", "-q", "--unmerged", "--refresh")
142 r1 = shell.eval("git", "diff-files", "--name-only").strip()
143 r2 = shell.eval("git", "diff-index", "--name-only", "HEAD").strip()
145 raise LocalChangesError()
146 except shell.CallError:
148 # Working copy is not anchored anywhere useful for git describe,
149 # so we need to give it a hint.
150 self.wc.setAppVersion(self.prod.app_version)
154 Make sure that a number of pre-upgrade invariants are met before
157 options = self.options
159 self.prod = deploy.ProductionCopy(".")
161 self.repo = self.prod.application.repository(options.srv_path)
162 # XXX: put this in Application
163 self.version = shell.eval("git", "--git-dir="+self.repo, "describe", "--tags", "master")
164 self.preflightBlacklist()
166 self.prod.verifyDatabase()
167 self.prod.verifyTag(options.srv_path)
168 self.prod.verifyGit(options.srv_path)
169 if not options.skip_verification:
170 self.prod.verifyConfigured()
172 shell.call("git", "fetch", "--tags") # XXX: hack since some installs have stale tags
173 except shell.CallError as e:
174 if "Disk quota exceeded" in e.stderr:
178 self.prod.verifyVersion()
179 except deploy.VersionMismatchError as e:
180 # XXX: kind of hacky, mainly it does change the Git working copy
181 # state (although /very/ non-destructively)
183 shell.call("git", "merge", "--strategy=ours", self.prod.application.makeVersion(str(e.real_version)).wizard_tag)
184 except shell.CallError as e2:
185 if "does not point to a commit" in e2.stderr:
186 raise UnknownVersionError(e.real_version)
192 raise VersionRematchFailed
193 if not options.skip_verification:
194 self.prod.verifyWeb()
195 self.preflightAlreadyUpgraded()
196 self.preflightQuota()
197 def preflightBlacklist(self):
198 # XXX: should use deploy info
199 if os.path.exists(".wizard/blacklisted"):
200 reason = open(".wizard/blacklisted").read()
201 # ignore blank blacklisted files
204 raise BlacklistedError(reason)
206 logging.warning("Application was blacklisted, but no reason was found");
207 def preflightAlreadyUpgraded(self):
208 if self.version == self.prod.app_version.wizard_tag and not self.options.force:
209 # don't log this error; we need to have the traceback line
210 # so that the parsing code can catch it
211 # XXX: maybe we should build this in as a flag to add
212 # to exceptions w/ our exception handler
213 sys.stderr.write("Traceback:\n (n/a)\nAlreadyUpgraded\n")
215 def preflightQuota(self):
219 if limit is not None and (limit - usage) < buffer:
220 logging.info("preflightQuota: limit = %d, usage = %d, buffer = %d", limit, usage, buffer)
224 if not self.options.dry_run:
225 self.mergePreCommit()
227 logging.debug("Temporary WC dir is %s", self.temp_wc_dir)
228 with util.ChangeDirectory(self.temp_wc_dir):
229 self.wc = deploy.WorkingCopy(".")
230 shell.call("git", "remote", "add", "wizard", self.repo)
231 shell.call("git", "fetch", "-q", "wizard")
232 self.user_commit = shell.eval("git", "rev-parse", "HEAD")
233 self.next_commit = shell.eval("git", "rev-parse", self.version)
234 self.mergeSaveState()
236 def mergePreCommit(self):
237 def get_file_set(rev):
238 return set(shell.eval("git", "ls-tree", "-r", "--name-only", rev).split("\n"))
239 # add all files that are unversioned but would be replaced by the pull,
240 # and generate a new commit
241 old_files = get_file_set("HEAD")
242 new_files = get_file_set(self.version)
243 added_files = new_files - old_files
244 for f in added_files:
245 if os.path.lexists(f): # broken symbolic links count too!
246 shell.call("git", "add", f)
247 message = "Pre-commit before autoinstall upgrade.\n\n%s" % util.get_git_footer()
249 message += "\nPre-commit-by: " + util.get_operator_git()
250 except util.NoOperatorInfo:
253 shell.call("git", "commit", "-a", "-m", message)
254 except shell.CallError as e:
255 if "Permission denied" in e.stderr:
256 raise util.PermissionsError
259 logging.info("No changes detected")
260 def mergeClone(self):
261 # If /dev/shm exists, it's a tmpfs and we can use it
262 # to do a fast git merge. Don't forget to move it to
264 if not self.options.dry_run and not self.options.debug:
265 self.use_shm = os.path.exists("/dev/shm")
267 dir = "/dev/shm/wizard"
268 if not os.path.exists(dir):
274 self.temp_dir = tempfile.mkdtemp(prefix="wizard", dir=dir)
275 self.temp_wc_dir = os.path.join(self.temp_dir, "repo")
276 logging.info("Using temporary directory: " + self.temp_wc_dir)
277 shell.call("git", "clone", "-q", "--shared", ".", self.temp_wc_dir)
278 def mergeSaveState(self):
279 """Save variables so that ``--continue`` will work."""
280 # yeah yeah no trailing newline whatever
281 open(".git/WIZARD_UPGRADE_VERSION", "w").write(self.version)
282 open(".git/WIZARD_PARENTS", "w").write("%s\n%s" % (self.user_commit, self.next_commit))
283 open(".git/WIZARD_SIZE", "w").write(str(util.disk_usage()))
284 if self.options.log_file:
285 open(".git/WIZARD_LOG_FILE", "w").write(self.options.log_file)
286 def mergePerform(self):
287 def prepare_config():
288 self.wc.prepareConfig()
289 shell.call("git", "add", ".")
290 def resolve_conflicts():
291 return self.wc.resolveConflicts()
292 shell.call("git", "config", "merge.conflictstyle", "diff3")
294 if self.options.rr_cache is None:
295 self.options.rr_cache = os.path.join(self.prod.location, ".git", "rr-cache")
296 if not os.path.exists(self.options.rr_cache):
297 os.mkdir(self.options.rr_cache)
298 os.symlink(self.options.rr_cache, os.path.join(self.wc.location, ".git", "rr-cache"))
299 shell.call("git", "config", "rerere.enabled", "true")
301 merge.merge(self.wc.app_version.wizard_tag, self.version,
302 prepare_config, resolve_conflicts)
303 except merge.MergeError:
307 for line in shell.eval("git", "ls-files", "--unmerged").splitlines():
308 files.add(line.split(None, 3)[-1])
309 conflicts = len(files)
310 # XXX: this is kind of fiddly; note that temp_dir still points at the OLD
311 # location after this code.
312 self.temp_wc_dir = mv_shm_to_tmp(os.getcwd(), self.use_shm)
313 self.wc.location = self.temp_wc_dir
314 os.chdir(self.temp_wc_dir)
315 open(self.prod.pending_file, "w").write(self.temp_wc_dir)
316 if self.options.non_interactive:
317 print "%d %s" % (conflicts, self.temp_wc_dir)
320 user_shell = os.getenv("SHELL")
321 if not user_shell: user_shell = "/bin/bash"
322 # XXX: scripts specific hack, since mbash doesn't respect the current working directory
323 # When the revolution comes (i.e. $ATHENA_HOMEDIR/Scripts is your Scripts home
324 # directory) this isn't strictly necessary, but we'll probably need to support
325 # web_scripts directories ad infinitum.
326 if user_shell == "/usr/local/bin/mbash": user_shell = "/bin/bash"
329 print "ERROR: The merge failed with %d conflicts in these files:" % conflicts
331 for file in sorted(files):
334 print "Please resolve these conflicts (edit and then `git add`), and"
335 print "then type 'exit'. You will now be dropped into a shell whose working"
336 print "directory is %s" % self.temp_wc_dir
338 shell.call(user_shell, "-i", interactive=True)
339 except shell.CallError as e:
340 logging.warning("Shell returned non-zero exit code %d" % e.code)
341 if shell.eval("git", "ls-files", "--unmerged").strip():
343 print "WARNING: There are still unmerged files."
344 out = raw_input("Continue editing? [y/N]: ")
345 if out == "y" or out == "Y":
348 print "Aborting. The conflicted working copy can be found at:"
350 print " %s" % self.temp_wc_dir
352 print "and you can resume the upgrade process by running in that directory:"
354 print " wizard upgrade --continue"
358 def postflight(self):
359 with util.ChangeDirectory(self.temp_wc_dir):
360 if shell.eval("git", "ls-files", "-u").strip():
361 raise UnmergedChangesError
362 shell.call("git", "commit", "--allow-empty", "-am", "throw-away commit")
363 self.wc.parametrize(self.prod)
364 shell.call("git", "add", ".")
365 message = self.postflightCommitMessage()
366 new_tree = shell.eval("git", "write-tree")
367 final_commit = shell.eval("git", "commit-tree", new_tree,
368 "-p", self.user_commit, "-p", self.next_commit, input=message, log=True)
369 # a master branch may not necessarily exist if the user
370 # was manually installed to an earlier version
372 shell.call("git", "checkout", "-q", "-b", "master", "--")
373 except shell.CallError:
374 shell.call("git", "checkout", "-q", "master", "--")
375 shell.call("git", "reset", "-q", "--hard", final_commit)
376 # This is a quick sanity check to make sure we didn't completely
378 self.wc.invalidateCache()
379 self.wc.verifyVersion()
380 def postflightCommitMessage(self):
381 message = "Upgraded autoinstall to %s.\n\n%s" % (self.version, util.get_git_footer())
383 message += "\nUpgraded-by: " + util.get_operator_git()
384 except util.NoOperatorInfo:
389 # Ok, now we have to do a crazy complicated dance to see if we're
390 # going to have enough quota to finish what we need
391 pre_size = int(open(os.path.join(self.temp_wc_dir, ".git/WIZARD_SIZE"), "r").read())
392 post_size = util.disk_usage(self.temp_wc_dir)
393 backup = self.prod.backup(self.options)
397 if limit is not None and (limit - usage) - (post_size - pre_size) < buffer:
398 shutil.rmtree(os.path.join(self.prod.backup_dir, shell.eval("wizard", "restore").splitlines()[0]))
402 def upgrade(self, backup):
403 # XXX: frob .htaccess to make site inaccessible
404 # XXX: frob Git to disallow Git operations after the pull
405 with util.IgnoreKeyboardInterrupts():
406 with util.LockDirectory(".wizard-upgrade-lock"):
407 shell.call("git", "fetch", "--tags")
408 # git merge (which performs a fast forward)
409 shell.call("git", "pull", "-q", self.temp_wc_dir, "master")
410 version_obj = distutils.version.LooseVersion(self.version.partition('-')[2])
413 self.prod.upgrade(version_obj, self.options)
414 self.prod.verifyWeb()
415 except app.UpgradeFailure:
416 logging.warning("Upgrade failed: rolling back")
417 self.upgradeRollback(backup)
419 except deploy.WebVerificationError as e:
420 logging.warning("Web verification failed: rolling back")
421 self.upgradeRollback(backup)
422 raise app.UpgradeVerificationFailure()
423 # XXX: frob .htaccess to make site accessible
424 # to do this, check if .htaccess changed, first. Upgrade
425 # process might have frobbed it. Don't be
426 # particularly worried if the segment disappeared
427 def upgradeRollback(self, backup):
428 # You don't want d.restore() because it doesn't perform
429 # the file level backup
430 if not self.options.disable_rollback:
431 shell.call("wizard", "restore", backup)
433 self.prod.verifyWeb()
434 except deploy.WebVerificationError:
435 logging.critical("Web verification failed after rollback")
437 logging.warning("Rollback was disabled; you can rollback with `wizard restore %s`", backup)
441 def mv_shm_to_tmp(curdir, use_shm):
442 if not use_shm: return curdir
443 # Keeping all of our autoinstalls in shared memory is
444 # a recipe for disaster, so let's move them to slightly
445 # less volatile storage (a temporary directory)
446 os.chdir(tempfile.gettempdir())
447 newdir = tempfile.mkdtemp(prefix="wizard")
448 # shutil, not os; at least on Ubuntu os.move fails
449 # with "[Errno 18] Invalid cross-device link"
450 shutil.move(curdir, newdir)
451 shutil.rmtree(os.path.dirname(curdir))
452 curdir = os.path.join(newdir, "repo")
455 def parse_args(argv, baton):
456 usage = """usage: %prog upgrade [ARGS] [DIR]
458 Upgrades an autoinstall to the latest version. This involves updating
459 files the upgrade script associated with this application. If the merge
460 fails, this program will write the number of conflicts and the directory
461 of the conflicted working tree to stdout, separated by a space."""
462 parser = command.WizardOptionParser(usage)
463 parser.add_option("--dry-run", dest="dry_run", action="store_true",
464 default=False, help="Prints would would be run without changing anything")
465 # notice trailing underscore
466 parser.add_option("--continue", dest="continue_", action="store_true",
467 default=False, help="Continues an upgrade that has had its merge manually "
468 "resolved using the current working directory as the resolved copy.")
469 parser.add_option("--force", dest="force", action="store_true",
470 default=False, help="Force running upgrade even if it's already at latest version.")
471 parser.add_option("--skip-verification", dest="skip_verification", action="store_true",
472 default=False, help="Skip running configuration and web verification checks.")
473 parser.add_option("--non-interactive", dest="non_interactive", action="store_true",
474 default=False, help="Don't drop to shell in event of conflict.")
475 parser.add_option("--rr-cache", dest="rr_cache", metavar="PATH",
476 default=None, help="Use this folder to reuse recorded merge resolutions. Defaults to"
477 "your production copy's rr-cache, if it exists.")
478 parser.add_option("--disable-rollback", dest="disable_rollback", action="store_true",
479 default=util.boolish(os.getenv("WIZARD_DISABLE_ROLLBACK")),
480 help="Skips rollback in the event of a failed upgrade. Envvar is WIZARD_DISABLE_ROLLBACK.")
481 baton.push(parser, "srv_path")
482 options, args = parser.parse_all(argv)
484 parser.error("too many arguments")
487 class Error(command.Error):
488 """Base exception for all exceptions raised by upgrade"""
491 class QuotaTooLow(Error):
495 ERROR: The locker quota was too low to complete the autoinstall
499 class AlreadyUpgraded(Error):
504 ERROR: This autoinstall is already at the latest version."""
506 class MergeFailed(Error):
511 ERROR: Merge failed. Above is the temporary directory that
512 the conflicted merge is in: resolve the merge by cd'ing to the
513 temporary directory, finding conflicted files with `git status`,
514 resolving the files, adding them using `git add` and then
515 running `wizard upgrade --continue`."""
517 class LocalChangesError(Error):
521 ERROR: Local changes occurred in the install while the merge was
522 being processed so that a pull would not result in a fast-forward.
523 The best way to resolve this is probably to attempt an upgrade again,
524 with git rerere to remember merge resolutions (XXX: not sure if
525 this actually works)."""
527 class UnmergedChangesError(Error):
531 ERROR: You attempted to continue an upgrade, but there were
532 still local unmerged changes in your working copy. Please resolve
533 them all and try again."""
535 class BlacklistedError(Error):
536 #: Reason why the autoinstall was blacklisted
538 exitcode = errno_blacklisted
539 def __init__(self, reason):
544 ERROR: This autoinstall was manually blacklisted against errors;
545 if the user has not been notified of this, please send them
548 The reason was: %s""" % self.reason
550 class CannotResumeError(Error):
554 ERROR: We cannot resume the upgrade process; either this working
555 copy is missing essential metadata, or you've attempt to continue
556 from a production copy that does not have any pending upgrades.
559 class VersionRematchFailed(Error):
563 ERROR: Your Git version information was not consistent with your
564 files on the system, and we were unable to create a fake merge
565 to make the two consistent."""
567 class UnknownVersionError(Error):
568 #: Version that we didn't have
570 def __init__(self, version):
571 self.version = version
575 ERROR: The version you are attempting to upgrade from (%s)
576 is unknown to the repository Wizard is using.""" % str(self.version)