blob: e74b5c048ecb301603094af1adbe8533955ad167 [file] [log] [blame]
#!/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 getpass
import re
import requests
import socket
import sys
import time
try:
import simplejson as json
except ImportError:
import json
JsonHeader = {'content-type': 'application/json'}
# 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
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))
class MozpoolHandler:
def __init__(self, mozpool_api_url, log_obj=None):
self.mozpool_api_url = mozpool_api_url
self.mozpool_timeout=20
self.user = getpass.getuser()
self.hostname = socket.gethostname()
if log_obj:
self.log_obj = log_obj
else:
import logging as log
self.log_obj = log
# Helper methods {{{2
def url_get(self, url, decode_json=True, **kwargs):
"""Generic get output from a url method.
This could be moved to a generic url handler object.
"""
self.log_obj.debug("Request GET %s..." % url)
if kwargs.get("timeout") is None:
kwargs["timeout"] = self.mozpool_timeout
num_retries = 10
try_num = 0
while try_num <= num_retries:
try_num += 1
try:
r = requests.get(url, **kwargs)
self.log_obj.debug("Status code: %s" % str(r.status_code))
if decode_json:
j = self.decode_json(r.text)
if j is not None:
return (j, r.status_code)
else:
raise MozpoolException("Try %d: Can't decode json from %s!" % (try_num, url))
else:
return (r.text, r.status_code)
except requests.exceptions.RequestException, e:
raise MozpoolException("Try %d: Can't get %s: %s!" % (try_num, url, str(e)))
if try_num <= num_retries:
sleep_time = 2 * try_num
self.log_obj.info("Sleeping %d..." % sleep_time)
time.sleep(sleep_time)
def decode_json(self, contents):
try:
return json.loads(contents, encoding="ascii")
except ValueError, e:
raise MozpoolException("Can't decode json: %s!" % str(e))
except TypeError, e:
raise MozpoolException("Can't decode json: %s!" % str(e))
else:
raise MozpoolException("Can't decode json: Unknown error!" % str(e))
def url_post(self, url, data, auth=None, params=None,
good_statuses=None, decode_json=True, **kwargs):
"""Generic post to a url method.
This could be moved to a generic url handler object.
"""
self.log_obj.debug("Request POST %s..." % url)
if kwargs.get("timeout") is None:
kwargs["timeout"] = self.mozpool_timeout
num_retries = 10
if good_statuses is None:
good_statuses = [200, 201, 202, 204, 302]
try_num = 0
while try_num <= num_retries:
try_num += 1
try:
r = requests.post(url, data=data, **kwargs)
if r.status_code in good_statuses:
self.log_obj.debug("Status code: %s" % str(r.status_code))
if decode_json:
j = self.decode_json(r.text)
if j is not None:
return (j, r.status_code)
else:
raise MozpoolException("Try %d: Can't decode json from %s!" % (try_num, url))
else:
return (r.text, r.status_code)
else:
self.log_obj.critical("Bad return status from %s: %d!" % (url, r.status_code))
return (None, r.status_code)
except requests.exceptions.RequestException, e:
self.log_obj.exception("Try %d: Can't get %s: %s!" % (try_num, url, str(e)))
if try_num <= num_retries:
sleep_time = 2 * try_num
self.log_obj.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 partial_url_post(self, partial_url, **kwargs):
return self.url_post(self.mozpool_api_url + partial_url, **kwargs)
# Device queries {{{2
def query_device_status(self, device, **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, **kwargs)
check_mozpool_status(status)
return response
def query_device_state(self, device, **kwargs):
""" returns a JSON response body whose "state" key contains
a short string describing the last-known status of the device.
"""
response, status = self.partial_url_get("/api/device/%s/state/" % device, **kwargs)
check_mozpool_status(status)
return response
def query_device_details(self, device, **kwargs):
bmm = self.mozpool_api_url
response, status = self.url_get("%s/api/device/list/?details=1" % bmm, **kwargs)
check_mozpool_status(status)
devices = response.get("devices")
if isinstance(devices, list):
matches = filter(lambda dd: dd['name'] == device, devices)
if len(matches) != 1:
self.log_obj.critical("Couldn't find %s in device list!" % device_id)
return
else:
return matches[0]
else:
# We shouldn't get here if query_all_device_details() FATALs...
raise MozpoolException("Invalid response from query_all_device_details()!")
def request_device(self, device, image, duration=30*60, assignee=None,
pxe_config=None, b2gbase=None, environment='any', **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 not assignee:
assignee = "%s-%s" % (self.user, self.hostname)
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, 'environment': environment}
if pxe_config is not None:
data['pxe_config'] = pxe_config
data['b2gbase'] = b2gbase
response, status = self.url_post(
"%s/api/device/%s/request/" % (self.mozpool_api_url, device),
data=json.dumps(data), headers=JsonHeader)
check_mozpool_status(status)
return response
def renew_request(self, request_url, new_duration):
""" 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, **kwargs):
""" returns the device to the pool and deletes the request. Returns
204 No Content.
"""
if request_url:
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
else:
return "request_url doesn't exist. Already closed?"
def device_power_cycle(self, device, duration, assignee=None, 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/`.
"""
if assignee == None:
assignee = "%s-%s" % (self.user, self.hostname)
data = {'assignee': assignee, 'duration': duration}
if pxe_config is not None:
data['pxe_config'] = pxe_config
response, status = self.url_post(
"%s/api/device/%s/power-cycle/" % (self.mozpool_api_url, device),
data=json.dumps(data), headers=JsonHeader
)
check_mozpool_status(status)
return response
def device_ping(self, device, **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.url_get(
"%s/api/device/%s/ping" % (self.mozpool_api_url, device)
)
check_mozpool_status(status)
return response
def query_device_log(self, device, **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.url_get(
device, "/api/device/%s/log/" % device, **kwargs)
check_mozpool_status(status)
return response
def query_request_status(self, request_url, **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, **kwargs)
check_mozpool_status(status)
return response
def query_request_details(self, request_url, **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, **kwargs)
check_mozpool_status(status)
return response