summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2017-11-06 14:09:54 +0100
committerLars Wirzenius <liw@liw.fi>2017-11-06 14:09:54 +0100
commit41128e5726c80938351b85b8adddbe02c2b59d6e (patch)
tree30c83f6b18731bfdde4268d4a467740fc09c3d9c
parent2cf2195240b5befe58bd34fd77141444ec07e3d3 (diff)
parentc6ce6faab75ea291d85e5780b95cccc255a15c2a (diff)
downloadick2-41128e5726c80938351b85b8adddbe02c2b59d6e.tar.gz
Merge: yarn build scenario and implementation
-rw-r--r--ick2/__init__.py4
-rw-r--r--ick2/controllerapi.py344
-rw-r--r--ick2/controllerapi_tests.py219
-rw-r--r--ick2/state.py6
-rw-r--r--yarns/100-projects.yarn2
-rw-r--r--yarns/200-version.yarn2
-rw-r--r--yarns/300-workers.yarn2
-rw-r--r--yarns/400-build.yarn332
-rw-r--r--yarns/900-implements.yarn42
-rw-r--r--yarns/900-local.yarn5
-rw-r--r--yarns/900-remote.yarn5
-rw-r--r--yarns/lib.py29
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