summaryrefslogtreecommitdiff
path: root/yarns
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2017-08-06 11:46:02 +0300
committerLars Wirzenius <liw@liw.fi>2017-08-06 18:56:34 +0300
commit6299228754893813341085d99c3924f7fefe1c18 (patch)
tree432e9f076b3b226487b8a77359545adba50e1714 /yarns
parent888db73b93aefe70d838d499f7f9cc43eee7372b (diff)
downloadick2-6299228754893813341085d99c3924f7fefe1c18.tar.gz
Add: ControllerAPI, ControllerState
Diffstat (limited to 'yarns')
-rw-r--r--yarns/000.yarn11
-rw-r--r--yarns/100-projects.yarn132
-rw-r--r--yarns/900-implements.yarn140
-rw-r--r--yarns/lib.py94
4 files changed, 377 insertions, 0 deletions
diff --git a/yarns/000.yarn b/yarns/000.yarn
new file mode 100644
index 0000000..d022d12
--- /dev/null
+++ b/yarns/000.yarn
@@ -0,0 +1,11 @@
+---
+title: Ick2 integration tests
+author: Lars Wirzenius
+version: work in progress
+...
+
+
+# Introduction
+
+This is a set of integration tests for Ick2, a continuous integration
+system. Written for execution by yarn.
diff --git a/yarns/100-projects.yarn b/yarns/100-projects.yarn
new file mode 100644
index 0000000..315116f
--- /dev/null
+++ b/yarns/100-projects.yarn
@@ -0,0 +1,132 @@
+# Controller project management
+
+The Ick2 controller manages information about projects. Projects are
+things the controller builds. A project is described by a resource
+like this:
+
+ EXAMPLE project resource
+ {
+ "name": "ick2-website",
+ "shell_commands": [
+ "git clone git://git.liw.fi/ick2-website src",
+ "cd src && ikiwiki --setup ikiwiki.setup",
+ "cd html && rsync -a --delete . www-data@www.example.com:/srv/http/ick2/."
+ ]
+ }
+
+In other words, there are two things that define a project:
+
+* The name. This is used for referreing to the project in the API.
+* A sequence of shell commands to be run to build the project. At this
+ point Ick2 does not know about git repositories, so the shell
+ commands need to do the cloning explicitly; this will obviously have
+ to be changed later on. Note also that each string in the list is
+ run by a new shell process, and that the current working directory,
+ or environment variables, do not get preserved. Ick2 will create a
+ new, empty directory for running the commands.
+
+## Managing projects
+
+First we test the controller API for managing projects, without
+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
+ ... uapi_projects_get
+ ... uapi_projects_post
+ ... uapi_projects_id_get
+ ... uapi_projects_id_put
+ ... uapi_projects_id_delete
+ AND controller config uses statedir at the state directory
+ AND a running ick controller
+
+ WHEN user makes request GET /projects
+ THEN result has status code 200
+ AND body matches { "projects": [] }
+
+ WHEN user makes request POST /projects
+ ... {
+ ... "project": "website",
+ ... "shell_steps": [
+ ... "git clone git://repo src",
+ ... "mkdir html",
+ ... "ikiwiki src html"
+ ... ]
+ ... }
+ THEN result has status code 201
+ AND body matches
+ ... {
+ ... "project": "website",
+ ... "shell_steps": [
+ ... "git clone git://repo src",
+ ... "mkdir html",
+ ... "ikiwiki src html"
+ ... ]
+ ... }
+ AND controller state directory contains project website
+
+ WHEN user makes request GET /projects
+ THEN result has status code 200
+ AND body matches
+ ... {
+ ... "projects": [
+ ... {
+ ... "project": "website",
+ ... "shell_steps": [
+ ... "git clone git://repo src",
+ ... "mkdir html",
+ ... "ikiwiki src html"
+ ... ]
+ ... }
+ ... ]
+ ... }
+
+ WHEN user stops ick controller
+ GIVEN a running ick controller
+ WHEN user makes request GET /projects/website
+ THEN result has status code 200
+ AND body matches
+ ... {
+ ... "project": "website",
+ ... "shell_steps": [
+ ... "git clone git://repo src",
+ ... "mkdir html",
+ ... "ikiwiki src html"
+ ... ]
+ ... }
+
+ WHEN user makes request PUT /projects/website
+ ... {
+ ... "project": "website",
+ ... "shell_steps": [
+ ... "build-it"
+ ... ]
+ ... }
+ THEN result has status code 200
+ AND body matches
+ ... {
+ ... "project": "website",
+ ... "shell_steps": [
+ ... "build-it"
+ ... ]
+ ... }
+ AND controller state directory contains project website
+
+ WHEN user makes request GET /projects/website
+ THEN result has status code 200
+ AND body matches
+ ... {
+ ... "project": "website",
+ ... "shell_steps": [
+ ... "build-it"
+ ... ]
+ ... }
+
+ WHEN user makes request DELETE /projects/website
+ THEN result has status code 200
+ WHEN user makes request GET /projects/website
+ THEN result has status code 404
+
+
+ FINALLY stop ick controller
diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn
new file mode 100644
index 0000000..c9c5cf5
--- /dev/null
+++ b/yarns/900-implements.yarn
@@ -0,0 +1,140 @@
+# Scenario step implementations
+
+## Authentication setup
+
+ IMPLEMENTS GIVEN an RSA key pair for token signing
+ argv = [
+ os.path.join(srcdir, 'generate-rsa-key'),
+ 'token.key',
+ ]
+ cliapp.runcmd(argv, stdout=None, stderr=None)
+
+ IMPLEMENTS GIVEN an access token for scopes (.+)
+ scopes = get_next_match()
+ vars['issuer'] = 'yarn-issuer'
+ vars['audience'] = 'yarn-audience'
+ argv = [
+ os.path.join(srcdir, 'create-token'),
+ 'token.key',
+ vars['issuer'],
+ vars['audience'],
+ scopes,
+ ]
+ token = cliapp.runcmd(argv)
+ write('token.jwt', token)
+
+## Controller configuration
+
+ IMPLEMENTS GIVEN controller config uses (\S+) at the state directory
+ vars['statedir'] = get_next_match()
+
+## Start and stop the controller
+
+ IMPLEMENTS GIVEN a running ick controller
+ import os, time, cliapp, yaml
+ vars['controller.log'] = 'ick_controller.log'
+ vars['gunicorn3.log'] = 'gunicorn3.log'
+ vars['port'] = random_free_port()
+ vars['url'] = 'http://127.0.0.1:{}'.format(vars['port'])
+ config = {
+ 'token-issuer': vars['issuer'],
+ 'token-audience': vars['audience'],
+ 'token-public-key': cat('token.key.pub'),
+ 'log': [
+ {
+ 'filename': vars['controller.log'],
+ },
+ ],
+ 'statedir': vars['statedir'],
+ }
+ env = dict(os.environ)
+ env['ICK_CONTROLLER_CONFIG'] = 'ick_controller.yaml'
+ yaml.safe_dump(config, open('ick_controller.yaml', 'w'))
+ argv = [
+ 'gunicorn3',
+ '--daemon',
+ '--bind', '127.0.0.1:{}'.format(vars['port']),
+ '--log-file', vars['gunicorn3.log'],
+ '--log-level', 'debug',
+ '-p', 'pid',
+ 'ick_controller:app',
+ ]
+ cliapp.runcmd(argv, env=env)
+ vars['pid'] = int(cat('pid'))
+ wait_for_port(vars['port'])
+
+ IMPLEMENTS WHEN user stops ick controller
+ import os, signal
+ os.kill(vars['pid'], signal.SIGTERM)
+
+ IMPLEMENTS FINALLY stop ick controller
+ import os, signal
+ os.kill(vars['pid'], signal.SIGTERM)
+
+
+## HTTP requests of various kinds
+
+ IMPLEMENTS WHEN user makes request GET (\S+)
+ path = get_next_match()
+ token = cat('token.jwt')
+ url = vars['url']
+ status, content_type, body = get(url + path, token)
+ vars['status_code'] = status
+ vars['content_type'] = content_type
+ vars['body'] = body
+
+ IMPLEMENTS WHEN user makes request POST (\S+) (.+)
+ path = get_next_match()
+ body_text = get_next_match()
+ print('path', path)
+ print('body', body_text)
+ token = cat('token.jwt')
+ url = vars['url']
+ status, content_type, body = post(url + path, body_text, token)
+ vars['status_code'] = status
+ vars['content_type'] = content_type
+ vars['body'] = body
+
+ IMPLEMENTS WHEN user makes request PUT (\S+) (.+)
+ path = get_next_match()
+ body_text = get_next_match()
+ print('path', path)
+ print('body', body_text)
+ token = cat('token.jwt')
+ url = vars['url']
+ status, content_type, body = put(url + path, body_text, token)
+ vars['status_code'] = status
+ vars['content_type'] = content_type
+ vars['body'] = body
+
+ IMPLEMENTS WHEN user makes request DELETE (\S+)
+ path = get_next_match()
+ token = cat('token.jwt')
+ url = vars['url']
+ status, content_type, body = delete(url + path, token)
+ vars['status_code'] = status
+ vars['content_type'] = content_type
+ 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'])
+
+ IMPLEMENTS THEN body matches (.+)
+ expected_text = get_next_match()
+ expected = json.loads(expected_text)
+ print('actual body', repr(vars['body']))
+ actual = json.loads(vars['body'])
+ assertEqual(expected, actual)
+
+
+## Controller state inspection
+
+ IMPLEMENTS THEN controller state directory contains project (\S+)
+ name = get_next_match()
+ filename = os.path.join(vars['statedir'], 'projects', name + '.yaml')
+ assertTrue(os.path.exists(filename))
diff --git a/yarns/lib.py b/yarns/lib.py
new file mode 100644
index 0000000..4d305c7
--- /dev/null
+++ b/yarns/lib.py
@@ -0,0 +1,94 @@
+import errno
+import json
+import os
+import random
+import socket
+import sys
+import time
+
+import cliapp
+import requests
+
+from yarnutils import *
+
+
+srcdir = os.environ['SRCDIR']
+datadir = os.environ['DATADIR']
+vars = Variables(datadir)
+
+
+def random_free_port():
+ MAX = 1000
+ for i in range(MAX):
+ port = random.randint(1025, 2**15-1)
+ s = socket.socket()
+ try:
+ s.bind(('0.0.0.0', port))
+ except OSError as e:
+ if e.errno == errno.EADDRINUSE:
+ continue
+ print('cannot find a random free port')
+ raise
+ s.close()
+ break
+ print('picked port', port)
+ return port
+
+
+def wait_for_port(port):
+ MAX = 5
+ t = time.time()
+ while time.time() < t + MAX:
+ try:
+ s = socket.socket()
+ s.connect(('127.0.0.1', port))
+ except OSError as e:
+ raise
+ else:
+ return
+
+def write(filename, data):
+ with open(filename, 'w') as f:
+ f.write(data)
+
+
+def cat(filename):
+ MAX_CAT_WAIT = 5 # in seconds
+ t = time.time()
+ while time.time() < t + MAX_CAT_WAIT:
+ if os.path.exists(filename):
+ return open(filename, 'r').read()
+
+
+def get(url, token):
+ headers = {
+ 'Authorization': 'Bearer {}'.format(token),
+ }
+ r = requests.get(url, headers=headers)
+ return r.status_code, r.headers['Content-Type'], r.text
+
+
+def post(url, body_text, token):
+ headers = {
+ 'Authorization': 'Bearer {}'.format(token),
+ 'Content-Type': 'application/json',
+ }
+ r = requests.post(url, headers=headers, data=body_text)
+ return r.status_code, r.headers['Content-Type'], r.text
+
+
+def put(url, body_text, token):
+ headers = {
+ 'Authorization': 'Bearer {}'.format(token),
+ 'Content-Type': 'application/json',
+ }
+ r = requests.put(url, headers=headers, data=body_text)
+ return r.status_code, r.headers['Content-Type'], r.text
+
+
+def delete(url, token):
+ headers = {
+ 'Authorization': 'Bearer {}'.format(token),
+ }
+ r = requests.delete(url, headers=headers)
+ return r.status_code, r.headers['Content-Type'], r.text