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