1# Copyright 2016 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import abc 6import copy 7import json 8import logging 9 10import common 11from autotest_lib.server.cros import provision 12 13 14class HostInfo(object): 15 """Holds label/attribute information about a host as understood by infra. 16 17 This class is the source of truth of label / attribute information about a 18 host for the test runner (autoserv) and the tests, *from the point of view 19 of the infrastructure*. 20 21 Typical usage: 22 store = AfeHostInfoStore(...) 23 host_info = store.get() 24 update_somehow(host_info) 25 store.commit(host_info) 26 27 Besides the @property listed below, the following rw variables are part of 28 the public API: 29 labels: The list of labels for this host. 30 attributes: The list of attributes for this host. 31 """ 32 33 __slots__ = ['labels', 'attributes'] 34 35 # Constants related to exposing labels as more semantic properties. 36 _BOARD_PREFIX = 'board' 37 _MODEL_PREFIX = 'model' 38 _OS_PREFIX = 'os' 39 _POOL_PREFIX = 'pool' 40 41 _VERSION_LABELS = ( 42 provision.CROS_VERSION_PREFIX, 43 provision.CROS_ANDROID_VERSION_PREFIX, 44 provision.ANDROID_BUILD_VERSION_PREFIX, 45 provision.TESTBED_BUILD_VERSION_PREFIX, 46 ) 47 48 def __init__(self, labels=None, attributes=None): 49 """ 50 @param labels: (optional list) labels to set on the HostInfo. 51 @param attributes: (optional dict) attributes to set on the HostInfo. 52 """ 53 self.labels = labels if labels is not None else [] 54 self.attributes = attributes if attributes is not None else {} 55 56 57 @property 58 def build(self): 59 """Retrieve the current build for the host. 60 61 TODO(pprabhu) Make provision.py depend on this instead of the other way 62 around. 63 64 @returns The first build label for this host (if there are multiple). 65 None if no build label is found. 66 """ 67 for label_prefix in self._VERSION_LABELS: 68 build_labels = self._get_stripped_labels_with_prefix(label_prefix) 69 if build_labels: 70 return build_labels[0] 71 return None 72 73 74 @property 75 def board(self): 76 """Retrieve the board label value for the host. 77 78 @returns: The (stripped) board label, or None if no label is found. 79 """ 80 return self.get_label_value(self._BOARD_PREFIX) 81 82 83 @property 84 def model(self): 85 """Retrieve the model label value for the host. 86 87 @returns: The (stripped) model label, or None if no label is found. 88 """ 89 return self.get_label_value(self._MODEL_PREFIX) 90 91 92 @property 93 def os(self): 94 """Retrieve the os for the host. 95 96 @returns The os (str) or None if no os label exists. Returns the first 97 matching os if mutiple labels are found. 98 """ 99 return self.get_label_value(self._OS_PREFIX) 100 101 102 @property 103 def pools(self): 104 """Retrieve the set of pools for the host. 105 106 @returns: set(str) of pool values. 107 """ 108 return set(self._get_stripped_labels_with_prefix(self._POOL_PREFIX)) 109 110 111 def get_label_value(self, prefix): 112 """Retrieve the value stored as a label with a well known prefix. 113 114 @param prefix: The prefix of the desired label. 115 @return: For the first label matching 'prefix:value', returns value. 116 Returns '' if no label matches the given prefix. 117 """ 118 values = self._get_stripped_labels_with_prefix(prefix) 119 return values[0] if values else '' 120 121 122 def clear_version_labels(self, version_prefix=None): 123 """Clear all or a particular version label(s) for the host. 124 125 @param version_prefix: The prefix label which needs to be cleared. 126 If this is set to None, all version labels will 127 be cleared. 128 """ 129 version_labels = ([version_prefix] if version_prefix else 130 self._VERSION_LABELS) 131 self.labels = [ 132 label for label in self.labels if 133 not any(label.startswith(prefix + ':') 134 for prefix in version_labels)] 135 136 137 def set_version_label(self, version_prefix, version): 138 """Sets the version label for the host. 139 140 If a label with version_prefix exists, this updates the value for that 141 label, else appends a new label to the end of the label list. 142 143 @param version_prefix: The prefix to use (without the infix ':'). 144 @param version: The version label value to set. 145 """ 146 full_prefix = _to_label_prefix(version_prefix) 147 new_version_label = full_prefix + version 148 for index, label in enumerate(self.labels): 149 if label.startswith(full_prefix): 150 self.labels[index] = new_version_label 151 return 152 else: 153 self.labels.append(new_version_label) 154 155 156 def _get_stripped_labels_with_prefix(self, prefix): 157 """Search for labels with the prefix and remove the prefix. 158 159 e.g. 160 prefix = blah 161 labels = ['blah:a', 'blahb', 'blah:c', 'doo'] 162 returns: ['a', 'c'] 163 164 @returns: A list of stripped labels. [] in case of no match. 165 """ 166 full_prefix = prefix + ':' 167 prefix_len = len(full_prefix) 168 return [label[prefix_len:] for label in self.labels 169 if label.startswith(full_prefix)] 170 171 172 def __str__(self): 173 return ('%s[Labels: %s, Attributes: %s]' 174 % (type(self).__name__, self.labels, self.attributes)) 175 176 177 def __eq__(self, other): 178 if isinstance(other, type(self)): 179 return (self.labels == other.labels 180 and self.attributes == other.attributes) 181 else: 182 return NotImplemented 183 184 185 def __ne__(self, other): 186 return not (self == other) 187 188 189class StoreError(Exception): 190 """Raised when a CachingHostInfoStore operation fails.""" 191 192 193class CachingHostInfoStore(object): 194 """Abstract class to obtain and update host information from the infra. 195 196 This class describes the API used to retrieve host information from the 197 infrastructure. The actual, uncached implementation to obtain / update host 198 information is delegated to the concrete store classes. 199 200 We use two concrete stores: 201 AfeHostInfoStore: Directly obtains/updates the host information from 202 the AFE. 203 LocalHostInfoStore: Obtains/updates the host information from a local 204 file. 205 An extra store is provided for unittests: 206 InMemoryHostInfoStore: Just store labels / attributes in-memory. 207 """ 208 209 __metaclass__ = abc.ABCMeta 210 211 def __init__(self): 212 self._private_cached_info = None 213 214 215 def get(self, force_refresh=False): 216 """Obtain (possibly cached) host information. 217 218 @param force_refresh: If True, forces the cached HostInfo to be 219 refreshed from the store. 220 @returns: A HostInfo object. 221 """ 222 if force_refresh: 223 return self._get_uncached() 224 225 # |_cached_info| access is costly, so do it only once. 226 info = self._cached_info 227 if info is None: 228 return self._get_uncached() 229 return info 230 231 232 def commit(self, info): 233 """Update host information in the infrastructure. 234 235 @param info: A HostInfo object with the new information to set. You 236 should obtain a HostInfo object using the |get| or 237 |get_uncached| methods, update it as needed and then commit. 238 """ 239 logging.debug('Committing HostInfo to store %s', self) 240 try: 241 self._commit_impl(info) 242 self._cached_info = info 243 logging.debug('HostInfo updated to: %s', info) 244 except Exception: 245 self._cached_info = None 246 raise 247 248 249 @abc.abstractmethod 250 def _refresh_impl(self): 251 """Actual implementation to refresh host_info from the store. 252 253 Concrete stores must implement this function. 254 @returns: A HostInfo object. 255 """ 256 raise NotImplementedError 257 258 259 @abc.abstractmethod 260 def _commit_impl(self, host_info): 261 """Actual implementation to commit host_info to the store. 262 263 Concrete stores must implement this function. 264 @param host_info: A HostInfo object. 265 """ 266 raise NotImplementedError 267 268 269 def _get_uncached(self): 270 """Obtain freshly synced host information. 271 272 @returns: A HostInfo object. 273 """ 274 logging.debug('Refreshing HostInfo using store %s', self) 275 logging.debug('Old host_info: %s', self._cached_info) 276 try: 277 info = self._refresh_impl() 278 self._cached_info = info 279 except Exception: 280 self._cached_info = None 281 raise 282 283 logging.debug('New host_info: %s', info) 284 return info 285 286 287 @property 288 def _cached_info(self): 289 """Access the cached info, enforcing a deepcopy.""" 290 return copy.deepcopy(self._private_cached_info) 291 292 293 @_cached_info.setter 294 def _cached_info(self, info): 295 """Update the cached info, enforcing a deepcopy. 296 297 @param info: The new info to update from. 298 """ 299 self._private_cached_info = copy.deepcopy(info) 300 301 302class InMemoryHostInfoStore(CachingHostInfoStore): 303 """A simple store that gives unittests direct access to backing data. 304 305 Unittests can access the |info| attribute to obtain the backing HostInfo. 306 """ 307 308 def __init__(self, info=None): 309 """Seed object with initial data. 310 311 @param info: Initial backing HostInfo object. 312 """ 313 super(InMemoryHostInfoStore, self).__init__() 314 self.info = info if info is not None else HostInfo() 315 316 317 def __str__(self): 318 return '%s[%s]' % (type(self).__name__, self.info) 319 320 def _refresh_impl(self): 321 """Return a copy of the private HostInfo.""" 322 return copy.deepcopy(self.info) 323 324 325 def _commit_impl(self, info): 326 """Copy HostInfo data to in-memory store. 327 328 @param info: The HostInfo object to commit. 329 """ 330 self.info = copy.deepcopy(info) 331 332 333def get_store_from_machine(machine): 334 """Obtain the host_info_store object stuffed in the machine dict. 335 336 The machine argument to jobs can be a string (a hostname) or a dict because 337 of legacy reasons. If we can't get a real store, return a dummy. 338 """ 339 if isinstance(machine, dict): 340 return machine['host_info_store'] 341 else: 342 return InMemoryHostInfoStore() 343 344 345class DeserializationError(Exception): 346 """Raised when deserialization fails due to malformed input.""" 347 348 349# Default serialzation version. This should be uprevved whenever a change to 350# HostInfo is backwards incompatible, i.e. we can no longer correctly 351# deserialize a previously serialized HostInfo. An example of such change is if 352# a field in the HostInfo object is dropped. 353_CURRENT_SERIALIZATION_VERSION = 1 354 355 356def json_serialize(info, file_obj, version=_CURRENT_SERIALIZATION_VERSION): 357 """Serialize the given HostInfo. 358 359 @param info: A HostInfo object to serialize. 360 @param file_obj: A file like object to serialize info into. 361 @param version: Use a specific serialization version. Should mostly use the 362 default. 363 """ 364 info_json = { 365 'serializer_version': version, 366 'labels': info.labels, 367 'attributes': info.attributes, 368 } 369 return json.dump(info_json, file_obj, sort_keys=True, indent=4, 370 separators=(',', ': ')) 371 372 373def json_deserialize(file_obj): 374 """Deserialize a HostInfo from the given file. 375 376 @param file_obj: a file like object containing a json_serialized()ed 377 HostInfo. 378 @returns: The deserialized HostInfo object. 379 """ 380 try: 381 deserialized_json = json.load(file_obj) 382 except ValueError as e: 383 raise DeserializationError(e) 384 385 serializer_version = deserialized_json.get('serializer_version') 386 if serializer_version != 1: 387 raise DeserializationError('Unsupported serialization version %s' % 388 serializer_version) 389 390 try: 391 return HostInfo(deserialized_json['labels'], 392 deserialized_json['attributes']) 393 except KeyError as e: 394 raise DeserializationError('Malformed serialized host_info: %r' % e) 395 396 397def _to_label_prefix(prefix): 398 """Ensure that prefix has the expected format for label prefixes. 399 400 @param prefix: The (str) prefix to sanitize. 401 @returns: The sanitized (str) prefix. 402 """ 403 return prefix if prefix.endswith(':') else prefix + ':' 404