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