blob: e331fe528783864db596b966306158ad7062ff7a [file] [log] [blame] [edit]
# Lint as: python2, python3
# Copyright Martin J. Bligh, Google Inc 2008
# Released under the GPL v2
"""
This class allows you to communicate with the frontend to submit jobs etc
It is designed for writing more sophisiticated server-side control files that
can recursively add and manage other jobs.
We turn the JSON dictionaries into real objects that are more idiomatic
For docs, see:
http://www.chromium.org/chromium-os/testing/afe-rpc-infrastructure
http://docs.djangoproject.com/en/dev/ref/models/querysets/#queryset-api
"""
#pylint: disable=missing-docstring
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
import getpass
import os
import re
import common
from autotest_lib.frontend.afe import rpc_client_lib
from autotest_lib.client.common_lib import control_data
from autotest_lib.client.common_lib import global_config
from autotest_lib.client.common_lib import host_states
from autotest_lib.client.common_lib import priorities
from autotest_lib.client.common_lib import utils
from autotest_lib.tko import db
from six.moves import zip
try:
from chromite.lib import metrics
except ImportError:
metrics = utils.metrics_mock
try:
from autotest_lib.server.site_common import site_utils as server_utils
except:
from autotest_lib.server import utils as server_utils
form_ntuples_from_machines = server_utils.form_ntuples_from_machines
GLOBAL_CONFIG = global_config.global_config
DEFAULT_SERVER = 'autotest'
def dump_object(header, obj):
"""
Standard way to print out the frontend objects (eg job, host, acl, label)
in a human-readable fashion for debugging
"""
result = header + '\n'
for key in obj.hash:
if key == 'afe' or key == 'hash':
continue
result += '%20s: %s\n' % (key, obj.hash[key])
return result
class RpcClient(object):
"""
Abstract RPC class for communicating with the autotest frontend
Inherited for both TKO and AFE uses.
All the constructors go in the afe / tko class.
Manipulating methods go in the object classes themselves
"""
def __init__(self, path, user, server, print_log, debug, reply_debug):
"""
Create a cached instance of a connection to the frontend
user: username to connect as
server: frontend server to connect to
print_log: pring a logging message to stdout on every operation
debug: print out all RPC traffic
"""
if not user and utils.is_in_container():
user = GLOBAL_CONFIG.get_config_value('SSP', 'user', default=None)
if not user:
user = getpass.getuser()
if not server:
if 'AUTOTEST_WEB' in os.environ:
server = os.environ['AUTOTEST_WEB']
else:
server = GLOBAL_CONFIG.get_config_value('SERVER', 'hostname',
default=DEFAULT_SERVER)
self.server = server
self.user = user
self.print_log = print_log
self.debug = debug
self.reply_debug = reply_debug
headers = {'AUTHORIZATION': self.user}
rpc_server = rpc_client_lib.add_protocol(server) + path
if debug:
print('SERVER: %s' % rpc_server)
print('HEADERS: %s' % headers)
self.proxy = rpc_client_lib.get_proxy(rpc_server, headers=headers)
def run(self, call, **dargs):
"""
Make a RPC call to the AFE server
"""
rpc_call = getattr(self.proxy, call)
if self.debug:
print('DEBUG: %s %s' % (call, dargs))
try:
result = utils.strip_unicode(rpc_call(**dargs))
if self.reply_debug:
print(result)
return result
except Exception:
raise
def log(self, message):
if self.print_log:
print(message)
class TKO(RpcClient):
def __init__(self, user=None, server=None, print_log=True, debug=False,
reply_debug=False):
super(TKO, self).__init__(path='/new_tko/server/noauth/rpc/',
user=user,
server=server,
print_log=print_log,
debug=debug,
reply_debug=reply_debug)
self._db = None
@metrics.SecondsTimerDecorator(
'chromeos/autotest/tko/get_job_status_duration')
def get_job_test_statuses_from_db(self, job_id):
"""Get job test statuses from the database.
Retrieve a set of fields from a job that reflect the status of each test
run within a job.
fields retrieved: status, test_name, reason, test_started_time,
test_finished_time, afe_job_id, job_owner, hostname.
@param job_id: The afe job id to look up.
@returns a TestStatus object of the resulting information.
"""
if self._db is None:
self._db = db.db()
fields = ['status', 'test_name', 'subdir', 'reason',
'test_started_time', 'test_finished_time', 'afe_job_id',
'job_owner', 'hostname', 'job_tag']
table = 'tko_test_view_2'
where = 'job_tag like "%s-%%"' % job_id
test_status = []
# Run commit before we query to ensure that we are pulling the latest
# results.
self._db.commit()
for entry in self._db.select(','.join(fields), table, (where, None)):
status_dict = {}
for key,value in zip(fields, entry):
# All callers expect values to be a str object.
status_dict[key] = str(value)
# id is used by TestStatus to uniquely identify each Test Status
# obj.
status_dict['id'] = [status_dict['reason'], status_dict['hostname'],
status_dict['test_name']]
test_status.append(status_dict)
return [TestStatus(self, e) for e in test_status]
def get_status_counts(self, job, **data):
entries = self.run('get_status_counts',
group_by=['hostname', 'test_name', 'reason'],
job_tag__startswith='%s-' % job, **data)
return [TestStatus(self, e) for e in entries['groups']]
class _StableVersionMap(object):
"""
A mapping from board names to strings naming software versions.
The mapping is meant to allow finding a nominally "stable" version
of software associated with a given board. The mapping identifies
specific versions of software that should be installed during
operations such as repair.
Conceptually, there are multiple version maps, each handling
different types of image. For instance, a single board may have
both a stable OS image (e.g. for CrOS), and a separate stable
firmware image.
Each different type of image requires a certain amount of special
handling, implemented by a subclass of `StableVersionMap`. The
subclasses take care of pre-processing of arguments, delegating
actual RPC calls to this superclass.
@property _afe AFE object through which to make the actual RPC
calls.
@property _android Value of the `android` parameter to be passed
when calling the `get_stable_version` RPC.
"""
def __init__(self, afe):
self._afe = afe
def get_all_versions(self):
"""
Get all mappings in the stable versions table.
Extracts the full content of the `stable_version` table
in the AFE database, and returns it as a dictionary
mapping board names to version strings.
@return A dictionary mapping board names to version strings.
"""
return self._afe.run('get_all_stable_versions')
def get_version(self, board):
"""
Get the mapping of one board in the stable versions table.
Look up and return the version mapped to the given board in the
`stable_versions` table in the AFE database.
@param board The board to be looked up.
@return The version mapped for the given board.
"""
return self._afe.run('get_stable_version', board=board)
def set_version(self, board, version):
"""
Change the mapping of one board in the stable versions table.
Set the mapping in the `stable_versions` table in the AFE
database for the given board to the given version.
@param board The board to be updated.
@param version The new version to be assigned to the board.
"""
raise RuntimeError("server.frontend._StableVersionMap::set_version is intentionally deleted")
def delete_version(self, board):
"""
Remove the mapping of one board in the stable versions table.
Remove the mapping in the `stable_versions` table in the AFE
database for the given board.
@param board The board to be updated.
"""
raise RuntimeError("server.frontend._StableVersionMap::delete_version is intentionally deleted")
class _OSVersionMap(_StableVersionMap):
"""
Abstract stable version mapping for full OS images of various types.
"""
def _version_is_valid(self, version):
return True
def get_all_versions(self):
versions = super(_OSVersionMap, self).get_all_versions()
for board in versions.keys():
if ('/' in board
or not self._version_is_valid(versions[board])):
del versions[board]
return versions
def get_version(self, board):
version = super(_OSVersionMap, self).get_version(board)
return version if self._version_is_valid(version) else None
def format_cros_image_name(board, version):
"""
Return an image name for a given `board` and `version`.
This formats `board` and `version` into a string identifying an
image file. The string represents part of a URL for access to
the image.
The returned image name is typically of a form like
"falco-release/R55-8872.44.0".
"""
build_pattern = GLOBAL_CONFIG.get_config_value(
'CROS', 'stable_build_pattern')
return build_pattern % (board, version)
class _CrosVersionMap(_OSVersionMap):
"""
Stable version mapping for Chrome OS release images.
This class manages a mapping of Chrome OS board names to known-good
release (or canary) images. The images selected can be installed on
DUTs during repair tasks, as a way of getting a DUT into a known
working state.
"""
def _version_is_valid(self, version):
return version is not None and '/' not in version
def get_image_name(self, board):
"""
Return the full image name of the stable version for `board`.
This finds the stable version for `board`, and returns a string
identifying the associated image as for `format_image_name()`,
above.
@return A string identifying the image file for the stable
image for `board`.
"""
return format_cros_image_name(board, self.get_version(board))
class _SuffixHackVersionMap(_StableVersionMap):
"""
Abstract super class for mappings using a pseudo-board name.
For non-OS image type mappings, we look them up in the
`stable_versions` table by constructing a "pseudo-board" from the
real board name plus a suffix string that identifies the image type.
So, for instance the name "lulu/firmware" is used to look up the
FAFT firmware version for lulu boards.
"""
# _SUFFIX - The suffix used in constructing the "pseudo-board"
# lookup key. Each subclass must define this value for itself.
#
_SUFFIX = None
def get_all_versions(self):
# Get all the mappings from the AFE, extract just the mappings
# with our suffix, and replace the pseudo-board name keys with
# the real board names.
#
all_versions = super(
_SuffixHackVersionMap, self).get_all_versions()
return {
board[0 : -len(self._SUFFIX)]: all_versions[board]
for board in all_versions.keys()
if board.endswith(self._SUFFIX)
}
def get_version(self, board):
board += self._SUFFIX
return super(_SuffixHackVersionMap, self).get_version(board)
def set_version(self, board, version):
board += self._SUFFIX
super(_SuffixHackVersionMap, self).set_version(board, version)
def delete_version(self, board):
board += self._SUFFIX
super(_SuffixHackVersionMap, self).delete_version(board)
class _FAFTVersionMap(_SuffixHackVersionMap):
"""
Stable version mapping for firmware versions used in FAFT repair.
When DUTs used for FAFT fail repair, stable firmware may need to be
flashed directly from original tarballs. The FAFT firmware version
mapping finds the appropriate tarball for a given board.
"""
_SUFFIX = '/firmware'
def get_version(self, board):
# If there's no mapping for `board`, the lookup will return the
# default CrOS version mapping. To eliminate that case, we
# require a '/' character in the version, since CrOS versions
# won't match that.
#
# TODO(jrbarnette): This is, of course, a hack. Ultimately,
# the right fix is to move handling to the RPC server side.
#
version = super(_FAFTVersionMap, self).get_version(board)
return version if '/' in version else None
class _FirmwareVersionMap(_SuffixHackVersionMap):
"""
Stable version mapping for firmware supplied in Chrome OS images.
A Chrome OS image bundles a version of the firmware that the
device should update to when the OS version is installed during
AU.
Test images suppress the firmware update during AU. Instead, during
repair and verify we check installed firmware on a DUT, compare it
against the stable version mapping for the board, and update when
the DUT is out-of-date.
"""
_SUFFIX = '/rwfw'
def get_version(self, board):
# If there's no mapping for `board`, the lookup will return the
# default CrOS version mapping. To eliminate that case, we
# require the version start with "Google_", since CrOS versions
# won't match that.
#
# TODO(jrbarnette): This is, of course, a hack. Ultimately,
# the right fix is to move handling to the RPC server side.
#
version = super(_FirmwareVersionMap, self).get_version(board)
return version if version.startswith('Google_') else None
class AFE(RpcClient):
# Known image types for stable version mapping objects.
# CROS_IMAGE_TYPE - Mappings for Chrome OS images.
# FAFT_IMAGE_TYPE - Mappings for Firmware images for FAFT repair.
# FIRMWARE_IMAGE_TYPE - Mappings for released RW Firmware images.
#
CROS_IMAGE_TYPE = 'cros'
FAFT_IMAGE_TYPE = 'faft'
FIRMWARE_IMAGE_TYPE = 'firmware'
_IMAGE_MAPPING_CLASSES = {
CROS_IMAGE_TYPE: _CrosVersionMap,
FAFT_IMAGE_TYPE: _FAFTVersionMap,
FIRMWARE_IMAGE_TYPE: _FirmwareVersionMap,
}
def __init__(self, user=None, server=None, print_log=True, debug=False,
reply_debug=False, job=None):
self.job = job
super(AFE, self).__init__(path='/afe/server/noauth/rpc/',
user=user,
server=server,
print_log=print_log,
debug=debug,
reply_debug=reply_debug)
def get_stable_version_map(self, image_type):
"""
Return a stable version mapping for the given image type.
@return An object mapping board names to version strings for
software of the given image type.
"""
return self._IMAGE_MAPPING_CLASSES[image_type](self)
def host_statuses(self, live=None):
dead_statuses = ['Repair Failed', 'Repairing']
statuses = self.run('get_static_data')['host_statuses']
if live == True:
return list(set(statuses) - set(dead_statuses))
if live == False:
return dead_statuses
else:
return statuses
@staticmethod
def _dict_for_host_query(hostnames=(), status=None, label=None):
query_args = {}
if hostnames:
query_args['hostname__in'] = hostnames
if status:
query_args['status'] = status
if label:
query_args['labels__name'] = label
return query_args
def get_hosts(self, hostnames=(), status=None, label=None, **dargs):
query_args = dict(dargs)
query_args.update(self._dict_for_host_query(hostnames=hostnames,
status=status,
label=label))
hosts = self.run('get_hosts', **query_args)
return [Host(self, h) for h in hosts]
def get_hostnames(self, status=None, label=None, **dargs):
"""Like get_hosts() but returns hostnames instead of Host objects."""
# This implementation can be replaced with a more efficient one
# that does not query for entire host objects in the future.
return [host_obj.hostname for host_obj in
self.get_hosts(status=status, label=label, **dargs)]
def reverify_hosts(self, hostnames=(), status=None, label=None):
query_args = dict(locked=False,
aclgroup__users__login=self.user)
query_args.update(self._dict_for_host_query(hostnames=hostnames,
status=status,
label=label))
return self.run('reverify_hosts', **query_args)
def repair_hosts(self, hostnames=(), status=None, label=None):
query_args = dict(locked=False,
aclgroup__users__login=self.user)
query_args.update(self._dict_for_host_query(hostnames=hostnames,
status=status,
label=label))
return self.run('repair_hosts', **query_args)
def create_host(self, hostname, **dargs):
id = self.run('add_host', hostname=hostname, **dargs)
return self.get_hosts(id=id)[0]
def get_host_attribute(self, attr, **dargs):
host_attrs = self.run('get_host_attribute', attribute=attr, **dargs)
return [HostAttribute(self, a) for a in host_attrs]
def set_host_attribute(self, attr, val, **dargs):
self.run('set_host_attribute', attribute=attr, value=val, **dargs)
def get_labels(self, **dargs):
labels = self.run('get_labels', **dargs)
return [Label(self, l) for l in labels]
def create_label(self, name, **dargs):
id = self.run('add_label', name=name, **dargs)
return self.get_labels(id=id)[0]
def get_acls(self, **dargs):
acls = self.run('get_acl_groups', **dargs)
return [Acl(self, a) for a in acls]
def create_acl(self, name, **dargs):
id = self.run('add_acl_group', name=name, **dargs)
return self.get_acls(id=id)[0]
def get_users(self, **dargs):
users = self.run('get_users', **dargs)
return [User(self, u) for u in users]
def generate_control_file(self, tests, **dargs):
ret = self.run('generate_control_file', tests=tests, **dargs)
return ControlFile(self, ret)
def get_jobs(self, summary=False, **dargs):
if summary:
jobs_data = self.run('get_jobs_summary', **dargs)
else:
jobs_data = self.run('get_jobs', **dargs)
jobs = []
for j in jobs_data:
job = Job(self, j)
# Set up some extra information defaults
job.testname = re.sub('\s.*', '', job.name) # arbitrary default
job.platform_results = {}
job.platform_reasons = {}
jobs.append(job)
return jobs
def get_host_queue_entries(self, **kwargs):
"""Find JobStatus objects matching some constraints.
@param **kwargs: Arguments to pass to the RPC
"""
entries = self.run('get_host_queue_entries', **kwargs)
return self._entries_to_statuses(entries)
def get_host_queue_entries_by_insert_time(self, **kwargs):
"""Like get_host_queue_entries, but using the insert index table.
@param **kwargs: Arguments to pass to the RPC
"""
entries = self.run('get_host_queue_entries_by_insert_time', **kwargs)
return self._entries_to_statuses(entries)
def _entries_to_statuses(self, entries):
"""Converts HQEs to JobStatuses
Sadly, get_host_queue_entries doesn't return platforms, we have
to get those back from an explicit get_hosts queury, then patch
the new host objects back into the host list.
:param entries: A list of HQEs from get_host_queue_entries or
get_host_queue_entries_by_insert_time.
"""
job_statuses = [JobStatus(self, e) for e in entries]
hostnames = [s.host.hostname for s in job_statuses if s.host]
hosts = {}
for host in self.get_hosts(hostname__in=hostnames):
hosts[host.hostname] = host
for status in job_statuses:
if status.host:
status.host = hosts.get(status.host.hostname)
# filter job statuses that have either host or meta_host
return [status for status in job_statuses if (status.host or
status.meta_host)]
def get_special_tasks(self, **data):
tasks = self.run('get_special_tasks', **data)
return [SpecialTask(self, t) for t in tasks]
def get_host_special_tasks(self, host_id, **data):
tasks = self.run('get_host_special_tasks',
host_id=host_id, **data)
return [SpecialTask(self, t) for t in tasks]
def get_host_status_task(self, host_id, end_time):
task = self.run('get_host_status_task',
host_id=host_id, end_time=end_time)
return SpecialTask(self, task) if task else None
def get_host_diagnosis_interval(self, host_id, end_time, success):
return self.run('get_host_diagnosis_interval',
host_id=host_id, end_time=end_time,
success=success)
def create_job(self, control_file, name=' ',
priority=priorities.Priority.DEFAULT,
control_type=control_data.CONTROL_TYPE_NAMES.CLIENT,
**dargs):
id = self.run('create_job', name=name, priority=priority,
control_file=control_file, control_type=control_type, **dargs)
return self.get_jobs(id=id)[0]
def abort_jobs(self, jobs):
"""Abort a list of jobs.
Already completed jobs will not be affected.
@param jobs: List of job ids to abort.
"""
for job in jobs:
self.run('abort_host_queue_entries', job_id=job)
def get_hosts_by_attribute(self, attribute, value):
"""
Get the list of hosts that share the same host attribute value.
@param attribute: String of the host attribute to check.
@param value: String of the value that is shared between hosts.
@returns List of hostnames that all have the same host attribute and
value.
"""
return self.run('get_hosts_by_attribute',
attribute=attribute, value=value)
def lock_host(self, host, lock_reason, fail_if_locked=False):
"""
Lock the given host with the given lock reason.
Locking a host that's already locked using the 'modify_hosts' rpc
will raise an exception. That's why fail_if_locked exists so the
caller can determine if the lock succeeded or failed. This will
save every caller from wrapping lock_host in a try-except.
@param host: hostname of host to lock.
@param lock_reason: Reason for locking host.
@param fail_if_locked: Return False if host is already locked.
@returns Boolean, True if lock was successful, False otherwise.
"""
try:
self.run('modify_hosts',
host_filter_data={'hostname': host},
update_data={'locked': True,
'lock_reason': lock_reason})
except Exception:
return not fail_if_locked
return True
def unlock_hosts(self, locked_hosts):
"""
Unlock the hosts.
Unlocking a host that's already unlocked will do nothing so we don't
need any special try-except clause here.
@param locked_hosts: List of hostnames of hosts to unlock.
"""
self.run('modify_hosts',
host_filter_data={'hostname__in': locked_hosts},
update_data={'locked': False,
'lock_reason': ''})
class TestResults(object):
"""
Container class used to hold the results of the tests for a job
"""
def __init__(self):
self.good = []
self.fail = []
self.pending = []
def add(self, result):
if result.complete_count > result.pass_count:
self.fail.append(result)
elif result.incomplete_count > 0:
self.pending.append(result)
else:
self.good.append(result)
class RpcObject(object):
"""
Generic object used to construct python objects from rpc calls
"""
def __init__(self, afe, hash):
self.afe = afe
self.hash = hash
self.__dict__.update(hash)
def __str__(self):
return dump_object(self.__repr__(), self)
class ControlFile(RpcObject):
"""
AFE control file object
Fields: synch_count, dependencies, control_file, is_server
"""
def __repr__(self):
return 'CONTROL FILE: %s' % self.control_file
class Label(RpcObject):
"""
AFE label object
Fields:
name, invalid, platform, kernel_config, id, only_if_needed
"""
def __repr__(self):
return 'LABEL: %s' % self.name
def add_hosts(self, hosts):
# We must use the label's name instead of the id because label ids are
# not consistent across main-shard.
return self.afe.run('label_add_hosts', id=self.name, hosts=hosts)
def remove_hosts(self, hosts):
# We must use the label's name instead of the id because label ids are
# not consistent across main-shard.
return self.afe.run('label_remove_hosts', id=self.name, hosts=hosts)
class Acl(RpcObject):
"""
AFE acl object
Fields:
users, hosts, description, name, id
"""
def __repr__(self):
return 'ACL: %s' % self.name
def add_hosts(self, hosts):
self.afe.log('Adding hosts %s to ACL %s' % (hosts, self.name))
return self.afe.run('acl_group_add_hosts', self.id, hosts)
def remove_hosts(self, hosts):
self.afe.log('Removing hosts %s from ACL %s' % (hosts, self.name))
return self.afe.run('acl_group_remove_hosts', self.id, hosts)
def add_users(self, users):
self.afe.log('Adding users %s to ACL %s' % (users, self.name))
return self.afe.run('acl_group_add_users', id=self.name, users=users)
class Job(RpcObject):
"""
AFE job object
Fields:
name, control_file, control_type, synch_count, reboot_before,
run_verify, priority, email_list, created_on, dependencies,
timeout, owner, reboot_after, id
"""
def __repr__(self):
return 'JOB: %s' % self.id
class JobStatus(RpcObject):
"""
AFE job_status object
Fields:
status, complete, deleted, meta_host, host, active, execution_subdir, id
"""
def __init__(self, afe, hash):
super(JobStatus, self).__init__(afe, hash)
self.job = Job(afe, self.job)
if getattr(self, 'host'):
self.host = Host(afe, self.host)
def __repr__(self):
if self.host and self.host.hostname:
hostname = self.host.hostname
else:
hostname = 'None'
return 'JOB STATUS: %s-%s' % (self.job.id, hostname)
class SpecialTask(RpcObject):
"""
AFE special task object
"""
def __init__(self, afe, hash):
super(SpecialTask, self).__init__(afe, hash)
self.host = Host(afe, self.host)
def __repr__(self):
return 'SPECIAL TASK: %s' % self.id
class Host(RpcObject):
"""
AFE host object
Fields:
status, lock_time, locked_by, locked, hostname, invalid,
labels, platform, protection, dirty, id
"""
def __repr__(self):
return 'HOST OBJECT: %s' % self.hostname
def show(self):
labels = list(set(self.labels) - set([self.platform]))
print('%-6s %-7s %-7s %-16s %s' % (self.hostname, self.status,
self.locked, self.platform,
', '.join(labels)))
def delete(self):
return self.afe.run('delete_host', id=self.id)
def modify(self, **dargs):
return self.afe.run('modify_host', id=self.id, **dargs)
def get_acls(self):
return self.afe.get_acls(hosts__hostname=self.hostname)
def add_acl(self, acl_name):
self.afe.log('Adding ACL %s to host %s' % (acl_name, self.hostname))
return self.afe.run('acl_group_add_hosts', id=acl_name,
hosts=[self.hostname])
def remove_acl(self, acl_name):
self.afe.log('Removing ACL %s from host %s' % (acl_name, self.hostname))
return self.afe.run('acl_group_remove_hosts', id=acl_name,
hosts=[self.hostname])
def get_labels(self):
return self.afe.get_labels(host__hostname__in=[self.hostname])
def add_labels(self, labels):
self.afe.log('Adding labels %s to host %s' % (labels, self.hostname))
return self.afe.run('host_add_labels', id=self.id, labels=labels)
def remove_labels(self, labels):
self.afe.log('Removing labels %s from host %s' % (labels,self.hostname))
return self.afe.run('host_remove_labels', id=self.id, labels=labels)
def is_available(self):
"""Check whether DUT host is available.
@return: bool
"""
return not (self.locked
or self.status in host_states.UNAVAILABLE_STATES)
class User(RpcObject):
def __repr__(self):
return 'USER: %s' % self.login
class TestStatus(RpcObject):
"""
TKO test status object
Fields:
test_idx, hostname, testname, id
complete_count, incomplete_count, group_count, pass_count
"""
def __repr__(self):
return 'TEST STATUS: %s' % self.id
class HostAttribute(RpcObject):
"""
AFE host attribute object
Fields:
id, host, attribute, value
"""
def __repr__(self):
return 'HOST ATTRIBUTE %d' % self.id