Browse files

Bug 810439 - mozharness support for mozpool. r=aki

  • Loading branch information...
1 parent 3f722e5 commit ed4a254edc4445d279361ff0233683ff5d2da184 @jhopkinsmoz jhopkinsmoz committed Dec 4, 2012
View
21 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',
+ ],
+}
View
4 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()
View
488 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
View
76 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
View
152 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()
View
65 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()
View
42 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()
+
View
68 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()
View
69 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()
View
24 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()
View
117 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()
View
23 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()
View
8 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

0 comments on commit ed4a254

Please sign in to comment.