Home | History | Annotate | Download | only in hosts
      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 
      5 import abc
      6 import copy
      7 import json
      8 import logging
      9 
     10 import common
     11 from autotest_lib.server.cros import provision
     12 
     13 
     14 class 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 
    189 class StoreError(Exception):
    190     """Raised when a CachingHostInfoStore operation fails."""
    191 
    192 
    193 class 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 
    302 class 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 
    333 def 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 
    345 class 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 
    356 def 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 
    373 def 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 
    397 def _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