• 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            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
189class StoreError(Exception):
190    """Raised when a CachingHostInfoStore operation fails."""
191
192
193class 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
302class 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
333def 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
345class 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
356def 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
373def 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
397def _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