1# Copyright (c) 2014 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"""RDB Host objects.
6
7RDBHost: Basic host object, capable of retrieving fields of a host that
8correspond to columns of the host table.
9
10RDBServerHostWrapper: Server side host adapters that help in making a raw
11database host object more ameanable to the classes and functions in the rdb
12and/or rdb clients.
13
14RDBClientHostWrapper: Scheduler host proxy that converts host information
15returned by the rdb into a client host object capable of proxying updates
16back to the rdb.
17"""
18
19import logging
20import time
21
22from django.core import exceptions as django_exceptions
23
24import common
25from autotest_lib.client.common_lib import utils
26from autotest_lib.frontend.afe import rdb_model_extensions as rdb_models
27from autotest_lib.frontend.afe import models as afe_models
28from autotest_lib.scheduler import rdb_requests
29from autotest_lib.scheduler import rdb_utils
30from autotest_lib.site_utils import lab_inventory
31from autotest_lib.site_utils import metadata_reporter
32from autotest_lib.site_utils.suite_scheduler import constants
33
34try:
35    from chromite.lib import metrics
36except ImportError:
37    metrics = utils.metrics_mock
38
39
40class RDBHost(object):
41    """A python host object representing a django model for the host."""
42
43    required_fields = set(
44            rdb_models.AbstractHostModel.get_basic_field_names() + ['id'])
45
46
47    def _update_attributes(self, new_attributes):
48        """Updates attributes based on an input dictionary.
49
50        Since reads are not proxied to the rdb this method caches updates to
51        the host tables as class attributes.
52
53        @param new_attributes: A dictionary of attributes to update.
54        """
55        for name, value in new_attributes.iteritems():
56            setattr(self, name, value)
57
58
59    def __init__(self, **kwargs):
60        if self.required_fields - set(kwargs.keys()):
61            raise rdb_utils.RDBException('Creating %s requires %s, got %s '
62                    % (self.__class__, self.required_fields, kwargs.keys()))
63        self._update_attributes(kwargs)
64
65
66    @classmethod
67    def get_required_fields_from_host(cls, host):
68        """Returns all required attributes of the host parsed into a dict.
69
70        Required attributes are defined as the attributes required to
71        create an RDBHost, and mirror the columns of the host table.
72
73        @param host: A host object containing all required fields as attributes.
74        """
75        required_fields_map = {}
76        try:
77            for field in cls.required_fields:
78                required_fields_map[field] = getattr(host, field)
79        except AttributeError as e:
80            raise rdb_utils.RDBException('Required %s' % e)
81        required_fields_map['id'] = host.id
82        return required_fields_map
83
84
85    def wire_format(self):
86        """Returns information about this host object.
87
88        @return: A dictionary of fields representing the host.
89        """
90        return RDBHost.get_required_fields_from_host(self)
91
92
93class RDBServerHostWrapper(RDBHost):
94    """A host wrapper for the base host object.
95
96    This object contains all the attributes of the raw database columns,
97    and a few more that make the task of host assignment easier. It handles
98    the following duties:
99        1. Serialization of the host object and foreign keys
100        2. Conversion of label ids to label names, and retrieval of platform
101        3. Checking the leased bit/status of a host before leasing it out.
102    """
103
104    def __init__(self, host):
105        """Create an RDBServerHostWrapper.
106
107        @param host: An instance of the Host model class.
108        """
109        host_fields = RDBHost.get_required_fields_from_host(host)
110        super(RDBServerHostWrapper, self).__init__(**host_fields)
111        self.labels = rdb_utils.LabelIterator(host.labels.all())
112        self.acls = [aclgroup.id for aclgroup in host.aclgroup_set.all()]
113        self.protection = host.protection
114        platform = host.platform()
115        # Platform needs to be a method, not an attribute, for
116        # backwards compatibility with the rest of the host model.
117        self.platform_name = platform.name if platform else None
118        self.shard_id = host.shard_id
119
120
121    def refresh(self, fields=None):
122        """Refresh the attributes on this instance.
123
124        @param fields: A list of fieldnames to refresh. If None
125            all the required fields of the host are refreshed.
126
127        @raises RDBException: If refreshing a field fails.
128        """
129        # TODO: This is mainly required for cache correctness. If it turns
130        # into a bottleneck, cache host_ids instead of rdbhosts and rebuild
131        # the hosts once before leasing them out. The important part is to not
132        # trust the leased bit on a cached host.
133        fields = self.required_fields if not fields else fields
134        try:
135            refreshed_fields = afe_models.Host.objects.filter(
136                    id=self.id).values(*fields)[0]
137        except django_exceptions.FieldError as e:
138            raise rdb_utils.RDBException('Couldn\'t refresh fields %s: %s' %
139                    fields, e)
140        self._update_attributes(refreshed_fields)
141
142
143    def lease(self):
144        """Set the leased bit on the host object, and in the database.
145
146        @raises RDBException: If the host is already leased.
147        """
148        self.refresh(fields=['leased'])
149        if self.leased:
150            raise rdb_utils.RDBException('Host %s is already leased' %
151                                         self.hostname)
152        self.leased = True
153        # TODO: Avoid leaking django out of rdb.QueryManagers. This is still
154        # preferable to calling save() on the host object because we're only
155        # updating/refreshing a single indexed attribute, the leased bit.
156        afe_models.Host.objects.filter(id=self.id).update(leased=self.leased)
157
158
159    def wire_format(self, unwrap_foreign_keys=True):
160        """Returns all information needed to scheduler jobs on the host.
161
162        @param unwrap_foreign_keys: If true this method will retrieve and
163            serialize foreign keys of the original host, which are stored
164            in the RDBServerHostWrapper as iterators.
165
166        @return: A dictionary of host information.
167        """
168        host_info = super(RDBServerHostWrapper, self).wire_format()
169
170        if unwrap_foreign_keys:
171            host_info['labels'] = self.labels.get_label_names()
172            host_info['acls'] = self.acls
173            host_info['platform_name'] = self.platform_name
174            host_info['protection'] = self.protection
175        return host_info
176
177
178class RDBClientHostWrapper(RDBHost):
179    """A client host wrapper for the base host object.
180
181    This wrapper is used whenever the queue entry needs direct access
182    to the host.
183    """
184
185    _HOST_WORKING_METRIC = 'chromeos/autotest/dut_working'
186    _HOST_STATUS_METRIC = 'chromeos/autotest/dut_status'
187    _HOST_POOL_METRIC = 'chromeos/autotest/dut_pool'
188
189
190    def __init__(self, **kwargs):
191
192        # This class is designed to only check for the bare minimum
193        # attributes on a host, so if a client tries accessing an
194        # unpopulated foreign key it will result in an exception. Doing
195        # so makes it easier to add fields to the rdb host without
196        # updating all the clients.
197        super(RDBClientHostWrapper, self).__init__(**kwargs)
198
199        # TODO(beeps): Remove this once we transition to urls
200        from autotest_lib.scheduler import rdb
201        self.update_request_manager = rdb_requests.RDBRequestManager(
202                rdb_requests.UpdateHostRequest, rdb.update_hosts)
203        self.dbg_str = ''
204        self.metadata = {}
205
206
207    def _update(self, payload):
208        """Send an update to rdb, save the attributes of the payload locally.
209
210        @param: A dictionary representing 'key':value of the update required.
211
212        @raises RDBException: If the update fails.
213        """
214        logging.info('Host %s in %s updating %s through rdb on behalf of: %s ',
215                     self.hostname, self.status, payload, self.dbg_str)
216        self.update_request_manager.add_request(host_id=self.id,
217                payload=payload)
218        for response in self.update_request_manager.response():
219            if response:
220                raise rdb_utils.RDBException('Host %s unable to perform update '
221                        '%s through rdb on behalf of %s: %s',  self.hostname,
222                        payload, self.dbg_str, response)
223        super(RDBClientHostWrapper, self)._update_attributes(payload)
224
225
226    def record_state(self, type_str, state, value):
227        """Record metadata in elasticsearch.
228
229        @param type_str: sets the _type field in elasticsearch db.
230        @param state: string representing what state we are recording,
231                      e.g. 'status'
232        @param value: value of the state, e.g. 'running'
233        """
234        metadata = {
235            state: value,
236            'hostname': self.hostname,
237            'board': self.board,
238            'pools': self.pools,
239            'dbg_str': self.dbg_str,
240            '_type': type_str,
241            'time_recorded': time.time(),
242        }
243        metadata.update(self.metadata)
244        metadata_reporter.queue(metadata)
245
246
247    def get_metric_fields(self):
248        """Generate default set of fields to include for Monarch.
249
250        @return: Dictionary of default fields.
251        """
252        fields = {
253            'dut_host_name': self.hostname,
254            'board': self.board or '',
255        }
256
257        return fields
258
259
260    def record_pool(self, fields):
261        """Report to Monarch current pool of dut.
262
263        @param fields   Dictionary of fields to include.
264        """
265        pool = ''
266        if len(self.pools) == 1:
267            pool = self.pools[0]
268        if pool in lab_inventory.MANAGED_POOLS:
269            pool = 'managed:' + pool
270
271        metrics.String(self._HOST_POOL_METRIC,
272                       reset_after=True).set(pool, fields=fields)
273
274
275    def set_status(self, status):
276        """Proxy for setting the status of a host via the rdb.
277
278        @param status: The new status.
279        """
280        # Update elasticsearch db.
281        self._update({'status': status})
282        self.record_state('host_history', 'status', status)
283
284        # Update Monarch.
285        fields = self.get_metric_fields()
286        self.record_pool(fields)
287        # As each device switches state, indicate that it is not in any
288        # other state.  This allows Monarch queries to avoid double counting
289        # when additional points are added by the Window Align operation.
290        host_status_metric = metrics.Boolean(
291                self._HOST_STATUS_METRIC, reset_after=True)
292        for s in rdb_models.AbstractHostModel.Status.names:
293            fields['status'] = s
294            host_status_metric.set(s == status, fields=fields)
295
296
297    def record_working_state(self, working, timestamp):
298        """Report to Monarch whether we are working or broken.
299
300        @param working    Host repair status. `True` means that the DUT
301                          is up and expected to pass tests.  `False`
302                          means the DUT has failed repair and requires
303                          manual intervention.
304        @param timestamp  Time that the status was recorded.
305        """
306        fields = self.get_metric_fields()
307        metrics.Boolean(
308                self._HOST_WORKING_METRIC, reset_after=True).set(
309                        working, fields=fields)
310        self.record_pool(fields)
311
312
313    def update_field(self, fieldname, value):
314        """Proxy for updating a field on the host.
315
316        @param fieldname: The fieldname as a string.
317        @param value: The value to assign to the field.
318        """
319        self._update({fieldname: value})
320
321
322    def platform_and_labels(self):
323        """Get the platform and labels on this host.
324
325        @return: A tuple containing a list of label names and the platform name.
326        """
327        platform = self.platform_name
328        labels = [label for label in self.labels if label != platform]
329        return platform, labels
330
331
332    def platform(self):
333        """Get the name of the platform of this host.
334
335        @return: A string representing the name of the platform.
336        """
337        return self.platform_name
338
339
340    def find_labels_start_with(self, search_string):
341        """Find all labels started with given string.
342
343        @param search_string: A string to match the beginning of the label.
344        @return: A list of all matched labels.
345        """
346        try:
347            return [l for l in self.labels if l.startswith(search_string)]
348        except AttributeError:
349            return []
350
351
352    @property
353    def board(self):
354        """Get the names of the board of this host.
355
356        @return: A string of the name of the board, e.g., lumpy.
357        """
358        boards = self.find_labels_start_with(constants.Labels.BOARD_PREFIX)
359        return (boards[0][len(constants.Labels.BOARD_PREFIX):] if boards
360                else None)
361
362
363    @property
364    def pools(self):
365        """Get the names of the pools of this host.
366
367        @return: A list of pool names that the host is assigned to.
368        """
369        return [label[len(constants.Labels.POOL_PREFIX):] for label in
370                self.find_labels_start_with(constants.Labels.POOL_PREFIX)]
371
372
373    def get_object_dict(self, **kwargs):
374        """Serialize the attributes of this object into a dict.
375
376        This method is called through frontend code to get a serialized
377        version of this object.
378
379        @param kwargs:
380            extra_fields: Extra fields, outside the columns of a host table.
381
382        @return: A dictionary representing the fields of this host object.
383        """
384        # TODO(beeps): Implement support for extra fields. Currently nothing
385        # requires them.
386        return self.wire_format()
387
388
389    def save(self):
390        """Save any local data a client of this host object might have saved.
391
392        Setting attributes on a model before calling its save() method is a
393        common django pattern. Most, if not all updates to the host happen
394        either through set status or update_field. Though we keep the internal
395        state of the RDBClientHostWrapper consistent through these updates
396        we need a bulk save method such as this one to save any attributes of
397        this host another model might have set on it before calling its own
398        save method. Eg:
399            task = ST.objects.get(id=12)
400            task.host.status = 'Running'
401            task.save() -> this should result in the hosts status changing to
402            Running.
403
404        Functions like add_host_to_labels will have to update this host object
405        differently, as that is another level of foreign key indirection.
406        """
407        self._update(self.get_required_fields_from_host(self))
408
409
410def return_rdb_host(func):
411    """Decorator for functions that return a list of Host objects.
412
413    @param func: The decorated function.
414    @return: A functions capable of converting each host_object to a
415        rdb_hosts.RDBServerHostWrapper.
416    """
417    def get_rdb_host(*args, **kwargs):
418        """Takes a list of hosts and returns a list of host_infos.
419
420        @param hosts: A list of hosts. Each host is assumed to contain
421            all the fields in a host_info defined above.
422        @return: A list of rdb_hosts.RDBServerHostWrappers, one per host, or an
423            empty list is no hosts were found..
424        """
425        hosts = func(*args, **kwargs)
426        return [RDBServerHostWrapper(host) for host in hosts]
427    return get_rdb_host
428