Add machine status properties (#133)
authorCory Johns <johnsca@gmail.com>
Mon, 5 Jun 2017 14:28:16 +0000 (10:28 -0400)
committerGitHub <noreply@github.com>
Mon, 5 Jun 2017 14:28:16 +0000 (10:28 -0400)
* Add machine status properties

Add properties to access the machine provisioning and Juju machine agent
status information.

Unfortunately, there seems to be a bug with the AllWatcher related to
these fields (https://bugs.launchpad.net/juju/+bug/1695335) that causes
the deltas to contain stale data, so this also includes a hack to work
around that by fetching newer data from the FullStatus API.

* Fixed test for AWS

juju/machine.py
tests/integration/test_machine.py [new file with mode: 0644]

index b06d54b..18c333c 100644 (file)
@@ -1,12 +1,86 @@
 import logging
 
-from . import model
+from dateutil.parser import parse as parse_date
+
+from . import model, utils
 from .client import client
 
 log = logging.getLogger(__name__)
 
 
 class Machine(model.ModelEntity):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.on_change(self._workaround_1695335)
+
+    async def _workaround_1695335(self, delta, old, new, model):
+        """
+        This is a (hacky) temporary work around for a bug in Juju where the
+        instance status and agent version fields don't get updated properly
+        by the AllWatcher.
+
+        Deltas never contain a value for `data['agent-status']['version']`,
+        and once the `instance-status` reaches `pending`, we no longer get
+        any updates for it (the deltas come in, but the `instance-status`
+        data is always the same after that).
+
+        To work around this, whenever a delta comes in for this machine, we
+        query FullStatus and use the data from there if and only if it's newer.
+        Luckily, the timestamps on the `since` field does seem to be accurate.
+
+        See https://bugs.launchpad.net/juju/+bug/1695335
+        """
+        if delta.data.get('synthetic', False):
+            # prevent infinite loops re-processing already processed deltas
+            return
+
+        full_status = await utils.run_with_interrupt(model.get_status(),
+                                                     model._watch_stopping,
+                                                     model.loop)
+        if model._watch_stopping.is_set():
+            return
+
+        if self.id not in full_status.machines:
+            return
+
+        if not full_status.machines[self.id]['instance-status']['since']:
+            return
+
+        machine = full_status.machines[self.id]
+
+        change_log = []
+        key_map = {
+            'status': 'current',
+            'info': 'message',
+            'since': 'since',
+        }
+
+        # handle agent version specially, because it's never set in
+        # deltas, and we don't want even a newer delta to clear it
+        agent_version = machine['agent-status']['version']
+        if agent_version:
+            delta.data['agent-status']['version'] = agent_version
+            change_log.append(('agent-version', '', agent_version))
+
+        # only update (other) delta fields if status data is newer
+        status_since = parse_date(machine['instance-status']['since'])
+        delta_since = parse_date(delta.data['instance-status']['since'])
+        if status_since > delta_since:
+            for status_key in ('status', 'info', 'since'):
+                delta_key = key_map[status_key]
+                status_value = machine['instance-status'][status_key]
+                delta_value = delta.data['instance-status'][delta_key]
+                change_log.append((delta_key, delta_value, status_value))
+                delta.data['instance-status'][delta_key] = status_value
+
+        if change_log:
+            log.debug('Overriding machine delta with FullStatus data')
+            for log_item in change_log:
+                log.debug('    {}: {} -> {}'.format(*log_item))
+            delta.data['synthetic'] = True
+            old_obj, new_obj = self.model.state.apply_delta(delta)
+            await model._notify_observers(delta, old_obj, new_obj)
+
     async def destroy(self, force=False):
         """Remove this machine from the model.
 
@@ -85,3 +159,50 @@ class Machine(model.ModelEntity):
 
         """
         raise NotImplementedError()
+
+    @property
+    def agent_status(self):
+        """Returns the current Juju agent status string.
+
+        """
+        return self.safe_data['agent-status']['current']
+
+    @property
+    def agent_status_since(self):
+        """Get the time when the `agent_status` was last updated.
+
+        """
+        return parse_date(self.safe_data['agent-status']['since'])
+
+    @property
+    def agent_version(self):
+        """Get the version of the Juju machine agent.
+
+        May return None if the agent is not yet available.
+        """
+        version = self.safe_data['agent-status']['version']
+        if version:
+            return client.Number.from_json(version)
+        else:
+            return None
+
+    @property
+    def status(self):
+        """Returns the current machine provisioning status string.
+
+        """
+        return self.safe_data['instance-status']['current']
+
+    @property
+    def status_message(self):
+        """Returns the current machine provisioning status message.
+
+        """
+        return self.safe_data['instance-status']['message']
+
+    @property
+    def status_since(self):
+        """Get the time when the `status` was last updated.
+
+        """
+        return parse_date(self.safe_data['instance-status']['since'])
diff --git a/tests/integration/test_machine.py b/tests/integration/test_machine.py
new file mode 100644 (file)
index 0000000..5909c05
--- /dev/null
@@ -0,0 +1,37 @@
+import asyncio
+
+import pytest
+
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_status(event_loop):
+    async with base.CleanModel() as model:
+        await model.deploy(
+            'ubuntu-0',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+
+        await asyncio.wait_for(
+            model.block_until(lambda: len(model.machines)),
+            timeout=240)
+        machine = model.machines['0']
+
+        assert machine.status in ('allocating', 'pending')
+        assert machine.agent_status == 'pending'
+        assert not machine.agent_version
+
+        await asyncio.wait_for(
+            model.block_until(lambda: (machine.status == 'running' and
+                                       machine.agent_status == 'started')),
+            timeout=240)
+
+        assert machine.status == 'running'
+        # there is some inconsistency in the message case between providers
+        assert machine.status_message.lower() == 'running'
+        assert machine.agent_status == 'started'
+        assert machine.agent_version.major >= 2