blob: a5be0a5918689031407fd9f4286c108babf80591 [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
28import zipfile
29
Sen Jianga1784b72017-08-09 17:42:36 -070030import update_payload.payload
31
Alex Deymo6751bbe2017-03-21 11:20:02 -070032
33# The path used to store the OTA package when applying the package from a file.
34OTA_PACKAGE_PATH = '/data/ota_package'
35
Sen Jianga1784b72017-08-09 17:42:36 -070036# The path to the payload public key on the device.
37PAYLOAD_KEY_PATH = '/etc/update_engine/update-payload-key.pub.pem'
38
39# The port on the device that update_engine should connect to.
40DEVICE_PORT = 1234
Alex Deymo6751bbe2017-03-21 11:20:02 -070041
42def CopyFileObjLength(fsrc, fdst, buffer_size=128 * 1024, copy_length=None):
43 """Copy from a file object to another.
44
45 This function is similar to shutil.copyfileobj except that it allows to copy
46 less than the full source file.
47
48 Args:
49 fsrc: source file object where to read from.
50 fdst: destination file object where to write to.
51 buffer_size: size of the copy buffer in memory.
52 copy_length: maximum number of bytes to copy, or None to copy everything.
53
54 Returns:
55 the number of bytes copied.
56 """
57 copied = 0
58 while True:
59 chunk_size = buffer_size
60 if copy_length is not None:
61 chunk_size = min(chunk_size, copy_length - copied)
62 if not chunk_size:
63 break
64 buf = fsrc.read(chunk_size)
65 if not buf:
66 break
67 fdst.write(buf)
68 copied += len(buf)
69 return copied
70
71
72class AndroidOTAPackage(object):
73 """Android update payload using the .zip format.
74
75 Android OTA packages traditionally used a .zip file to store the payload. When
76 applying A/B updates over the network, a payload binary is stored RAW inside
77 this .zip file which is used by update_engine to apply the payload. To do
78 this, an offset and size inside the .zip file are provided.
79 """
80
81 # Android OTA package file paths.
82 OTA_PAYLOAD_BIN = 'payload.bin'
83 OTA_PAYLOAD_PROPERTIES_TXT = 'payload_properties.txt'
84
85 def __init__(self, otafilename):
86 self.otafilename = otafilename
87
88 otazip = zipfile.ZipFile(otafilename, 'r')
89 payload_info = otazip.getinfo(self.OTA_PAYLOAD_BIN)
90 self.offset = payload_info.header_offset + len(payload_info.FileHeader())
91 self.size = payload_info.file_size
92 self.properties = otazip.read(self.OTA_PAYLOAD_PROPERTIES_TXT)
93
94
95class UpdateHandler(BaseHTTPServer.BaseHTTPRequestHandler):
96 """A HTTPServer that supports single-range requests.
97
98 Attributes:
99 serving_payload: path to the only payload file we are serving.
100 """
101
102 @staticmethod
Sen Jiang10485592017-08-15 18:20:24 -0700103 def _parse_range(range_str, file_size):
Alex Deymo6751bbe2017-03-21 11:20:02 -0700104 """Parse an HTTP range string.
105
106 Args:
107 range_str: HTTP Range header in the request, not including "Header:".
108 file_size: total size of the serving file.
109
110 Returns:
111 A tuple (start_range, end_range) with the range of bytes requested.
112 """
113 start_range = 0
114 end_range = file_size
115
116 if range_str:
117 range_str = range_str.split('=', 1)[1]
118 s, e = range_str.split('-', 1)
119 if s:
120 start_range = int(s)
121 if e:
122 end_range = int(e) + 1
123 elif e:
124 if int(e) < file_size:
125 start_range = file_size - int(e)
126 return start_range, end_range
127
128
129 def do_GET(self): # pylint: disable=invalid-name
130 """Reply with the requested payload file."""
131 if self.path != '/payload':
132 self.send_error(404, 'Unknown request')
133 return
134
135 if not self.serving_payload:
136 self.send_error(500, 'No serving payload set')
137 return
138
139 try:
140 f = open(self.serving_payload, 'rb')
141 except IOError:
142 self.send_error(404, 'File not found')
143 return
144 # Handle the range request.
145 if 'Range' in self.headers:
146 self.send_response(206)
147 else:
148 self.send_response(200)
149
150 stat = os.fstat(f.fileno())
Sen Jiang10485592017-08-15 18:20:24 -0700151 start_range, end_range = self._parse_range(self.headers.get('range'),
152 stat.st_size)
Alex Deymo6751bbe2017-03-21 11:20:02 -0700153 logging.info('Serving request for %s from %s [%d, %d) length: %d',
Sen Jiang10485592017-08-15 18:20:24 -0700154 self.path, self.serving_payload, start_range, end_range,
155 end_range - start_range)
Alex Deymo6751bbe2017-03-21 11:20:02 -0700156
157 self.send_header('Accept-Ranges', 'bytes')
158 self.send_header('Content-Range',
159 'bytes ' + str(start_range) + '-' + str(end_range - 1) +
160 '/' + str(end_range - start_range))
161 self.send_header('Content-Length', end_range - start_range)
162
163 self.send_header('Last-Modified', self.date_time_string(stat.st_mtime))
164 self.send_header('Content-type', 'application/octet-stream')
165 self.end_headers()
166
167 f.seek(start_range)
168 CopyFileObjLength(f, self.wfile, copy_length=end_range - start_range)
169
170
Sen Jianga1784b72017-08-09 17:42:36 -0700171 def do_POST(self): # pylint: disable=invalid-name
172 """Reply with the omaha response xml."""
173 if self.path != '/update':
174 self.send_error(404, 'Unknown request')
175 return
176
177 if not self.serving_payload:
178 self.send_error(500, 'No serving payload set')
179 return
180
181 try:
182 f = open(self.serving_payload, 'rb')
183 except IOError:
184 self.send_error(404, 'File not found')
185 return
186
187 self.send_response(200)
188 self.send_header("Content-type", "text/xml")
189 self.end_headers()
190
191 stat = os.fstat(f.fileno())
192 sha256sum = subprocess.check_output(['sha256sum', self.serving_payload])
193 payload_hash = sha256sum.split()[0]
194 payload = update_payload.Payload(f)
195 payload.Init()
196
197 xml = '''
198 <?xml version="1.0" encoding="UTF-8"?>
199 <response protocol="3.0">
200 <app appid="appid">
201 <updatecheck status="ok">
202 <urls>
203 <url codebase="http://127.0.0.1:%d/"/>
204 </urls>
205 <manifest version="0.0.0.1">
206 <actions>
207 <action event="install" run="payload"/>
208 <action event="postinstall" MetadataSize="%d"/>
209 </actions>
210 <packages>
211 <package hash_sha256="%s" name="payload" size="%d"/>
212 </packages>
213 </manifest>
214 </updatecheck>
215 </app>
216 </response>
217 ''' % (DEVICE_PORT, payload.metadata_size, payload_hash, stat.st_size)
218 self.wfile.write(xml.strip())
219 return
220
221
Alex Deymo6751bbe2017-03-21 11:20:02 -0700222class ServerThread(threading.Thread):
223 """A thread for serving HTTP requests."""
224
225 def __init__(self, ota_filename):
226 threading.Thread.__init__(self)
227 # serving_payload is a class attribute and the UpdateHandler class is
228 # instantiated with every request.
229 UpdateHandler.serving_payload = ota_filename
230 self._httpd = BaseHTTPServer.HTTPServer(('127.0.0.1', 0), UpdateHandler)
231 self.port = self._httpd.server_port
232
233 def run(self):
234 try:
235 self._httpd.serve_forever()
236 except (KeyboardInterrupt, socket.error):
237 pass
238 logging.info('Server Terminated')
239
240 def StopServer(self):
241 self._httpd.socket.close()
242
243
244def StartServer(ota_filename):
245 t = ServerThread(ota_filename)
246 t.start()
247 return t
248
249
250def AndroidUpdateCommand(ota_filename, payload_url):
251 """Return the command to run to start the update in the Android device."""
252 ota = AndroidOTAPackage(ota_filename)
253 headers = ota.properties
254 headers += 'USER_AGENT=Dalvik (something, something)\n'
255
256 # headers += 'POWERWASH=1\n'
257 headers += 'NETWORK_ID=0\n'
258
259 return ['update_engine_client', '--update', '--follow',
260 '--payload=%s' % payload_url, '--offset=%d' % ota.offset,
261 '--size=%d' % ota.size, '--headers="%s"' % headers]
262
263
Sen Jianga1784b72017-08-09 17:42:36 -0700264def OmahaUpdateCommand(omaha_url):
265 """Return the command to run to start the update in a device using Omaha."""
266 return ['update_engine_client', '--update', '--follow',
267 '--omaha_url=%s' % omaha_url]
268
269
Alex Deymo6751bbe2017-03-21 11:20:02 -0700270class AdbHost(object):
271 """Represents a device connected via ADB."""
272
273 def __init__(self, device_serial=None):
274 """Construct an instance.
275
276 Args:
277 device_serial: options string serial number of attached device.
278 """
279 self._device_serial = device_serial
280 self._command_prefix = ['adb']
281 if self._device_serial:
282 self._command_prefix += ['-s', self._device_serial]
283
284 def adb(self, command):
285 """Run an ADB command like "adb push".
286
287 Args:
288 command: list of strings containing command and arguments to run
289
290 Returns:
291 the program's return code.
292
293 Raises:
294 subprocess.CalledProcessError on command exit != 0.
295 """
296 command = self._command_prefix + command
297 logging.info('Running: %s', ' '.join(str(x) for x in command))
298 p = subprocess.Popen(command, universal_newlines=True)
299 p.wait()
300 return p.returncode
301
Sen Jianga1784b72017-08-09 17:42:36 -0700302 def adb_output(self, command):
303 """Run an ADB command like "adb push" and return the output.
304
305 Args:
306 command: list of strings containing command and arguments to run
307
308 Returns:
309 the program's output as a string.
310
311 Raises:
312 subprocess.CalledProcessError on command exit != 0.
313 """
314 command = self._command_prefix + command
315 logging.info('Running: %s', ' '.join(str(x) for x in command))
316 return subprocess.check_output(command, universal_newlines=True)
317
Alex Deymo6751bbe2017-03-21 11:20:02 -0700318
319def main():
320 parser = argparse.ArgumentParser(description='Android A/B OTA helper.')
321 parser.add_argument('otafile', metavar='ZIP', type=str,
322 help='the OTA package file (a .zip file).')
323 parser.add_argument('--file', action='store_true',
324 help='Push the file to the device before updating.')
325 parser.add_argument('--no-push', action='store_true',
326 help='Skip the "push" command when using --file')
327 parser.add_argument('-s', type=str, default='', metavar='DEVICE',
328 help='The specific device to use.')
329 parser.add_argument('--no-verbose', action='store_true',
330 help='Less verbose output')
Sen Jianga1784b72017-08-09 17:42:36 -0700331 parser.add_argument('--public-key', type=str, default='',
332 help='Override the public key used to verify payload.')
Alex Deymo6751bbe2017-03-21 11:20:02 -0700333 args = parser.parse_args()
334 logging.basicConfig(
335 level=logging.WARNING if args.no_verbose else logging.INFO)
336
337 dut = AdbHost(args.s)
338
339 server_thread = None
340 # List of commands to execute on exit.
341 finalize_cmds = []
342 # Commands to execute when canceling an update.
343 cancel_cmd = ['shell', 'su', '0', 'update_engine_client', '--cancel']
344 # List of commands to perform the update.
345 cmds = []
346
Sen Jianga1784b72017-08-09 17:42:36 -0700347 help_cmd = ['shell', 'su', '0', 'update_engine_client', '--help']
348 use_omaha = 'omaha' in dut.adb_output(help_cmd)
349
Alex Deymo6751bbe2017-03-21 11:20:02 -0700350 if args.file:
351 # Update via pushing a file to /data.
352 device_ota_file = os.path.join(OTA_PACKAGE_PATH, 'debug.zip')
353 payload_url = 'file://' + device_ota_file
354 if not args.no_push:
355 cmds.append(['push', args.otafile, device_ota_file])
356 cmds.append(['shell', 'su', '0', 'chown', 'system:cache', device_ota_file])
357 cmds.append(['shell', 'su', '0', 'chmod', '0660', device_ota_file])
358 else:
359 # Update via sending the payload over the network with an "adb reverse"
360 # command.
Sen Jianga1784b72017-08-09 17:42:36 -0700361 payload_url = 'http://127.0.0.1:%d/payload' % DEVICE_PORT
Alex Deymo6751bbe2017-03-21 11:20:02 -0700362 server_thread = StartServer(args.otafile)
363 cmds.append(
Sen Jianga1784b72017-08-09 17:42:36 -0700364 ['reverse', 'tcp:%d' % DEVICE_PORT, 'tcp:%d' % server_thread.port])
365 finalize_cmds.append(['reverse', '--remove', 'tcp:%d' % DEVICE_PORT])
366
367 if args.public_key:
368 payload_key_dir = os.path.dirname(PAYLOAD_KEY_PATH)
369 cmds.append(
370 ['shell', 'su', '0', 'mount', '-t', 'tmpfs', 'tmpfs', payload_key_dir])
371 # Allow adb push to payload_key_dir
372 cmds.append(['shell', 'su', '0', 'chcon', 'u:object_r:shell_data_file:s0',
373 payload_key_dir])
374 cmds.append(['push', args.public_key, PAYLOAD_KEY_PATH])
375 # Allow update_engine to read it.
376 cmds.append(['shell', 'su', '0', 'chcon', '-R', 'u:object_r:system_file:s0',
377 payload_key_dir])
378 finalize_cmds.append(['shell', 'su', '0', 'umount', payload_key_dir])
Alex Deymo6751bbe2017-03-21 11:20:02 -0700379
380 try:
381 # The main update command using the configured payload_url.
Sen Jianga1784b72017-08-09 17:42:36 -0700382 if use_omaha:
383 update_cmd = \
384 OmahaUpdateCommand('http://127.0.0.1:%d/update' % DEVICE_PORT)
385 else:
386 update_cmd = AndroidUpdateCommand(args.otafile, payload_url)
Alex Deymo6751bbe2017-03-21 11:20:02 -0700387 cmds.append(['shell', 'su', '0'] + update_cmd)
388
389 for cmd in cmds:
390 dut.adb(cmd)
391 except KeyboardInterrupt:
392 dut.adb(cancel_cmd)
393 finally:
394 if server_thread:
395 server_thread.StopServer()
396 for cmd in finalize_cmds:
397 dut.adb(cmd)
398
399 return 0
400
401if __name__ == '__main__':
402 sys.exit(main())