blob: ca7518daca8b4d62c694b878f04f35095ca22ab1 [file] [log] [blame]
Alex Deymo6751bbe2017-03-21 11:20:02 -07001#!/usr/bin/env python
2#
3# Copyright (C) 2017 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16#
17
18"""Send an A/B update to an Android device over adb."""
19
20import argparse
21import BaseHTTPServer
22import logging
23import os
24import socket
25import subprocess
26import sys
27import threading
Sen Jiang144f9f82017-09-26 15:49:45 -070028import xml.etree.ElementTree
Alex Deymo6751bbe2017-03-21 11:20:02 -070029import zipfile
30
Sen Jianga1784b72017-08-09 17:42:36 -070031import update_payload.payload
32
Alex Deymo6751bbe2017-03-21 11:20:02 -070033
34# The path used to store the OTA package when applying the package from a file.
35OTA_PACKAGE_PATH = '/data/ota_package'
36
Sen Jianga1784b72017-08-09 17:42:36 -070037# The path to the payload public key on the device.
38PAYLOAD_KEY_PATH = '/etc/update_engine/update-payload-key.pub.pem'
39
40# The port on the device that update_engine should connect to.
41DEVICE_PORT = 1234
Alex Deymo6751bbe2017-03-21 11:20:02 -070042
43def CopyFileObjLength(fsrc, fdst, buffer_size=128 * 1024, copy_length=None):
44 """Copy from a file object to another.
45
46 This function is similar to shutil.copyfileobj except that it allows to copy
47 less than the full source file.
48
49 Args:
50 fsrc: source file object where to read from.
51 fdst: destination file object where to write to.
52 buffer_size: size of the copy buffer in memory.
53 copy_length: maximum number of bytes to copy, or None to copy everything.
54
55 Returns:
56 the number of bytes copied.
57 """
58 copied = 0
59 while True:
60 chunk_size = buffer_size
61 if copy_length is not None:
62 chunk_size = min(chunk_size, copy_length - copied)
63 if not chunk_size:
64 break
65 buf = fsrc.read(chunk_size)
66 if not buf:
67 break
68 fdst.write(buf)
69 copied += len(buf)
70 return copied
71
72
73class AndroidOTAPackage(object):
74 """Android update payload using the .zip format.
75
76 Android OTA packages traditionally used a .zip file to store the payload. When
77 applying A/B updates over the network, a payload binary is stored RAW inside
78 this .zip file which is used by update_engine to apply the payload. To do
79 this, an offset and size inside the .zip file are provided.
80 """
81
82 # Android OTA package file paths.
83 OTA_PAYLOAD_BIN = 'payload.bin'
84 OTA_PAYLOAD_PROPERTIES_TXT = 'payload_properties.txt'
85
86 def __init__(self, otafilename):
87 self.otafilename = otafilename
88
89 otazip = zipfile.ZipFile(otafilename, 'r')
90 payload_info = otazip.getinfo(self.OTA_PAYLOAD_BIN)
91 self.offset = payload_info.header_offset + len(payload_info.FileHeader())
92 self.size = payload_info.file_size
93 self.properties = otazip.read(self.OTA_PAYLOAD_PROPERTIES_TXT)
94
95
96class UpdateHandler(BaseHTTPServer.BaseHTTPRequestHandler):
97 """A HTTPServer that supports single-range requests.
98
99 Attributes:
100 serving_payload: path to the only payload file we are serving.
101 """
102
103 @staticmethod
Sen Jiang10485592017-08-15 18:20:24 -0700104 def _parse_range(range_str, file_size):
Alex Deymo6751bbe2017-03-21 11:20:02 -0700105 """Parse an HTTP range string.
106
107 Args:
108 range_str: HTTP Range header in the request, not including "Header:".
109 file_size: total size of the serving file.
110
111 Returns:
112 A tuple (start_range, end_range) with the range of bytes requested.
113 """
114 start_range = 0
115 end_range = file_size
116
117 if range_str:
118 range_str = range_str.split('=', 1)[1]
119 s, e = range_str.split('-', 1)
120 if s:
121 start_range = int(s)
122 if e:
123 end_range = int(e) + 1
124 elif e:
125 if int(e) < file_size:
126 start_range = file_size - int(e)
127 return start_range, end_range
128
129
130 def do_GET(self): # pylint: disable=invalid-name
131 """Reply with the requested payload file."""
132 if self.path != '/payload':
133 self.send_error(404, 'Unknown request')
134 return
135
136 if not self.serving_payload:
137 self.send_error(500, 'No serving payload set')
138 return
139
140 try:
141 f = open(self.serving_payload, 'rb')
142 except IOError:
143 self.send_error(404, 'File not found')
144 return
145 # Handle the range request.
146 if 'Range' in self.headers:
147 self.send_response(206)
148 else:
149 self.send_response(200)
150
151 stat = os.fstat(f.fileno())
Sen Jiang10485592017-08-15 18:20:24 -0700152 start_range, end_range = self._parse_range(self.headers.get('range'),
153 stat.st_size)
Alex Deymo6751bbe2017-03-21 11:20:02 -0700154 logging.info('Serving request for %s from %s [%d, %d) length: %d',
Sen Jiang10485592017-08-15 18:20:24 -0700155 self.path, self.serving_payload, start_range, end_range,
156 end_range - start_range)
Alex Deymo6751bbe2017-03-21 11:20:02 -0700157
158 self.send_header('Accept-Ranges', 'bytes')
159 self.send_header('Content-Range',
160 'bytes ' + str(start_range) + '-' + str(end_range - 1) +
161 '/' + str(end_range - start_range))
162 self.send_header('Content-Length', end_range - start_range)
163
164 self.send_header('Last-Modified', self.date_time_string(stat.st_mtime))
165 self.send_header('Content-type', 'application/octet-stream')
166 self.end_headers()
167
168 f.seek(start_range)
169 CopyFileObjLength(f, self.wfile, copy_length=end_range - start_range)
170
171
Sen Jianga1784b72017-08-09 17:42:36 -0700172 def do_POST(self): # pylint: disable=invalid-name
173 """Reply with the omaha response xml."""
174 if self.path != '/update':
175 self.send_error(404, 'Unknown request')
176 return
177
178 if not self.serving_payload:
179 self.send_error(500, 'No serving payload set')
180 return
181
182 try:
183 f = open(self.serving_payload, 'rb')
184 except IOError:
185 self.send_error(404, 'File not found')
186 return
187
Sen Jiang144f9f82017-09-26 15:49:45 -0700188 content_length = int(self.headers.getheader('Content-Length'))
189 request_xml = self.rfile.read(content_length)
190 xml_root = xml.etree.ElementTree.fromstring(request_xml)
191 appid = None
192 for app in xml_root.iter('app'):
193 if 'appid' in app.attrib:
194 appid = app.attrib['appid']
195 break
196 if not appid:
197 self.send_error(400, 'No appid in Omaha request')
198 return
199
Sen Jianga1784b72017-08-09 17:42:36 -0700200 self.send_response(200)
201 self.send_header("Content-type", "text/xml")
202 self.end_headers()
203
204 stat = os.fstat(f.fileno())
205 sha256sum = subprocess.check_output(['sha256sum', self.serving_payload])
206 payload_hash = sha256sum.split()[0]
207 payload = update_payload.Payload(f)
208 payload.Init()
209
Sen Jiang144f9f82017-09-26 15:49:45 -0700210 response_xml = '''
Sen Jianga1784b72017-08-09 17:42:36 -0700211 <?xml version="1.0" encoding="UTF-8"?>
212 <response protocol="3.0">
Sen Jiang144f9f82017-09-26 15:49:45 -0700213 <app appid="{appid}">
Sen Jianga1784b72017-08-09 17:42:36 -0700214 <updatecheck status="ok">
215 <urls>
Sen Jiang144f9f82017-09-26 15:49:45 -0700216 <url codebase="http://127.0.0.1:{port}/"/>
Sen Jianga1784b72017-08-09 17:42:36 -0700217 </urls>
218 <manifest version="0.0.0.1">
219 <actions>
220 <action event="install" run="payload"/>
Sen Jiang144f9f82017-09-26 15:49:45 -0700221 <action event="postinstall" MetadataSize="{metadata_size}"/>
Sen Jianga1784b72017-08-09 17:42:36 -0700222 </actions>
223 <packages>
Sen Jiang144f9f82017-09-26 15:49:45 -0700224 <package hash_sha256="{payload_hash}" name="payload" size="{payload_size}"/>
Sen Jianga1784b72017-08-09 17:42:36 -0700225 </packages>
226 </manifest>
227 </updatecheck>
228 </app>
229 </response>
Sen Jiang144f9f82017-09-26 15:49:45 -0700230 '''.format(appid=appid, port=DEVICE_PORT,
231 metadata_size=payload.metadata_size, payload_hash=payload_hash,
232 payload_size=stat.st_size)
233 self.wfile.write(response_xml.strip())
Sen Jianga1784b72017-08-09 17:42:36 -0700234 return
235
236
Alex Deymo6751bbe2017-03-21 11:20:02 -0700237class ServerThread(threading.Thread):
238 """A thread for serving HTTP requests."""
239
240 def __init__(self, ota_filename):
241 threading.Thread.__init__(self)
242 # serving_payload is a class attribute and the UpdateHandler class is
243 # instantiated with every request.
244 UpdateHandler.serving_payload = ota_filename
245 self._httpd = BaseHTTPServer.HTTPServer(('127.0.0.1', 0), UpdateHandler)
246 self.port = self._httpd.server_port
247
248 def run(self):
249 try:
250 self._httpd.serve_forever()
251 except (KeyboardInterrupt, socket.error):
252 pass
253 logging.info('Server Terminated')
254
255 def StopServer(self):
256 self._httpd.socket.close()
257
258
259def StartServer(ota_filename):
260 t = ServerThread(ota_filename)
261 t.start()
262 return t
263
264
265def AndroidUpdateCommand(ota_filename, payload_url):
266 """Return the command to run to start the update in the Android device."""
267 ota = AndroidOTAPackage(ota_filename)
268 headers = ota.properties
269 headers += 'USER_AGENT=Dalvik (something, something)\n'
270
271 # headers += 'POWERWASH=1\n'
272 headers += 'NETWORK_ID=0\n'
273
274 return ['update_engine_client', '--update', '--follow',
275 '--payload=%s' % payload_url, '--offset=%d' % ota.offset,
276 '--size=%d' % ota.size, '--headers="%s"' % headers]
277
278
Sen Jianga1784b72017-08-09 17:42:36 -0700279def OmahaUpdateCommand(omaha_url):
280 """Return the command to run to start the update in a device using Omaha."""
281 return ['update_engine_client', '--update', '--follow',
282 '--omaha_url=%s' % omaha_url]
283
284
Alex Deymo6751bbe2017-03-21 11:20:02 -0700285class AdbHost(object):
286 """Represents a device connected via ADB."""
287
288 def __init__(self, device_serial=None):
289 """Construct an instance.
290
291 Args:
292 device_serial: options string serial number of attached device.
293 """
294 self._device_serial = device_serial
295 self._command_prefix = ['adb']
296 if self._device_serial:
297 self._command_prefix += ['-s', self._device_serial]
298
299 def adb(self, command):
300 """Run an ADB command like "adb push".
301
302 Args:
303 command: list of strings containing command and arguments to run
304
305 Returns:
306 the program's return code.
307
308 Raises:
309 subprocess.CalledProcessError on command exit != 0.
310 """
311 command = self._command_prefix + command
312 logging.info('Running: %s', ' '.join(str(x) for x in command))
313 p = subprocess.Popen(command, universal_newlines=True)
314 p.wait()
315 return p.returncode
316
Sen Jianga1784b72017-08-09 17:42:36 -0700317 def adb_output(self, command):
318 """Run an ADB command like "adb push" and return the output.
319
320 Args:
321 command: list of strings containing command and arguments to run
322
323 Returns:
324 the program's output as a string.
325
326 Raises:
327 subprocess.CalledProcessError on command exit != 0.
328 """
329 command = self._command_prefix + command
330 logging.info('Running: %s', ' '.join(str(x) for x in command))
331 return subprocess.check_output(command, universal_newlines=True)
332
Alex Deymo6751bbe2017-03-21 11:20:02 -0700333
334def main():
335 parser = argparse.ArgumentParser(description='Android A/B OTA helper.')
336 parser.add_argument('otafile', metavar='ZIP', type=str,
337 help='the OTA package file (a .zip file).')
338 parser.add_argument('--file', action='store_true',
339 help='Push the file to the device before updating.')
340 parser.add_argument('--no-push', action='store_true',
341 help='Skip the "push" command when using --file')
342 parser.add_argument('-s', type=str, default='', metavar='DEVICE',
343 help='The specific device to use.')
344 parser.add_argument('--no-verbose', action='store_true',
345 help='Less verbose output')
Sen Jianga1784b72017-08-09 17:42:36 -0700346 parser.add_argument('--public-key', type=str, default='',
347 help='Override the public key used to verify payload.')
Alex Deymo6751bbe2017-03-21 11:20:02 -0700348 args = parser.parse_args()
349 logging.basicConfig(
350 level=logging.WARNING if args.no_verbose else logging.INFO)
351
352 dut = AdbHost(args.s)
353
354 server_thread = None
355 # List of commands to execute on exit.
356 finalize_cmds = []
357 # Commands to execute when canceling an update.
358 cancel_cmd = ['shell', 'su', '0', 'update_engine_client', '--cancel']
359 # List of commands to perform the update.
360 cmds = []
361
Sen Jianga1784b72017-08-09 17:42:36 -0700362 help_cmd = ['shell', 'su', '0', 'update_engine_client', '--help']
363 use_omaha = 'omaha' in dut.adb_output(help_cmd)
364
Alex Deymo6751bbe2017-03-21 11:20:02 -0700365 if args.file:
366 # Update via pushing a file to /data.
367 device_ota_file = os.path.join(OTA_PACKAGE_PATH, 'debug.zip')
368 payload_url = 'file://' + device_ota_file
369 if not args.no_push:
370 cmds.append(['push', args.otafile, device_ota_file])
371 cmds.append(['shell', 'su', '0', 'chown', 'system:cache', device_ota_file])
372 cmds.append(['shell', 'su', '0', 'chmod', '0660', device_ota_file])
373 else:
374 # Update via sending the payload over the network with an "adb reverse"
375 # command.
Sen Jianga1784b72017-08-09 17:42:36 -0700376 payload_url = 'http://127.0.0.1:%d/payload' % DEVICE_PORT
Alex Deymo6751bbe2017-03-21 11:20:02 -0700377 server_thread = StartServer(args.otafile)
378 cmds.append(
Sen Jianga1784b72017-08-09 17:42:36 -0700379 ['reverse', 'tcp:%d' % DEVICE_PORT, 'tcp:%d' % server_thread.port])
380 finalize_cmds.append(['reverse', '--remove', 'tcp:%d' % DEVICE_PORT])
381
382 if args.public_key:
383 payload_key_dir = os.path.dirname(PAYLOAD_KEY_PATH)
384 cmds.append(
385 ['shell', 'su', '0', 'mount', '-t', 'tmpfs', 'tmpfs', payload_key_dir])
386 # Allow adb push to payload_key_dir
387 cmds.append(['shell', 'su', '0', 'chcon', 'u:object_r:shell_data_file:s0',
388 payload_key_dir])
389 cmds.append(['push', args.public_key, PAYLOAD_KEY_PATH])
390 # Allow update_engine to read it.
391 cmds.append(['shell', 'su', '0', 'chcon', '-R', 'u:object_r:system_file:s0',
392 payload_key_dir])
393 finalize_cmds.append(['shell', 'su', '0', 'umount', payload_key_dir])
Alex Deymo6751bbe2017-03-21 11:20:02 -0700394
395 try:
396 # The main update command using the configured payload_url.
Sen Jianga1784b72017-08-09 17:42:36 -0700397 if use_omaha:
398 update_cmd = \
399 OmahaUpdateCommand('http://127.0.0.1:%d/update' % DEVICE_PORT)
400 else:
401 update_cmd = AndroidUpdateCommand(args.otafile, payload_url)
Alex Deymo6751bbe2017-03-21 11:20:02 -0700402 cmds.append(['shell', 'su', '0'] + update_cmd)
403
404 for cmd in cmds:
405 dut.adb(cmd)
406 except KeyboardInterrupt:
407 dut.adb(cancel_cmd)
408 finally:
409 if server_thread:
410 server_thread.StopServer()
411 for cmd in finalize_cmds:
412 dut.adb(cmd)
413
414 return 0
415
416if __name__ == '__main__':
417 sys.exit(main())