Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(877)

Side by Side Diff: bin/cros_image_to_target.py

Issue 3391008: New application cros_image_to_target.py for ssh_tunnel upgrade (Closed) Base URL: ssh://gitrw.chromium.org/crosutils.git
Patch Set: Latest batch of sosa fixes -- changed child exit behavior Created 10 years, 3 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « no previous file | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 #!/usr/bin/python
2 #
3 # Copyright (c) 2010 The Chromium OS Authors. All rights reserved.
4 # Use of this source code is governed by a BSD-style license that can be
5 # found in the LICENSE file.
6
7 """Create and copy update image to target host.
8
9 auto-update and devserver change out from beneath us often enough
10 that despite having to duplicate a litte code, it seems that the
11 right thing to do here is to start over and do something that is
12 simple enough and easy enough to understand so that when more
13 stuff breaks, at least we can solve them faster.
14 """
15
16 import BaseHTTPServer
17 import cgi
18 import errno
19 import optparse
20 import os
21 import signal
22 import subprocess
23 import sys
24 import tempfile
25 import time
26 import traceback
27
28 from xml.dom import minidom
29
30
31 # This is the default filename within the image directory to load updates from
32 DEFAULT_IMAGE_NAME = 'chromiumos_image.bin'
33
34 # The filenames we provide to clients to pull updates
35 UPDATE_FILENAME = 'update.gz'
36 STATEFUL_FILENAME = 'stateful.image.gz'
37
38 # How long do we wait for the server to start before launching client
39 SERVER_STARTUP_WAIT = 1
40
41
42 class Command(object):
43 """Shell command ease-ups for Python."""
44
45 def __init__(self, env):
46 self.env = env
47
48 def RunPipe(self, pipeline, infile=None, outfile=None,
49 capture=False, oneline=False):
50 """Perform a command pipeline, with optional input/output filenames."""
51
52 last_pipe = None
53 while pipeline:
54 cmd = pipeline.pop(0)
55 kwargs = {}
56 if last_pipe is not None:
57 kwargs['stdin'] = last_pipe.stdout
58 elif infile:
59 kwargs['stdin'] = open(infile, 'rb')
60 if pipeline or capture:
61 kwargs['stdout'] = subprocess.PIPE
62 elif outfile:
63 kwargs['stdout'] = open(outfile, 'wb')
64
65 self.env.Info('Running: %s' % ' '.join(cmd))
66 last_pipe = subprocess.Popen(cmd, **kwargs)
67
68 if capture:
69 ret = last_pipe.communicate()[0]
70 if not ret:
71 return None
72 elif oneline:
73 return ret.rstrip('\r\n')
74 else:
75 return ret
76 else:
77 return os.waitpid(last_pipe.pid, 0)[1] == 0
78
79 def Output(self, *cmd):
80 return self.RunPipe([cmd], capture=True)
81
82 def OutputOneLine(self, *cmd):
83 return self.RunPipe([cmd], capture=True, oneline=True)
84
85 def Run(self, *cmd, **kwargs):
86 return self.RunPipe([cmd], **kwargs)
87
88
89 class SSHCommand(Command):
90 """Remote shell commands."""
91
92 CONNECT_TIMEOUT = 5
93
94 def __init__(self, env, remote):
95 Command.__init__(self, env)
96 self.remote = remote
97 self.ssh_dir = None
98 self.identity = env.CrosUtilsPath('mod_for_test_scripts/ssh_keys/'
99 'testing_rsa')
100
101 def Setup(self):
102 self.ssh_dir = tempfile.mkdtemp(prefix='ssh-tmp-')
103 self.known_hosts = os.path.join(self.ssh_dir, 'known-hosts')
104
105 def Cleanup(self):
106 Command.RunPipe(self, [['rm', '-rf', self.ssh_dir]])
107 self.ssh_dir = None
108
109 def GetArgs(self):
110 if not self.ssh_dir:
111 self.Setup()
112
113 return ['-o', 'Compression=no',
114 '-o', 'ConnectTimeout=%d' % self.CONNECT_TIMEOUT,
115 '-o', 'StrictHostKeyChecking=no',
116 '-o', 'UserKnownHostsFile=%s' % self.known_hosts,
117 '-i', self.identity]
118
119 def RunPipe(self, pipeline, **kwargs):
120 args = ['ssh'] + self.GetArgs()
121 if 'remote_tunnel' in kwargs:
122 ports = kwargs.pop('remote_tunnel')
123 args += ['-R %d:localhost:%d' % ports]
124 pipeline[0] = args + ['root@%s' % self.remote] + list(pipeline[0])
125 return Command.RunPipe(self, pipeline, **kwargs)
126
127 def Reset(self):
128 os.unlink(self.known_hosts)
129
130 def Copy(self, src, dest):
131 return Command.RunPipe(self, [['scp'] + self.GetArgs() +
132 [src, 'root@%s:%s' %
133 (self.remote, dest)]])
134
135
136 class CrosEnv(object):
137 """Encapsulates the ChromeOS build system environment functionality."""
138
139 REBOOT_START_WAIT = 5
140 REBOOT_WAIT_TIME = 60
141
142 def __init__(self, verbose=False):
143 self.cros_root = os.path.dirname(os.path.abspath(sys.argv[0]))
144 parent = os.path.dirname(self.cros_root)
145 if os.path.exists(os.path.join(parent, 'chromeos-common.sh')):
146 self.cros_root = parent
147 self.cmd = Command(self)
148 self.verbose = verbose
149
150 def Error(self, msg):
151 print >> sys.stderr, 'ERROR: %s' % msg
152
153 def Fatal(self, msg=None):
154 if msg:
155 self.Error(msg)
156 sys.exit(1)
157
158 def Info(self, msg):
159 if self.verbose:
160 print 'INFO: %s' % msg
161
162 def CrosUtilsPath(self, filename):
163 return os.path.join(self.cros_root, filename)
164
165 def ChrootPath(self, filename):
166 return self.CrosUtilsPath(os.path.join('..', '..', 'chroot',
167 filename.strip(os.path.sep)))
168
169 def FileOneLine(self, filename):
170 return file(filename).read().rstrip('\r\n')
171
172 def GetLatestImage(self, board):
173 return self.cmd.OutputOneLine(self.CrosUtilsPath('get_latest_image.sh'),
174 '--board=%s' % board)
175
176 def GetCached(self, src, dst):
177 return (os.path.exists(dst) and
178 os.path.getmtime(dst) >= os.path.getmtime(src))
179
180 def GenerateUpdatePayload(self, src, dst):
181 """Generate an update image from a build-image output file."""
182
183 if self.GetCached(src, dst):
184 self.Info('Using cached update image %s' % dst)
185 return True
186
187 if not self.cmd.Run(self.CrosUtilsPath('cros_generate_update_payload'),
188 '--image=%s' % src, '--output=%s' % dst,
189 '--patch_kernel'):
190 self.Error('generate_payload failed')
191 return False
192
193 return True
194
195 def BuildStateful(self, src, dst):
196 """Create a stateful partition update image."""
197
198 if self.GetCached(src, dst):
199 self.Info('Using cached stateful %s' % dst)
200 return True
201
202 cgpt = self.ChrootPath('/usr/bin/cgpt')
203 offset = self.cmd.OutputOneLine(cgpt, 'show', '-b', '-i', '1', src)
204 size = self.cmd.OutputOneLine(cgpt, 'show', '-s', '-i', '1', src)
205 if None in (size, offset):
206 self.Error('Unable to use cgpt to get image geometry')
207 return False
208
209 return self.cmd.RunPipe([['dd', 'if=%s' % src, 'bs=512',
210 'skip=%s' % offset, 'count=%s' % size],
211 ['gzip', '-c']], outfile=dst)
212
213 def GetSize(self, filename):
214 return os.path.getsize(filename)
215
216 def GetHash(self, filename):
217 return self.cmd.RunPipe([['openssl', 'sha1', '-binary'],
218 ['openssl', 'base64']],
219 infile=filename,
220 capture=True, oneline=True)
221
222 def GetDefaultBoard(self):
223 def_board_file = self.CrosUtilsPath('.default_board')
224 if not os.path.exists(def_board_file):
225 return None
226 return self.FileOneLine(def_board_file)
227
228 def SetRemote(self, remote):
229 self.ssh_cmd = SSHCommand(self, remote)
230
231 def ParseShVars(self, string):
232 """Parse an input file into a dict containing all variable assignments."""
233
234 ret = {}
235 for line in string.splitlines():
236 if '=' in line:
237 var, sep, val = line.partition('=')
238 var = var.strip('\t ').rstrip('\t ')
239 if var:
240 ret[var] = val.strip('\t ').rstrip('\t ')
241 return ret
242
243 def GetRemoteRelease(self):
244 lsb_release = self.ssh_cmd.Output('cat', '/etc/lsb-release')
245 if not lsb_release:
246 return None
247 return self.ParseShVars(lsb_release)
248
249 def CreateServer(self, port, update_file, stateful_file):
250 """Start the devserver clone."""
251
252 PingUpdateResponse.Setup(self.GetHash(update_file),
253 self.GetSize(update_file))
254
255 UpdateHandler.SetupUrl('/update', PingUpdateResponse())
256 UpdateHandler.SetupUrl('/%s' % UPDATE_FILENAME,
257 FileUpdateResponse(update_file,
258 verbose=self.verbose))
259 UpdateHandler.SetupUrl('/%s' % STATEFUL_FILENAME,
260 FileUpdateResponse(stateful_file,
261 verbose=self.verbose))
262
263 self.http_server = BaseHTTPServer.HTTPServer(('', port), UpdateHandler)
264
265 def StartServer(self):
266 self.Info('Starting http server')
267 self.http_server.serve_forever()
268
269 def GetUpdateStatus(self):
270 status = self.ssh_cmd.Output('/usr/bin/update_engine_client', '--status')
271 if not status:
272 self.Error('Cannot get update status')
273 return None
274
275 return self.ParseShVars(status).get('CURRENT_OP', None)
276
277 def ClientReboot(self):
278 """Send "reboot" command to the client, and wait for it to return."""
279
280 self.ssh_cmd.Reset()
281 self.ssh_cmd.Run('reboot')
282 self.Info('Waiting for client to reboot')
283 time.sleep(self.REBOOT_START_WAIT)
284 for attempt in range(self.REBOOT_WAIT_TIME/SSHCommand.CONNECT_TIMEOUT):
285 start = time.time()
286 if self.ssh_cmd.Run('/bin/true'):
287 return True
288 # Make sure we wait at least as long as the connect timeout would have,
289 # since we calculated our number of attempts based on that
290 self.Info('Client has not yet restarted (try %d). Waiting...' % attempt)
291 wait_time = SSHCommand.CONNECT_TIMEOUT - (time.time() - start)
292 if wait_time > 0:
293 time.sleep(wait_time)
294
295 return False
296
297 def StartClient(self, port):
298 """Ask the client machine to update from our server."""
299
300 status = self.GetUpdateStatus()
301 if status != 'UPDATE_STATUS_IDLE':
302 self.Error('Client update status is not IDLE: %s' % status)
303 return False
304
305 url_base = 'http://localhost:%d' % port
306 update_url = '%s/update' % url_base
307 fd, update_log = tempfile.mkstemp(prefix='image-to-target-')
308 self.Info('Starting update on client. Client output stored to %s' %
309 update_log)
310 self.ssh_cmd.Run('/usr/bin/update_engine_client', '--update',
311 '--omaha_url', update_url, remote_tunnel=(port, port),
312 outfile=update_log)
313
314 if self.GetUpdateStatus() != 'UPDATE_STATUS_UPDATED_NEED_REBOOT':
315 self.Error('Client update failed')
316 return False
317
318 self.ssh_cmd.Copy(self.CrosUtilsPath('../platform/dev/stateful_update'),
319 '/tmp')
320 if not self.ssh_cmd.Run('/tmp/stateful_update', url_base,
321 remote_tunnel=(port, port)):
322 self.Error('Client stateful update failed')
323 return False
324
325 self.Info('Rebooting client')
326 if not self.ClientReboot():
327 self.Error('Client may not have successfully rebooted...')
328 return False
329
330 print 'Client update completed successfully!'
331 return True
332
333
334 class UpdateResponse(object):
335 """Default response is the 404 error response."""
336
337 def Reply(self, handler, send_content=True, post_data=None):
338 handler.send_Error(404, 'File not found')
339 return None
340
341
342 class FileUpdateResponse(UpdateResponse):
343 """Respond by sending the contents of a file."""
344
345 def __init__(self, filename, content_type='application/octet-stream',
346 verbose=False, blocksize=16*1024):
347 self.filename = filename
348 self.content_type = content_type
349 self.verbose = verbose
350 self.blocksize = blocksize
351
352 def Reply(self, handler, send_content=True, post_data=None):
353 """Return file contents to the client. Optionally display progress."""
354
355 try:
356 f = open(self.filename, 'rb')
357 except IOError:
358 return UpdateResponse.Reply(self, handler)
359
360 handler.send_response(200)
361 handler.send_header('Content-type', self.content_type)
362 filestat = os.fstat(f.fileno())
363 filesize = filestat[6]
364 handler.send_header('Content-Length', str(filesize))
365 handler.send_header('Last-Modified',
366 handler.date_time_string(filestat.st_mtime))
367 handler.end_headers()
368
369 if not send_content:
370 return
371
372 if filesize <= self.blocksize:
373 handler.wfile.write(f.read())
374 else:
375 sent_size = 0
376 sent_percentage = None
377 while True:
378 buf = f.read(self.blocksize)
379 if not buf:
380 break
381 handler.wfile.write(buf)
382 if self.verbose:
383 sent_size += len(buf)
384 percentage = int(100 * sent_size / filesize)
385 if sent_percentage != percentage:
386 sent_percentage = percentage
387 print '\rSent %d%%' % sent_percentage,
388 sys.stdout.flush()
389 if self.verbose:
390 print '\n'
391 f.close()
392
393
394 class StringUpdateResponse(UpdateResponse):
395 """Respond by sending the contents of a string."""
396
397 def __init__(self, string, content_type='text/plain'):
398 self.string = string
399 self.content_type = content_type
400
401 def Reply(self, handler, send_content=True, post_data=None):
402 handler.send_response(200)
403 handler.send_header('Content-type', self.content_type)
404 handler.send_header('Content-Length', len(self.string))
405 handler.end_headers()
406
407 if not send_content:
408 return
409
410 handler.wfile.write(self.string)
411
412
413 class PingUpdateResponse(StringUpdateResponse):
414 """Respond to a client ping with pre-fab XML response."""
415
416 app_id = '87efface-864d-49a5-9bb3-4b050a7c227a'
417 xmlns = 'http://www.google.com/update2/response'
418 payload_success_template = """<?xml version="1.0" encoding="UTF-8"?>
419 <gupdate xmlns="%s" protocol="2.0">
420 <daystart elapsed_seconds="%s"/>
421 <app appid="{%s}" status="ok">
422 <ping status="ok"/>
423 <updatecheck
424 codebase="%s"
425 hash="%s"
426 needsadmin="false"
427 size="%s"
428 status="ok"/>
429 </app>
430 </gupdate>
431 """
432 payload_failure_template = """<?xml version="1.0" encoding="UTF-8"?>
433 <gupdate xmlns="%s" protocol="2.0">
434 <daystart elapsed_seconds="%s"/>
435 <app appid="{%s}" status="ok">
436 <ping status="ok"/>
437 <updatecheck status="noupdate"/>
438 </app>
439 </gupdate>
440 """
441
442 def __init__(self):
443 self.content_type = 'text/xml'
444
445 @staticmethod
446 def Setup(filehash, filesize):
447 PingUpdateResponse.file_hash = filehash
448 PingUpdateResponse.file_size = filesize
449
450 def Reply(self, handler, send_content=True, post_data=None):
451 """Return (using StringResponse) an XML reply to ForcedUpdate clients."""
452
453 if not post_data:
454 return UpdateResponse.Reply(self, handler)
455
456 request_version = (minidom.parseString(post_data).firstChild.
457 getElementsByTagName('o:app')[0].
458 getAttribute('version'))
459
460 if request_version == 'ForcedUpdate':
461 host, pdict = cgi.parse_header(handler.headers.getheader('Host'))
462 self.string = (self.payload_success_template %
463 (self.xmlns, self.SecondsSinceMidnight(),
464 self.app_id, 'http://%s/%s' % (host, UPDATE_FILENAME),
465 self.file_hash, self.file_size))
466 else:
467 self.string = (self.payload_failure_template %
468 (self.xmlns, self.SecondsSinceMidnight(), self.app_id))
469
470 StringUpdateResponse.Reply(self, handler, send_content)
471
472 def SecondsSinceMidnight(self):
473 now = time.localtime()
474 return now[3] * 3600 + now[4] * 60 + now[5]
475
476
477 class UpdateHandler(BaseHTTPServer.BaseHTTPRequestHandler):
478 """Handler for HTTP requests to devserver clone."""
479
480 server_version = 'ImageToTargetUpdater/0.0'
481 url_mapping = {}
482
483 @staticmethod
484 def SetupUrl(url, response):
485 UpdateHandler.url_mapping[url] = response
486
487 def do_GET(self):
488 """Serve a GET request."""
489 response = UpdateHandler.url_mapping.get(self.path, UpdateResponse())
490 response.Reply(self, True)
491
492 def do_HEAD(self):
493 """Serve a HEAD request."""
494 response = UpdateHandler.url_mapping.get(self.path, UpdateResponse())
495 response.Reply(self, False)
496
497 def do_POST(self):
498 content_length = int(self.headers.getheader('Content-Length'))
499 request = self.rfile.read(content_length)
500 response = UpdateHandler.url_mapping.get(self.path, UpdateResponse())
501 response.Reply(self, True, request)
502
503
504 class ChildFinished(Exception):
505 """Child exit exception."""
506
507 def __init__(self, pid):
508 Exception.__init__(self)
509 self.pid = pid
510 self.status = None
511
512 def __str__(self):
513 return 'Process %d exited status %d' % (self.pid, self.status)
514
515 def __nonzero__(self):
516 return self.status is not None
517
518 def SigHandler(self, signum, frame):
519 """Handle SIGCHLD signal, and retreive client exit code."""
520
521 while True:
522 try:
523 (pid, status) = os.waitpid(-1, os.WNOHANG)
524 except OSError, e:
525 if e.args[0] != errno.ECHILD:
526 raise e
527
528 # TODO(pstew): returning here won't help -- SocketServer gets EINTR
529 return
530
531 if pid == self.pid:
532 if os.WIFEXITED(status):
533 self.status = os.WEXITSTATUS(status)
534 else:
535 self.status = 255
536 raise self
537
538
539 def main(argv):
540 usage = 'usage: %prog'
541 parser = optparse.OptionParser(usage=usage)
542 parser.add_option('--board', dest='board', default=None,
543 help='Board platform type')
544 parser.add_option('--force-mismatch', dest='force_mismatch', default=False,
545 action='store_true',
546 help='Upgrade even if client arch does not match')
547 parser.add_option('--from', dest='src', default=None,
548 help='Source image to install')
549 parser.add_option('--image-name', dest='image_name',
550 default=DEFAULT_IMAGE_NAME,
551 help='Filename within image directory to load')
552 parser.add_option('--port', dest='port', default=8081, type='int',
553 help='TCP port to serve from and tunnel through')
554 parser.add_option('--remote', dest='remote', default=None,
555 help='Remote device-under-test IP address')
556 parser.add_option('--server-only', dest='server_only', default=False,
557 action='store_true', help='Do not start client')
558 parser.add_option('--verbose', dest='verbose', default=False,
559 action='store_true', help='Display running commands')
560
561 (options, args) = parser.parse_args(argv)
562
563 cros_env = CrosEnv(verbose=options.verbose)
564
565 if not options.board:
566 options.board = cros_env.GetDefaultBoard()
567
568 if not options.src:
569 options.src = cros_env.GetLatestImage(options.board)
570 if options.src is None:
571 parser.error('No --from argument given and no default image found')
572
573 cros_env.Info('Performing update from %s' % options.src)
574
575 if not os.path.exists(options.src):
576 parser.error('Path %s does not exist' % options.src)
577
578 if os.path.isdir(options.src):
579 image_directory = options.src
580 image_file = os.path.join(options.src, options.image_name)
581
582 if not os.path.exists(image_file):
583 parser.error('Image file %s does not exist' % image_file)
584 else:
585 image_file = options.src
586 image_directory = os.path.dirname(options.src)
587
588 if options.remote:
589 cros_env.SetRemote(options.remote)
590 rel = cros_env.GetRemoteRelease()
591 if not rel:
592 cros_env.Fatal('Could not retrieve remote lsb-release')
593 board = rel.get('CHROMEOS_RELEASE_BOARD', '(None)')
594 if board != options.board and not options.force_mismatch:
595 cros_env.Error('Board %s does not match expected %s' %
596 (board, options.board))
597 cros_env.Error('(Use --force-mismatch option to override this)')
598 cros_env.Fatal()
599
600 elif not options.server_only:
601 parser.error('Either --server-only must be specified or '
602 '--remote=<client> needs to be given')
603
604 update_file = os.path.join(image_directory, UPDATE_FILENAME)
605 stateful_file = os.path.join(image_directory, STATEFUL_FILENAME)
606
607 if (not cros_env.GenerateUpdatePayload(image_file, update_file) or
608 not cros_env.BuildStateful(image_file, stateful_file)):
609 cros_env.Fatal()
610
611 cros_env.CreateServer(options.port, update_file, stateful_file)
612
613 exit_status = 1
614 if options.server_only:
615 child = None
616 else:
617 # Start an "image-to-live" instance that will pull bits from the server
618 child = os.fork()
619 if child:
620 signal.signal(signal.SIGCHLD, ChildFinished(child).SigHandler)
621 else:
622 try:
623 time.sleep(SERVER_STARTUP_WAIT)
624 if cros_env.StartClient(options.port):
625 exit_status = 0
626 except KeyboardInterrupt:
627 cros_env.Error('Client Exiting on Control-C')
628 except:
629 cros_env.Error('Exception in client code:')
630 traceback.print_exc(file=sys.stdout)
631
632 cros_env.ssh_cmd.Cleanup()
633 cros_env.Info('Client exiting with status %d' % exit_status)
634 sys.exit(exit_status)
635
636 try:
637 cros_env.StartServer()
638 except KeyboardInterrupt:
639 cros_env.Info('Server Exiting on Control-C')
640 exit_status = 0
641 except ChildFinished, e:
642 cros_env.Info('Server Exiting on Client Exit (%d)' % e.status)
643 exit_status = e.status
644 child = None
645 except:
646 cros_env.Error('Exception in server code:')
647 traceback.print_exc(file=sys.stdout)
648
649 if child:
650 os.kill(child, 15)
651
652 cros_env.Info('Server exiting with status %d' % exit_status)
653 sys.exit(exit_status)
654
655
656 if __name__ == '__main__':
657 main(sys.argv)
OLDNEW
« no previous file with comments | « no previous file | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698