From ed4a254edc4445d279361ff0233683ff5d2da184 Mon Sep 17 00:00:00 2001 From: John Hopkins Date: Tue, 4 Dec 2012 09:59:11 -0500 Subject: [PATCH] Bug 810439 - mozharness support for mozpool. r=aki --- configs/b2g/panda_releng.py | 21 ++ mozharness/base/script.py | 4 +- mozharness/mozilla/testing/mozpool.py | 488 ++++++++++++++++++++++++++ mpunit.sh | 76 ++++ scripts/b2g_panda.py | 152 ++++++++ scripts/mozpooltest.py | 65 ++++ test/mozpool/mozpoolmisc.py | 42 +++ test/mozpool/test_devices.py | 68 ++++ test/mozpool/test_list_devices.py | 69 ++++ test/mozpool/test_power_cycle.py | 24 ++ test/mozpool/test_request_devices.py | 117 ++++++ test/mozpool/test_state_change.py | 23 ++ unit.sh | 8 +- 13 files changed, 1152 insertions(+), 5 deletions(-) create mode 100644 configs/b2g/panda_releng.py create mode 100644 mozharness/mozilla/testing/mozpool.py create mode 100755 mpunit.sh create mode 100755 scripts/b2g_panda.py create mode 100755 scripts/mozpooltest.py create mode 100644 test/mozpool/mozpoolmisc.py create mode 100644 test/mozpool/test_devices.py create mode 100644 test/mozpool/test_list_devices.py create mode 100644 test/mozpool/test_power_cycle.py create mode 100644 test/mozpool/test_request_devices.py create mode 100644 test/mozpool/test_state_change.py diff --git a/configs/b2g/panda_releng.py b/configs/b2g/panda_releng.py new file mode 100644 index 00000000..fdefcd21 --- /dev/null +++ b/configs/b2g/panda_releng.py @@ -0,0 +1,21 @@ +# This is a template config file for gaia smoketests production. + +config = { + # Values for the foopies + "exes": { + 'python': '/tools/buildbot/bin/python', + 'virtualenv': ['/tools/buildbot/bin/python', '/tools/misc-python/virtualenv.py'], + }, + "virtualenv_path": "venv", + "find_links": ["http://puppetagain.pub.build.mozilla.org/data/python/packages"], + "buildbot_json_path": "buildprops.json", + "mozpool_api_url": "http://mobile-imaging-001.p1.releng.scl1.mozilla.com", + "default_actions": [ + 'clobber', + #'read-buildbot-config', + 'create-virtualenv', + 'request-device', + 'run-test', + 'close-request', + ], +} diff --git a/mozharness/base/script.py b/mozharness/base/script.py index b44b185b..03f812c7 100755 --- a/mozharness/base/script.py +++ b/mozharness/base/script.py @@ -582,7 +582,9 @@ def get_output_from_command(self, command, cwd=None, shell = False p = subprocess.Popen(command, shell=shell, stdout=tmp_stdout, cwd=cwd, stderr=tmp_stderr, env=env) - self.debug("Temporary files: %s and %s" % (tmp_stdout_filename, tmp_stderr_filename)) + #XXX: changed from self.debug to self.log due to this error: + # TypeError: debug() takes exactly 1 argument (2 given) + self.log("Temporary files: %s and %s" % (tmp_stdout_filename, tmp_stderr_filename), level=DEBUG) p.wait() tmp_stdout.close() tmp_stderr.close() diff --git a/mozharness/mozilla/testing/mozpool.py b/mozharness/mozilla/testing/mozpool.py new file mode 100644 index 00000000..a94909a2 --- /dev/null +++ b/mozharness/mozilla/testing/mozpool.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python +# ***** BEGIN LICENSE BLOCK ***** +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# ***** END LICENSE BLOCK ***** +'''Interact with mozpool/lifeguard/bmm. +''' + +import sys +import time + +try: + import simplejson as json +except ImportError: + import json + +from mozharness.base.log import LogMixin, DEBUG, ERROR, WARNING, FATAL +from mozharness.base.script import ShellMixin, OSMixin + +JsonHeader = {'content-type': 'application/json'} + +# TODO do something with r.status_code? +# 200 OK +# 201 Created +# 202 Accepted +# 300 Multiple Choices +# 301 Moved Permanently +# 302 Found +# 304 Not Modified +# 400 Bad Request +# 401 Unauthorized +# 403 Forbidden +# 404 Not Found +# 405 Method Not Allowed +# 409 Conflict +# 500 Server Error +# 501 Not Implemented +# 503 Service Unavailable + +# Usage: +# Clients should only need to use the following Mozpool methods: +# close_request +# query_all_device_details +# query_all_device_list +# query_all_requests +# query_device_status +# query_request_details +# query_request_status +# renew_request +# request_device + +class MozpoolException(Exception): + pass + +class MozpoolConflictException(MozpoolException): + pass + +def mozpool_status_ok(status): + if status in range(200,400): + return True + else: + return False + +def check_mozpool_status(status): + if not mozpool_status_ok(status): + if status == 409: + raise MozpoolConflictException() + import pprint + raise MozpoolException('mozpool status not ok, code %s' % pprint.pformat(status)) + +# MozpoolHandler {{{1 +class MozpoolHandler(ShellMixin, OSMixin, LogMixin): + """ Depends on /requests/; if you don't have this installed you need to + instantiate this after installing /requests/ via VirtualenvMixin. + """ + def __init__(self, mozpool_api_url, mozpool_config=None, config=None, + log_obj=None, script_obj=None): + self.config = config + self.log_obj = log_obj + super(MozpoolHandler, self).__init__() + self.mozpool_api_url = mozpool_api_url + self.mozpool_config = mozpool_config or {} + self.script_obj = script_obj + self.mozpool_auth = self.mozpool_config.get("mozpool_auth") + self.mozpool_timeout = self.mozpool_config.get("mozpool_timeout", 60) + try: + site_packages_path = self.script_obj.query_python_site_packages_path() + sys.path.append(site_packages_path) + global requests + requests = __import__('requests', globals(), locals(), [], -1) + except ImportError: + self.fatal("Can't instantiate MozpoolHandler until requests python package is installed! (VirtualenvMixin?)") + + # Helper methods {{{2 + def url_get(self, url, auth=None, params=None, num_retries=None, + decode_json=True, error_level=FATAL, verbose_level=DEBUG, + **kwargs): + """Generic get output from a url method. + + This could be moved to a generic url handler object. + """ + self.info("Request GET %s..." % url) + if kwargs.get("timeout") is None: + kwargs["timeout"] = self.mozpool_timeout + if kwargs.get("auth") is None and self.mozpool_auth: + kwargs["auth"] = self.mozpool_auth + if num_retries is None: + num_retries = self.config.get("global_retries", 10) + try_num = 0 + while try_num <= num_retries: + try_num += 1 + log_level = WARNING + if try_num == num_retries: + log_level = error_level + try: + r = requests.get(url, **kwargs) + self.info("Status code: %s" % str(r.status_code)) + if verbose_level: + self.log(r.text, level=verbose_level) + if decode_json: + j = self.decode_json(r.text) + if j is not None: + return (j, r.status_code) + else: + self.log("Try %d: Can't decode json from %s!" % (try_num, url), level=log_level) + else: + return (r.text, r.status_code) + except requests.exceptions.RequestException, e: + self.log("Try %d: Can't get %s: %s!" % (try_num, url, str(e)), + level=log_level) + if try_num <= num_retries: + sleep_time = 2 * try_num + self.info("Sleeping %d..." % sleep_time) + time.sleep(sleep_time) + + def partial_url_get(self, partial_url, **kwargs): + return self.url_get(self.mozpool_api_url + partial_url, **kwargs) + + def decode_json(self, contents, error_level=WARNING): + try: + return json.loads(contents, encoding="ascii") + except ValueError, e: + self.log("Can't decode json: %s!" % str(e), level=error_level) + except TypeError, e: + self.log("Can't decode json: %s!" % str(e), level=error_level) + else: + self.log("Can't decode json: Unknown error!" % str(e), level=error_level) + + def url_post(self, url, data, auth=None, params=None, num_retries=None, + good_statuses=None, decode_json=True, error_level=ERROR, + verbose_level=DEBUG, **kwargs): + """Generic post to a url method. + + This could be moved to a generic url handler object. + """ + self.info("Request POST %s..." % url) + if kwargs.get("timeout") is None: + kwargs["timeout"] = self.mozpool_timeout + if kwargs.get("auth") is None and self.mozpool_auth: + kwargs["auth"] = self.mozpool_auth + if num_retries is None: + num_retries = self.config.get("global_retries", 10) + if good_statuses is None: + good_statuses = [200, 201, 202, 302] + try_num = 0 + while try_num <= num_retries: + try_num += 1 + log_level = WARNING + if try_num == num_retries: + log_level = error_level + try: + r = requests.post(url, data=data, **kwargs) + if r.status_code in good_statuses: + self.info("Status code: %s" % str(r.status_code)) + + if verbose_level: + self.log(r.text, level=verbose_level) + if decode_json: + j = self.decode_json(r.text) + if j is not None: + return (j, r.status_code) + else: + self.log("Try %d: Can't decode json from %s!" % (try_num, url), level=log_level) + else: + return (r.text, r.status_code) + else: + self.log("Bad return status from %s: %d!" % (url, r.status_code), level=error_level) + return (None, r.status_code) + except requests.exceptions.RequestException, e: + self.log("Try %d: Can't get %s: %s!" % (try_num, url, str(e)), + level=log_level) + if try_num <= num_retries: + sleep_time = 2 * try_num + self.info("Sleeping %d..." % sleep_time) + time.sleep(sleep_time) + + def partial_url_post(self, partial_url, **kwargs): + return self.url_post(self.mozpool_api_url + partial_url, **kwargs) + + # TODO we could do some caching and more error checking + # Device queries {{{2 + def query_all_device_list(self, **kwargs): + """ returns a JSON response body whose "devices" key contains an array + of the names of devices known to the system. Device names can be passed + as the id in the following device APIs. + """ + response, status = self.partial_url_get("/api/device/list/", **kwargs) + check_mozpool_status(status) + return response.get("devices") + + def query_all_device_details(self, **kwargs): + """ returns a JSON response body whose "devices" key + contains an array of objects, each representing a single device. + The objects have keys id, name, fqdn, invenetory_id, mac_address, + imaging_server, and relay_info. + """ + response, status = self.partial_url_get("/api/device/list?details=1", **kwargs) + check_mozpool_status(status) + return response.get("devices") + + def query_device_status(self, device, error_level=WARNING, **kwargs): + """ returns a JSON response body whose "status" key contains + a short string describing the last-known status of the device, + and whose "log" key contains an array of recent log entries + for the device. + """ + response, status = self.partial_url_get("/api/device/%s/status/" % device, + error_level=error_level, **kwargs) + check_mozpool_status(status) + return response + + def query_device_details(self, device_id, error_level=WARNING, **kwargs): + devices = self.query_all_device_details(**kwargs) + if isinstance(devices, list): + matches = filter(lambda dd: dd['name'] == device_id, devices) + if len(matches) != 1: + self.log("Couldn't find %s in device list!" % device_id, + level=error_level) + return + else: + return matches[0] + else: + # We shouldn't get here if query_all_device_details() FATALs... + self.log("Invalid response from query_all_device_details()!", + level=error_level) + + def request_device(self, device_id, assignee, image, duration, pxe_config=None, + b2gbase=None, error_level=WARNING, **kwargs): + """ requests the given device. {id} may be "any" to let MozPool choose an + unassigned device. The body must be a JSON object with at least the keys + "requester", "duration", and "image". The value for "requester" takes an + email address, for human users, or a hostname, for machine users. "duration" + must be a value, in seconds, of the duration of the request (which can be + renewed; see below). + + "image" specifies low-level configuration that should be done on the device + by mozpool. Some image types will require additional parameters. Currently + the only supported value is "b2g", for which a "b2gbase" key must also be + present. The value of "b2gbase" must be a URL to a b2g build directory + containing boot, system, and userdata tarballs. + + If successful, returns 200 OK with a JSON object with the key "request". + The value of "request" is an object detailing the request, with the keys + "assigned_device" (which is blank if mozpool is still attempting to find + a device, "assignee", "expires", "id", "requested_device", + and "url". The "url" attribute contains a partial URL + for the request object and should be used in request calls, as detailed + below. If 'any' device was requested, always returns 200 OK, since it will + retry a few times if no devices are free. If a specific device is requested + but is already assigned, returns 409 Conflict; otherwise, returns 200 OK. + + If a 200 OK code is returned, the client should then poll for the request's + state (using the value of request["url"] returned in the JSON object with + "status/" appended. A normal request will move through the states "new", + "find_device", "contact_lifeguard", "pending", and "ready", in that order. + When, and *only* when, the device is in the "ready" state, it is safe to be + used by the client. Other possible states are "expired", "closed", + "device_not_found", and "device_busy"; the assigned device (if any) is + returned to the pool when any of these states are entered. + """ + if image == 'b2g': + assert b2gbase is not None, "b2gbase must be supplied when image=='b2gbase'" + assert duration == int(duration) + + data = {'assignee': assignee, 'duration': duration, 'image': image} + if pxe_config is not None: + data['pxe_config'] = pxe_config + if b2gbase is not None: + data['b2gbase'] = b2gbase + response, status = self.partial_url_post("/api/device/%s/request/" % device_id, + data=json.dumps(data), + headers=JsonHeader) + check_mozpool_status(status) + return response + + def renew_request(self, request_url, new_duration, error_level=WARNING, **kwargs): + """ requests that the request's lifetime be updated. The request body + should be a JSON object with the key "duration", the value of which is the + *new* remaining time, in seconds, of the request. Returns 204 No Content. + """ + request_url = request_url + 'renew/' + data = {'duration': new_duration} + response, status = self.url_post(request_url, data=json.dumps(data), headers=JsonHeader, decode_json=False) + check_mozpool_status(status) + return response + + def close_request(self, request_url, error_level=WARNING, **kwargs): + """ returns the device to the pool and deletes the request. Returns + 204 No Content. + """ + request_url = request_url + 'return/' + data = {} + response, status = self.url_post(request_url, data=json.dumps(data), headers=JsonHeader, decode_json=False) + check_mozpool_status(status) + return response + + def device_state_change(self, device, assignee, duration, old_state, new_state, pxe_config=None): + """ conditionally set the lifeguard state of a device from old_state to + new_state. If the current state is not old_state, the request will fail. + The POST body is as described for `/api/device/{id}/power-cycle/`. + """ + + data = {'assignee': assignee, 'duration': duration, 'pxe_config': pxe_config} + response, status = self.partial_url_post("/api/device/%s/state-change/%s/to/%s/" % + (device, old_state, new_state), data=json.dumps(data), headers=JsonHeader) + check_mozpool_status(status) + return response + + def device_power_cycle(self, device, assignee, duration, pxe_config=None): + """ initiate a power-cycle of this device. The POST body is a JSON object, + with optional keys `pxe_config` and `boot_config`. If `pxe_config` is + specified, then the device is configured to boot with that PXE config; + otherwise, the device boots from its internal storage. If `boot_config` is + supplied (as a string), it is stored for later use by the device via + `/api/device/{id}/config/`. + """ + data = {'assignee': assignee, 'duration': duration} + if pxe_config is not None: + data['pxe_config'] = pxe_config + response, status = self.partial_url_post("/api/device/%s/power-cycle/" % + (device), data=json.dumps(data), headers=JsonHeader) + check_mozpool_status(status) + return response + + def device_ping(self, device, error_level=WARNING, **kwargs): + """ ping this device. Returns a JSON object with a `success` key, and + value true or false. The ping happens synchronously, and takes around a + half-second. + """ + response, status = self.partial_url_get("/api/device/%s/ping/" % device, + error_level=error_level, **kwargs) + check_mozpool_status(status) + return response + + def device_power_off(self, device, error_level=ERROR, **kwargs): + """ initiate a power-off of this device. Use the power-cycle API to + turn power back on. + """ + response, status = self.partial_url_get("/api/device/%s/power-off/" % device, + error_level=error_level, decode_json=False, **kwargs) + check_mozpool_status(status) + return response + + def query_device_log(self, device, error_level=ERROR, **kwargs): + """ get a list of recent log lines for this device. The return value has + a 'log' key containing a list of objects representing log lines. + """ + response, status = self.partial_url_get("/api/device/%s/log/" % device, + error_level=error_level, **kwargs) + check_mozpool_status(status) + + return response + + def query_device_bootconfig(self, device, error_level=WARNING, **kwargs): + """ get the boot configuration string set for this device. + """ + response, status = self.partial_url_get("/api/device/%s/bootconfig/" % device, + error_level=error_level, decode_json=False, **kwargs) + check_mozpool_status(status) + + return response + + def query_all_requests(self, include_closed_requests=False, error_level=ERROR, **kwargs): + """ returns a JSON response body whose "requests" key contains an array of + objects representing all current requests. The objects have the keys id, + assignee, assigned_device, boot_config, device_status, expires, + imaging_server, requested_device, and state. "assigned_device" and + "device_status" will be blank if no suitable free device has been found. + "expires" is given in UTC. By default, closed requests are omitted. They + can be included by giving the "include_closed" argument (with any value). + + Once a request is fulfilled using the "request" API above, all further + actions related to the requested device should be done using that URL, which + includes up to "/api/request/{id}/". This ensures that only one server + handles any given request. Attempts to access that request ID on a different + server will result in a 302 Found redirection to the correct server. + + The full paths of request APIs are presented below for clarity. + + Note that a request will be automatically terminated once it expires. The + "renew" call should be used to extend the request lifetime. + """ + incl_closed = "" + if include_closed_requests: + incl_closed = "?include_closed=1" + response, status = self.partial_url_get("/api/request/list/%s" % incl_closed, + error_level=error_level, **kwargs) + check_mozpool_status(status) + return response + + def query_request_status(self, request_url, error_level=WARNING, **kwargs): + """ returns a JSON response body with keys "log" and "state". Log objects + contain "message", "source", and "timestamp" keys. "state" is the name of + the current state, "ready" being the state in which it is safe to use the + device. + """ + request_url = request_url + 'status/' + response, status = self.url_get(request_url, error_level=error_level, **kwargs) + check_mozpool_status(status) + return response + + def query_request_details(self, request_url, error_level=ERROR, **kwargs): + """ returns a JSON response body whose "request" key contains an object + representing the given request with the keys id, device_id, assignee, + expires, and status. The expires field is given as an ISO-formatted time. + """ + request_url = request_url + 'details/' + response, status = self.url_get(request_url, error_level=error_level, **kwargs) + check_mozpool_status(status) + return response + + def bmm_device_clear_pxe(self, device_id, error_level=ERROR, **kwargs): + """ clear the PXE configuration for the device. Call this after a + `power_cycle` operation with a `pxe_config` argument has been successful, so + that any subsequent device-initiated reboots will not PXE boot. + """ + data = {} + response, status = self.partial_url_post("/api/device/%s/clear-pxe/" % + (device_id), data=json.dumps(data), headers=JsonHeader) + check_mozpool_status(status) + return response + + def bmm_pxe_config_list(self, include_active_only=False, error_level=ERROR, **kwargs): + """ returns a JSON response body whose "pxe_configs" key + contains an array of the names of boot images known to the system. + Bootimage names can be passed as the id in the following bootimage APIs. + With `?active_only=1` appended, this will return only active PXE configs. + """ + active_only = "" + if include_active_only: + active_only = "?active_only=1" + response, status = self.partial_url_get("/api/bmm/pxe_config/list/%s" % active_only, + error_level=error_level, **kwargs) + check_mozpool_status(status) + return response + + def bmm_pxe_config_details(self, device_id, error_level=ERROR, **kwargs): + """ returns a JSON response body whose "details" key contains + an object that provides information about this PXE config. + The keys of this object are: "name", "version", "description" and + "content". + """ + response, status = self.partial_url_get("/api/bmm/pxe_config/%s/details" % device_id, + error_level=error_level, **kwargs) + check_mozpool_status(status) + return response + + +# MozpoolMixin {{{1 +class MozpoolMixin(object): + mozpool_handler = None + + def query_mozpool_handler(self): + if not self.mozpool_handler: + if 'mozpool_api_url' not in self.config: + self.fatal("Can't create mozpool handler without mozpool_api_url set!") + mozpool_config = {} + for var in ("mozpool_auth", "mozpool_timeout"): + if self.config.get(var): + mozpool_config[var] = self.config[var] + self.mozpool_handler = MozpoolHandler( + self.config["mozpool_api_url"], + config=self.config, + log_obj=self.log_obj, + script_obj=self, + ) + return self.mozpool_handler diff --git a/mpunit.sh b/mpunit.sh new file mode 100755 index 00000000..c0d09989 --- /dev/null +++ b/mpunit.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +set -x + +########################################################################### +# This requires coverage and nosetests: +# +# easy_install coverage +# easy_install nose +# easy_install pylint +# easy_install pyflakes +# +# test_base_vcs_mercurial.py requires hg >= 1.6.0 with mq, rebase, share +# extensions to fully test. +########################################################################### + +# this breaks mercurial unit tests +unset HG_SHARE_BASE_DIR + +COVERAGE_ARGS="--omit='/usr/*,/opt/*'" +OS_TYPE='linux' +uname -v | grep -q Darwin +if [ $? -eq 0 ] ; then + OS_TYPE='osx' + COVERAGE_ARGS="--omit='/Library/*,/usr/*,/opt/*'" +fi +uname -s | egrep -q MINGW32 # Cygwin will be linux in this case? +if [ $? -eq 0 ] ; then + OS_TYPE='windows' +fi +NOSETESTS=`env which nosetests` + +#echo "### Finding mozharness/ .py files..." +#files=`find mozharness -name [a-z]\*.py` +#if [ $OS_TYPE == 'windows' ] ; then +# MOZHARNESS_PY_FILES="" +# for f in $files; do +# file $f | grep -q "Assembler source" +# if [ $? -ne 0 ] ; then +# MOZHARNESS_PY_FILES="$MOZHARNESS_PY_FILES $f" +# fi +# done +#else +# MOZHARNESS_PY_FILES=$files +#fi +echo "### Finding scripts/ .py files..." +files=`find scripts -name [a-z]\*.py` +if [ $OS_TYPE == 'windows' ] ; then + SCRIPTS_PY_FILES="" + for f in $files; do + file $f | grep -q "Assembler source" + if [ $? -ne 0 ] ; then + SCRIPTS_PY_FILES="$SCRIPTS_PY_FILES $f" + fi + done +else + SCRIPTS_PY_FILES=$files +fi +export PYTHONPATH=`env pwd`:$PYTHONPATH + +rm -rf logs +#rm -rf build logs +if [ ! -d build ]; then + virtualenv-2.7 --no-site-packages build/venv + build/venv/bin/pip install requests +fi + +if [ $OS_TYPE != 'windows' ] ; then + echo "### Testing mozpool unit tests" + coverage run -a --branch $COVERAGE_ARGS $NOSETESTS test/mozpool/test_*.py + echo "### Running *.py [--list-actions]" +else + echo "### Running nosetests..." + nosetests +fi +#rm -rf build logs diff --git a/scripts/b2g_panda.py b/scripts/b2g_panda.py new file mode 100755 index 00000000..2bb5fdba --- /dev/null +++ b/scripts/b2g_panda.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python +# ***** BEGIN LICENSE BLOCK ***** +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# ***** END LICENSE BLOCK ***** + +import os +import sys +from time import sleep + +# load modules from parent dir +sys.path.insert(1, os.path.dirname(sys.path[0])) + +from mozharness.mozilla.buildbot import TBPL_SUCCESS, TBPL_WARNING, TBPL_FAILURE, TBPL_RETRY, BuildbotMixin +from mozharness.base.python import VirtualenvMixin +from mozharness.base.script import BaseScript +from mozharness.mozilla.testing.testbase import TestingMixin +from mozharness.base.log import WARNING, ERROR +from mozharness.mozilla.testing.mozpool import MozpoolMixin, MozpoolConflictException + +#TODO - adjust these values +MAX_RETRIES = 20 +RETRY_INTERVAL = 60 +REQUEST_DURATION = 60 * 40 + +class PandaTest(TestingMixin, BaseScript, VirtualenvMixin, MozpoolMixin, BuildbotMixin): + config_options = [ + [["--mozpool-api-url"], { + "dest": "mozpool_api_url", + "help": "Override mozpool api url", + }], + [["--mozpool-device"], { + "dest": "mozpool_device", + "help": "Set Panda device to run tests on", + }], + [["--mozpool-assignee"], { + "dest": "mozpool_assignee", + "help": "Set mozpool assignee (requestor name, free-form)", + }], + [["--mozpool-b2gbase"], { + "dest": "mozpool_b2gbase", + "help": "Set b2gbase url", + }], + ] + + error_list = [] + + mozbase_dir = os.path.join('tests', 'mozbase') + virtualenv_modules = [ + 'requests', + ] + + def __init__(self, require_config_file=False): + super(PandaTest, self).__init__( + config_options=self.config_options, + all_actions=['clobber', + 'read-buildbot-config', + 'create-virtualenv', + 'request-device', + 'run-test', + 'close-request'], + default_actions=['clobber', + 'create-virtualenv', + 'request-device', + 'run-test', + 'close-request'], + require_config_file=require_config_file, + config={'virtualenv_modules': self.virtualenv_modules, + 'require_test_zip': True,}) + + # these are necessary since self.config is read only + self.mozpool_device = self.config.get('mozpool_device') + if self.mozpool_device is None: + self.fatal('--mozpool-device is required') + self.mozpool_assignee = self.config.get('mozpool_assignee') + if self.mozpool_assignee is None: + self.mozpool_assignee = self.buildbot_config.get('properties')["slavename"] + if self.mozpool_assignee is None: + self.fatal('--mozpool-assignee is required') + self.mozpool_b2gbase = self.config.get('mozpool_b2gbase') + self.request_url = None + + def retry_sleep(self, sleep_time=1, max_retries=5, error_message=None, tbpl_status=None): + for x in range(1, max_retries + 1): + yield x + sleep(sleep_time) + if error_message: + self.error(error_message) + if tbpl_status: + self.buildbot_status(tbpl_status) + self.fatal('Retries limit exceeded') + + def request_device(self): + mph = self.query_mozpool_handler() + for retry in self.retry_sleep(sleep_time=RETRY_INTERVAL, max_retries=MAX_RETRIES, + error_message="INFRA-ERROR: Could not request device '%s'" % self.mozpool_device, + tbpl_status=TBPL_RETRY): + try: + duration = REQUEST_DURATION + image = 'b2g' + b2gbase = self.mozpool_b2gbase + + response = mph.request_device(self.mozpool_device, self.mozpool_assignee, image, duration, \ + b2gbase=b2gbase, pxe_config=None) + break + except MozpoolConflictException: + self.warning("Device unavailable. Retry#%i.." % retry) + + self.request_url = response['request']['url'] + self.info("Got request, url=%s" % self.request_url) + self.wait_for_request_ready() + + def wait_for_request_ready(self): + mph = self.query_mozpool_handler() + for retry in self.retry_sleep(sleep_time=RETRY_INTERVAL, max_retries=MAX_RETRIES, + error_message="INFRA-ERROR: Request did not become ready in time", + tbpl_status=TBPL_RETRY): + response = mph.query_request_status(self.request_url) + state = response['state'] + if state == 'ready': + return + self.info("Waiting for request 'ready' stage. Current state: '%s'" % state) + + def run_test(self): + """ + Run the Panda tests + """ + + ''' + level = INFO + if code == 0: + status = "success" + tbpl_status = TBPL_SUCCESS + elif code == 10: + status = "test failures" + tbpl_status = TBPL_WARNING + else: + status = "harness failures" + level = ERROR + tbpl_status = TBPL_FAILURE + ''' + + def close_request(self): + mph = self.query_mozpool_handler() + mph.close_request(self.request_url) + print("Request '%s' deleted on cleanup" % self.request_url) + self.request_url = None + +if __name__ == '__main__': + pandaTest = PandaTest() + pandaTest.run() diff --git a/scripts/mozpooltest.py b/scripts/mozpooltest.py new file mode 100755 index 00000000..40eb21a3 --- /dev/null +++ b/scripts/mozpooltest.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +# ***** BEGIN LICENSE BLOCK ***** +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# ***** END LICENSE BLOCK ***** +"""mozpooltest.py + +""" + +import os +import pprint +import sys + +sys.path.insert(1, os.path.dirname(sys.path[0])) + +from mozharness.base.python import VirtualenvMixin, virtualenv_config_options +from mozharness.base.script import BaseScript +from mozharness.mozilla.testing.mozpool import MozpoolMixin + +# MozpoolTest {{{1 +class MozpoolTest(VirtualenvMixin, MozpoolMixin, BaseScript): + config_options = virtualenv_config_options + [[ + ["--mozpool-api-url"], + {"action": "store", + "dest": "mozpool_api_url", + "help": "Specify the URL of the mozpool api" + } + ]] + def __init__(self, require_config_file=False): + BaseScript.__init__( + self, config_options=self.config_options, + all_actions=[ + 'create-virtualenv', + 'run-tests', + ], + config={ + 'virtualenv_modules': ['requests'], + 'mozpool_api_url': "http://localhost:8080", + 'global_retries': 1, + }, + require_config_file=require_config_file + ) + + def run_tests(self): + mph = self.query_mozpool_handler() + self.info("query_all_device_list()") + self.info(pprint.pformat(mph.query_all_device_list())) + self.info("query_all_device_details()") + self.info(pprint.pformat(mph.query_all_device_details())) + self.info("query_device_status('panda-0209')") + self.info(pprint.pformat(mph.query_device_status("panda-0209"))) + self.info("query_device_status('ed-209')") + self.info(pprint.pformat(mph.query_device_status("ed-209"))) + self.info("request_device('ed-209')") + self.info(pprint.pformat(mph.request_device("ed-209", "me", "imageX", "2d"))) + self.info("request_device('any')") + device_blob = mph.request_device("any", "me", "imageX", "1s") + self.info(pprint.pformat(device_blob)) + + +# __main__ {{{1 +if __name__ == '__main__': + mozpool_test = MozpoolTest() + mozpool_test.run() diff --git a/test/mozpool/mozpoolmisc.py b/test/mozpool/mozpoolmisc.py new file mode 100644 index 00000000..0b8b3ddd --- /dev/null +++ b/test/mozpool/mozpoolmisc.py @@ -0,0 +1,42 @@ +import unittest + +from mozharness.base.python import VirtualenvMixin, virtualenv_config_options +from mozharness.base.script import BaseScript +from mozharness.mozilla.testing.mozpool import MozpoolMixin + +MIN_TEST_DEVICES = 20 +# reduce limit to speed up unit test runs, increase limit to increase device coverage +MAX_DEVICES_TO_CHECK = 10 +TEST_DEVICE1 = 'device1' +TEST_DEVICE2 = 'device2' +TEST_ASSIGNEE = 'test@example.com' +TEST_B2GBASE = 'https://pvtbuilds.mozilla.org/pub/mozilla.org/b2g/tinderbox-builds/mozilla-central-panda/20121127133607/' + +class BaseMozpoolTest(unittest.TestCase, VirtualenvMixin, MozpoolMixin, BaseScript): + + def setup(self): + self.config_options = virtualenv_config_options + [[ + ["--mozpool-api-url"], + {"action": "store", + "dest": "mozpool_api_url", + "help": "Specify the URL of the mozpool api" + } + ]] + BaseScript.__init__( + self, config_options=self.config_options, + all_actions=[ + 'create-virtualenv', + 'run-tests', + ], + default_actions=[ + 'run-tests', + ], + config={ + 'virtualenv_modules': ['requests'], + 'mozpool_api_url': "http://localhost:8080", + 'global_retries': 1, + }, + require_config_file=False, + ) + self.mph = self.query_mozpool_handler() + diff --git a/test/mozpool/test_devices.py b/test/mozpool/test_devices.py new file mode 100644 index 00000000..60b2533b --- /dev/null +++ b/test/mozpool/test_devices.py @@ -0,0 +1,68 @@ +import unittest + +from mozpoolmisc import TEST_DEVICE1, BaseMozpoolTest + +class TestBlackMobileMagic(BaseMozpoolTest): + def test_device_ping(self): + self.setup() + + device = TEST_DEVICE1 + + response = self.mph.device_ping(device) + self.assertIn(response['success'], [True, False]) + + def test_device_power_off(self): + self.setup() + + device = TEST_DEVICE1 + + self.mph.device_power_off(device) + + def test_query_device_log(self): + self.setup() + + device = TEST_DEVICE1 + + response = self.mph.query_device_log(device) + self.assertIsNotNone(response) + self.assertIsInstance(response['log'], list) + + def test_query_device_bootconfig(self): + self.setup() + + device = TEST_DEVICE1 + + response = self.mph.query_device_bootconfig(device) + self.assertIsNotNone(response) + + def _test_bmm_pxe_config_details(self, pxe_config): + self.setup() + + response = self.mph.bmm_pxe_config_details(pxe_config) + #{"details": {"active": true, "name": "image1", "contents": "some config", "description": "test img"}} + self.assertIsInstance(response['details'], dict) + for k in response['details'].keys(): + self.assertIn(k, ['active','name','contents','description']) + + def test_bmm_pxe_config_list(self): + self.setup() + + response = self.mph.bmm_pxe_config_list(include_active_only=True) + self.assertIsNotNone(response) + response = self.mph.bmm_pxe_config_list(include_active_only=False) + self.assertIsNotNone(response) + pxe_configs = response['pxe_configs'] + self.assertIsInstance(pxe_configs, list) + for pxe_config in pxe_configs: + self._test_bmm_pxe_config_details(pxe_config) + + def test_bmm_device_clear_pxe(self): + self.setup() + + device = TEST_DEVICE1 + + self.mph.bmm_device_clear_pxe(device) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/mozpool/test_list_devices.py b/test/mozpool/test_list_devices.py new file mode 100644 index 00000000..e7314bd8 --- /dev/null +++ b/test/mozpool/test_list_devices.py @@ -0,0 +1,69 @@ +import unittest + +from mozpoolmisc import MIN_TEST_DEVICES, MAX_DEVICES_TO_CHECK, BaseMozpoolTest + +class QueryAllDeviceList(BaseMozpoolTest): + def test_query_all_device_list(self): + self.setup() + + dl = self.mph.query_all_device_list() + self.assertIsNotNone(dl) + self.assertIsInstance(dl, list) + self.assertGreaterEqual(len(dl), MIN_TEST_DEVICES) + # ensure each item in the list is a [unicode] string + for d in dl[:MAX_DEVICES_TO_CHECK]: + self.assertIsInstance(d, basestring) + +class QueryAllDeviceDetails(BaseMozpoolTest): + def test_query_all_device_details(self): + self.setup() + + dl = self.mph.query_all_device_details() + self.assertIsInstance(dl, list) + self.assertGreaterEqual(len(dl), MIN_TEST_DEVICES) + for d in dl[:MAX_DEVICES_TO_CHECK]: + self.assertIsInstance(d, dict) + for k, v in d.items(): + self.assertIsInstance(k, basestring) + if k in ['fqdn', 'imaging_server', 'mac_address', 'name', 'relay_info', 'state']: + self.assertIsInstance(v, basestring) + elif k in ['id', 'inventory_id']: + self.assertIsInstance(v, int) + else: + self.fail('Unrecognized device key "%s"' % k) + +class QueryDeviceStatus(BaseMozpoolTest): + def test_query_device_status(self): + self.setup() + + dl = self.mph.query_all_device_list() + for d in dl[:MAX_DEVICES_TO_CHECK]: + status = self.mph.query_device_status(d) + self.assertIsNotNone(status) + # {u'log': [], u'state': u'new'} + self.assertIsInstance(status, dict) + for k, v in status.items(): + self.assertIsInstance(k, basestring) + if k == 'log': + self.assertIsInstance(v, list) + elif k == 'state': + self.assertIsInstance(v, basestring) + self.assertIn(v, ['new','free','find_device','contact_lifeguard','pending','ready','pxe_power_cycling']) + else: + self.fail('Unrecognized device status key "%s"' % k) + +class QueryDeviceDetails(BaseMozpoolTest): + def test_query_device_details(self): + self.setup() + + dl = self.mph.query_all_device_list() + for d in dl[:MAX_DEVICES_TO_CHECK]: + details = self.mph.query_device_details(d) + self.assertIsNotNone(details) + self.assertEquals(details['name'], d) + + details = self.mph.query_device_details('thiswillnevermatch') + self.assertIsNone(details) + +if __name__ == '__main__': + unittest.main() diff --git a/test/mozpool/test_power_cycle.py b/test/mozpool/test_power_cycle.py new file mode 100644 index 00000000..6c5179f4 --- /dev/null +++ b/test/mozpool/test_power_cycle.py @@ -0,0 +1,24 @@ +import unittest + +from mozpoolmisc import TEST_ASSIGNEE, TEST_B2GBASE, BaseMozpoolTest + +class TestBlackMobileMagic(BaseMozpoolTest): + def test_power_cycle(self): + self.setup() + + device = 'any' + duration = 10 + pxe_config = None + image = 'b2g' + + device_blob = self.mph.request_device(device, TEST_ASSIGNEE, image, duration, b2gbase=TEST_B2GBASE, pxe_config=pxe_config) + assigned_device = device_blob['request']['assigned_device'] + self.assertIsNotNone(assigned_device) + + self.mph.device_power_cycle(assigned_device, TEST_ASSIGNEE, duration, pxe_config=pxe_config) + dd = self.mph.query_device_details(assigned_device) + new_state = dd['state'] + self.assertEquals('pxe_power_cycling', new_state) + +if __name__ == '__main__': + unittest.main() diff --git a/test/mozpool/test_request_devices.py b/test/mozpool/test_request_devices.py new file mode 100644 index 00000000..b537bfeb --- /dev/null +++ b/test/mozpool/test_request_devices.py @@ -0,0 +1,117 @@ +import unittest + +from mozharness.mozilla.testing.mozpool import MozpoolConflictException +from mozpoolmisc import TEST_ASSIGNEE, TEST_B2GBASE, BaseMozpoolTest + +class RequestDevice(BaseMozpoolTest): + def _force_state(self, device, assignee, pxe_config, duration, desired_state): + dd = self.mph.query_device_details(device) + old_state = dd['state'] + self.mph.device_state_change(device, assignee, pxe_config, duration, old_state, desired_state) + dd = self.mph.query_device_details(device) + new_state = dd['state'] + self.assertEquals(desired_state, new_state) + + def _test_query_all_requests(self): + """ Get a list of request. Expects at least one request to be active. + """ + # query all requests + response = self.mph.query_all_requests() + self.assertIsNotNone(response) + self.assertGreaterEqual(1, len(response)) + for request in response['requests']: + for request_key in request.keys(): + self.assertIn(request_key, ['assigned_device', 'assignee', 'boot_config', 'device_state', + 'expires', 'id', 'imaging_server', 'requested_device', 'state']) + self.assertNotIn(request['state'], ['closed']) + + response = self.mph.query_all_requests(include_closed_requests=True) + self.assertIsNotNone(response) + self.assertGreaterEqual(2, len(response)) + for request in response['requests']: + for request_key in request.keys(): + self.assertIn(request_key, ['assigned_device', 'assignee', 'boot_config', 'device_state', + 'expires', 'id', 'imaging_server', 'requested_device', 'state']) + + def _test_renew_request(self, request_url, old_expires): + """ Reset the lifetime of a request. + """ + self.mph.renew_request(request_url, 12) + response = self.mph.query_request_details(request_url) + expires = response['expires'] + self.assertIsNotNone(expires) + self.assertNotEquals(expires, old_expires) + + def _test_query_request_details(self, request_url): + """ Get request details. Expects at least one request to be active. + """ + response = self.mph.query_request_details(request_url) + self.assertIsNotNone(response) + for k in response.keys(): + self.assertIn(k, ['assigned_device','assignee','boot_config','expires','id','requested_device','url']) + old_expires = response['expires'] + + self._test_renew_request(request_url, old_expires) + + def _test_close_request(self, request_url): + """ Returns the device to the pool and deletes the request. + """ + self.mph.close_request(request_url) + + response = self.mph.query_request_status(request_url) + self.assertEqual(response['state'], 'closed') + + def test_request_any_device(self): + """If 'any' device was requested, always returns 200 OK, since it will + retry a few times if no devices are free. If a specific device is requested + but is already assigned, returns 409 Conflict; otherwise, returns 200 OK. + """ + self.setup() + + device = 'any' + duration = 15 + image = 'b2g' + pxe_config = None + + device_blob = self.mph.request_device(device, TEST_ASSIGNEE, image, duration, b2gbase=TEST_B2GBASE, pxe_config=pxe_config) + self.assertIsNotNone(device_blob) + request_url = device_blob['request']['url'] + self.assertIsNotNone(request_url) + self.assertIn('http', request_url) + + self._test_query_all_requests() + self._test_query_request_details(request_url) + self._test_close_request(request_url) + + def test_request_conflicting_device(self): + self.setup() + + device = 'any' + duration = 10 + image = 'b2g' + pxe_config = None + + device_blob = self.mph.request_device(device, TEST_ASSIGNEE, image, duration, b2gbase=TEST_B2GBASE, pxe_config=pxe_config) + self.assertIsNotNone(device_blob) + assigned_device = device_blob['request']['assigned_device'] + device_blob['request']['url'] + # try and request the same device + with self.assertRaises(MozpoolConflictException): + self.mph.request_device(assigned_device, TEST_ASSIGNEE, image, duration, b2gbase=TEST_B2GBASE, pxe_config=pxe_config) + + def test_query_request_status(self): + self.setup() + + device = 'any' + duration = 10 + image = 'b2g' + pxe_config = None + + device_blob = self.mph.request_device(device, TEST_ASSIGNEE, image, duration, b2gbase=TEST_B2GBASE, pxe_config=pxe_config) + request_url = device_blob['request']['url'] + + response = self.mph.query_request_status(request_url) + self.assertIsNotNone(response) + +if __name__ == '__main__': + unittest.main() diff --git a/test/mozpool/test_state_change.py b/test/mozpool/test_state_change.py new file mode 100644 index 00000000..ba088b09 --- /dev/null +++ b/test/mozpool/test_state_change.py @@ -0,0 +1,23 @@ +import unittest + +from mozpoolmisc import TEST_DEVICE1, TEST_ASSIGNEE, BaseMozpoolTest + +class TestDeviceStateChange(BaseMozpoolTest): + def test_device_state_change(self): + self.setup() + + device = TEST_DEVICE1 + dd = self.mph.query_device_details(device) + old_state = dd['state'] + desired_state = 'ready' + duration = 10 + pxe_config = None + + self.mph.device_state_change(device, TEST_ASSIGNEE, duration, old_state, desired_state, pxe_config=pxe_config) + dd = self.mph.query_device_details(device) + new_state = dd['state'] + self.assertEquals(desired_state, new_state) + + +if __name__ == '__main__': + unittest.main() diff --git a/unit.sh b/unit.sh index 00a890de..a2f437d9 100755 --- a/unit.sh +++ b/unit.sh @@ -63,10 +63,10 @@ pylint -E -e F -f parseable $MOZHARNESS_PY_FILES $SCRIPTS_PY_FILES 2>&1 | egrep rm -rf build logs if [ $OS_TYPE != 'windows' ] ; then -echo "### Testing non-networked unit tests" -coverage run -a --branch $COVERAGE_ARGS $NOSETESTS test/test_*.py -echo "### Testing networked unit tests" -coverage run -a --branch $COVERAGE_ARGS $NOSETESTS test/networked/test_*.py + echo "### Testing non-networked unit tests" + coverage run -a --branch $COVERAGE_ARGS $NOSETESTS test/test_*.py + echo "### Testing networked unit tests" + coverage run -a --branch $COVERAGE_ARGS $NOSETESTS test/networked/test_*.py echo "### Running *.py [--list-actions]" for filename in $MOZHARNESS_PY_FILES; do coverage run -a --branch $COVERAGE_ARGS $filename