• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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