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