OLD | NEW |
(Empty) | |
| 1 #!/usr/bin/python |
| 2 |
| 3 # This is a fully automated VM test of autoupdate. Here's what it does: |
| 4 # - Downloads the latest dev channel image |
| 5 # - Creates a VMware image based on that image |
| 6 # - Creates an update for the image under test |
| 7 # - Creates a copy of that dev channel image that's one number higher than |
| 8 # the image under test (called the rollback image) |
| 9 # - Creates an update for the rollback image |
| 10 # - Launches a local HTTP server to pretend to be the AU server |
| 11 # - Fires up the VM and waits for it to contact the AU server |
| 12 # - AU server gives the VM the image under test update |
| 13 # - Waits for the image to be installed, then reboots the VM |
| 14 # - AU server gives the VM the rollback image |
| 15 # - Waits for the image to be installed, then reboots the VM |
| 16 # - Waits for the image to ping the AU server with the rollback image |
| 17 # - Done! |
| 18 |
| 19 # Run this program by passing it a path to the build directory you want |
| 20 # to test (this directory should contain rootfs.image). |
| 21 |
| 22 from xml.dom import minidom |
| 23 from BaseHTTPServer import BaseHTTPRequestHandler |
| 24 from BaseHTTPServer import HTTPServer |
| 25 |
| 26 import os |
| 27 import re |
| 28 import signal |
| 29 import socket |
| 30 import string |
| 31 import subprocess |
| 32 import sys |
| 33 import tempfile |
| 34 import threading |
| 35 import time |
| 36 |
| 37 tmp_dir = '/tmp/au_vm_test' |
| 38 scripts_dir = '../../scripts/' |
| 39 |
| 40 original_version = '0.0.0.0' |
| 41 test_version = '0.0.0.0' |
| 42 |
| 43 # This class stores the state that the server is in and serves as glue |
| 44 # between the AU server the control of the VMware process |
| 45 class TestState(object): |
| 46 # States we can be in |
| 47 INIT, \ |
| 48 INITIAL_WAIT, \ |
| 49 TEST_DOWNLOAD, \ |
| 50 TEST_WAIT, \ |
| 51 ROLLBACK_DOWNLOAD, \ |
| 52 ROLLBACK_WAIT = \ |
| 53 xrange(6) |
| 54 |
| 55 def __init__(self, orig_vers, test_vers, rollback_vers, vm): |
| 56 self.reboot_timeout_seconds = 60 * 4 |
| 57 self.orig_vers = orig_vers |
| 58 self.test_vers = test_vers |
| 59 self.rollback_vers = rollback_vers |
| 60 self.SetState(TestState.INIT) |
| 61 self.vm = vm |
| 62 |
| 63 def Die(self, message): |
| 64 print message |
| 65 self.vm.Destroy() |
| 66 # TODO(adlr): exit the entire process, not just this tread |
| 67 sys.exit(1) |
| 68 |
| 69 def SetState(self, state): |
| 70 self.state = state |
| 71 |
| 72 # Should be called to start the VM initially |
| 73 def Start(self): |
| 74 if self.state != TestState.INIT: |
| 75 self.Die('Start called while in bad state') |
| 76 self.SetState(TestState.INITIAL_WAIT) |
| 77 self.vm.Launch() |
| 78 # Kick off timer to wait for the AU ping |
| 79 self.timer = threading.Timer(self.reboot_timeout_seconds, |
| 80 self.StartupTimeout) |
| 81 self.timer.start() |
| 82 |
| 83 def StartupTimeout(self): |
| 84 self.Die('VM Failed to start and ping' + str(id(self))) |
| 85 # TODO(adlr): exit the entire process, not just this tread |
| 86 sys.exit(1) |
| 87 |
| 88 def FinishInstallTimeout(self): |
| 89 self.vm.Shutdown() |
| 90 self.vm.Launch() |
| 91 self.timer = threading.Timer(60 * 5, # Sometimes VMWare is very slow |
| 92 self.StartupTimeout) |
| 93 self.timer.start() |
| 94 |
| 95 # Called by AU server when an update request comes in. Should return |
| 96 # the version that the server should return to the AU client, or |
| 97 # None if no update. |
| 98 def HandleUpdateRequest(self, from_version): |
| 99 print 'HandleUpdateRequest(%s) id:%s state:%s' % \ |
| 100 (from_version, id(self), self.state) |
| 101 ret = None |
| 102 # Only cancel timer if we're waiting for the machine to startup |
| 103 if self.timer != None and self.state == TestState.INITIAL_WAIT and \ |
| 104 from_version == self.orig_vers: |
| 105 print 'Successfully booted initial' |
| 106 self.timer.cancel() |
| 107 self.timer = None |
| 108 elif self.timer != None and self.state == TestState.TEST_WAIT and \ |
| 109 from_version == self.test_vers: |
| 110 print 'Successfully booted test' |
| 111 self.timer.cancel() |
| 112 self.timer = None |
| 113 elif self.timer != None and self.state == TestState.ROLLBACK_WAIT and \ |
| 114 from_version == self.rollback_vers: |
| 115 print 'Successfully booted rollback' |
| 116 self.timer.cancel() |
| 117 self.timer = None |
| 118 print 'All done!' |
| 119 # TODO(adlr): exit the entire process, not just this tread |
| 120 sys.exit(0) |
| 121 |
| 122 # Pick the version to return |
| 123 if from_version == self.orig_vers: |
| 124 ret = self.test_vers |
| 125 elif from_version == self.test_vers: |
| 126 ret = self.rollback_vers |
| 127 |
| 128 # Checks to make sure we move through states correctly |
| 129 if from_version == self.orig_vers: |
| 130 if self.state != TestState.INITIAL_WAIT and \ |
| 131 self.state != TestState.TEST_DOWNLOAD and \ |
| 132 self.state != TestState.INITIAL_WAIT: |
| 133 self.Die('Error: Request from %s while in state %s' % |
| 134 (from_version, self.state)) |
| 135 elif from_version == self.test_vers: |
| 136 if self.state != TestState.TEST_WAIT and \ |
| 137 self.state != TestState.ROLLBACK_DOWNLOAD and \ |
| 138 self.state != TestState.ROLLBACK_WAIT: |
| 139 self.Die('Error: Request from %s while in state %s' % |
| 140 (from_version, self.state)) |
| 141 else: |
| 142 print 'odd version to be pinged from: %s' % from_version |
| 143 print 'state is %s' % self.state |
| 144 |
| 145 # Update state if needed |
| 146 if self.state == TestState.INITIAL_WAIT: |
| 147 self.SetState(TestState.TEST_DOWNLOAD) |
| 148 elif self.state == TestState.TEST_WAIT: |
| 149 self.SetState(TestState.ROLLBACK_DOWNLOAD) |
| 150 |
| 151 if ret is not None: |
| 152 return ret |
| 153 print 'Ignoring update request while in state %s' % self.state |
| 154 return '' |
| 155 |
| 156 # Called by AU server when the AU client has finished downloading an image |
| 157 def ImageDownloadComplete(self): |
| 158 print 'ImageDownloadComplete()' |
| 159 valid_state = False |
| 160 if self.state == TestState.TEST_DOWNLOAD: |
| 161 valid_state = True |
| 162 self.SetState(TestState.TEST_WAIT) |
| 163 if self.state == TestState.ROLLBACK_DOWNLOAD: |
| 164 valid_state = True |
| 165 self.SetState(TestState.ROLLBACK_WAIT) |
| 166 if not valid_state: |
| 167 print 'Image download done called at invalid state' |
| 168 # TODO(adlr): exit the entire process, not just this tread |
| 169 sys.exit(1) |
| 170 # Put a timer to reboot the VM |
| 171 if self.timer is not None: |
| 172 self.timer.cancel() |
| 173 self.timer = None |
| 174 self.timer = threading.Timer(self.reboot_timeout_seconds, |
| 175 self.FinishInstallTimeout) |
| 176 self.timer.start() |
| 177 return |
| 178 |
| 179 # This subclass of HTTPServer contains info about the versions of |
| 180 # software that the AU server should know about. The AUServerHandler |
| 181 # object(s) will access this data. |
| 182 class AUHTTPServer(HTTPServer): |
| 183 def __init__(self, ip_port, klass): |
| 184 HTTPServer.__init__(self, ip_port, klass) |
| 185 self.update_info = {} |
| 186 self.files = {} |
| 187 |
| 188 # For a given version of the software, the URL, size, and hash of the update |
| 189 # that gives the user that version of the software. |
| 190 def AddUpdateInfo(self, version, url, size, the_hash): |
| 191 self.update_info[version] = (url, the_hash, size) |
| 192 return |
| 193 |
| 194 # For a given path part of a url, return to the client the file at file_path |
| 195 def AddServedFile(self, url_path, file_path): |
| 196 self.files[url_path] = file_path |
| 197 |
| 198 def SetTestState(self, test_state): |
| 199 self.test_state = test_state |
| 200 |
| 201 # This class handles HTTP requests. POST requests are when the client |
| 202 # is pinging to see if there's an update. GET requests are to download |
| 203 # an update. |
| 204 class AUServerHandler(BaseHTTPRequestHandler): |
| 205 def do_GET(self): |
| 206 self.send_response(200) |
| 207 self.end_headers() |
| 208 print 'GET: %s' % self.path |
| 209 |
| 210 if self.server.files[self.path] != None: |
| 211 print 'GET returning path %s' % self.server.files[self.path] |
| 212 f = open(self.server.files[self.path]) |
| 213 while True: |
| 214 data = f.read(1024 * 1024 * 8) |
| 215 if not data: |
| 216 break |
| 217 self.wfile.write(data) |
| 218 self.wfile.flush() |
| 219 f.close() |
| 220 self.server.test_state.ImageDownloadComplete() |
| 221 else: |
| 222 print 'GET returning no path' |
| 223 self.wfile.write(self.path + '\n') |
| 224 return |
| 225 |
| 226 def do_POST(self): |
| 227 # Parse the form data posted |
| 228 post_length = int(self.headers.getheader('content-length')) |
| 229 post_data = self.rfile.read(post_length) |
| 230 |
| 231 update_dom = minidom.parseString(post_data) |
| 232 root = update_dom.firstChild |
| 233 query = root.getElementsByTagName('o:app')[0] |
| 234 client_version = query.getAttribute('version') |
| 235 print 'Got update request from %s' % client_version |
| 236 |
| 237 # Send response |
| 238 self.send_response(200) |
| 239 self.end_headers() |
| 240 |
| 241 new_version = self.server.test_state.HandleUpdateRequest(client_version) |
| 242 print 'Appropriate new version is: %s' % new_version |
| 243 |
| 244 if self.server.update_info[new_version] == None: |
| 245 print 'Not sure how to serve reply for %s' % new_version |
| 246 return |
| 247 |
| 248 payload = """<?xml version="1.0" encoding="UTF-8"?> |
| 249 <gupdate xmlns="http://www.google.com/update2/response" protocol="2.0"> |
| 250 <app appid="{87efface-864d-49a5-9bb3-4b050a7c227a}" status="ok"> |
| 251 <ping status="ok"/> |
| 252 <updatecheck |
| 253 codebase="%s" |
| 254 hash="%s" |
| 255 needsadmin="false" |
| 256 size="%s" |
| 257 status="ok"/> |
| 258 </app> |
| 259 </gupdate> |
| 260 """ % self.server.update_info[new_version] |
| 261 |
| 262 self.wfile.write(payload) |
| 263 return |
| 264 |
| 265 # A wrapper for the vmplayer process. Can Launch/Shutdown a vm. |
| 266 class vmplayer(object): |
| 267 def __init__(self, filename): |
| 268 self.filename = filename |
| 269 |
| 270 # Launch may (read: probably will) return before the OS has booted |
| 271 def Launch(self): |
| 272 self.process = subprocess.Popen(['/usr/bin/vmplayer', self.filename]) |
| 273 self.running = True |
| 274 |
| 275 def Destroy(self): |
| 276 if self.running: |
| 277 self.Shutdown() |
| 278 |
| 279 # Shutdown will not return until the vmplayer process has fully terminated |
| 280 # and any cleanup is done. |
| 281 def Shutdown(self): |
| 282 # Pretend user sent Ctrl-C to the vmplayer process |
| 283 os.kill(self.process.pid, signal.SIGINT) |
| 284 |
| 285 # Wait while vmplayer saves the vm state... |
| 286 self.process.wait() |
| 287 |
| 288 # Delete the saved vm state |
| 289 # TODO(adlr): remove the state file from the disk |
| 290 self.process = None |
| 291 subprocess.check_call(['/bin/sed', '-i', '/checkpoint.vmState/d', |
| 292 self.filename]) |
| 293 self.running = False |
| 294 |
| 295 def MakePath(path): |
| 296 subprocess.check_call(['/bin/mkdir', '-p', path]) |
| 297 |
| 298 def DownloadLatestImage(out_path): |
| 299 url = 'http://codf30.jail.google.com/internal/archive/' + \ |
| 300 'x86-image-official/LATEST-dev-channel/image.zip' + \ |
| 301 '.NOT_SAFE_FOR_USB_INSTALL' |
| 302 url = 'http://www.corp.google.com/~adlr/adlr_test_orig.zip' |
| 303 subprocess.check_call(['/usr/bin/wget', '-O', out_path, url]) |
| 304 |
| 305 def UnzipImage(path, directory): |
| 306 subprocess.check_call(['/usr/bin/unzip', path, '-d', directory]) |
| 307 |
| 308 # Create a stateful partition with a text file that points the AU client |
| 309 # to localhost at local_port (which is this very script). |
| 310 def CreateDefaultStatefulPartition(local_ip, local_port, out_dir, out_file): |
| 311 # Create sparse file for partition |
| 312 part_size = 512 * 1024 * 1024 # bytes, so 500 MiB |
| 313 |
| 314 subprocess.check_call(["""#!/bin/bash |
| 315 set -ex |
| 316 OUT_FILE="%s" |
| 317 STATE_DIR="%s" |
| 318 SIZE="%s" |
| 319 dd if=/dev/zero of="$OUT_FILE" bs=1 count=1 seek=$(($SIZE - 1)) |
| 320 mkfs.ext3 -F "$OUT_FILE" |
| 321 mkdir -p "$STATE_DIR" |
| 322 sudo mount -o loop "$OUT_FILE" "$STATE_DIR" |
| 323 sudo mkdir -p "$STATE_DIR/etc" |
| 324 cat <<EOF |sudo dd of="$STATE_DIR/etc/lsb-release" |
| 325 CHROMEOS_AUSERVER=http://%s:%s/update |
| 326 HTTP_SERVER_OVERRIDE=true |
| 327 EOF |
| 328 for i in "$STATE_DIR/etc/lsb-release" "$STATE_DIR/etc" "$STATE_DIR"; do |
| 329 sudo chown root:root "$i" |
| 330 if [ -d "$i" ]; then |
| 331 sudo chmod 0755 "$i" |
| 332 else |
| 333 sudo chmod 0644 "$i" |
| 334 fi |
| 335 done |
| 336 sudo umount -d "$STATE_DIR" |
| 337 """ % (out_file, out_dir + '/state', part_size, local_ip, local_port)], |
| 338 shell=True) |
| 339 return |
| 340 |
| 341 |
| 342 def CreateVMForImage(image_dir): |
| 343 subprocess.check_call([scripts_dir + 'image_to_vmware.sh', '--from', |
| 344 image_dir, '--to', image_dir, '--state_image', |
| 345 image_dir + '/state.image']) |
| 346 return |
| 347 |
| 348 # Returns (size, hash, path) of the generated image.gz for the given rootfs |
| 349 def CreateUpdateForImage(rootfs_image): |
| 350 output = subprocess.Popen([scripts_dir + 'mk_memento_images.sh', |
| 351 rootfs_image], |
| 352 stdout=subprocess.PIPE).communicate()[0] |
| 353 matches = re.search('Success. hash is ([^\n]+)', output) |
| 354 the_hash = matches.group(1) |
| 355 path = os.path.dirname(rootfs_image) + '/update.gz' |
| 356 size = os.path.getsize(path) |
| 357 return (size, the_hash, path) |
| 358 |
| 359 # Modify rootfs 'root_img' to have a new version new_version |
| 360 def ModifyImageForRollback(root_img, new_version): |
| 361 subprocess.check_call(["""#!/bin/bash |
| 362 set -ex |
| 363 DIR=$(mktemp -d) |
| 364 sudo mount -o loop "%s" $DIR |
| 365 # update versions in lsb-release |
| 366 sudo sed -i \\ |
| 367 -e 's/\\(^GOOGLE_RELEASE=\\|CHROMEOS_RELEASE_VERSION=\\).*/\\1%s/' \\ |
| 368 -e 's/^\\(CHROMEOS_RELEASE_DESCRIPTION=.*\\)/\\1-ROLLBACK/' \\ |
| 369 "$DIR"/etc/lsb-release |
| 370 sudo umount -d $DIR |
| 371 """ % (root_img, new_version)], shell=True) |
| 372 |
| 373 # Returns the release version of a rootfs (e.g. 0.6.39.201003241739-a1) |
| 374 def GetVersionForRootfs(rootfs_image): |
| 375 mount_dir = tempfile.mkdtemp() |
| 376 subprocess.check_call(['sudo', 'mount', '-o', 'loop,ro', |
| 377 rootfs_image, mount_dir]) |
| 378 version = subprocess.Popen(['awk', '-F', '=', |
| 379 '/GOOGLE_RELEASE=/{print $2}', |
| 380 mount_dir + '/etc/lsb-release'], |
| 381 stdout=subprocess.PIPE).communicate()[0].rstrip() |
| 382 subprocess.check_call(['sudo', 'umount', '-d', mount_dir]) |
| 383 subprocess.check_call(['sudo', 'rm', '-rf', mount_dir]) |
| 384 return version |
| 385 |
| 386 # For a given version, increment the last number by 1. E.g.: |
| 387 # IncrementVersionNumber('0.23.144.842') = '0.23.144.843' |
| 388 def IncrementVersionNumber(version): |
| 389 parts = version.split('.') |
| 390 parts[-1] = str(int(parts[-1]) + 1) |
| 391 return string.join(parts, '.') |
| 392 |
| 393 def UnpackRootfs(directory): |
| 394 subprocess.check_call(["""#!/bin/bash -x |
| 395 cd "%s" |
| 396 ./unpack_partitions.sh chromiumos_image.bin |
| 397 mv -f part_3 rootfs.image |
| 398 """ % directory], shell=True) |
| 399 |
| 400 def main(): |
| 401 if len(sys.argv) != 2: |
| 402 print 'usage: %s path/to/new/image/dir' % sys.argv[0] |
| 403 sys.exit(1) |
| 404 orig_dir = tmp_dir + '/orig' |
| 405 new_dir = sys.argv[1] |
| 406 rollback_dir = tmp_dir + '/rollback' |
| 407 |
| 408 state_image = orig_dir + '/state.image' |
| 409 port = 8080 |
| 410 |
| 411 MakePath(tmp_dir) |
| 412 |
| 413 # Download latest dev channel release |
| 414 orig_zip = tmp_dir + '/orig.zip' |
| 415 DownloadLatestImage(orig_zip) |
| 416 UnzipImage(orig_zip, orig_dir) |
| 417 UnpackRootfs(orig_dir) |
| 418 orig_version = GetVersionForRootfs(orig_dir + '/rootfs.image') |
| 419 print 'Have original image at version: %s' % orig_version |
| 420 |
| 421 # Create new AU image |
| 422 print 'Creating update.gz for test image' |
| 423 UnpackRootfs(new_dir) |
| 424 new_update_details = CreateUpdateForImage(new_dir + '/rootfs.image') |
| 425 new_version = GetVersionForRootfs(new_dir + '/rootfs.image') |
| 426 print 'Have test image at version: %s' % new_version |
| 427 |
| 428 # Create rollback image |
| 429 rollback_version = IncrementVersionNumber(new_version) |
| 430 print 'Creating rollback image' |
| 431 UnzipImage(orig_zip, rollback_dir) |
| 432 UnpackRootfs(rollback_dir) |
| 433 ModifyImageForRollback(rollback_dir + '/rootfs.image', rollback_version) |
| 434 print 'Creating update.gz for rollback image' |
| 435 rollback_update_details = CreateUpdateForImage(rollback_dir + '/rootfs.image') |
| 436 print 'Have rollback image at version: %s' % rollback_version |
| 437 |
| 438 CreateDefaultStatefulPartition(socket.gethostname(), port, orig_dir, |
| 439 state_image) |
| 440 CreateVMForImage(orig_dir) |
| 441 |
| 442 player = vmplayer(orig_dir + '/chromeos.vmx') |
| 443 |
| 444 test_state = TestState(orig_version, new_version, rollback_version, player) |
| 445 |
| 446 server = AUHTTPServer((socket.gethostname(), port), AUServerHandler) |
| 447 |
| 448 base_url = 'http://%s:%s' % (socket.gethostname(), port) |
| 449 |
| 450 server.SetTestState(test_state) |
| 451 server.AddUpdateInfo(new_version, base_url + '/' + new_version, |
| 452 new_update_details[0], new_update_details[1]) |
| 453 server.AddUpdateInfo(rollback_version, base_url + '/' + rollback_version, |
| 454 rollback_update_details[0], rollback_update_details[1]) |
| 455 server.AddServedFile('/' + new_version, new_update_details[2]) |
| 456 server.AddServedFile('/' + rollback_version, rollback_update_details[2]) |
| 457 |
| 458 test_state.Start() |
| 459 print 'Starting server, use <Ctrl-C> to stop' |
| 460 server.serve_forever() |
| 461 |
| 462 if __name__ == '__main__': |
| 463 main() |
OLD | NEW |