summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2018-05-28 19:51:58 +0300
committerLars Wirzenius <liw@liw.fi>2018-06-10 19:44:16 +0300
commit9759c2b51a1250aa345c21b7cc6b793f4965ac2d (patch)
treea96339ec340bdb1e7b4bef4cf5cb3a7a4a0754b4
parent269ee474d77a5210288cf33ee9d687c8aaa29de9 (diff)
downloadick2-9759c2b51a1250aa345c21b7cc6b793f4965ac2d.tar.gz
Add: BuildStateMachine class
-rw-r--r--ick2/__init__.py23
-rw-r--r--ick2/build.py13
-rw-r--r--ick2/build_tests.py72
-rw-r--r--ick2/buildgraph.py39
-rw-r--r--ick2/buildgraph_tests.py43
-rw-r--r--ick2/buildsm.py223
-rw-r--r--ick2/buildsm_tests.py144
-rw-r--r--ick2/projectapi.py5
-rw-r--r--ick2/workapi.py163
-rw-r--r--ick2/workapi_tests.py171
-rw-r--r--yarns/400-build.yarn207
-rw-r--r--yarns/500-build-fail.yarn53
-rw-r--r--yarns/900-implements.yarn22
13 files changed, 978 insertions, 200 deletions
diff --git a/ick2/__init__.py b/ick2/__init__.py
index 59344a3..a6a4295 100644
--- a/ick2/__init__.py
+++ b/ick2/__init__.py
@@ -23,7 +23,28 @@ from .persistent import (
)
from .trans import TransactionalState
from .build import Build, WrongBuildStatusChange
-from .buildgraph import BuildGraph
+from .buildgraph import (
+ BuildGraph,
+ ACTION_BLOCKED,
+ ACTION_READY,
+ ACTION_BUILDING,
+ ACTION_DONE,
+ ACTION_FAILED,
+)
+from .buildsm import (
+ create_build_event,
+ BuildStateMachine,
+ BuildStartsEvent,
+ NeedWorkEvent,
+ PartialActionOutputEvent,
+ ActionFinishedEvent,
+ ActionFailedEvent,
+ BUILD_TRIGGERED,
+ BUILD_BUILDING,
+ BUILD_NOTIFYING,
+ BUILD_DONE,
+ BUILD_FAILED,
+)
from .exceptions import (
BadUpdate,
ExistsAlready,
diff --git a/ick2/build.py b/ick2/build.py
index cfb1f71..7025780 100644
--- a/ick2/build.py
+++ b/ick2/build.py
@@ -19,12 +19,12 @@ import ick2
class Build:
- acceptable = {
- 'triggered': ['building'],
- 'building': ['done', 'failed'],
- }
-
def __init__(self, resource):
+ self.acceptable = {
+ ick2.BUILD_TRIGGERED: [ick2.BUILD_BUILDING],
+ ick2.BUILD_BUILDING: [ick2.BUILD_DONE, ick2.BUILD_FAILED],
+ }
+
self.resource = resource
self.graph = ick2.BuildGraph(graph=self.resource.get('graph', {}))
self.graph.set_observer(self.update_graph_in_resource)
@@ -38,6 +38,9 @@ class Build:
raise WrongBuildStatusChange(current, status)
self.resource['status'] = status
+ def is_finished(self):
+ return self.get_status() in (ick2.BUILD_DONE, ick2.BUILD_FAILED)
+
def get_graph(self):
return self.graph
diff --git a/ick2/build_tests.py b/ick2/build_tests.py
index 47b2db8..9d17168 100644
--- a/ick2/build_tests.py
+++ b/ick2/build_tests.py
@@ -24,51 +24,75 @@ class BuildTests(unittest.TestCase):
def setUp(self):
as_dict = {
- 'status': 'triggered',
+ 'status': ick2.BUILD_TRIGGERED,
}
self.resource = ick2.resource_from_dict(as_dict)
+ def test_is_finished_when_done(self):
+ build = ick2.Build(self.resource)
+
+ self.assertEqual(build.get_status(), ick2.BUILD_TRIGGERED)
+ self.assertFalse(build.is_finished())
+
+ build.set_status(ick2.BUILD_BUILDING)
+ self.assertFalse(build.is_finished())
+
+ build.set_status(ick2.BUILD_DONE)
+ self.assertTrue(build.is_finished())
+
+ def test_is_finished_when_failed(self):
+ build = ick2.Build(self.resource)
+
+ self.assertEqual(build.get_status(), ick2.BUILD_TRIGGERED)
+ self.assertFalse(build.is_finished())
+
+ build.set_status(ick2.BUILD_BUILDING)
+ self.assertFalse(build.is_finished())
+
+ build.set_status(ick2.BUILD_FAILED)
+ self.assertTrue(build.is_finished())
+
def test_sets_status_from_triggered_only_when_acceptable(self):
build = ick2.Build(self.resource)
with self.assertRaises(ick2.WrongBuildStatusChange):
- build.set_status('triggered')
- self.assertEqual(build.get_status(), 'triggered')
+ build.set_status(ick2.BUILD_TRIGGERED)
+ self.assertEqual(build.get_status(), ick2.BUILD_TRIGGERED)
with self.assertRaises(ick2.WrongBuildStatusChange):
- build.set_status('done')
- self.assertEqual(build.get_status(), 'triggered')
+ build.set_status(ick2.BUILD_DONE)
+ self.assertEqual(build.get_status(), ick2.BUILD_TRIGGERED)
with self.assertRaises(ick2.WrongBuildStatusChange):
- build.set_status('failed')
- self.assertEqual(build.get_status(), 'triggered')
+ build.set_status(ick2.BUILD_FAILED)
+ self.assertEqual(build.get_status(), ick2.BUILD_TRIGGERED)
- build.set_status('building')
- self.assertEqual(build.get_status(), 'building')
+ build.set_status(ick2.BUILD_BUILDING)
+ self.assertEqual(build.get_status(), ick2.BUILD_BUILDING)
def test_refuses_changing_status_from_building_when_unacceptable(self):
build = ick2.Build(self.resource)
- build.set_status('building')
+ build.set_status(ick2.BUILD_BUILDING)
with self.assertRaises(ick2.WrongBuildStatusChange):
- build.set_status('triggered')
- self.assertEqual(build.get_status(), 'building')
+ build.set_status(ick2.BUILD_TRIGGERED)
+ self.assertEqual(build.get_status(), ick2.BUILD_BUILDING)
with self.assertRaises(ick2.WrongBuildStatusChange):
- build.set_status('building')
- self.assertEqual(build.get_status(), 'building')
+ build.set_status(ick2.BUILD_BUILDING)
+ self.assertEqual(build.get_status(), ick2.BUILD_BUILDING)
def test_changes_status_from_building_to_done(self):
build = ick2.Build(self.resource)
- build.set_status('building')
- build.set_status('done')
- self.assertEqual(build.get_status(), 'done')
+ build.set_status(ick2.BUILD_BUILDING)
+ build.set_status(ick2.BUILD_DONE)
+ self.assertEqual(build.get_status(), ick2.BUILD_DONE)
def test_changes_status_from_building_to_failed(self):
build = ick2.Build(self.resource)
- build.set_status('building')
- build.set_status('failed')
- self.assertEqual(build.get_status(), 'failed')
+ build.set_status(ick2.BUILD_BUILDING)
+ build.set_status(ick2.BUILD_FAILED)
+ self.assertEqual(build.get_status(), ick2.BUILD_FAILED)
def test_has_empty_build_graph_initially(self):
build = ick2.Build(self.resource)
@@ -80,7 +104,7 @@ class BuildTests(unittest.TestCase):
self.resource['graph'] = {
1: {
'action': {'action': 'foo'},
- 'status': 'ready',
+ 'status': ick2.ACTION_READY,
'depends': [],
},
}
@@ -97,7 +121,7 @@ class BuildTests(unittest.TestCase):
{
action_id: {
'action': {'action': 'foo'},
- 'status': 'ready',
+ 'status': ick2.ACTION_READY,
'depends': [],
},
}
@@ -107,13 +131,13 @@ class BuildTests(unittest.TestCase):
build = ick2.Build(self.resource)
graph = build.get_graph()
action_id = graph.append_action({'action': 'foo'})
- graph.set_action_status(action_id, 'building')
+ graph.set_action_status(action_id, ick2.BUILD_BUILDING)
self.assertEqual(
self.resource['graph'],
{
action_id: {
'action': {'action': 'foo'},
- 'status': 'building',
+ 'status': ick2.BUILD_BUILDING,
'depends': [],
},
}
diff --git a/ick2/buildgraph.py b/ick2/buildgraph.py
index ee52e46..0fd8db1 100644
--- a/ick2/buildgraph.py
+++ b/ick2/buildgraph.py
@@ -17,6 +17,21 @@
import copy
+ACTION_BLOCKED = 'blocked'
+ACTION_READY = 'ready'
+ACTION_BUILDING = 'building'
+ACTION_DONE = 'done'
+ACTION_FAILED = 'failed'
+
+action_states = [
+ ACTION_BLOCKED,
+ ACTION_READY,
+ ACTION_BUILDING,
+ ACTION_DONE,
+ ACTION_FAILED,
+]
+
+
class BuildGraph:
def __init__(self, graph=None):
@@ -37,26 +52,26 @@ class BuildGraph:
return self.actions[action_id]['status']
def set_action_status(self, action_id, status):
+ assert status in action_states
self.actions[action_id]['status'] = status
self.trigger_observer()
def has_more_to_do(self):
return (
- self.find_actions('ready') or
- self.find_actions('building') or
- self.find_actions('blocked')
+ self.find_actions(ACTION_READY) or
+ self.find_actions(ACTION_BUILDING)
)
def unblock(self):
- blocked_ids = self.find_actions('blocked')
+ blocked_ids = self.find_actions(ACTION_BLOCKED)
for blocked_id in blocked_ids:
blocked = self.actions[blocked_id]
if self.is_unblockable(blocked):
- self.set_action_status(blocked_id, 'ready')
+ self.set_action_status(blocked_id, ACTION_READY)
def is_unblockable(self, action):
- return all(
- self.get_action_status(dep) == 'done'
+ return action['status'] == ACTION_BLOCKED and all(
+ self.get_action_status(dep) == ACTION_DONE
for dep in action['depends']
)
@@ -64,7 +79,7 @@ class BuildGraph:
if self.observer is not None:
self.observer()
- def append_action(self, action):
+ def append_action(self, action, status=None, depends=None):
prev_id, action_id = self.idgen.next_id()
graph_node = {
@@ -72,11 +87,11 @@ class BuildGraph:
}
if not self.actions:
- graph_node['status'] = 'ready'
- graph_node['depends'] = []
+ graph_node['status'] = ACTION_READY if status is None else status
+ graph_node['depends'] = [] if depends is None else depends
else:
- graph_node['status'] = 'blocked'
- graph_node['depends'] = [prev_id]
+ graph_node['status'] = ACTION_BLOCKED if status is None else status
+ graph_node['depends'] = [prev_id] if depends is None else depends
self.actions[action_id] = graph_node
self.trigger_observer()
diff --git a/ick2/buildgraph_tests.py b/ick2/buildgraph_tests.py
index e661294..53c9e74 100644
--- a/ick2/buildgraph_tests.py
+++ b/ick2/buildgraph_tests.py
@@ -35,7 +35,7 @@ class BuildGraphTests(unittest.TestCase):
},
'2': {
'action': {'action': 'bar'},
- 'status': 'blocked',
+ 'status': ick2.ACTION_BLOCKED,
'depends': ['1'],
},
}
@@ -55,7 +55,7 @@ class BuildGraphTests(unittest.TestCase):
{
action_id: {
'action': {'action': 'foo'},
- 'status': 'ready',
+ 'status': ick2.ACTION_READY,
'depends': []
}
}
@@ -80,12 +80,12 @@ class BuildGraphTests(unittest.TestCase):
{
action_id1: {
'action': {'action': 'foo'},
- 'status': 'ready',
+ 'status': ick2.ACTION_READY,
'depends': [],
},
action_id2: {
'action': {'action': 'bar'},
- 'status': 'blocked',
+ 'status': ick2.ACTION_BLOCKED,
'depends': [action_id1],
},
}
@@ -98,10 +98,11 @@ class BuildGraphTests(unittest.TestCase):
graph = ick2.BuildGraph()
action_id = graph.append_action(action)
- self.assertEqual(graph.get_action_status(action_id), 'ready')
+ self.assertEqual(graph.get_action_status(action_id), ick2.ACTION_READY)
- graph.set_action_status(action_id, 'building')
- self.assertEqual(graph.get_action_status(action_id), 'building')
+ graph.set_action_status(action_id, ick2.ACTION_BUILDING)
+ self.assertEqual(
+ graph.get_action_status(action_id), ick2.ACTION_BUILDING)
def test_appends_pipeline_actions(self):
pipeline_as_dict = {
@@ -123,12 +124,12 @@ class BuildGraphTests(unittest.TestCase):
'1': {
'action': {'action': 'foo'},
'depends': [],
- 'status': 'ready',
+ 'status': ick2.ACTION_READY,
},
'2': {
'action': {'action': 'bar'},
'depends': ['1'],
- 'status': 'blocked',
+ 'status': ick2.ACTION_BLOCKED,
},
}
)
@@ -163,8 +164,8 @@ class BuildGraphTests(unittest.TestCase):
graph.append_pipeline(pipeline)
self.assertEqual(graph.find_actions('no-such-state'), [])
- self.assertEqual(graph.find_actions('ready'), ['1'])
- self.assertEqual(graph.find_actions('blocked'), ['2'])
+ self.assertEqual(graph.find_actions(ick2.ACTION_READY), ['1'])
+ self.assertEqual(graph.find_actions(ick2.ACTION_BLOCKED), ['2'])
def test_tracks_nothing_to_do(self):
pipeline_as_dict = {
@@ -198,18 +199,18 @@ class BuildGraphTests(unittest.TestCase):
self.assertFalse(graph.has_more_to_do())
graph.append_pipeline(pipeline)
- graph.set_action_status('1', 'building')
- self.assertEqual(graph.get_action_status('2'), 'blocked')
+ graph.set_action_status('1', ick2.ACTION_BUILDING)
+ self.assertEqual(graph.get_action_status('2'), ick2.ACTION_BLOCKED)
self.assertTrue(graph.has_more_to_do())
- graph.set_action_status('1', 'done')
- self.assertEqual(graph.get_action_status('2'), 'blocked')
- self.assertTrue(graph.has_more_to_do())
+ graph.set_action_status('1', ick2.ACTION_DONE)
+ self.assertEqual(graph.get_action_status('2'), ick2.ACTION_BLOCKED)
+ self.assertFalse(graph.has_more_to_do())
- graph.set_action_status('2', 'ready')
+ graph.set_action_status('2', ick2.ACTION_READY)
self.assertTrue(graph.has_more_to_do())
- graph.set_action_status('2', 'done')
+ graph.set_action_status('2', ick2.ACTION_DONE)
self.assertFalse(graph.has_more_to_do())
def test_unblocks_when_deps_are_done(self):
@@ -227,7 +228,7 @@ class BuildGraphTests(unittest.TestCase):
graph = ick2.BuildGraph()
graph.append_pipeline(pipeline)
- graph.set_action_status('1', 'done')
+ graph.set_action_status('1', ick2.ACTION_DONE)
graph.unblock()
- self.assertEqual(graph.get_action_status('1'), 'done')
- self.assertEqual(graph.get_action_status('2'), 'ready')
+ self.assertEqual(graph.get_action_status('1'), ick2.ACTION_DONE)
+ self.assertEqual(graph.get_action_status('2'), ick2.ACTION_READY)
diff --git a/ick2/buildsm.py b/ick2/buildsm.py
new file mode 100644
index 0000000..2e6d079
--- /dev/null
+++ b/ick2/buildsm.py
@@ -0,0 +1,223 @@
+# Copyright (C) 2018 Lars Wirzenius
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import ick2
+
+
+BUILD_TRIGGERED = 'triggered'
+BUILD_BUILDING = 'building'
+BUILD_NOTIFYING = 'notifying'
+BUILD_DONE = 'done'
+BUILD_FAILED = 'failed'
+
+
+class StateMachine:
+
+ def __init__(self, get_state, set_state):
+ self.transitions = {}
+ self.get_state = get_state
+ self.set_state = set_state
+
+ def add_transition(self, state, event_class, handler):
+ if state not in self.transitions:
+ self.transitions[state] = {}
+ assert event_class not in self.transitions[state]
+ self.transitions[state][event_class] = handler
+
+ def handle_event(self, event):
+ state = self.get_state()
+ assert state in self.transitions
+ assert event.__class__ in self.transitions[state]
+ func = self.transitions[state][event.__class__]
+ new_state, resp = func(event)
+ self.set_state(new_state)
+ return resp
+
+
+class BuildStateMachine:
+
+ def __init__(self, build):
+ self.build = build
+ self.sm = self.init_sm()
+
+ def init_sm(self):
+ transitions = {
+ BUILD_TRIGGERED: {
+ BuildStartsEvent: self.set_state_to_building,
+ },
+ BUILD_BUILDING: {
+ NeedWorkEvent: self.pick_action,
+ PartialActionOutputEvent: self.set_state_to_building,
+ ActionFinishedEvent: self.mark_action_done,
+ ActionFailedEvent: self.mark_build_failed,
+ },
+ BUILD_NOTIFYING: {
+ NeedWorkEvent: self.pick_notification_action,
+ PartialActionOutputEvent: self.set_state_to_notifying,
+ ActionFinishedEvent: self.mark_notification_done,
+ ActionFailedEvent: self.mark_build_failed,
+ },
+ }
+
+ sm = StateMachine(self.get_state, self.set_state)
+ for state in transitions:
+ for event_class in transitions[state]:
+ func = transitions[state][event_class]
+ sm.add_transition(state, event_class, func)
+ return sm
+
+ def get_state(self):
+ return self.build.resource['status']
+
+ def set_state(self, state):
+ self.build.resource['status'] = state
+
+ def handle_event(self, event):
+ old_state = self.get_state()
+ resp = self.sm.handle_event(event)
+ new_state = self.get_state()
+ return resp
+
+ def set_state_to_building(self, event):
+ return BUILD_BUILDING, None
+
+ def set_state_to_notifying(self, event): # pragma: no cover
+ return BUILD_NOTIFYING, None
+
+ def pick_action(self, event):
+ graph = self.build.get_graph()
+ action_ids = graph.find_actions(ick2.ACTION_READY)
+ if not action_ids: # pragma: no cover
+ self.build.resource['exit_code'] = 0
+ return BUILD_DONE, None
+
+ action_id = action_ids[0]
+ graph.set_action_status(action_id, ick2.ACTION_BUILDING)
+ action = graph.get_action(action_id)
+ return BUILD_BUILDING, (action_id, action)
+
+ def pick_notification_action(self, event):
+ graph = self.build.get_graph()
+ action_ids = graph.find_actions(ick2.ACTION_READY)
+ if not action_ids: # pragma: no cover
+ self.build.resource['exit_code'] = 0
+ return BUILD_DONE, None
+
+ action_id = action_ids[0]
+ graph.set_action_status(action_id, ick2.ACTION_BUILDING)
+ action = graph.get_action(action_id)
+ return BUILD_NOTIFYING, (action_id, action)
+
+ def mark_action_done(self, event):
+ graph = self.build.get_graph()
+ graph.set_action_status(event.action_id, ick2.ACTION_DONE)
+ graph.unblock()
+ if graph.has_more_to_do():
+ return BUILD_BUILDING, None
+
+ self.add_notification_action()
+ return BUILD_NOTIFYING, None
+
+ def add_notification_action(self):
+ action = {
+ 'action': 'notify',
+ }
+ graph = self.build.get_graph()
+ graph.append_action(action, ick2.ACTION_READY, depends=[])
+
+ def mark_notification_done(self, event):
+ graph = self.build.get_graph()
+ graph.set_action_status(event.action_id, ick2.ACTION_DONE)
+ graph.unblock()
+ if graph.has_more_to_do(): # pragma: no cover
+ return BUILD_NOTIFYING, None
+
+ if self.build.resource.get('exit_code') is None:
+ self.build.resource['exit_code'] = 0
+ return BUILD_DONE, None
+
+ return BUILD_FAILED, None
+
+ def mark_build_failed(self, event):
+ graph = self.build.get_graph()
+ graph.set_action_status(event.action_id, ick2.BUILD_FAILED)
+ self.build.resource['exit_code'] = event.exit_code
+ self.add_notification_action()
+ return BUILD_NOTIFYING, None
+
+
+# Thing should be something we can create a BuildEvent from.
+def create_build_event(thing):
+ if thing == BuildStartsEvent:
+ return BuildStartsEvent()
+
+ if thing == NeedWorkEvent:
+ return NeedWorkEvent()
+
+ if isinstance(thing, dict):
+ exit_code = thing.get('exit_code')
+ action_id = thing.get('action_id')
+ if exit_code is None:
+ return PartialActionOutputEvent()
+ if exit_code == 0:
+ return ActionFinishedEvent(action_id)
+ return ActionFailedEvent(action_id, exit_code)
+
+
+class BuildEvent: # pragma: no cover
+
+ event_type = 'BuildEvent'
+
+ def __str__(self):
+ return self.event_type
+
+
+class BuildStartsEvent(BuildEvent):
+
+ event_type = 'build-starts'
+
+
+class NeedWorkEvent(BuildEvent):
+
+ event_type = 'need-work'
+
+
+class PartialActionOutputEvent(BuildEvent):
+
+ event_type = 'partial-output'
+
+
+class ActionFinishedEvent(BuildEvent):
+
+ event_type = 'action-finished'
+
+ def __init__(self, action_id):
+ self.action_id = action_id
+
+
+class ActionFailedEvent(BuildEvent):
+
+ event_type = 'action-failed'
+
+ def __init__(self, action_id, exit_code):
+ self.action_id = action_id
+ self.exit_code = exit_code
+
+
+class UnexpectedEvent(Exception): # pragma: no cover
+
+ def __init__(self, event, state):
+ super().__init__('Did not expect %s in %s' % (event, state))
diff --git a/ick2/buildsm_tests.py b/ick2/buildsm_tests.py
new file mode 100644
index 0000000..9d56989
--- /dev/null
+++ b/ick2/buildsm_tests.py
@@ -0,0 +1,144 @@
+# Copyright (C) 2018 Lars Wirzenius
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import unittest
+
+
+import ick2
+
+
+class BuildStateMachineTests(unittest.TestCase):
+
+ def setUp(self):
+ self.resource = None
+
+ def create_state_machine(self):
+ build_dict = {
+ 'build_id': 'foo/1',
+ 'status': 'triggered',
+ 'graph': {
+ '1': {
+ 'action': {'action': 'step1'},
+ 'status': ick2.ACTION_READY,
+ 'depends': [],
+ },
+ '2': {
+ 'action': {'action': 'step2'},
+ 'status': ick2.ACTION_BLOCKED,
+ 'depends': ['1'],
+ },
+ },
+ }
+ self.resource = ick2.resource_from_dict(build_dict)
+ build = ick2.Build(self.resource)
+ return ick2.BuildStateMachine(build)
+
+ def test_is_triggered_initially(self):
+ sm = self.create_state_machine()
+ self.assertEqual(sm.get_state(), ick2.BUILD_TRIGGERED)
+
+ def test_successful_build(self):
+ sm = self.create_state_machine()
+ self.assertEqual(sm.get_state(), ick2.BUILD_TRIGGERED)
+
+ resp = sm.handle_event(ick2.BuildStartsEvent())
+ self.assertEqual(sm.get_state(), ick2.BUILD_BUILDING)
+ self.assertEqual(resp, None)
+
+ resp = sm.handle_event(ick2.NeedWorkEvent())
+ self.assertEqual(sm.get_state(), ick2.BUILD_BUILDING)
+ self.assertEqual(resp, ('1', {'action': 'step1'}))
+
+ resp = sm.handle_event(ick2.PartialActionOutputEvent())
+ self.assertEqual(sm.get_state(), ick2.BUILD_BUILDING)
+ self.assertEqual(resp, None)
+
+ resp = sm.handle_event(ick2.ActionFinishedEvent('1'))
+ self.assertEqual(sm.get_state(), ick2.BUILD_BUILDING)
+ self.assertEqual(resp, None)
+
+ resp = sm.handle_event(ick2.NeedWorkEvent())
+ self.assertEqual(sm.get_state(), ick2.BUILD_BUILDING)
+ self.assertEqual(resp, ('2', {'action': 'step2'}))
+
+ resp = sm.handle_event(ick2.ActionFinishedEvent('2'))
+ self.assertEqual(sm.get_state(), ick2.BUILD_NOTIFYING)
+ self.assertEqual(resp, None)
+
+ resp = sm.handle_event(ick2.NeedWorkEvent())
+ self.assertEqual(sm.get_state(), ick2.BUILD_NOTIFYING)
+ self.assertEqual(resp, ('3', {'action': 'notify'}))
+
+ resp = sm.handle_event(ick2.ActionFinishedEvent('3'))
+ self.assertEqual(sm.get_state(), ick2.BUILD_DONE)
+ self.assertEqual(resp, None)
+ self.assertEqual(self.resource['status'], ick2.BUILD_DONE)
+ self.assertEqual(self.resource['exit_code'], 0)
+
+ def test_failed_build(self):
+ sm = self.create_state_machine()
+ self.assertEqual(sm.get_state(), ick2.BUILD_TRIGGERED)
+
+ resp = sm.handle_event(ick2.BuildStartsEvent())
+ self.assertEqual(sm.get_state(), ick2.BUILD_BUILDING)
+ self.assertEqual(resp, None)
+
+ resp = sm.handle_event(ick2.ActionFailedEvent('1', 42))
+ self.assertEqual(sm.get_state(), ick2.BUILD_NOTIFYING)
+ self.assertEqual(resp, None)
+
+ resp = sm.handle_event(ick2.NeedWorkEvent())
+ self.assertEqual(sm.get_state(), ick2.BUILD_NOTIFYING)
+ self.assertEqual(resp, ('3', {'action': 'notify'}))
+
+ resp = sm.handle_event(ick2.ActionFinishedEvent('3'))
+ self.assertEqual(sm.get_state(), ick2.BUILD_FAILED)
+ self.assertEqual(resp, None)
+ self.assertEqual(self.resource['status'], ick2.BUILD_FAILED)
+ self.assertEqual(self.resource['exit_code'], 42)
+
+
+class CreateBuildEventTests(unittest.TestCase):
+
+ def test_creates_build_starts(self):
+ e = ick2.create_build_event(ick2.BuildStartsEvent)
+ self.assertTrue(isinstance(e, ick2.BuildStartsEvent))
+
+ def test_creates_need_work(self):
+ e = ick2.create_build_event(ick2.NeedWorkEvent)
+ self.assertTrue(isinstance(e, ick2.NeedWorkEvent))
+
+ def test_creates_partial_action_output(self):
+ work_update = {}
+ e = ick2.create_build_event(work_update)
+ self.assertTrue(isinstance(e, ick2.PartialActionOutputEvent))
+
+ def test_creates_action_finished(self):
+ work_update = {
+ 'action_id': '123',
+ 'exit_code': 0,
+ }
+ e = ick2.create_build_event(work_update)
+ self.assertTrue(isinstance(e, ick2.ActionFinishedEvent))
+ self.assertEqual(e.action_id, '123')
+
+ def test_creates_action_failed(self):
+ work_update = {
+ 'exit_code': 42,
+ }
+ e = ick2.create_build_event(work_update)
+ self.assertTrue(isinstance(e, ick2.ActionFailedEvent))
+ self.assertEqual(e.exit_code, 42)
diff --git a/ick2/projectapi.py b/ick2/projectapi.py
index ffa46bb..f351e8d 100644
--- a/ick2/projectapi.py
+++ b/ick2/projectapi.py
@@ -54,7 +54,7 @@ class ProjectAPI(ick2.ResourceApiBase):
def trigger_project(self, project, **kwargs): # pragma: no cover
with self._trans.modify('projects', project) as p:
self._start_build(p)
- return {'status': 'triggered'}
+ return {'status': ick2.BUILD_TRIGGERED}
def _start_build(self, project): # pragma: no cover
build_no = self._pick_build_number(project)
@@ -70,7 +70,8 @@ class ProjectAPI(ick2.ResourceApiBase):
'worker': None,
'project': project['project'],
'parameters': parameters,
- 'status': 'triggered',
+ 'status': ick2.BUILD_TRIGGERED,
+ 'exit_code': None,
'graph': {},
})
diff --git a/ick2/workapi.py b/ick2/workapi.py
index c054f0a..01f9932 100644
--- a/ick2/workapi.py
+++ b/ick2/workapi.py
@@ -13,6 +13,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import json
+import time
+
+
import ick2
@@ -38,44 +42,69 @@ class WorkAPI(ick2.APIbase):
def get_work(self, **kwargs):
worker_id = self._get_client_id(**kwargs)
+ ick2.log.log(
+ 'trace', msg_text='Worker wants work', worker_id=worker_id)
with self._trans.modify('workers', worker_id) as worker:
doing = worker.get('doing')
if doing:
+ ick2.log.log(
+ 'trace', msg_text='Worker already got work', doing=doing)
return doing
build_id = self._pick_build(worker_id)
if build_id is None:
+ ick2.log.log('trace', msg_text='No suitable build for worker')
return {}
with self._trans.modify('builds', build_id) as build:
- build['status'] = 'building'
- build['worker'] = worker_id
-
- build_obj = ick2.Build(build)
- graph = build_obj.get_graph()
- action_id = self._pick_next_action(graph)
- if action_id is None: # pragma: no cover
- return {}
-
- graph.set_action_status(action_id, 'building')
- action = graph.get_action(action_id)
-
- doing = {
- 'build_id': build_id,
- 'build_number': build['build_number'],
- 'worker': worker_id,
- 'project': build['project'],
- 'parameters': build['parameters'],
- 'action_id': action_id,
- 'step': action,
- 'log': build['log'],
- }
-
- worker.from_dict({
- 'worker': worker_id,
- 'doing': doing,
- })
+ with self._trans.modify('log', build_id) as log:
+ ick2.log.log(
+ 'trace', msg_text='Picked build for worker',
+ build_id=build_id, build=build.as_dict())
+ build_obj = ick2.Build(build)
+ sm = ick2.BuildStateMachine(build_obj)
+
+ if sm.get_state() == ick2.BUILD_TRIGGERED:
+ sm.handle_event(ick2.BuildStartsEvent())
+ self._append_text_to_build_log(
+ log,
+ 'Starting build at {}\n'.format(
+ time.strftime('%Y-%m-%d %H:%M:%S')))
+
+ resp = sm.handle_event(ick2.NeedWorkEvent())
+ if resp is None: # pragma: no cover
+ ick2.log.log(
+ 'trace', msg_text='Did not find work in build')
+ self._append_text_to_build_log(
+ log, 'Did not find work to do in build\n')
+ return {}
+ action_id, action = resp
+
+ build['worker'] = worker_id
+ doing = {
+ 'build_id': build_id,
+ 'build_number': build['build_number'],
+ 'worker': worker_id,
+ 'project': build['project'],
+ 'parameters': build['parameters'],
+ 'action_id': action_id,
+ 'step': action,
+ 'log': build['log'],
+ }
+
+ worker.from_dict({
+ 'worker': worker_id,
+ 'doing': doing,
+ })
+ self._append_text_to_build_log(
+ log,
+ 'Giving worker action:\n{}\n'.format(
+ json.dumps(doing, indent=4)))
+
+ ick2.log.log(
+ 'trace', msg_text='Returning work for worker',
+ doing=worker['doing'])
return worker['doing']
def _get_client_id(self, **kwargs):
@@ -93,10 +122,11 @@ class WorkAPI(ick2.APIbase):
return build.get('status')
def is_building(build):
- return status(build) == 'building'
+ building_states = (ick2.BUILD_BUILDING, ick2.BUILD_NOTIFYING)
+ return status(build) in building_states
def is_triggered(build):
- return status(build) == 'triggered'
+ return status(build) == ick2.BUILD_TRIGGERED
builds = self._trans.get_resources('builds')
return (self._find_build(builds, on_worker, is_building) or
@@ -108,12 +138,6 @@ class WorkAPI(ick2.APIbase):
return build['build_id']
return None
- def _pick_next_action(self, graph):
- action_ids = graph.find_actions('ready')
- if not action_ids: # pragma: no cover
- return None
- return action_ids[0]
-
def update_work(self, update, **kwargs):
try:
worker_id = update['worker']
@@ -125,32 +149,40 @@ class WorkAPI(ick2.APIbase):
with self._trans.modify('workers', worker_id) as worker:
with self._trans.modify('builds', build_id) as build:
- build_obj = ick2.Build(build)
- graph = build_obj.get_graph()
- doing = worker.get('doing', {})
- self._check_work_update(doing, update)
- self._append_to_build_log(update)
- action_id = doing['action_id']
-
- if exit_code is not None:
- if exit_code == 0:
- graph.set_action_status(action_id, 'done')
- graph.unblock()
- if not graph.has_more_to_do():
- build_obj.set_status('done')
- build['status'] = 0
- elif exit_code is not None:
- graph.set_action_status(action_id, 'failed')
- build_obj.set_status('failed')
- build['status'] = exit_code
-
- worker.from_dict({
- 'worker': worker_id,
- 'doing': {},
- })
+ with self._trans.modify('log', build_id) as log:
+ doing = worker.get('doing', {})
+ self._check_work_update(doing, update)
+ self._append_to_build_log(log, update)
+ action_id = doing['action_id']
+
+ build_obj = ick2.Build(build)
+ sm = ick2.BuildStateMachine(build_obj)
+ event = ick2.create_build_event(update)
+ sm.handle_event(event)
+ action_ended = update['exit_code'] is not None
+
+ if action_ended:
+ self._append_text_to_build_log(
+ log,
+ 'Action ended at {}, exit code {}\n'.format(
+ time.strftime('%Y-%m-%d %H:%M:%S'),
+ update['exit_code']))
+
+ if build_obj.is_finished(): # pragma: no cover
+ self._append_text_to_build_log(
+ log,
+ 'Build ended at {}, exit code {}\n'.format(
+ time.strftime('%Y-%m-%d %H:%M:%S'),
+ build['exit_code']))
+
+ if action_ended or build_obj.is_finished():
+ worker.from_dict({
+ 'worker': worker_id,
+ 'doing': {},
+ })
def _check_work_update(self, doing, update): # pragma: no cover
- must_match = ['worker', 'project', 'build_id']
+ must_match = ['worker', 'project', 'build_id', 'action_id']
for name in must_match:
if name not in update:
raise ick2.BadUpdate('{} not specified'.format(name))
@@ -159,12 +191,13 @@ class WorkAPI(ick2.APIbase):
'{} differs from current work: {} vs {}'.format(
name, doing.get(name), update[name]))
- def _append_to_build_log(self, update):
- build_id = update['build_id']
- with self._trans.modify('log', build_id) as log:
- for stream in ['stdout', 'stderr']:
- text = update.get(stream, '')
- log['log'] = log.get('log', '') + text
+ def _append_to_build_log(self, log, update):
+ for stream in ['stdout', 'stderr']:
+ text = update.get(stream, '')
+ self._append_text_to_build_log(log, text)
+
+ def _append_text_to_build_log(self, log, text):
+ log['log'] = log.get('log', '') + text
def create(self, body, **kwargs): # pragma: no cover
pass
diff --git a/ick2/workapi_tests.py b/ick2/workapi_tests.py
index b4d72a7..c368b4b 100644
--- a/ick2/workapi_tests.py
+++ b/ick2/workapi_tests.py
@@ -13,6 +13,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import copy
import os
import shutil
import tempfile
@@ -29,6 +30,7 @@ class WorkAPITests(unittest.TestCase):
self.statedir = os.path.join(self.tempdir, 'state/dir')
self.state = ick2.FilePersistentState()
self.state.set_directory(self.statedir)
+ self.claims = None
def tearDown(self):
shutil.rmtree(self.tempdir)
@@ -60,11 +62,11 @@ class WorkAPITests(unittest.TestCase):
worker = {
'doing': {},
}
- claims = {
+ self.claims = {
'aud': 'asterix',
}
api = ick2.WorkerAPI(self.state)
- api.create(worker, claims=claims)
+ api.create(worker, claims=self.claims)
return api
def create_work_api(self):
@@ -74,8 +76,7 @@ class WorkAPITests(unittest.TestCase):
self.create_project_api()
self.create_worker_api()
work = self.create_work_api()
- claims = {'aud': 'asterix'}
- self.assertEqual(work.get_work(claims=claims), {})
+ self.assertEqual(work.get_work(claims=self.claims), {})
def test_worker_gets_work_when_a_build_has_been_triggered(self):
projects = self.create_project_api()
@@ -97,27 +98,17 @@ class WorkAPITests(unittest.TestCase):
},
'log': '/logs/foo/1',
}
- claims = {'aud': 'asterix'}
- self.assertEqual(work.get_work(claims=claims), expected)
+ self.assertEqual(work.get_work(claims=self.claims), expected)
# Check we get the same thing twice.
- claims = {'aud': 'asterix'}
- self.assertEqual(work.get_work(claims=claims), expected)
+ self.assertEqual(work.get_work(claims=self.claims), expected)
def test_worker_manager_posts_work_updates(self):
- projects = self.create_project_api()
- self.create_worker_api()
- work = self.create_work_api()
-
- # No builds have been triggered, nothing to do.
- claims = {'aud': 'asterix'}
- self.assertEqual(work.get_work(claims=claims), {})
+ # Define the actions we expect to get from the controller.
+ # They're mostly identical so we copy and change what needs to
+ # be changed.
- # Trigger a build.
- projects.trigger_project('foo')
-
- # Ask for some work.
- expected = {
+ action_1 = {
'build_id': 'foo/1',
'build_number': 1,
'worker': 'asterix',
@@ -132,12 +123,40 @@ class WorkAPITests(unittest.TestCase):
},
'log': '/logs/foo/1',
}
- claims = {'aud': 'asterix'}
- self.assertEqual(work.get_work(claims=claims), expected)
- # Post a partial update.
- done = {
+ action_2 = copy.deepcopy(action_1)
+ action_2.update({
+ 'action_id': '2',
+ 'step': {
+ 'shell': 'step-1',
+ 'where': 'host',
+ },
+ })
+
+ action_3 = copy.deepcopy(action_1)
+ action_3.update({
+ 'action_id': '3',
+ 'step': {
+ 'shell': 'step-2',
+ 'where': 'host',
+ },
+ })
+
+ action_4 = copy.deepcopy(action_1)
+ action_4.update({
+ 'action_id': '4',
+ 'step': {
+ 'action': 'notify',
+ },
+ })
+
+ # Define the work updates we will be sending to indicate
+ # progress on executing the actions above. Again, they're
+ # mostly identical.
+
+ done_1_partial = {
'build_id': 'foo/1',
+ 'action_id': '1',
'worker': 'asterix',
'project': 'foo',
'exit_code': None,
@@ -145,38 +164,67 @@ class WorkAPITests(unittest.TestCase):
'stderr': 'err',
'timestamp': '2000-01-01T00:00:00',
}
- work.update_work(done)
+ done_1 = copy.deepcopy(done_1_partial)
+ done_1.update({
+ 'exit_code': 0,
+ })
+ done_2 = copy.deepcopy(done_1)
+ done_2.update({
+ 'action_id': '2',
+ })
+ done_3 = copy.deepcopy(done_1)
+ done_3.update({
+ 'action_id': '3',
+ })
+ done_4 = copy.deepcopy(done_1)
+ done_4.update({
+ 'action_id': '4',
+ })
+
+ # Set up the various API objects.
+ projects = self.create_project_api()
+ self.create_worker_api()
+ work = self.create_work_api()
+
+ # No builds have been triggered, nothing to do.
+ self.assertEqual(work.get_work(claims=self.claims), {})
+
+ # Trigger a build.
+ projects.trigger_project('foo')
+
+ # Get the first action.
+ self.assertEqual(work.get_work(claims=self.claims), action_1)
+
+ # Post a partial update.
+ work.update_work(done_1_partial)
# Ask for work again. We didn't finish the previous step, so
# should get same thing.
- claims = {'aud': 'asterix'}
- self.assertEqual(work.get_work(claims=claims), expected)
+ self.assertEqual(work.get_work(claims=self.claims), action_1)
# Finish the step.
- done['exit_code'] = 0
- work.update_work(done)
+ work.update_work(done_1)
# We should get the next step now.
- got = work.get_work(claims=claims)
- expected['action_id'] = '2'
- expected['step'] = {'shell': 'step-1', 'where': 'host'}
- self.assertEqual(got, expected)
+ self.assertEqual(work.get_work(claims=self.claims), action_2)
# Finish the step.
- done['exit_code'] = 0
- work.update_work(done)
+ work.update_work(done_2)
- # We should get the next step now.
- expected['action_id'] = '3'
- expected['step'] = {'shell': 'step-2', 'where': 'host'}
- self.assertEqual(work.get_work(claims=claims), expected)
+ # We should get the final actual step now.
+ self.assertEqual(work.get_work(claims=self.claims), action_3)
# Finish the step.
- done['exit_code'] = 0
- work.update_work(done)
+ work.update_work(done_3)
+
+ # We should get the notification step now.
+ self.assertEqual(work.get_work(claims=self.claims), action_4)
+
+ # Finish the step.
+ work.update_work(done_4)
# We now get nothing further to do.
- self.assertEqual(work.get_work(claims=claims), {})
+ self.assertEqual(work.get_work(claims=self.claims), {})
def test_worker_manager_posts_failure(self):
projects = self.create_project_api()
@@ -200,12 +248,12 @@ class WorkAPITests(unittest.TestCase):
},
'log': '/logs/foo/1',
}
- claims = {'aud': 'asterix'}
- self.assertEqual(work.get_work(claims=claims), expected)
+ self.assertEqual(work.get_work(claims=self.claims), expected)
- # Post a partial update.
+ # Post an update.
done = {
'build_id': 'foo/1',
+ 'action_id': '1',
'worker': 'asterix',
'project': 'foo',
'exit_code': 1,
@@ -215,6 +263,35 @@ class WorkAPITests(unittest.TestCase):
}
work.update_work(done)
+ # Ask for some work.
+ expected = {
+ 'build_id': 'foo/1',
+ 'build_number': 1,
+ 'worker': 'asterix',
+ 'project': 'foo',
+ 'parameters': {
+ 'foo': 'bar',
+ },
+ 'action_id': '4',
+ 'step': {
+ 'action': 'notify',
+ },
+ 'log': '/logs/foo/1',
+ }
+ self.assertEqual(work.get_work(claims=self.claims), expected)
+
+ # Post an update.
+ done = {
+ 'build_id': 'foo/1',
+ 'action_id': '4',
+ 'worker': 'asterix',
+ 'project': 'foo',
+ 'exit_code': 0,
+ 'stdout': 'out',
+ 'stderr': 'err',
+ 'timestamp': '2000-01-01T00:00:00',
+ }
+ work.update_work(done)
+
# Ask for work again.
- claims = {'aud': 'asterix'}
- self.assertEqual(work.get_work(claims=claims), {})
+ self.assertEqual(work.get_work(claims=self.claims), {})
diff --git a/yarns/400-build.yarn b/yarns/400-build.yarn
index 5ab5895..1674189 100644
--- a/yarns/400-build.yarn
+++ b/yarns/400-build.yarn
@@ -197,6 +197,7 @@ User can now see pipeline is running and which worker is building it.
... "foo": "bar"
... },
... "status": "building",
+ ... "exit_code": null,
... "log": "/logs/rome/1"
... }
... ]
@@ -205,7 +206,6 @@ User can now see pipeline is running and which worker is building it.
WHEN user makes request GET /logs/rome/1
THEN result has status code 200
AND result has header Content-Type: text/plain
- AND body text is ""
Worker reports workspace creation is done. Note the zero exit code.
@@ -286,7 +286,7 @@ The build log is immediately accessible.
WHEN user makes request GET /logs/rome/1
THEN result has status code 200
AND result has header Content-Type: text/plain
- AND body text is "hey ho"
+ AND body text contains "hey ho"
Report the step is done, and successfully.
@@ -306,7 +306,7 @@ Report the step is done, and successfully.
WHEN user makes request GET /logs/rome/1
THEN result has status code 200
AND result has header Content-Type: text/plain
- AND body text is "hey ho, hey ho\n"
+ AND body text contains "hey ho, hey ho\n"
The build status now shows the next step as the active one.
@@ -342,6 +342,7 @@ The build status now shows the next step as the active one.
... }
... },
... "status": "building",
+ ... "exit_code": null,
... "log": "/logs/rome/1"
... }
... ]
@@ -407,6 +408,41 @@ Report it done.
... }
THEN result has status code 201
+Worker now gets told to notify about the build.
+
+ WHEN obelix makes request GET /work
+ THEN result has status code 200
+ AND body matches
+ ... {
+ ... "build_id": "rome/1",
+ ... "build_number": 1,
+ ... "worker": "obelix",
+ ... "project": "rome",
+ ... "parameters": {
+ ... "foo": "bar"
+ ... },
+ ... "action_id": "4",
+ ... "step": {
+ ... "action": "notify"
+ ... },
+ ... "log": "/logs/rome/1"
+ ... }
+
+Report it's done.
+
+ WHEN obelix makes request POST /work with a valid token and body
+ ... {
+ ... "build_id": "rome/1",
+ ... "action_id": "4",
+ ... "worker": "obelix",
+ ... "project": "rome",
+ ... "exit_code": 0,
+ ... "stdout": "",
+ ... "stderr": "",
+ ... "timestamp": "2017-10-27T17:08:49"
+ ... }
+ THEN result has status code 201
+
Now there's no more work to do.
WHEN obelix makes request GET /work
@@ -445,9 +481,15 @@ current action.
... "status": "done",
... "depends": ["2"],
... "action": {"where": "host", "shell": "day 2" }
+ ... },
+ ... "4": {
+ ... "status": "done",
+ ... "depends": [],
+ ... "action": {"action": "notify" }
... }
... },
- ... "status": 0
+ ... "status": "done",
+ ... "exit_code": 0
... }
... ]
... }
@@ -479,15 +521,22 @@ current action.
... "status": "done",
... "depends": ["2"],
... "action": {"where": "host", "shell": "day 2" }
+ ... },
+ ... "4": {
+ ... "status": "done",
+ ... "depends": [],
+ ... "action": {"action": "notify" }
... }
... },
- ... "status": 0
+ ... "status": "done",
+ ... "exit_code": 0
... }
WHEN user makes request GET /logs/rome/1
THEN result has status code 200
AND result has header Content-Type: text/plain
- AND body text is "hey ho, hey ho\nto the gold mine we go!\n"
+ AND body text contains "hey ho, hey ho\n"
+ AND body text contains "to the gold mine we go!\n"
Start build again. This should become build number 2.
@@ -542,9 +591,15 @@ Start build again. This should become build number 2.
... "status": "done",
... "depends": ["2"],
... "action": {"where": "host", "shell": "day 2" }
+ ... },
+ ... "4": {
+ ... "status": "done",
+ ... "depends": [],
+ ... "action": {"action": "notify" }
... }
... },
- ... "status": 0
+ ... "status": "done",
+ ... "exit_code": 0
... },
... {
... "build_id": "rome/2",
@@ -572,7 +627,8 @@ Start build again. This should become build number 2.
... "action": {"where": "host", "shell": "day 2" }
... }
... },
- ... "status": "building"
+ ... "status": "building",
+ ... "exit_code": null
... }
... ]
... }
@@ -638,6 +694,41 @@ Start build again. This should become build number 2.
... }
THEN result has status code 201
+ WHEN obelix makes request GET /work
+ THEN result has status code 200
+ AND body matches
+ ... {
+ ... "build_id": "rome/2",
+ ... "build_number": 2,
+ ... "worker": "obelix",
+ ... "project": "rome",
+ ... "parameters": {
+ ... "foo": "bar"
+ ... },
+ ... "action_id": "4",
+ ... "step": {
+ ... "action": "notify"
+ ... },
+ ... "log": "/logs/rome/2"
+ ... }
+
+ WHEN obelix makes request POST /work with a valid token and body
+ ... {
+ ... "build_id": "rome/2",
+ ... "action_id": "4",
+ ... "worker": "obelix",
+ ... "project": "rome",
+ ... "exit_code": 0,
+ ... "stdout": "",
+ ... "stderr": "",
+ ... "timestamp": "2017-10-27T17:08:49"
+ ... }
+ THEN result has status code 201
+
+ WHEN obelix makes request GET /work
+ THEN result has status code 200
+ AND body matches {}
+
WHEN user makes request GET /builds
THEN result has status code 200
AND body matches
@@ -667,9 +758,15 @@ Start build again. This should become build number 2.
... "status": "done",
... "depends": ["2"],
... "action": {"where": "host", "shell": "day 2" }
+ ... },
+ ... "4": {
+ ... "status": "done",
+ ... "depends": [],
+ ... "action": {"action": "notify" }
... }
... },
- ... "status": 0
+ ... "status": "done",
+ ... "exit_code": 0
... },
... {
... "build_id": "rome/2",
@@ -695,9 +792,15 @@ Start build again. This should become build number 2.
... "status": "done",
... "depends": ["2"],
... "action": {"where": "host", "shell": "day 2" }
+ ... },
+ ... "4": {
+ ... "status": "done",
+ ... "depends": [],
+ ... "action": {"action": "notify" }
... }
... },
- ... "status": 0
+ ... "status": "done",
+ ... "exit_code": 0
... }
... ]
... }
@@ -810,6 +913,29 @@ Build the first project.
... }
THEN result has status code 201
+ WHEN obelix makes request GET /work
+ THEN result is step
+ ... {
+ ... "action": "notify"
+ ... }
+
+ WHEN obelix makes request POST /work with a valid token and body
+ ... {
+ ... "build_id": "first/1",
+ ... "action_id": "3",
+ ... "build_number": 1,
+ ... "worker": "obelix",
+ ... "project": "first",
+ ... "exit_code": 0,
+ ... "stdout": "",
+ ... "stderr": "",
+ ... "timestamp": "2017-10-27T17:08:49"
+ ... }
+ THEN result has status code 201
+
+ WHEN obelix makes request GET /work
+ THEN body matches {}
+
WHEN user requests list of builds
THEN the list of builds is ["first/1"]
@@ -858,6 +984,29 @@ Build second project.
... }
THEN result has status code 201
+ WHEN obelix makes request GET /work
+ THEN result is step
+ ... {
+ ... "action": "notify"
+ ... }
+
+ WHEN obelix makes request POST /work with a valid token and body
+ ... {
+ ... "build_id": "second/1",
+ ... "action_id": "3",
+ ... "build_number": 1,
+ ... "worker": "obelix",
+ ... "project": "second",
+ ... "exit_code": 0,
+ ... "stdout": "",
+ ... "stderr": "",
+ ... "timestamp": "2017-10-27T17:08:49"
+ ... }
+ THEN result has status code 201
+
+ WHEN obelix makes request GET /work
+ THEN body matches {}
+
WHEN user requests list of builds
THEN the list of builds is ["first/1", "second/1"]
@@ -1029,6 +1178,25 @@ Trigger both projects.
THEN result has status code 201
WHEN asterix makes request GET /work
+ THEN result is step
+ ... {
+ ... "action": "notify"
+ ... }
+
+ WHEN asterix makes request POST /work with a valid token and body
+ ... {
+ ... "build_id": "first/1",
+ ... "action_id": "3",
+ ... "worker": "asterix",
+ ... "project": "first",
+ ... "exit_code": 0,
+ ... "stdout": "",
+ ... "stderr": "",
+ ... "timestamp": "2017-10-27T17:08:49"
+ ... }
+ THEN result has status code 201
+
+ WHEN asterix makes request GET /work
THEN body matches {}
WHEN obelix makes request POST /work with a valid token and body
@@ -1045,6 +1213,25 @@ Trigger both projects.
THEN result has status code 201
WHEN obelix makes request GET /work
+ THEN result is step
+ ... {
+ ... "action": "notify"
+ ... }
+
+ WHEN obelix makes request POST /work with a valid token and body
+ ... {
+ ... "build_id": "second/1",
+ ... "action_id": "3",
+ ... "worker": "obelix",
+ ... "project": "second",
+ ... "exit_code": 0,
+ ... "stdout": "",
+ ... "stderr": "",
+ ... "timestamp": "2017-10-27T17:08:49"
+ ... }
+ THEN result has status code 201
+
+ WHEN obelix makes request GET /work
THEN body matches {}
WHEN user requests list of builds
diff --git a/yarns/500-build-fail.yarn b/yarns/500-build-fail.yarn
index 4d4071a..204bfd5 100644
--- a/yarns/500-build-fail.yarn
+++ b/yarns/500-build-fail.yarn
@@ -101,6 +101,7 @@ failure.
WHEN obelix makes request POST /work with a valid token and body
... {
... "build_id": "rome/1",
+ ... "action_id": "1",
... "worker": "obelix",
... "project": "rome",
... "exit_code": 1,
@@ -110,8 +111,38 @@ failure.
... }
THEN result has status code 201
-A build step failed, so now the build has ended, and there's no more
-work to do.
+Worker is next told to notify end of build.
+
+ WHEN obelix makes request GET /work
+ THEN result has status code 200
+ AND body matches
+ ... {
+ ... "build_id": "rome/1",
+ ... "build_number": 1,
+ ... "log": "/logs/rome/1",
+ ... "worker": "obelix",
+ ... "project": "rome",
+ ... "parameters": {},
+ ... "action_id": "4",
+ ... "step": {
+ ... "action": "notify"
+ ... }
+ ... }
+
+ WHEN obelix makes request POST /work with a valid token and body
+ ... {
+ ... "build_id": "rome/1",
+ ... "action_id": "4",
+ ... "worker": "obelix",
+ ... "project": "rome",
+ ... "exit_code": 0,
+ ... "stdout": "",
+ ... "stderr": "eek!",
+ ... "timestamp": "2017-10-27T17:08:49"
+ ... }
+ THEN result has status code 201
+
+The build has ended, and there's no more work to do.
WHEN obelix makes request GET /work
THEN result has status code 200
@@ -159,9 +190,15 @@ There's a build with a log.
... "status": "blocked",
... "depends": ["2"],
... "action": {"shell": "day 2", "where": "host"}
+ ... },
+ ... "4": {
+ ... "status": "done",
+ ... "depends": [],
+ ... "action": {"action": "notify"}
... }
... },
- ... "status": 1
+ ... "status": "failed",
+ ... "exit_code": 1
... }
... ]
... }
@@ -194,14 +231,20 @@ There's a build with a log.
... "status": "blocked",
... "depends": ["2"],
... "action": {"shell": "day 2", "where": "host"}
+ ... },
+ ... "4": {
+ ... "status": "done",
+ ... "depends": [],
+ ... "action": {"action": "notify"}
... }
... },
- ... "status": 1
+ ... "status": "failed",
+ ... "exit_code": 1
... }
WHEN user makes request GET /logs/rome/1
THEN result has status code 200
AND result has header Content-Type: text/plain
- AND body text is "eek!"
+ AND body text contains "eek!"
FINALLY stop ick controller
diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn
index 4086a2f..5468a6c 100644
--- a/yarns/900-implements.yarn
+++ b/yarns/900-implements.yarn
@@ -108,17 +108,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
expected_text = get_next_match()
expected = json.loads(expected_text)
actual = json.loads(vars['body'])
- print('expected', json.dumps(expected, indent=4))
- print('actual', json.dumps(actual, indent=4))
+ print 'expected'
+ json.dump(expected, sys.stdout, indent=4, sort_keys=True)
+ print
+ print 'actual'
+ json.dump(actual, sys.stdout, indent=4, sort_keys=True)
+ print
diff = dict_diff(expected, actual)
if diff is not None:
print(diff)
assert 0
- IMPLEMENTS THEN body text is "(.*)"
- expected = unescape(get_next_match())
- actual = vars['body']
- assertEqual(expected, actual)
+ IMPLEMENTS THEN body text contains "(.*)"
+ pattern = unescape(get_next_match())
+ text = vars['body']
+ print 'pattern:', repr(pattern)
+ print 'text:', text
+ assertTrue(pattern in text)
IMPLEMENTS THEN body is the same as the blob (\S+)
filename = get_next_match()
@@ -142,10 +148,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
IMPLEMENTS THEN result is step (.+)
step = json.loads(get_next_match())
- body = json.loads(vars['body'])
- actual_step = body['step']
print('expected step', step)
+ body = json.loads(vars['body'])
print('actual body', body)
+ actual_step = body['step']
print('actual step', actual_step)
diff = dict_diff(step, actual_step)
print('diff', diff)