From 6299228754893813341085d99c3924f7fefe1c18 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sun, 6 Aug 2017 11:46:02 +0300 Subject: Add: ControllerAPI, ControllerState --- yarns/000.yarn | 11 ++++ yarns/100-projects.yarn | 132 +++++++++++++++++++++++++++++++++++++++++++ yarns/900-implements.yarn | 140 ++++++++++++++++++++++++++++++++++++++++++++++ yarns/lib.py | 94 +++++++++++++++++++++++++++++++ 4 files changed, 377 insertions(+) create mode 100644 yarns/000.yarn create mode 100644 yarns/100-projects.yarn create mode 100644 yarns/900-implements.yarn create mode 100644 yarns/lib.py (limited to 'yarns') 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 -- cgit v1.2.1