From 9759c2b51a1250aa345c21b7cc6b793f4965ac2d Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Mon, 28 May 2018 19:51:58 +0300 Subject: Add: BuildStateMachine class --- ick2/__init__.py | 23 ++++- ick2/build.py | 13 +-- ick2/build_tests.py | 72 ++++++++++----- ick2/buildgraph.py | 39 +++++--- ick2/buildgraph_tests.py | 43 ++++----- ick2/buildsm.py | 223 ++++++++++++++++++++++++++++++++++++++++++++++ ick2/buildsm_tests.py | 144 ++++++++++++++++++++++++++++++ ick2/projectapi.py | 5 +- ick2/workapi.py | 163 +++++++++++++++++++-------------- ick2/workapi_tests.py | 171 +++++++++++++++++++++++++---------- yarns/400-build.yarn | 207 +++++++++++++++++++++++++++++++++++++++--- yarns/500-build-fail.yarn | 53 +++++++++-- yarns/900-implements.yarn | 22 +++-- 13 files changed, 978 insertions(+), 200 deletions(-) create mode 100644 ick2/buildsm.py create mode 100644 ick2/buildsm_tests.py 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 . + + +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 . + + +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 . +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 . +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"] @@ -1028,6 +1177,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 {} @@ -1044,6 +1212,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 {} 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 . 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 . 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) -- cgit v1.2.1