blob: 755f2cecf134fa8a3107ece28320665f05b99fc9 [file] [log] [blame]
Chris Sosa7cd23202013-10-15 17:22:57 -07001#!/usr/bin/python
2
3# Copyright (c) 2013 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"""Integration tests for the devserver.
8
9This module is responsible for testing the actual devserver APIs and should be
10run whenever changes are made to the devserver.
11
12Note there are two classes of tests here and they can be run separately.
13
14To just run the short-running "unittests" run:
15 ./devserver_integration_tests.py DevserverUnittests
16
17To just run the longer-running tests, run:
18 ./devserver_integration_tests.py DevserverIntegrationTests
19"""
20
21import devserver_constants
22import json
23import logging
24from xml.dom import minidom
25import os
26import psutil
27import shutil
28import signal
Gilad Arnold7de05f72014-02-14 13:14:20 -080029import socket
Chris Sosa7cd23202013-10-15 17:22:57 -070030import subprocess
31import tempfile
32import time
33import unittest
34import urllib2
35
36
37# Paths are relative to this script's base directory.
38LABEL = 'devserver'
39TEST_IMAGE_PATH = 'testdata/devserver'
40TEST_IMAGE_NAME = 'update.gz'
41EXPECTED_HASH = 'kGcOinJ0vA8vdYX53FN0F5BdwfY='
42
43# Update request based on Omaha v3 protocol format.
44UPDATE_REQUEST = """<?xml version="1.0" encoding="UTF-8"?>
45<request version="ChromeOSUpdateEngine-0.1.0.0" updaterversion="ChromeOSUpdateEngine-0.1.0.0" protocol="3.0" ismachine="1">
46 <os version="Indy" platform="Chrome OS" sp="0.11.254.2011_03_09_1814_i686"></os>
47 <app appid="{DEV-BUILD}" version="11.254.2011_03_09_1814" lang="en-US" track="developer-build" board="x86-generic" hardware_class="BETA DVT" delta_okay="true">
48 <updatecheck></updatecheck>
49 </app>
50</request>
51"""
52
53# RPC constants.
54STAGE = 'stage'
55IS_STAGED = 'is_staged'
56STATIC = 'static'
57UPDATE = 'update'
58CHECK_HEALTH = 'check_health'
59CONTROL_FILES = 'controlfiles'
60XBUDDY = 'xbuddy'
61
62# API rpcs and constants.
63API_HOST_INFO = 'api/hostinfo'
64API_SET_NEXT_UPDATE = 'api/setnextupdate'
65API_SET_UPDATE_REQUEST = 'new_update-test/the-new-update'
66API_TEST_IP_ADDR = '127.0.0.1'
67
68DEVSERVER_START_TIMEOUT = 15
Gilad Arnold08516112014-02-14 13:14:03 -080069DEVSERVER_START_SLEEP = 1
Gilad Arnold7de05f72014-02-14 13:14:20 -080070MAX_START_ATTEMPTS = 5
Chris Sosa7cd23202013-10-15 17:22:57 -070071
72
73class DevserverFailedToStart(Exception):
74 """Raised if we could not start the devserver."""
75
76
Gilad Arnold08516112014-02-14 13:14:03 -080077class DevserverTestBase(unittest.TestCase):
Chris Sosa7cd23202013-10-15 17:22:57 -070078 """Class containing common logic between devserver test classes."""
79
80 def setUp(self):
Gilad Arnold08516112014-02-14 13:14:03 -080081 """Creates and populates a test directory, temporary files."""
Chris Sosa7cd23202013-10-15 17:22:57 -070082 self.test_data_path = tempfile.mkdtemp()
83 self.src_dir = os.path.dirname(__file__)
84
85 # Current location of testdata payload.
86 image_src = os.path.join(self.src_dir, TEST_IMAGE_PATH, TEST_IMAGE_NAME)
87
Gilad Arnold08516112014-02-14 13:14:03 -080088 # Copy the payload to the location of the update label.
89 self._CreateLabelAndCopyImage(LABEL, image_src)
Chris Sosa7cd23202013-10-15 17:22:57 -070090
91 # Copy the payload to the location of forced label.
Gilad Arnold08516112014-02-14 13:14:03 -080092 self._CreateLabelAndCopyImage(API_SET_UPDATE_REQUEST, image_src)
Chris Sosa7cd23202013-10-15 17:22:57 -070093
Gilad Arnold08516112014-02-14 13:14:03 -080094 # Allocate temporary files for various devserver outputs.
95 self.pidfile = self._MakeTempFile('pid')
96 self.portfile = self._MakeTempFile('port')
97 self.logfile = self._MakeTempFile('log')
Chris Sosa7cd23202013-10-15 17:22:57 -070098
Gilad Arnold08516112014-02-14 13:14:03 -080099 # Initialize various runtime values.
100 self.devserver_url = self.port = self.pid = None
Chris Sosa7cd23202013-10-15 17:22:57 -0700101
102 def tearDown(self):
Gilad Arnold08516112014-02-14 13:14:03 -0800103 """Kill the server, remove the test directory and temporary files."""
Chris Sosa7cd23202013-10-15 17:22:57 -0700104 if self.pid:
105 os.kill(self.pid, signal.SIGKILL)
106
Gilad Arnold08516112014-02-14 13:14:03 -0800107 self._RemoveFile(self.pidfile)
108 self._RemoveFile(self.portfile)
109 self._RemoveFile(self.logfile)
110 shutil.rmtree(self.test_data_path)
Chris Sosa7cd23202013-10-15 17:22:57 -0700111
112 # Helper methods begin here.
113
Gilad Arnold08516112014-02-14 13:14:03 -0800114 def _CreateLabelAndCopyImage(self, label, image):
115 """Creates a label location and copies an image to it."""
116 label_dir = os.path.join(self.test_data_path, label)
117 os.makedirs(label_dir)
118 shutil.copy(image, os.path.join(label_dir, TEST_IMAGE_NAME))
119
120 def _MakeTempFile(self, suffix):
121 """Return path of a newly created temporary file."""
122 with tempfile.NamedTemporaryFile(suffix='-devserver-%s' % suffix) as f:
123 name = f.name
124 f.close()
125
126 return name
127
128 def _RemoveFile(self, filename):
129 """Removes a file if it is present."""
130 if os.path.isfile(filename):
131 os.remove(filename)
132
133 def _ReadIntValueFromFile(self, path, desc):
134 """Reads a string from file and returns its conversion into an integer."""
135 if not os.path.isfile(path):
136 raise DevserverFailedToStart('Devserver did not drop %s (%r).' %
137 (desc, path))
138
139 with open(path) as f:
140 value_str = f.read()
141
142 try:
143 return int(value_str)
144 except ValueError:
145 raise DevserverFailedToStart('Devserver did not drop a valid value '
146 'in %s (%r).' % (desc, value_str))
147
148 def _StartServer(self, port=0):
Chris Sosa7cd23202013-10-15 17:22:57 -0700149 """Attempts to start devserver on |port|.
150
Gilad Arnold08516112014-02-14 13:14:03 -0800151 In the default case where port == 0, the server will bind to an arbitrary
152 availble port. If successful, this method will set the devserver's pid
153 (self.pid), actual listening port (self.port) and URL (self.devserver_url).
Chris Sosa7cd23202013-10-15 17:22:57 -0700154
155 Raises:
156 DevserverFailedToStart: If the devserver could not be started.
157 """
158 cmd = [
159 'python',
160 os.path.join(self.src_dir, 'devserver.py'),
161 'devserver.py',
162 '--static_dir', self.test_data_path,
163 '--pidfile', self.pidfile,
Gilad Arnold08516112014-02-14 13:14:03 -0800164 '--portfile', self.portfile,
Chris Sosa7cd23202013-10-15 17:22:57 -0700165 '--port', str(port),
166 '--logfile', self.logfile]
167
168 # Pipe all output. Use logfile to get devserver log.
169 subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
170
Gilad Arnold08516112014-02-14 13:14:03 -0800171 # Wait for devserver to start, determining its actual serving port and URL.
Chris Sosa7cd23202013-10-15 17:22:57 -0700172 current_time = time.time()
173 deadline = current_time + DEVSERVER_START_TIMEOUT
174 while current_time < deadline:
Chris Sosa7cd23202013-10-15 17:22:57 -0700175 try:
Gilad Arnold08516112014-02-14 13:14:03 -0800176 self.port = self._ReadIntValueFromFile(self.portfile, 'portfile')
177 self.devserver_url = 'http://127.0.0.1:%d' % self.port
Chris Sosa7cd23202013-10-15 17:22:57 -0700178 self._MakeRPC(CHECK_HEALTH, timeout=0.1)
179 break
180 except Exception:
Gilad Arnold08516112014-02-14 13:14:03 -0800181 time.sleep(DEVSERVER_START_SLEEP)
182 current_time = time.time()
Chris Sosa7cd23202013-10-15 17:22:57 -0700183 else:
184 raise DevserverFailedToStart('Devserver failed to start within timeout.')
185
Gilad Arnold08516112014-02-14 13:14:03 -0800186 # Retrieve PID.
187 self.pid = self._ReadIntValueFromFile(self.pidfile, 'pidfile')
Chris Sosa7cd23202013-10-15 17:22:57 -0700188
189 def VerifyHandleUpdate(self, label, use_test_payload=True):
190 """Verifies that we can send an update request to the devserver.
191
192 This method verifies (using a fake update_request blob) that the devserver
193 can interpret the payload and give us back the right payload.
194
195 Args:
196 label: Label that update is served from e.g. <board>-release/<version>
197 use_test_payload: If set to true, expects to serve payload under
198 testdata/ and does extra checks i.e. compares hash and content of
199 payload.
Gilad Arnold08516112014-02-14 13:14:03 -0800200
Chris Sosa7cd23202013-10-15 17:22:57 -0700201 Returns:
202 url of the update payload if we verified the update.
203 """
204 update_label = '/'.join([UPDATE, label])
205 response = self._MakeRPC(update_label, data=UPDATE_REQUEST)
206 self.assertNotEqual('', response)
207
208 # Parse the response and check if it contains the right result.
209 dom = minidom.parseString(response)
210 update = dom.getElementsByTagName('updatecheck')[0]
211 expected_static_url = '/'.join([self.devserver_url, STATIC, label])
212 expected_hash = EXPECTED_HASH if use_test_payload else None
213 url = self.VerifyV3Response(update, expected_static_url,
214 expected_hash=expected_hash)
215
216 # Verify the image we download is correct since we already know what it is.
217 if use_test_payload:
218 connection = urllib2.urlopen(url)
219 contents = connection.read()
220 connection.close()
221 self.assertEqual('Developers, developers, developers!\n', contents)
222
223 return url
224
225 def VerifyV3Response(self, update, expected_static_url, expected_hash):
226 """Verifies the update DOM from a v3 response and returns the url."""
227 # Parse the response and check if it contains the right result.
228 urls = update.getElementsByTagName('urls')[0]
229 url = urls.getElementsByTagName('url')[0]
230
231 static_url = url.getAttribute('codebase')
232 # Static url's end in /.
233 self.assertEqual(expected_static_url + '/', static_url)
234
235 manifest = update.getElementsByTagName('manifest')[0]
236 packages = manifest.getElementsByTagName('packages')[0]
237 package = packages.getElementsByTagName('package')[0]
238 filename = package.getAttribute('name')
239 self.assertEqual(TEST_IMAGE_NAME, filename)
240
241 if expected_hash:
242 hash_value = package.getAttribute('hash')
243 self.assertEqual(EXPECTED_HASH, hash_value)
244
245 url = os.path.join(static_url, filename)
246 return url
247
248 def _MakeRPC(self, rpc, data=None, timeout=None, **kwargs):
Gilad Arnold08516112014-02-14 13:14:03 -0800249 """Makes an RPC call to the devserver.
Chris Sosa7cd23202013-10-15 17:22:57 -0700250
251 Args:
Gilad Arnold08516112014-02-14 13:14:03 -0800252 rpc: The function to run on the devserver, e.g. 'stage'.
Chris Sosa7cd23202013-10-15 17:22:57 -0700253 data: Optional post data to send.
254 timeout: Optional timeout to pass to urlopen.
Gilad Arnold08516112014-02-14 13:14:03 -0800255 kwargs: Optional arguments to the function, e.g. artifact_url='foo/bar'.
Chris Sosa7cd23202013-10-15 17:22:57 -0700256
Gilad Arnold08516112014-02-14 13:14:03 -0800257 Returns:
258 The function output.
Chris Sosa7cd23202013-10-15 17:22:57 -0700259 """
260 request = '/'.join([self.devserver_url, rpc])
261 if kwargs:
262 # Join the kwargs to the URL.
263 request += '?' + '&'.join('%s=%s' % item for item in kwargs.iteritems())
264
265 output = None
266 try:
267 # Let's log output for all rpc's without timeouts because we only
268 # use timeouts to check to see if something is up and these checks tend
269 # to be small and so logging it will be extremely repetitive.
270 if not timeout:
271 logging.info('Making request using %s', request)
272
273 connection = urllib2.urlopen(request, data=data, timeout=timeout)
274 output = connection.read()
275 connection.close()
276 except urllib2.HTTPError:
277 raise
278
279 return output
280
281
Gilad Arnold08516112014-02-14 13:14:03 -0800282class AutoStartDevserverTestBase(DevserverTestBase):
283 """Test base class that automatically starts the devserver."""
284
285 def setUp(self):
286 """Initialize everything, then start the server."""
287 super(AutoStartDevserverTestBase, self).setUp()
288 self._StartServer()
289
290
Gilad Arnold7de05f72014-02-14 13:14:20 -0800291class DevserverStartTests(DevserverTestBase):
292 """Test that devserver starts up correctly."""
293
294 def testStartAnyPort(self):
295 """Starts the devserver, have it bind to an arbitrary available port."""
296 self._StartServer()
297
298 def testStartSpecificPort(self):
299 """Starts the devserver with a specific port."""
300 for _ in range(MAX_START_ATTEMPTS):
301 # This is a cheap hack to find an arbitrary unused port: we open a socket
302 # and bind it to port zero, then pull out the actual port number and
303 # close the socket. In all likelihood, this will leave us with an
304 # available port number that we can use for starting the devserver.
305 # However, this heuristic is susceptible to race conditions, hence the
306 # retry loop.
307 s = socket.socket()
308 s.bind(('', 0))
309 # s.getsockname() is definitely callable.
310 # pylint: disable=E1102
311 _, port = s.getsockname()
312 s.close()
313
314 self._StartServer(port=port)
315
316
Gilad Arnold08516112014-02-14 13:14:03 -0800317class DevserverBasicTests(AutoStartDevserverTestBase):
318 """Short running tests for the devserver (no remote deps).
Chris Sosa7cd23202013-10-15 17:22:57 -0700319
320 These are technically not unittests because they depend on being able to
321 start a devserver locally which technically requires external resources so
322 they are lumped with the remote tests here.
323 """
324
325 def testHandleUpdateV3(self):
326 self.VerifyHandleUpdate(label=LABEL)
327
328 def testApiBadSetNextUpdateRequest(self):
329 """Tests sending a bad setnextupdate request."""
330 # Send bad request and ensure it fails...
331 self.assertRaises(urllib2.URLError,
332 self._MakeRPC,
333 '/'.join([API_SET_NEXT_UPDATE, API_TEST_IP_ADDR]))
334
335 def testApiBadSetNextUpdateURL(self):
336 """Tests contacting a bad setnextupdate url."""
337 # Send bad request and ensure it fails...
338 self.assertRaises(urllib2.URLError,
339 self._MakeRPC, API_SET_NEXT_UPDATE)
340
341 def testApiBadHostInfoURL(self):
342 """Tests contacting a bad hostinfo url."""
343 # Host info should be invalid without a specified address.
344 self.assertRaises(urllib2.URLError,
345 self._MakeRPC, API_HOST_INFO)
346
347 def testApiHostInfoAndSetNextUpdate(self):
348 """Tests using the setnextupdate and hostinfo api commands."""
349 # Send setnextupdate command.
350 self._MakeRPC('/'.join([API_SET_NEXT_UPDATE, API_TEST_IP_ADDR]),
351 data=API_SET_UPDATE_REQUEST)
352
353 # Send hostinfo command and verify the setnextupdate worked.
354 response = self._MakeRPC('/'.join([API_HOST_INFO, API_TEST_IP_ADDR]))
355
356 self.assertEqual(
357 json.loads(response)['forced_update_label'], API_SET_UPDATE_REQUEST)
358
359 def testXBuddyLocalAlias(self):
360 """Extensive local image xbuddy unittest.
361
362 This test verifies all the local xbuddy logic by creating a new local folder
363 with the necessary update items and verifies we can use all of them.
364 """
365 update_data = 'TEST UPDATE'
366 image_data = 'TEST IMAGE'
367 stateful_data = 'STATEFUL STUFFS'
368 build_id = 'x86-generic/R32-9999.0.0-a1'
369 xbuddy_path = 'x86-generic/R32-9999.0.0-a1/test'
370 build_dir = os.path.join(self.test_data_path, build_id)
371 os.makedirs(build_dir)
372 test_image_file = os.path.join(build_dir,
373 devserver_constants.TEST_IMAGE_FILE)
374 update_file = os.path.join(build_dir, devserver_constants.UPDATE_FILE)
375 stateful_file = os.path.join(build_dir, devserver_constants.STATEFUL_FILE)
376
377 logging.info('Creating dummy files')
378
379 for item, filename, data in zip(
380 ['full_payload', 'test', 'stateful'],
381 [update_file, test_image_file, stateful_file],
382 [update_data, image_data, stateful_data]):
383 logging.info('Creating file %s', filename)
384 with open(filename, 'w') as fh:
385 fh.write(data)
386
387 xbuddy_path = '/'.join([build_id, item])
388 logging.info('Testing xbuddy path %s', xbuddy_path)
389 response = self._MakeRPC('/'.join([XBUDDY, xbuddy_path]))
390 self.assertEqual(response, data)
391
392 expected_dir = '/'.join([self.devserver_url, STATIC, build_id])
393 response = self._MakeRPC('/'.join([XBUDDY, xbuddy_path]), return_dir=True)
394 self.assertEqual(response, expected_dir)
395
Yu-Ju Hong51495eb2013-12-12 17:08:43 -0800396 response = self._MakeRPC('/'.join([XBUDDY, xbuddy_path]),
397 relative_path=True)
398 self.assertEqual(response, build_id)
399
Chris Sosa7cd23202013-10-15 17:22:57 -0700400 xbuddy_path = '/'.join([build_id, 'test'])
401 logging.info('Testing for_update for %s', xbuddy_path)
402 response = self._MakeRPC('/'.join([XBUDDY, xbuddy_path]), for_update=True)
403 expected_path = '/'.join([self.devserver_url, UPDATE, build_id])
404 self.assertTrue(response, expected_path)
405
406 logging.info('Verifying the actual payload data')
407 url = self.VerifyHandleUpdate(build_id, use_test_payload=False)
408 logging.info('Verify the actual content of the update payload')
409 connection = urllib2.urlopen(url)
410 contents = connection.read()
411 connection.close()
412 self.assertEqual(update_data, contents)
413
414 def testPidFile(self):
415 """Test that using a pidfile works correctly."""
416 with open(self.pidfile, 'r') as f:
417 pid = f.read()
418
419 # Let's assert some process information about the devserver.
420 self.assertTrue(pid.isdigit())
421 process = psutil.Process(int(pid))
422 self.assertTrue(process.is_running())
423 self.assertTrue('devserver.py' in process.cmdline)
424
425
Gilad Arnold08516112014-02-14 13:14:03 -0800426class DevserverExtendedTests(AutoStartDevserverTestBase):
Chris Sosa7cd23202013-10-15 17:22:57 -0700427 """Longer running integration tests that test interaction with Google Storage.
428
429 Note: due to the interaction with Google Storage, these tests both require
430 1) runner has access to the Google Storage bucket where builders store builds.
431 2) time. These tests actually download the artifacts needed.
432 """
433
434 def testStageAndUpdate(self):
435 """Tests core autotest workflow where we stage/update with a test payload.
436 """
437 build_id = 'x86-mario-release/R32-4810.0.0'
438 archive_url = 'gs://chromeos-image-archive/%s' % build_id
439
440 response = self._MakeRPC(IS_STAGED, archive_url=archive_url,
441 artifacts='full_payload,stateful')
442 self.assertEqual(response, 'False')
443
444 logging.info('Staging update artifacts')
445 self._MakeRPC(STAGE, archive_url=archive_url,
446 artifacts='full_payload,stateful')
447 logging.info('Staging complete. '
448 'Verifying files exist and are staged in the staging '
449 'directory.')
450 response = self._MakeRPC(IS_STAGED, archive_url=archive_url,
451 artifacts='full_payload,stateful')
452 self.assertEqual(response, 'True')
453 staged_dir = os.path.join(self.test_data_path, build_id)
454 self.assertTrue(os.path.isdir(staged_dir))
455 self.assertTrue(os.path.exists(
456 os.path.join(staged_dir, devserver_constants.UPDATE_FILE)))
457 self.assertTrue(os.path.exists(
458 os.path.join(staged_dir, devserver_constants.STATEFUL_FILE)))
459
460 logging.info('Verifying we can update using the stage update artifacts.')
461 self.VerifyHandleUpdate(build_id, use_test_payload=False)
462
463 def testStageAutotestAndGetPackages(self):
464 """Another autotest workflow test where we stage/update with a test payload.
465 """
466 build_id = 'x86-mario-release/R32-4810.0.0'
467 archive_url = 'gs://chromeos-image-archive/%s' % build_id
468 autotest_artifacts = 'autotest,test_suites,au_suite'
469 logging.info('Staging autotest artifacts (may take a while).')
470 self._MakeRPC(STAGE, archive_url=archive_url, artifacts=autotest_artifacts)
471
472 response = self._MakeRPC(IS_STAGED, archive_url=archive_url,
473 artifacts=autotest_artifacts)
474 self.assertEqual(response, 'True')
475
476 # Verify the files exist and are staged in the staging directory.
477 logging.info('Checking directories exist after we staged the files.')
478 staged_dir = os.path.join(self.test_data_path, build_id)
479 autotest_dir = os.path.join(staged_dir, 'autotest')
480 package_dir = os.path.join(autotest_dir, 'packages')
481 self.assertTrue(os.path.isdir(staged_dir))
482 self.assertTrue(os.path.isdir(autotest_dir))
483 self.assertTrue(os.path.isdir(package_dir))
484
485 control_files = self._MakeRPC(CONTROL_FILES, build=build_id,
486 suite_name='bvt')
487 logging.info('Checking for known control file in bvt suite.')
488 self.assertTrue('client/site_tests/platform_FilePerms/'
489 'control' in control_files)
490
491 def testRemoteXBuddyAlias(self):
492 """Another autotest workflow test where we stage/update with a test payload.
493 """
494 build_id = 'x86-mario-release/R32-4810.0.0'
495 xbuddy_path = 'remote/x86-mario/R32-4810.0.0/full_payload'
496 xbuddy_bad_path = 'remote/x86-mario/R32-9999.9999.9999'
497 logging.info('Staging artifacts using xbuddy.')
498 response = self._MakeRPC('/'.join([XBUDDY, xbuddy_path]), return_dir=True)
499
500 logging.info('Verifying static url returned is valid.')
501 expected_static_url = '/'.join([self.devserver_url, STATIC, build_id])
502 self.assertEqual(response, expected_static_url)
503
504 logging.info('Checking for_update returns an update_url for what we just '
505 'staged.')
506 expected_update_url = '/'.join([self.devserver_url, UPDATE, build_id])
507 response = self._MakeRPC('/'.join([XBUDDY, xbuddy_path]), for_update=True)
508 self.assertEqual(response, expected_update_url)
509
510 logging.info('Now give xbuddy a bad path.')
511 self.assertRaises(urllib2.HTTPError,
512 self._MakeRPC,
513 '/'.join([XBUDDY, xbuddy_bad_path]))
514
515
516if __name__ == '__main__':
517 logging_format = '%(levelname)-8s: %(message)s'
518 logging.basicConfig(level=logging.DEBUG, format=logging_format)
519 unittest.main()