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