diff options
author | Lars Wirzenius <liw@liw.fi> | 2017-11-06 14:09:54 +0100 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2017-11-06 14:09:54 +0100 |
commit | 41128e5726c80938351b85b8adddbe02c2b59d6e (patch) | |
tree | 30c83f6b18731bfdde4268d4a467740fc09c3d9c | |
parent | 2cf2195240b5befe58bd34fd77141444ec07e3d3 (diff) | |
parent | c6ce6faab75ea291d85e5780b95cccc255a15c2a (diff) | |
download | ick2-41128e5726c80938351b85b8adddbe02c2b59d6e.tar.gz |
Merge: yarn build scenario and implementation
-rw-r--r-- | ick2/__init__.py | 4 | ||||
-rw-r--r-- | ick2/controllerapi.py | 344 | ||||
-rw-r--r-- | ick2/controllerapi_tests.py | 219 | ||||
-rw-r--r-- | ick2/state.py | 6 | ||||
-rw-r--r-- | yarns/100-projects.yarn | 2 | ||||
-rw-r--r-- | yarns/200-version.yarn | 2 | ||||
-rw-r--r-- | yarns/300-workers.yarn | 2 | ||||
-rw-r--r-- | yarns/400-build.yarn | 332 | ||||
-rw-r--r-- | yarns/900-implements.yarn | 42 | ||||
-rw-r--r-- | yarns/900-local.yarn | 5 | ||||
-rw-r--r-- | yarns/900-remote.yarn | 5 | ||||
-rw-r--r-- | yarns/lib.py | 29 |
12 files changed, 956 insertions, 36 deletions
diff --git a/ick2/__init__.py b/ick2/__init__.py index 605c6fa..02a8e76 100644 --- a/ick2/__init__.py +++ b/ick2/__init__.py @@ -15,9 +15,11 @@ from .version import __version__, __version_info__ from .logging import setup_logging, log -from .state import ControllerState, NotFound +from .state import ControllerState, NotFound, WrongPipelineStatus from .controllerapi import ( ControllerAPI, ProjectAPI, VersionAPI, + WorkAPI, + WorkerAPI, ) diff --git a/ick2/controllerapi.py b/ick2/controllerapi.py index 1cf1aef..8b4bc29 100644 --- a/ick2/controllerapi.py +++ b/ick2/controllerapi.py @@ -33,7 +33,10 @@ class ControllerAPI: def find_missing_route(self, missing_path): # pragma: no cover apis = { '/version': VersionAPI, + '/builds': BuildsAPI, + '/logs': LogAPI, '/projects': ProjectAPI, + '/work': WorkAPI, '/workers': WorkerAPI, } @@ -89,11 +92,13 @@ class APIbase: # pragma: no cover if 'raw_uri_path' in kwargs: del kwargs['raw_uri_path'] body = callback(**kwargs) - ick2.log.log( - 'xxx', msg_text='GET callback returned', body=body) except ick2.NotFound as e: return not_found(e) - return OK(body) + if isinstance(body, dict): + return OK(body) + elif isinstance(body, str): + return text_plain(body) + raise Exception('this must not happen') return wrapper def POST(self, callback): @@ -113,7 +118,16 @@ class APIbase: # pragma: no cover content_type=content_type, body=body) if 'raw_uri_path' in kwargs: del kwargs['raw_uri_path'] - body = callback(body, **kwargs) + try: + body = callback(body, **kwargs) + except ick2.NotFound as e: + return not_found(e) + except ick2.WrongPipelineStatus as e: + ick2.log.log( + 'error', + msg_text='Wrong state for pipeline', + exception=str(e)) + return bad_request(e) ick2.log.log('trace', msg_text='returned body', body=repr(body)) return OK(body) return wrapper @@ -181,7 +195,7 @@ class VersionAPI(APIbase): pass -class SubAPI(APIbase): +class ResourceApiBase(APIbase): def __init__(self, type_name, state): super().__init__(state) @@ -209,7 +223,51 @@ class SubAPI(APIbase): self._state.remove_resource(self._type_name, name) -class ProjectAPI(SubAPI): +class WorkerAPI(ResourceApiBase): # pragma: no cover + + def __init__(self, state): + super().__init__('workers', state) + + def get_resource_name(self, resource): + return resource['worker'] + + +class BuildsAPI(ResourceApiBase): # pragma: no cover + + def __init__(self, state): + super().__init__('builds', state) + + def get_resource_name(self, resource): + return resource['build'] + + def create(self, body): # pragma: no cover + raise MethodNotAllowed('Creating builds directly is not allowed') + + def update(self, body, name): # pragma: no cover + raise MethodNotAllowed('Updating builds directly is not allowed') + + +class LogAPI(ResourceApiBase): # pragma: no cover + + def __init__(self, state): + super().__init__('log', state) + + def get_resource_name(self, resource): + return resource['log'] + + def create(self, body): # pragma: no cover + raise MethodNotAllowed('Creating builds directly is not allowed') + + def update(self, body, name): # pragma: no cover + raise MethodNotAllowed('Updating builds directly is not allowed') + + def show(self, name): + log = self._state.get_resource('log', str(name)) + ick2.log.log('info', msg_text='Returning log', log=log) + return log['log'] + + +class ProjectAPI(ResourceApiBase): def __init__(self, state): super().__init__('projects', state) @@ -217,14 +275,266 @@ class ProjectAPI(SubAPI): def get_resource_name(self, resource): return resource['project'] + def get_routes(self, path): # pragma: no cover + return super().get_routes(path) + self.get_pipeline_routes(path) + + def get_pipeline_routes(self, path): # pragma: no cover + pipeline_path = '{}/<project>/pipelines/<pipeline>'.format(path) + builds_path = '{}/<project>/builds'.format(path) + return [ + { + 'method': 'GET', + 'path': pipeline_path, + 'callback': self.GET(self.get_pipeline), + }, + { + 'method': 'PUT', + 'path': pipeline_path, + 'callback': self.PUT(self.set_pipeline_callback), + }, + { + 'method': 'GET', + 'path': builds_path, + 'callback': self.GET(self.get_builds), + }, + ] + + def get_pipeline(self, project, pipeline): + p = self._state.get_resource(self._type_name, project) + for pl in p['pipelines']: + if pl['name'] == pipeline: + return { + 'status': pl.get('status', 'idle'), + } + raise ick2.NotFound() + + def set_pipeline_callback( + self, body, project, pipeline): # pragma: no cover + return self.set_pipeline(body['status'], project, pipeline) + + def set_pipeline(self, state, project, pipeline): + allowed_changes = { + 'idle': 'triggered', + 'triggered': 'building', + 'building': 'idle', + } + p = self._state.get_resource(self._type_name, project) + for pl in p['pipelines']: + if pl['name'] == pipeline: + old_state = pl.get('status', 'idle') + if allowed_changes[old_state] != state: + raise ick2.WrongPipelineStatus(state) + pl['status'] = state + self._state.update_resource(self._type_name, project, p) + return {'status': state} + raise ick2.NotFound() + + def get_builds(self, project): + p = self._state.get_resource(self._type_name, project) + return { + 'project': project, + 'builds': p.get('builds', []), + } + -class WorkerAPI(SubAPI): # pragma: no cover +class WorkAPI(APIbase): def __init__(self, state): - super().__init__('workers', state) + super().__init__(state) + self._type_name = 'work' - def get_resource_name(self, resource): - return resource['worker'] + def get_routes(self, path): # pragma: no cover + return [ + { + 'method': 'GET', + 'path': '{}/<worker>'.format(path), + 'callback': self.GET(self.get_work), + }, + { + 'method': 'POST', + 'path': path, + 'callback': self.POST(self.update_work), + }, + ] + + def get_work(self, worker): + worker_state = self._get_worker(worker) + if not worker_state.get('doing'): + project, pipeline = self._pick_triggered_pipeline() + if project is None: + doing = {} + else: + pipeline['status'] = 'building' + self._update_project(project) + + build_id = self._start_build(project, pipeline, worker) + self._start_log(build_id) + + index = 0 + doing = { + 'build_id': build_id, + 'worker': worker, + 'project': project['project'], + 'pipeline': pipeline['name'], + 'step': pipeline['actions'][index], + 'step_index': index, + 'log': '/logs/{}'.format(build_id), + } + + worker_state = { + 'worker': worker, + 'doing': doing, + } + self._update_worker(worker_state) + + return worker_state['doing'] + + def _get_worker(self, worker): # pragma: no cover + try: + return self._state.get_resource('workers', worker) + except ick2.NotFound: + return { + 'worker': worker, + } + + def _update_worker(self, worker_state): + self._state.update_resource( + 'workers', worker_state['worker'], worker_state) + + def _pick_triggered_pipeline(self): + projects = self._get_projects() + for project in projects: + for pipeline in project['pipelines']: + if pipeline.get('status') == 'triggered': + return project, pipeline + return None, None + + def _get_projects(self): + return self._state.get_resources('projects') + + def _update_project(self, project): + self._state.update_resource('projects', project['project'], project) + + def update_work(self, update): + if 'worker' not in update: # pragma: no cover + raise BadUpdate('no worker specified') + + worker_state = self._get_worker(update['worker']) + doing = worker_state.get('doing', {}) + self._check_work_update(doing, update) + + project, pipeline = self._get_pipeline( + update['project'], update['pipeline']) + self._append_to_build_log(update) + + ick2.log.log( + 'trace', + msg_text='xxx update_work', + update=update, + project=project, + pipeline=pipeline, + doing=doing) + + if update.get('exit_code') == 0: + ick2.log.log('trace', msg_texg='xxx finishing step') + index = doing['step_index'] + 1 + actions = pipeline['actions'] + if index >= len(actions): + pipeline['status'] = 'idle' + doing = {} + self._finish_build(update) + else: + doing['step_index'] = index + doing['step'] = actions[index] + self._update_project(project) + + worker_state = { + 'worker': update['worker'], + 'doing': doing, + } + self._update_worker(worker_state) + + def _check_work_update(self, doing, update): # pragma: no cover + must_match = ['worker', 'project', 'pipeline', 'build_id'] + for name in must_match: + if name not in update: + raise BadUpdate('{} not specified'.format(name)) + if doing.get(name) != update[name]: + raise BadUpdate( + '{} differs from current work: {} vs {}'.format( + name, doing.get(name), update[name])) + + def _get_pipeline(self, project, pipeline): # pragma: no cover + projects = self._get_projects() + for p in projects: + for pl in p['pipelines']: + if pl.get('name') == pipeline: + return p, pl + raise ick2.NotFound() + + def _start_build(self, project, pipeline, worker): + ick2.log.log('info', msg_text='Starting new build') + build_id = 1 + build = { + 'build_id': build_id, + 'log': '/logs/{}'.format(build_id), + 'worker': worker, + 'project': project['project'], + 'pipeline': pipeline['name'], + 'status': 'building', + } + self._state.add_resource('builds', str(build_id), build) + return build_id + + def _start_log(self, build_id): + ick2.log.log('info', msg_text='Starting new log', build_id=build_id) + log = { + 'build_id': build_id, + 'log': '', + } + self._state.add_resource('log', str(build_id), log) + return build_id + + def _append_to_build_log(self, update): + build_id = update['build_id'] + log = self._state.get_resource('log', str(build_id)) + for kind in ['stdout', 'stderr']: + text = update.get(kind, '') + if text is not None: + log['log'] += text + self._state.update_resource('log', str(build_id), log) + + def _finish_build(self, update): + build = self._state.get_resource('builds', str(update['build_id'])) + build['status'] = update['exit_code'] + self._state.update_resource('builds', str(update['build_id']), build) + + def create(self, *args, **kwargs): # pragma: no cover + pass + + def update(self, *args, **kwargs): # pragma: no cover + pass + + def list(self, *args, **kwargs): # pragma: no cover + pass + + def show(self, *args, **kwargs): # pragma: no cover + pass + + def delete(self, *args, **kwargs): # pragma: no cover + pass + + +class BadUpdate(Exception): # pragma: no cover + + def __init__(self, how): + super().__init__('Work update is BAD: {}'.format(how)) + + +class MethodNotAllowed(Exception): # pragma: no cover + + def __init__(self, wat): + super().__init__(wat) def response(status_code, body, headers): # pragma: no cover @@ -243,6 +553,13 @@ def OK(body): # pragma: no cover return response(apifw.HTTP_OK, body, headers) +def text_plain(body): # pragma: no cover + headers = { + 'Content-Type': 'text/plain', + } + return response(apifw.HTTP_OK, body, headers) + + def not_found(error): # pragma: no cover headers = { 'Content-Type': 'text/plain', @@ -250,6 +567,13 @@ def not_found(error): # pragma: no cover return response(apifw.HTTP_NOT_FOUND, str(error), headers) +def bad_request(error): # pragma: no cover + headers = { + 'Content-Type': 'text/plain', + } + return response(apifw.HTTP_BAD_REQUEST, str(error), headers) + + def created(body): # pragma: no cover headers = { 'Content-Type': 'application/json', diff --git a/ick2/controllerapi_tests.py b/ick2/controllerapi_tests.py index 42d1003..8acfd62 100644 --- a/ick2/controllerapi_tests.py +++ b/ick2/controllerapi_tests.py @@ -86,11 +86,44 @@ class ProjectAPITests(unittest.TestCase): def test_creates_project(self): project = { 'project': 'foo', - 'shell_steps': ['build'], + 'pipelines': [ + { + 'name': 'build', + 'actions': [ + { + 'shell': 'step-1', + }, + ], + }, + ], } api = self.create_api() self.assertEqual(api.create(project), project) self.assertEqual(api.list(), {'projects': [project]}) + self.assertEqual(api.get_pipeline('foo', 'build'), {'status': 'idle'}) + self.assertEqual( + api.get_builds('foo'), + {'project': 'foo', 'builds': []} + ) + + def test_raises_error_when_getting_missing_pipeline(self): + project = { + 'project': 'foo', + 'pipelines': [ + { + 'name': 'build', + 'actions': [ + { + 'shell': 'step-1', + }, + ], + }, + ], + } + api = self.create_api() + api.create(project) + with self.assertRaises(ick2.NotFound): + api.get_pipeline('foo', 'does-not-exist') def test_loads_projects_from_state_directory(self): project = { @@ -141,3 +174,187 @@ class ProjectAPITests(unittest.TestCase): api = self.create_api() with self.assertRaises(ick2.NotFound): api.delete('foo') + + def test_updates_pipeline_status(self): + project = { + 'project': 'foo', + 'pipelines': [ + { + 'name': 'build', + 'actions': [ + { + 'shell': 'step-1', + }, + ], + }, + ], + } + api = self.create_api() + api.create(project) + self.assertEqual(api.get_pipeline('foo', 'build'), {'status': 'idle'}) + + with self.assertRaises(ick2.WrongPipelineStatus): + api.set_pipeline('building', 'foo', 'build') + + api.set_pipeline('triggered', 'foo', 'build') + self.assertEqual( + api.get_pipeline('foo', 'build'), + {'status': 'triggered'} + ) + + with self.assertRaises(ick2.WrongPipelineStatus): + api.set_pipeline('idle', 'foo', 'build') + + api.set_pipeline('building', 'foo', 'build') + self.assertEqual( + api.get_pipeline('foo', 'build'), + {'status': 'building'} + ) + + with self.assertRaises(ick2.WrongPipelineStatus): + api.set_pipeline('triggered', 'foo', 'build') + + api.set_pipeline('idle', 'foo', 'build') + self.assertEqual( + api.get_pipeline('foo', 'build'), + {'status': 'idle'} + ) + + def test_raises_error_updating_status_of_missing_pipeline(self): + project = { + 'project': 'foo', + 'pipelines': [], + } + api = self.create_api() + api.create(project) + with self.assertRaises(ick2.NotFound): + api.set_pipeline('idle', 'foo', 'build') + + +class WorkAPITests(unittest.TestCase): + + def setUp(self): + self.tempdir = tempfile.mkdtemp() + self.statedir = os.path.join(self.tempdir, 'state/dir') + self.state = ick2.ControllerState() + self.state.set_state_directory(self.statedir) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def create_project_api(self): + project = { + 'project': 'foo', + 'pipelines': [ + { + 'name': 'build', + 'actions': [ + { + 'shell': 'step-1', + }, + { + 'shell': 'step-2', + }, + ], + }, + ], + } + api = ick2.ProjectAPI(self.state) + api.create(project) + return api + + def create_worker_api(self): + worker = { + 'worker': 'asterix', + } + api = ick2.WorkerAPI(self.state) + api.create(worker) + return api + + def create_work_api(self): + return ick2.WorkAPI(self.state) + + def test_worker_gets_no_work_when_no_pipeline_is_triggered(self): + self.create_project_api() + self.create_worker_api() + work = self.create_work_api() + self.assertEqual(work.get_work('asterix'), {}) + + def test_worker_gets_work_when_a_pipeline_is_triggered(self): + projects = self.create_project_api() + projects.set_pipeline('triggered', 'foo', 'build') + self.create_worker_api() + work = self.create_work_api() + expected = { + 'build_id': 1, + 'worker': 'asterix', + 'project': 'foo', + 'pipeline': 'build', + 'step': { + 'shell': 'step-1', + }, + 'step_index': 0, + 'log': '/logs/1', + } + self.assertEqual(work.get_work('asterix'), expected) + + # Check we get the same thing twice. + self.assertEqual(work.get_work('asterix'), expected) + + def test_worker_manager_posts_work_updates(self): + projects = self.create_project_api() + projects.set_pipeline('triggered', 'foo', 'build') + self.create_worker_api() + work = self.create_work_api() + + # Ask for some work. + expected = { + 'build_id': 1, + 'worker': 'asterix', + 'project': 'foo', + 'pipeline': 'build', + 'step': { + 'shell': 'step-1', + }, + 'step_index': 0, + 'log': '/logs/1', + } + self.assertEqual(work.get_work('asterix'), expected) + + # Post a partial update. + done = { + 'build_id': 1, + 'worker': 'asterix', + 'project': 'foo', + 'pipeline': 'build', + 'exit_code': None, + 'stdout': 'out', + 'stderr': 'err', + 'timestamp': '2000-01-01T00:00:00', + } + work.update_work(done) + + # Ask for work again. We didn't finish the previous step, so + # should get same thing. + self.assertEqual(work.get_work('asterix'), expected) + + # Finish the step. + done['exit_code'] = 0 + work.update_work(done) + + # We should get the next step now. + expected['step'] = {'shell': 'step-2'} + expected['step_index'] = 1 + self.assertEqual(work.get_work('asterix'), expected) + + # Finish the step. + done['exit_code'] = 0 + work.update_work(done) + + # We now get nothing further to do. + self.assertEqual(work.get_work('asterix'), {}) + + # An pipeline status has changed. + self.assertEqual( + projects.get_pipeline('foo', 'build'), + {'status': 'idle'}) diff --git a/ick2/state.py b/ick2/state.py index 67cd057..548ce87 100644 --- a/ick2/state.py +++ b/ick2/state.py @@ -89,3 +89,9 @@ class NotFound(Exception): def __init__(self): super().__init__('Resource not found') + + +class WrongPipelineStatus(Exception): # pragma: no cover + + def __init__(self, new_state): + super().__init__('Cannot set pipeline state to {}'.format(new_state)) diff --git a/yarns/100-projects.yarn b/yarns/100-projects.yarn index 1291f5e..a4793b3 100644 --- a/yarns/100-projects.yarn +++ b/yarns/100-projects.yarn @@ -62,7 +62,7 @@ building them. We start by starting an instance of the controller. SCENARIO managing projects GIVEN an RSA key pair for token signing - AND an access token for scopes + AND an access token for user with scopes ... uapi_projects_get ... uapi_projects_post ... uapi_projects_id_get diff --git a/yarns/200-version.yarn b/yarns/200-version.yarn index 183ade9..fe92b34 100644 --- a/yarns/200-version.yarn +++ b/yarns/200-version.yarn @@ -23,7 +23,7 @@ The Ick controller reports is version upon request. SCENARIO checking controller version GIVEN an RSA key pair for token signing - AND an access token for scopes + AND an access token for user with scopes ... uapi_version_get AND controller config uses statedir at the state directory AND a running ick controller diff --git a/yarns/300-workers.yarn b/yarns/300-workers.yarn index 6386ae9..16bd108 100644 --- a/yarns/300-workers.yarn +++ b/yarns/300-workers.yarn @@ -53,7 +53,7 @@ controller API. It doesn't actually talk to the worker itself. SCENARIO managing workers GIVEN an RSA key pair for token signing - AND an access token for scopes + AND an access token for user with scopes ... uapi_workers_get ... uapi_workers_post ... uapi_workers_id_get diff --git a/yarns/400-build.yarn b/yarns/400-build.yarn new file mode 100644 index 0000000..44e01f3 --- /dev/null +++ b/yarns/400-build.yarn @@ -0,0 +1,332 @@ +<!-- + +Copyright 2017 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/>. + +--> + +# Build a project + +This scenario tests the controller API to simulate a build. + + SCENARIO build a project + +Set up the controller. + + GIVEN an RSA key pair for token signing + AND controller config uses statedir at the state directory + AND an access token for user with scopes + ... uapi_projects_post + ... uapi_projects_id_pipeline_id_put + ... uapi_projects_id_pipeline_id_get + ... uapi_projects_id_builds_get + ... uapi_workers_id_get + ... uapi_builds_get + ... uapi_builds_id_get + ... uapi_logs_id_get + AND a running ick controller + +Add up a project. + + WHEN user makes request POST /projects + ... { + ... "project": "rome", + ... "pipelines": [ + ... { + ... "name": "construct", + ... "actions": [ + ... { "shell": "day 1" }, + ... { "shell": "day 2" } + ... ] + ... } + ... ] + ... } + THEN result has status code 201 + +There are no builds for the project yet, and is idle. + + WHEN user makes request GET /projects/rome/pipelines/construct + THEN result has status code 200 + AND body matches { "status": "idle" } + + WHEN user makes request GET /builds + THEN result has status code 200 + AND body matches { "builds": [] } + +Register a worker. + + GIVEN an access token for worker-manager with scopes + ... uapi_workers_post + ... uapi_work_post + WHEN worker-manager makes request POST /workers + ... { + ... "worker": "obelix" + ... } + THEN result has status code 201 + +Trigger build. First with an invalid status, then a real one. + + WHEN user makes request PUT /projects/rome/pipelines/construct + ... { "status": "VANDALS!" } + THEN result has status code 400 + + WHEN user makes request PUT /projects/rome/pipelines/construct + ... { "status": "triggered" } + THEN result has status code 200 + +Worker wants work and gets the first step to run. If the worker asks +again, it gets the same answer. **FIXME: should the name of the worker +be in the path or can we get it in the access token?** + + WHEN worker-manager makes request GET /work/obelix + THEN result has status code 200 + AND body matches + ... { + ... "build_id": 1, + ... "log": "/logs/1", + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "step": { + ... "shell": "day 1" + ... }, + ... "step_index": 0 + ... } + + WHEN worker-manager makes request GET /work/obelix + THEN result has status code 200 + AND body matches + ... { + ... "build_id": 1, + ... "log": "/logs/1", + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "step": { + ... "shell": "day 1" + ... }, + ... "step_index": 0 + ... } + +User can now see pipeline is running and which worker is building it. + + WHEN user makes request GET /projects/rome/pipelines/construct + THEN result has status code 200 + AND body matches + ... { + ... "status": "building" + ... } + + WHEN user makes request GET /workers/obelix + THEN result has status code 200 + AND body matches + ... { + ... "worker": "obelix", + ... "doing": { + ... "build_id": 1, + ... "log": "/logs/1", + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "step_index": 0, + ... "step": { + ... "shell": "day 1" + ... } + ... } + ... } + + WHEN user makes request GET /builds + THEN result has status code 200 + AND body matches + ... { + ... "builds": [ + ... { + ... "build_id": 1, + ... "log": "/logs/1", + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "status": "building", + ... "log": "/logs/1" + ... } + ... ] + ... } + + WHEN user makes request GET /logs/1 + THEN result has status code 200 + AND result has header Content-Type: text/plain + AND body text is "" + +Worker reports some build output. Note the null exit code. + + WHEN worker-manager makes request POST /work + ... { + ... "build_id": 1, + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "exit_code": null, + ... "stdout": "hey ho", + ... "stderr": "", + ... "timestamp": "2017-10-27T17:08:49" + ... } + THEN result has status code 201 + +Still the same job, since the first build step didnt't finish. + + WHEN worker-manager makes request GET /work/obelix + THEN result has status code 200 + AND body matches + ... { + ... "build_id": 1, + ... "log": "/logs/1", + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "step": { + ... "shell": "day 1" + ... }, + ... "step_index": 0 + ... } + +The build log is immediately accessible. + + WHEN user makes request GET /logs/1 + THEN result has status code 200 + AND result has header Content-Type: text/plain + AND body text is "hey ho" + +Report the step is done, and successfully. + + WHEN worker-manager makes request POST /work + ... { + ... "build_id": 1, + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "exit_code": 0, + ... "stdout": ", hey ho\n", + ... "stderr": "", + ... "timestamp": "2017-10-27T17:08:49" + ... } + THEN result has status code 201 + + WHEN user makes request GET /logs/1 + THEN result has status code 200 + AND result has header Content-Type: text/plain + AND body text is "hey ho, hey ho\n" + +Now there's another step to do. + + WHEN worker-manager makes request GET /work/obelix + THEN result has status code 200 + AND body matches + ... { + ... "build_id": 1, + ... "log": "/logs/1", + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "step": { + ... "shell": "day 2" + ... }, + ... "step_index": 1 + ... } + +User sees changed status. + + WHEN user makes request GET /workers/obelix + THEN result has status code 200 + AND body matches + ... { + ... "worker": "obelix", + ... "doing": { + ... "build_id": 1, + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "step_index": 1, + ... "step": { + ... "shell": "day 2" + ... }, + ... "log": "/logs/1" + ... } + ... } + +Report it done. + + WHEN worker-manager makes request POST /work + ... { + ... "build_id": 1, + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "exit_code": 0, + ... "stdout": "to the gold mine we go!\n", + ... "stderr": "", + ... "timestamp": "2017-10-27T17:08:49" + ... } + THEN result has status code 201 + +Now there's no more work to do. + + WHEN worker-manager makes request GET /work/obelix + THEN result has status code 200 + AND body matches {} + +The pipeline status indicates success. + + WHEN user makes request GET /projects/rome/pipelines/construct + THEN result has status code 200 + AND body matches { "status": "idle" } + +Also, there's a build with a log. + + WHEN user makes request GET /builds + THEN result has status code 200 + AND body matches + ... { + ... "builds": [ + ... { + ... "build_id": 1, + ... "log": "/logs/1", + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "status": 0, + ... "log": "/logs/1" + ... } + ... ] + ... } + + WHEN user makes request GET /builds/1 + THEN result has status code 200 + AND body matches + ... { + ... "build_id": 1, + ... "log": "/logs/1", + ... "worker": "obelix", + ... "project": "rome", + ... "pipeline": "construct", + ... "status": 0, + ... "log": "/logs/1" + ... } + + WHEN user makes request GET /logs/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" + + FINALLY stop ick controller diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn index 3bb694e..5fb476c 100644 --- a/yarns/900-implements.yarn +++ b/yarns/900-implements.yarn @@ -21,25 +21,29 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. ## HTTP requests of various kinds - IMPLEMENTS WHEN user makes request GET (\S+) + IMPLEMENTS WHEN (\S+) makes request GET (\S+) + user = get_next_match() path = get_next_match() - token = cat('token.jwt') + token = get_token(user) url = vars['url'] - status, content_type, body = get(url + path, token) + status, content_type, headers, body = get(url + path, token) vars['status_code'] = status vars['content_type'] = content_type + vars['headers'] = headers vars['body'] = body - IMPLEMENTS WHEN user makes request POST (\S+) (.+) + IMPLEMENTS WHEN (\S+) makes request POST (\S+) (.+) + user = get_next_match() path = get_next_match() body_text = get_next_match() print('path', path) print('body', body_text) - token = cat('token.jwt') + token = get_token(user) url = vars['url'] - status, content_type, body = post(url + path, body_text, token) + status, content_type, headers, body = post(url + path, body_text, token) vars['status_code'] = status vars['content_type'] = content_type + vars['headers'] = headers vars['body'] = body IMPLEMENTS WHEN user makes request PUT (\S+) (.+) @@ -47,27 +51,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. body_text = get_next_match() print('path', path) print('body', body_text) - token = cat('token.jwt') + token = get_token('user') url = vars['url'] - status, content_type, body = put(url + path, body_text, token) + status, content_type, headers, body = put(url + path, body_text, token) vars['status_code'] = status vars['content_type'] = content_type + vars['headers'] = headers vars['body'] = body - IMPLEMENTS WHEN user makes request DELETE (\S+) + IMPLEMENTS WHEN (\S+) makes request DELETE (\S+) + user = get_next_match() path = get_next_match() - token = cat('token.jwt') + token = get_token(user) url = vars['url'] - status, content_type, body = delete(url + path, token) + status, content_type, headers, body = delete(url + path, token) vars['status_code'] = status vars['content_type'] = content_type + vars['headers'] = headers vars['body'] = body - ## HTTP response inspection IMPLEMENTS THEN result has status code (\d+) - print(cat('token.jwt')) expected = int(get_next_match()) assertEqual(expected, vars['status_code']) @@ -78,6 +83,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. actual = json.loads(vars['body']) assertEqual(expected, actual) + IMPLEMENTS THEN body text is "(.*)" + expected = unescape(get_next_match()) + actual = vars['body'] + assertEqual(expected, actual) + IMPLEMENTS THEN version in body matches version from setup.py body = vars['body'] obj = json.loads(body) @@ -85,3 +95,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. setup_py = os.path.join(srcdir, 'setup.py') wanted = cliapp.runcmd(['python3', setup_py, '--version']).strip() assertTrue(wanted.startswith(actual)) + + IMPLEMENTS THEN result has header (\S+): (\S+) + name = get_next_match() + value = get_next_match() + headers = vars['headers'] + assertEqual(headers[name].lower(), value.lower()) diff --git a/yarns/900-local.yarn b/yarns/900-local.yarn index 2bc4e0e..409a8e6 100644 --- a/yarns/900-local.yarn +++ b/yarns/900-local.yarn @@ -28,7 +28,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. ] cliapp.runcmd(argv, stdout=None, stderr=None) - IMPLEMENTS GIVEN an access token for scopes (.+) + IMPLEMENTS GIVEN an access token for (\S+) with scopes (.+) + user = get_next_match() scopes = get_next_match() key = open('token.key').read() argv = [ @@ -36,7 +37,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. scopes, ] token = cliapp.runcmd(argv, feed_stdin=key) - write('token.jwt', token) + store_token(user, token) vars['issuer'] = 'localhost' vars['audience'] = 'localhost' diff --git a/yarns/900-remote.yarn b/yarns/900-remote.yarn index 0a28c81..2875d27 100644 --- a/yarns/900-remote.yarn +++ b/yarns/900-remote.yarn @@ -25,7 +25,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. vars['private_key_file'] = os.environ['ICK_PRIVATE_KEY'] assertTrue(os.path.exists(vars['private_key_file'])) - IMPLEMENTS GIVEN an access token for scopes (.+) + IMPLEMENTS GIVEN an access token for (\S+) with scopes (.+) + user = get_next_match() scopes = get_next_match() key = open(vars['private_key_file']).read() argv = [ @@ -33,7 +34,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. scopes, ] token = cliapp.runcmd(argv, feed_stdin=key) - write('token.jwt', token) + store_token(user, token) vars['issuer'] = 'localhost' vars['audience'] = 'localhost' diff --git a/yarns/lib.py b/yarns/lib.py index 0b95a81..486757c 100644 --- a/yarns/lib.py +++ b/yarns/lib.py @@ -62,6 +62,17 @@ def wait_for_port(port): else: return +def unescape(s): + t = '' + while s: + if s.startswith('\\n'): + t += '\n' + s = s[2:] + else: + t += s[0] + s = s[1:] + return t + def write(filename, data): with open(filename, 'w') as f: f.write(data) @@ -75,12 +86,22 @@ def cat(filename): return open(filename, 'r').read() +def store_token(user, token): + filename = '{}.jwt'.format(user) + write(filename, token) + + +def get_token(user): + filename = '{}.jwt'.format(user) + return cat(filename) + + def get(url, token): headers = { 'Authorization': 'Bearer {}'.format(token), } r = requests.get(url, headers=headers, verify=False) - return r.status_code, r.headers['Content-Type'], r.text + return r.status_code, r.headers['Content-Type'], dict(r.headers), r.text def post(url, body_text, token): @@ -89,7 +110,7 @@ def post(url, body_text, token): 'Content-Type': 'application/json', } r = requests.post(url, headers=headers, data=body_text, verify=False) - return r.status_code, r.headers['Content-Type'], r.text + return r.status_code, r.headers['Content-Type'], dict(r.headers), r.text def put(url, body_text, token): @@ -98,7 +119,7 @@ def put(url, body_text, token): 'Content-Type': 'application/json', } r = requests.put(url, headers=headers, data=body_text, verify=False) - return r.status_code, r.headers['Content-Type'], r.text + return r.status_code, r.headers['Content-Type'], dict(r.headers), r.text def delete(url, token): @@ -106,4 +127,4 @@ def delete(url, token): 'Authorization': 'Bearer {}'.format(token), } r = requests.delete(url, headers=headers, verify=False) - return r.status_code, r.headers['Content-Type'], r.text + return r.status_code, r.headers['Content-Type'], dict(r.headers), r.text |