Index: src/platform/memento_softwareupdate/autoupdated_vm_test.py |
diff --git a/src/platform/memento_softwareupdate/autoupdated_vm_test.py b/src/platform/memento_softwareupdate/autoupdated_vm_test.py |
new file mode 100755 |
index 0000000000000000000000000000000000000000..ce6ebf3681b555ba1c9c6956bb8e5564a5d37d44 |
--- /dev/null |
+++ b/src/platform/memento_softwareupdate/autoupdated_vm_test.py |
@@ -0,0 +1,463 @@ |
+#!/usr/bin/python |
+ |
+# This is a fully automated VM test of autoupdate. Here's what it does: |
+# - Downloads the latest dev channel image |
+# - Creates a VMware image based on that image |
+# - Creates an update for the image under test |
+# - Creates a copy of that dev channel image that's one number higher than |
+# the image under test (called the rollback image) |
+# - Creates an update for the rollback image |
+# - Launches a local HTTP server to pretend to be the AU server |
+# - Fires up the VM and waits for it to contact the AU server |
+# - AU server gives the VM the image under test update |
+# - Waits for the image to be installed, then reboots the VM |
+# - AU server gives the VM the rollback image |
+# - Waits for the image to be installed, then reboots the VM |
+# - Waits for the image to ping the AU server with the rollback image |
+# - Done! |
+ |
+# Run this program by passing it a path to the build directory you want |
+# to test (this directory should contain rootfs.image). |
+ |
+from xml.dom import minidom |
+from BaseHTTPServer import BaseHTTPRequestHandler |
+from BaseHTTPServer import HTTPServer |
+ |
+import os |
+import re |
+import signal |
+import socket |
+import string |
+import subprocess |
+import sys |
+import tempfile |
+import threading |
+import time |
+ |
+tmp_dir = '/tmp/au_vm_test' |
+scripts_dir = '../../scripts/' |
+ |
+original_version = '0.0.0.0' |
+test_version = '0.0.0.0' |
+ |
+# This class stores the state that the server is in and serves as glue |
+# between the AU server the control of the VMware process |
+class TestState(object): |
+ # States we can be in |
+ INIT, \ |
+ INITIAL_WAIT, \ |
+ TEST_DOWNLOAD, \ |
+ TEST_WAIT, \ |
+ ROLLBACK_DOWNLOAD, \ |
+ ROLLBACK_WAIT = \ |
+ xrange(6) |
+ |
+ def __init__(self, orig_vers, test_vers, rollback_vers, vm): |
+ self.reboot_timeout_seconds = 60 * 4 |
+ self.orig_vers = orig_vers |
+ self.test_vers = test_vers |
+ self.rollback_vers = rollback_vers |
+ self.SetState(TestState.INIT) |
+ self.vm = vm |
+ |
+ def Die(self, message): |
+ print message |
+ self.vm.Destroy() |
+ # TODO(adlr): exit the entire process, not just this tread |
+ sys.exit(1) |
+ |
+ def SetState(self, state): |
+ self.state = state |
+ |
+ # Should be called to start the VM initially |
+ def Start(self): |
+ if self.state != TestState.INIT: |
+ self.Die('Start called while in bad state') |
+ self.SetState(TestState.INITIAL_WAIT) |
+ self.vm.Launch() |
+ # Kick off timer to wait for the AU ping |
+ self.timer = threading.Timer(self.reboot_timeout_seconds, |
+ self.StartupTimeout) |
+ self.timer.start() |
+ |
+ def StartupTimeout(self): |
+ self.Die('VM Failed to start and ping' + str(id(self))) |
+ # TODO(adlr): exit the entire process, not just this tread |
+ sys.exit(1) |
+ |
+ def FinishInstallTimeout(self): |
+ self.vm.Shutdown() |
+ self.vm.Launch() |
+ self.timer = threading.Timer(60 * 5, # Sometimes VMWare is very slow |
+ self.StartupTimeout) |
+ self.timer.start() |
+ |
+ # Called by AU server when an update request comes in. Should return |
+ # the version that the server should return to the AU client, or |
+ # None if no update. |
+ def HandleUpdateRequest(self, from_version): |
+ print 'HandleUpdateRequest(%s) id:%s state:%s' % \ |
+ (from_version, id(self), self.state) |
+ ret = None |
+ # Only cancel timer if we're waiting for the machine to startup |
+ if self.timer != None and self.state == TestState.INITIAL_WAIT and \ |
+ from_version == self.orig_vers: |
+ print 'Successfully booted initial' |
+ self.timer.cancel() |
+ self.timer = None |
+ elif self.timer != None and self.state == TestState.TEST_WAIT and \ |
+ from_version == self.test_vers: |
+ print 'Successfully booted test' |
+ self.timer.cancel() |
+ self.timer = None |
+ elif self.timer != None and self.state == TestState.ROLLBACK_WAIT and \ |
+ from_version == self.rollback_vers: |
+ print 'Successfully booted rollback' |
+ self.timer.cancel() |
+ self.timer = None |
+ print 'All done!' |
+ # TODO(adlr): exit the entire process, not just this tread |
+ sys.exit(0) |
+ |
+ # Pick the version to return |
+ if from_version == self.orig_vers: |
+ ret = self.test_vers |
+ elif from_version == self.test_vers: |
+ ret = self.rollback_vers |
+ |
+ # Checks to make sure we move through states correctly |
+ if from_version == self.orig_vers: |
+ if self.state != TestState.INITIAL_WAIT and \ |
+ self.state != TestState.TEST_DOWNLOAD and \ |
+ self.state != TestState.INITIAL_WAIT: |
+ self.Die('Error: Request from %s while in state %s' % |
+ (from_version, self.state)) |
+ elif from_version == self.test_vers: |
+ if self.state != TestState.TEST_WAIT and \ |
+ self.state != TestState.ROLLBACK_DOWNLOAD and \ |
+ self.state != TestState.ROLLBACK_WAIT: |
+ self.Die('Error: Request from %s while in state %s' % |
+ (from_version, self.state)) |
+ else: |
+ print 'odd version to be pinged from: %s' % from_version |
+ print 'state is %s' % self.state |
+ |
+ # Update state if needed |
+ if self.state == TestState.INITIAL_WAIT: |
+ self.SetState(TestState.TEST_DOWNLOAD) |
+ elif self.state == TestState.TEST_WAIT: |
+ self.SetState(TestState.ROLLBACK_DOWNLOAD) |
+ |
+ if ret is not None: |
+ return ret |
+ print 'Ignoring update request while in state %s' % self.state |
+ return '' |
+ |
+ # Called by AU server when the AU client has finished downloading an image |
+ def ImageDownloadComplete(self): |
+ print 'ImageDownloadComplete()' |
+ valid_state = False |
+ if self.state == TestState.TEST_DOWNLOAD: |
+ valid_state = True |
+ self.SetState(TestState.TEST_WAIT) |
+ if self.state == TestState.ROLLBACK_DOWNLOAD: |
+ valid_state = True |
+ self.SetState(TestState.ROLLBACK_WAIT) |
+ if not valid_state: |
+ print 'Image download done called at invalid state' |
+ # TODO(adlr): exit the entire process, not just this tread |
+ sys.exit(1) |
+ # Put a timer to reboot the VM |
+ if self.timer is not None: |
+ self.timer.cancel() |
+ self.timer = None |
+ self.timer = threading.Timer(self.reboot_timeout_seconds, |
+ self.FinishInstallTimeout) |
+ self.timer.start() |
+ return |
+ |
+# This subclass of HTTPServer contains info about the versions of |
+# software that the AU server should know about. The AUServerHandler |
+# object(s) will access this data. |
+class AUHTTPServer(HTTPServer): |
+ def __init__(self, ip_port, klass): |
+ HTTPServer.__init__(self, ip_port, klass) |
+ self.update_info = {} |
+ self.files = {} |
+ |
+ # For a given version of the software, the URL, size, and hash of the update |
+ # that gives the user that version of the software. |
+ def AddUpdateInfo(self, version, url, size, the_hash): |
+ self.update_info[version] = (url, the_hash, size) |
+ return |
+ |
+ # For a given path part of a url, return to the client the file at file_path |
+ def AddServedFile(self, url_path, file_path): |
+ self.files[url_path] = file_path |
+ |
+ def SetTestState(self, test_state): |
+ self.test_state = test_state |
+ |
+# This class handles HTTP requests. POST requests are when the client |
+# is pinging to see if there's an update. GET requests are to download |
+# an update. |
+class AUServerHandler(BaseHTTPRequestHandler): |
+ def do_GET(self): |
+ self.send_response(200) |
+ self.end_headers() |
+ print 'GET: %s' % self.path |
+ |
+ if self.server.files[self.path] != None: |
+ print 'GET returning path %s' % self.server.files[self.path] |
+ f = open(self.server.files[self.path]) |
+ while True: |
+ data = f.read(1024 * 1024 * 8) |
+ if not data: |
+ break |
+ self.wfile.write(data) |
+ self.wfile.flush() |
+ f.close() |
+ self.server.test_state.ImageDownloadComplete() |
+ else: |
+ print 'GET returning no path' |
+ self.wfile.write(self.path + '\n') |
+ return |
+ |
+ def do_POST(self): |
+ # Parse the form data posted |
+ post_length = int(self.headers.getheader('content-length')) |
+ post_data = self.rfile.read(post_length) |
+ |
+ update_dom = minidom.parseString(post_data) |
+ root = update_dom.firstChild |
+ query = root.getElementsByTagName('o:app')[0] |
+ client_version = query.getAttribute('version') |
+ print 'Got update request from %s' % client_version |
+ |
+ # Send response |
+ self.send_response(200) |
+ self.end_headers() |
+ |
+ new_version = self.server.test_state.HandleUpdateRequest(client_version) |
+ print 'Appropriate new version is: %s' % new_version |
+ |
+ if self.server.update_info[new_version] == None: |
+ print 'Not sure how to serve reply for %s' % new_version |
+ return |
+ |
+ payload = """<?xml version="1.0" encoding="UTF-8"?> |
+ <gupdate xmlns="http://www.google.com/update2/response" protocol="2.0"> |
+ <app appid="{87efface-864d-49a5-9bb3-4b050a7c227a}" status="ok"> |
+ <ping status="ok"/> |
+ <updatecheck |
+ codebase="%s" |
+ hash="%s" |
+ needsadmin="false" |
+ size="%s" |
+ status="ok"/> |
+ </app> |
+ </gupdate> |
+ """ % self.server.update_info[new_version] |
+ |
+ self.wfile.write(payload) |
+ return |
+ |
+# A wrapper for the vmplayer process. Can Launch/Shutdown a vm. |
+class vmplayer(object): |
+ def __init__(self, filename): |
+ self.filename = filename |
+ |
+ # Launch may (read: probably will) return before the OS has booted |
+ def Launch(self): |
+ self.process = subprocess.Popen(['/usr/bin/vmplayer', self.filename]) |
+ self.running = True |
+ |
+ def Destroy(self): |
+ if self.running: |
+ self.Shutdown() |
+ |
+ # Shutdown will not return until the vmplayer process has fully terminated |
+ # and any cleanup is done. |
+ def Shutdown(self): |
+ # Pretend user sent Ctrl-C to the vmplayer process |
+ os.kill(self.process.pid, signal.SIGINT) |
+ |
+ # Wait while vmplayer saves the vm state... |
+ self.process.wait() |
+ |
+ # Delete the saved vm state |
+ # TODO(adlr): remove the state file from the disk |
+ self.process = None |
+ subprocess.check_call(['/bin/sed', '-i', '/checkpoint.vmState/d', |
+ self.filename]) |
+ self.running = False |
+ |
+def MakePath(path): |
+ subprocess.check_call(['/bin/mkdir', '-p', path]) |
+ |
+def DownloadLatestImage(out_path): |
+ url = 'http://codf30.jail.google.com/internal/archive/' + \ |
+ 'x86-image-official/LATEST-dev-channel/image.zip' + \ |
+ '.NOT_SAFE_FOR_USB_INSTALL' |
+ url = 'http://www.corp.google.com/~adlr/adlr_test_orig.zip' |
+ subprocess.check_call(['/usr/bin/wget', '-O', out_path, url]) |
+ |
+def UnzipImage(path, directory): |
+ subprocess.check_call(['/usr/bin/unzip', path, '-d', directory]) |
+ |
+# Create a stateful partition with a text file that points the AU client |
+# to localhost at local_port (which is this very script). |
+def CreateDefaultStatefulPartition(local_ip, local_port, out_dir, out_file): |
+ # Create sparse file for partition |
+ part_size = 512 * 1024 * 1024 # bytes, so 500 MiB |
+ |
+ subprocess.check_call(["""#!/bin/bash |
+ set -ex |
+ OUT_FILE="%s" |
+ STATE_DIR="%s" |
+ SIZE="%s" |
+ dd if=/dev/zero of="$OUT_FILE" bs=1 count=1 seek=$(($SIZE - 1)) |
+ mkfs.ext3 -F "$OUT_FILE" |
+ mkdir -p "$STATE_DIR" |
+ sudo mount -o loop "$OUT_FILE" "$STATE_DIR" |
+ sudo mkdir -p "$STATE_DIR/etc" |
+ cat <<EOF |sudo dd of="$STATE_DIR/etc/lsb-release" |
+CHROMEOS_AUSERVER=http://%s:%s/update |
+HTTP_SERVER_OVERRIDE=true |
+EOF |
+ for i in "$STATE_DIR/etc/lsb-release" "$STATE_DIR/etc" "$STATE_DIR"; do |
+ sudo chown root:root "$i" |
+ if [ -d "$i" ]; then |
+ sudo chmod 0755 "$i" |
+ else |
+ sudo chmod 0644 "$i" |
+ fi |
+ done |
+ sudo umount -d "$STATE_DIR" |
+ """ % (out_file, out_dir + '/state', part_size, local_ip, local_port)], |
+ shell=True) |
+ return |
+ |
+ |
+def CreateVMForImage(image_dir): |
+ subprocess.check_call([scripts_dir + 'image_to_vmware.sh', '--from', |
+ image_dir, '--to', image_dir, '--state_image', |
+ image_dir + '/state.image']) |
+ return |
+ |
+# Returns (size, hash, path) of the generated image.gz for the given rootfs |
+def CreateUpdateForImage(rootfs_image): |
+ output = subprocess.Popen([scripts_dir + 'mk_memento_images.sh', |
+ rootfs_image], |
+ stdout=subprocess.PIPE).communicate()[0] |
+ matches = re.search('Success. hash is ([^\n]+)', output) |
+ the_hash = matches.group(1) |
+ path = os.path.dirname(rootfs_image) + '/update.gz' |
+ size = os.path.getsize(path) |
+ return (size, the_hash, path) |
+ |
+# Modify rootfs 'root_img' to have a new version new_version |
+def ModifyImageForRollback(root_img, new_version): |
+ subprocess.check_call(["""#!/bin/bash |
+ set -ex |
+ DIR=$(mktemp -d) |
+ sudo mount -o loop "%s" $DIR |
+ # update versions in lsb-release |
+ sudo sed -i \\ |
+ -e 's/\\(^GOOGLE_RELEASE=\\|CHROMEOS_RELEASE_VERSION=\\).*/\\1%s/' \\ |
+ -e 's/^\\(CHROMEOS_RELEASE_DESCRIPTION=.*\\)/\\1-ROLLBACK/' \\ |
+ "$DIR"/etc/lsb-release |
+ sudo umount -d $DIR |
+ """ % (root_img, new_version)], shell=True) |
+ |
+# Returns the release version of a rootfs (e.g. 0.6.39.201003241739-a1) |
+def GetVersionForRootfs(rootfs_image): |
+ mount_dir = tempfile.mkdtemp() |
+ subprocess.check_call(['sudo', 'mount', '-o', 'loop,ro', |
+ rootfs_image, mount_dir]) |
+ version = subprocess.Popen(['awk', '-F', '=', |
+ '/GOOGLE_RELEASE=/{print $2}', |
+ mount_dir + '/etc/lsb-release'], |
+ stdout=subprocess.PIPE).communicate()[0].rstrip() |
+ subprocess.check_call(['sudo', 'umount', '-d', mount_dir]) |
+ subprocess.check_call(['sudo', 'rm', '-rf', mount_dir]) |
+ return version |
+ |
+# For a given version, increment the last number by 1. E.g.: |
+# IncrementVersionNumber('0.23.144.842') = '0.23.144.843' |
+def IncrementVersionNumber(version): |
+ parts = version.split('.') |
+ parts[-1] = str(int(parts[-1]) + 1) |
+ return string.join(parts, '.') |
+ |
+def UnpackRootfs(directory): |
+ subprocess.check_call(["""#!/bin/bash -x |
+ cd "%s" |
+ ./unpack_partitions.sh chromiumos_image.bin |
+ mv -f part_3 rootfs.image |
+ """ % directory], shell=True) |
+ |
+def main(): |
+ if len(sys.argv) != 2: |
+ print 'usage: %s path/to/new/image/dir' % sys.argv[0] |
+ sys.exit(1) |
+ orig_dir = tmp_dir + '/orig' |
+ new_dir = sys.argv[1] |
+ rollback_dir = tmp_dir + '/rollback' |
+ |
+ state_image = orig_dir + '/state.image' |
+ port = 8080 |
+ |
+ MakePath(tmp_dir) |
+ |
+ # Download latest dev channel release |
+ orig_zip = tmp_dir + '/orig.zip' |
+ DownloadLatestImage(orig_zip) |
+ UnzipImage(orig_zip, orig_dir) |
+ UnpackRootfs(orig_dir) |
+ orig_version = GetVersionForRootfs(orig_dir + '/rootfs.image') |
+ print 'Have original image at version: %s' % orig_version |
+ |
+ # Create new AU image |
+ print 'Creating update.gz for test image' |
+ UnpackRootfs(new_dir) |
+ new_update_details = CreateUpdateForImage(new_dir + '/rootfs.image') |
+ new_version = GetVersionForRootfs(new_dir + '/rootfs.image') |
+ print 'Have test image at version: %s' % new_version |
+ |
+ # Create rollback image |
+ rollback_version = IncrementVersionNumber(new_version) |
+ print 'Creating rollback image' |
+ UnzipImage(orig_zip, rollback_dir) |
+ UnpackRootfs(rollback_dir) |
+ ModifyImageForRollback(rollback_dir + '/rootfs.image', rollback_version) |
+ print 'Creating update.gz for rollback image' |
+ rollback_update_details = CreateUpdateForImage(rollback_dir + '/rootfs.image') |
+ print 'Have rollback image at version: %s' % rollback_version |
+ |
+ CreateDefaultStatefulPartition(socket.gethostname(), port, orig_dir, |
+ state_image) |
+ CreateVMForImage(orig_dir) |
+ |
+ player = vmplayer(orig_dir + '/chromeos.vmx') |
+ |
+ test_state = TestState(orig_version, new_version, rollback_version, player) |
+ |
+ server = AUHTTPServer((socket.gethostname(), port), AUServerHandler) |
+ |
+ base_url = 'http://%s:%s' % (socket.gethostname(), port) |
+ |
+ server.SetTestState(test_state) |
+ server.AddUpdateInfo(new_version, base_url + '/' + new_version, |
+ new_update_details[0], new_update_details[1]) |
+ server.AddUpdateInfo(rollback_version, base_url + '/' + rollback_version, |
+ rollback_update_details[0], rollback_update_details[1]) |
+ server.AddServedFile('/' + new_version, new_update_details[2]) |
+ server.AddServedFile('/' + rollback_version, rollback_update_details[2]) |
+ |
+ test_state.Start() |
+ print 'Starting server, use <Ctrl-C> to stop' |
+ server.serve_forever() |
+ |
+if __name__ == '__main__': |
+ main() |