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