diff options
author | Lars Wirzenius <liw@liw.fi> | 2018-04-07 17:09:54 +0300 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2018-04-07 17:09:54 +0300 |
commit | df115d5300c8ea64df634c5e06f091667121c8ae (patch) | |
tree | e7d5a0b5b614852611a74a8324f17f1eceb40a43 | |
parent | 5aeef33219103eb5f39bfc0a79ed462f46a12420 (diff) | |
parent | 508835b5db20b545d71337d30dfcddc6a71ce737 (diff) | |
download | ick2-df115d5300c8ea64df634c5e06f091667121c8ae.tar.gz |
Merge: get access tokens from authentication server
-rw-r--r-- | NEWS | 10 | ||||
-rw-r--r-- | ick2/__init__.py | 1 | ||||
-rw-r--r-- | ick2/client.py | 96 | ||||
-rw-r--r-- | ick2/client_tests.py | 98 | ||||
-rwxr-xr-x | icktool | 559 | ||||
-rwxr-xr-x | icktool2 | 210 | ||||
-rw-r--r-- | pylint.conf | 1 | ||||
-rwxr-xr-x | run-debug | 2 | ||||
-rwxr-xr-x | worker_manager | 97 |
9 files changed, 498 insertions, 576 deletions
@@ -20,6 +20,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. Version 0.34+git, not yet released ---------------------------------- +* Controller now knows the URL to the identity provider's + authentication endpoint. This is reported via `/version`, which now + doesn't require authentication. + +* Worker-manager and `icktool` now fetch an access token from the IDP, + instead of generating token themselves. + +* `icktool` has been substantcially rewritten, with much loss of + functionality. Lost functionality will be added back as the loss is + noticed by users. Version 0.34, released 2018-04-05 ---------------------------------- diff --git a/ick2/__init__.py b/ick2/__init__.py index 8673046..1f6b514 100644 --- a/ick2/__init__.py +++ b/ick2/__init__.py @@ -57,6 +57,7 @@ from .client import ( HttpError, ControllerClient, BlobClient, + AuthClient, Reporter, ) from .actionenvs import ( diff --git a/ick2/client.py b/ick2/client.py index 8bcf45b..ec2a113 100644 --- a/ick2/client.py +++ b/ick2/client.py @@ -13,9 +13,11 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. +import base64 import json import logging import time +import urllib import requests @@ -64,18 +66,32 @@ class HttpAPI: self._send_request(self._session.post, url, headers=headers, body=body) return None + def post_auth(self, url, headers=None, body=None, auth=None): + assert auth is not None + if headers is None: + headers = {} + headers['Authorization'] = self._basic_auth(auth) + return self._send_request( + self._session.post, url, headers=headers, body=body, auth=auth) + + def _basic_auth(self, auth): + username, password = auth + cleartext = '{}:{}'.format(username, password).encode('UTF-8') + encoded = base64.b64encode(cleartext) + return 'Basic {}'.format(encoded.decode('UTF-8')) + def put(self, url, headers=None, body=None): self._send_request(self._session.put, url, headers=headers, body=body) return None - def _send_request(self, func, url, headers=None, body=None): + def _send_request(self, func, url, headers=None, body=None, auth=None): if headers is None: headers = {} headers = dict(headers) - h, body = self._get_content_type_header(body) - headers.update(h) - self._request(func, url, headers=headers, data=body) - return None + if not headers.get('Content-Type'): + h, body = self._get_content_type_header(body) + headers.update(h) + return self._request(func, url, headers=headers, data=body, auth=auth) def _get_content_type_header(self, body): if isinstance(body, dict): @@ -94,7 +110,12 @@ class HttpAPI: def _request(self, func, url, headers=None, **kwargs): if headers is None: headers = {} - headers.update(self._get_authorization_headers()) + + auth = kwargs.get('auth') + if auth is None: + headers.update(self._get_authorization_headers()) + if 'auth' in kwargs: + del kwargs['auth'] r = func(url, headers=headers, verify=self._verify, **kwargs) if not r.ok: @@ -108,6 +129,7 @@ class ControllerClient: self._name = None self._api = HttpAPI() self._url = None + self._auth_url = None def set_client_name(self, name): self._name = name @@ -127,13 +149,29 @@ class ControllerClient: def url(self, path): return '{}{}'.format(self._url, path) - def get_artifact_store_url(self): + def get_version(self): url = self.url('/version') - version = self._api.get_dict(url) + return self._api.get_dict(url) + + def get_artifact_store_url(self): + version = self.get_version() url = version.get('artifact_store') logging.info('Artifact store URL: %r', url) return url + def get_auth_url(self): + version = self.get_version() + url = version.get('auth_url') + logging.info('Authentication URL: %r', url) + return url + + def get_auth_client(self): + url = self.get_auth_url() + ac = AuthClient() + ac.set_auth_url(url) + ac.set_http_api(self._api) + return ac + def get_blob_client(self): url = self.get_artifact_store_url() blobs = BlobClient() @@ -168,6 +206,48 @@ class ControllerClient: body = json.dumps(work) self._api.post(url, headers=headers, body=body) + def show(self, path): # pragma: no cover + url = self.url(path) + return self._api.get_dict(url) + + def create(self, path, obj): # pragma: no cover + url = self.url(path) + return self._api.post(url, body=obj) + + +class AuthClient: + + def __init__(self): + self._auth_url = None + self._http_api = HttpAPI() + self._client_id = None + self._client_secret = None + + def set_auth_url(self, url): + self._auth_url = url + + def set_http_api(self, api): + self._http_api = api + + def set_client_creds(self, client_id, client_secret): + self._client_id = client_id + self._client_secret = client_secret + + def get_token(self, scope): + auth = (self._client_id, self._client_secret) + params = { + 'grant_type': 'client_credentials', + 'scope': scope, + } + body = urllib.parse.urlencode(params) + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + r = self._http_api.post_auth( + self._auth_url, headers=headers, body=body, auth=auth) + obj = r.json() + return obj['access_token'] + class Reporter: # pragma: no cover diff --git a/ick2/client_tests.py b/ick2/client_tests.py index 92da36a..f164e6a 100644 --- a/ick2/client_tests.py +++ b/ick2/client_tests.py @@ -97,6 +97,30 @@ class HttpAPITests(unittest.TestCase): obj = self.client.put('http://controller/work', body=blob) self.assertEqual(obj, None) + def test_post_auth_does_basic_auth(self): + token = 'this is a token' + scope = 'this and that' + response = { + 'access_token': token, + 'token_type': 'bearer', + 'scope': scope, + } + self.session.response = FakeResponse(200, body=response) + + client_id = 'this-is--my-client' + client_secret = '*****' + auth = (client_id, client_secret) + body = 'foo=bar' + self.client.post_auth( + 'http://auth.example.com/token', auth=auth, body=body) + + self.assertEqual(self.session.auth, auth) + + authz = self.session.headers['Authorization'] + self.assertTrue(authz.startswith('Basic ')) + + self.assertEqual(self.session.body, body) + class ControllerClientTests(unittest.TestCase): @@ -164,6 +188,70 @@ class ControllerClientTests(unittest.TestCase): 200, body=json.dumps(version), content_type=json_type) self.assertEqual(self.controller.get_artifact_store_url(), url) + def test_get_auth_url_raises_exception_on_error(self): + self.session.response = FakeResponse(400) + with self.assertRaises(ick2.HttpError): + self.controller.get_auth_url() + + def test_get_auth_url_succeeds(self): + url = 'https://blobs' + version = { + 'auth_url': url, + } + self.session.response = FakeResponse( + 200, body=json.dumps(version), content_type=json_type) + self.assertEqual(self.controller.get_auth_url(), url) + + def test_get_auth_client_returns_object(self): + url = 'https://blobs' + version = { + 'auth_url': url, + } + self.session.response = FakeResponse( + 200, body=json.dumps(version), content_type=json_type) + ac = self.controller.get_auth_client() + self.assertTrue(isinstance(ac, ick2.AuthClient)) + + +class AuthClientTests(unittest.TestCase): + + def setUp(self): + self.session = FakeHttpSession() + + self.client = ick2.HttpAPI() + self.client.set_session(self.session) + + def test_raises_exception_on_error(self): + self.session.response = FakeResponse(400) + + url = 'https://auth.example.com' + client_id = 'test-client' + client_secret = 'hunter2' + ac = ick2.AuthClient() + ac.set_auth_url(url) + ac.set_http_api(self.client) + ac.set_client_creds(client_id, client_secret) + with self.assertRaises(ick2.HttpError): + ac.get_token('') + + def test_returns_token(self): + token = 'this-is-my-token' + token_response = { + 'access_token': token, + } + + self.session.response = FakeResponse( + 200, body=json.dumps(token_response), content_type=json_type) + + url = 'https://auth.example.com' + client_id = 'test-client' + client_secret = 'hunter2' + ac = ick2.AuthClient() + ac.set_auth_url(url) + ac.set_http_api(self.client) + ac.set_client_creds(client_id, client_secret) + self.assertEqual(ac.get_token(''), token) + class BlobServiceClientTests(unittest.TestCase): @@ -231,15 +319,21 @@ class FakeHttpSession: def __init__(self): self.response = None self.token = None + self.auth = None + self.headers = None + self.body = None def get(self, url, headers=None, verify=None): assert self.response is not None assert self.is_authorized(headers) return self.response - def post(self, url, headers=None, data=None, verify=None): + def post(self, url, headers=None, data=None, verify=None, auth=None): assert self.response is not None - assert self.is_authorized(headers) + assert auth is not None or self.is_authorized(headers) + self.auth = auth + self.headers = headers + self.body = data return self.response def put(self, url, headers=None, data=None, verify=None): @@ -15,15 +15,12 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. +import configparser import json import logging import sys -import time -import apifw import cliapp -import Crypto.PublicKey.RSA -import requests import yaml import ick2 @@ -75,6 +72,18 @@ class Icktool(cliapp.Application): metavar='URL', ) + self.settings.string( + ['auth-url'], + 'use URL as the authentication URL', + metavar='URL', + ) + + self.settings.string( + ['secrets'], + 'use URL as the controller base URL', + metavar='URL', + ) + self.settings.boolean( ['verify-tls'], 'verify API provider TLS certificate ' @@ -88,15 +97,9 @@ class Icktool(cliapp.Application): metavar='TOKEN', ) - self.settings.string( - ['token-private-key-cmd'], - 'run CMD to print private key for token signing', - metavar='CMD', - ) - self.settings.string_list( ['scope'], - 'add SCOPE to the list of scope in generated token', + 'add SCOPE to the list of scope in requested token', metavar='SCOPE', default=self._default_scopes, ) @@ -107,507 +110,97 @@ class Icktool(cliapp.Application): def cmd_scopes(self, args): for scope in self.settings['scope']: - sys.stdout.write('{}\n'.format(scope)) + self.output.write('{}\n'.format(scope)) def cmd_token(self, args): token = self._new_token() - sys.stdout.write(token) + self.output.write('{}\n'.format(token)) def cmd_version(self, args): + token = self._new_token() api = self._new_api() + api.set_token(token) version = api.get_version() - if not version: - sys.exit(1) self._prettyson(version) - def cmd_status(self, args): - rows = [] - projects = self._get_projects() - project_objs = sorted( - projects['projects'], key=lambda p: p.get('project')) - builds = self._get_builds()['builds'] - for project in project_objs: - pipelines = sorted(project['pipelines']) - for pipeline in pipelines: - build = self._get_latest_build( - project['project'], pipeline, builds) - if build is None: - build = { - 'build_id': 'never', - 'log': 'none', - 'status': 'n/a', - } - status = self._get_pipeline_status( - project['project'], pipeline) - row = { - 'project': project['project'], - 'pipeline': pipeline, - 'build_id': build['build_id'], - 'status': status['status'], - 'build_status': build['status'], - 'log': build['log'], - } - rows.append(row) - self._pretty_table( - rows, ['project', 'pipeline', 'status', 'build_status', 'log']) - - def _get_projects(self): - rc = self._new_rc('/projects', 'project') - projects = rc.list() - return projects - - def _get_builds(self): - rc = self._new_rc('/builds', 'build') - return rc.list() - - def _get_latest_build(self, project, pipeline, builds): - latest = None - for build in builds: - if (build['project'], build['pipeline']) == (project, pipeline): - latest = build - return latest - - def cmd_make_it_so(self, args): - obj = self._read_object() - - projects = self._new_rc('/projects', 'project') - self._make_it_so(projects, obj.get('projects', [])) - - pipelines = self._new_rc('/pipelines', 'pipeline') - self._make_it_so(pipelines, obj.get('pipelines', [])) - - def _make_it_so(self, rc, objs): - for obj in objs: - if rc.exists(obj): - rc.update(obj) - else: - rc.create(obj) - - def cmd_list_projects(self, args): - self._prettyson(self._get_projects()) - - def cmd_create_project(self, args): - rc = self._new_rc('/projects', 'project') - obj = self._read_object() - rc.create(obj) - - def cmd_update_project(self, args): - rc = self._new_rc('/projects', 'project') + def cmd_make_it_so(self, argv): obj = self._read_object() - rc.update(obj) - def cmd_show_project(self, args): - rc = self._new_rc('/projects', 'project') - name = args[0] - self._prettyson(rc.show(name)) - - def cmd_delete_project(self, args): - rc = self._new_rc('/projects', 'project') - name = args[0] - rc.delete(name) - - def cmd_list_pipelines(self, args): - rc = self._new_rc('/pipelines', 'pipeline') - self._prettyson(rc.list()) - - def cmd_create_pipeline(self, args): - rc = self._new_rc('/pipelines', 'pipeline') - obj = self._read_object() - rc.create(obj) - - def cmd_update_pipeline(self, args): - rc = self._new_rc('/pipelines', 'pipeline') - obj = self._read_object() - rc.update(obj) + token = self._new_token() + api = self._new_api() + api.set_token(token) - def cmd_show_pipeline(self, args): - rc = self._new_rc('/pipelines', 'pipeline') - name = args[0] - self._prettyson(rc.show(name)) + self._create_resources(api, '/projects', obj.get('projects', [])) + self._create_resources(api, '/pipelines', obj.get('pipelines', [])) - def cmd_delete_pipeline(self, args): - rc = self._new_rc('/pipelines', 'pipeline') - name = args[0] - rc.delete(name) + def _read_object(self): + return yaml.load(sys.stdin) - def cmd_show_pipeline_status(self, args): - self._prettyson(self._get_pipeline_status(args[0], args[1])) + def _create_resources(self, api, path, objs): + for obj in objs: + api.create(path, obj) - def _get_pipeline_status(self, project, pipeline): - path = '/projects/{}/pipelines/{}'.format(project, pipeline) - api = self._new_api() - code, text = api.get(path) - self._report(code, 200, text) - return json.loads(text) - - def _report(self, code, expected, text): - if code != expected: - sys.stderr.write('HTTP status {}\n'.format(code)) - sys.stderr.write(text) - if not text.endswith('\n'): - sys.stderr.write('\n') - sys.exit(1) - - def cmd_set_pipeline(self, args): - project = args[0] - pipeline = args[1] - state = args[2] - path = '/projects/{}/pipelines/{}'.format(project, pipeline) - api = self._new_api() - code, text = api.put(path, {'status': state}) - self._report(code, 200, text) - obj = json.loads(text) - self._prettyson(obj) - - def cmd_trigger(self, args): - project = args[0] - pipeline = args[1] - path = '/projects/{}/pipelines/{}/+trigger'.format(project, pipeline) + def cmd_show(self, args): + token = self._new_token() api = self._new_api() - code, text = api.get(path) - self._report(code, 200, text) - obj = json.loads(text) - self._prettyson(obj) - - def cmd_list_workers(self, args): - rc = self._new_rc('/workers', 'worker') - self._prettyson(rc.list()) + api.set_token(token) + if not args: + args = [ + 'projects', + 'pipelines', + ] - def cmd_create_worker(self, args): - rc = self._new_rc('/workers', 'worker') - obj = self._read_object() - rc.create(obj) + for kind in args: + objs = api.show('/' + kind) + self._prettyson(objs) - def cmd_update_worker(self, args): - rc = self._new_rc('/workers', 'worker') - obj = self._read_object() - rc.update(obj) - - def cmd_show_worker(self, args): - rc = self._new_rc('/workers', 'worker') - name = args[0] - self._prettyson(rc.show(name)) + def _new_api(self): + api = ick2.ControllerClient() + api.set_verify_tls(self.settings['verify-tls']) + api.set_controller_url(self.settings['controller']) + return api - def cmd_delete_worker(self, args): - rc = self._new_rc('/workers', 'worker') - name = args[0] - rc.delete(name) + def _new_auth(self): + url = self.settings['auth-url'] + client_id, client_secret = self._get_client_creds(url) - def cmd_list_builds(self, args): - self._prettyson(self._get_builds()) + ac = ick2.AuthClient() + ac.set_auth_url(url) + ac.set_client_creds(client_id, client_secret) + return ac - def cmd_list_logs(self, args): - rc = self._new_rc('/logs', 'log') - self._prettyson(rc.list()) + def _new_token(self): + if self.settings['token']: + return self.settings['token'] + ac = self._new_auth() + wanted_scopes = ' '.join(self.settings['scope']) + return ac.get_token(wanted_scopes) + + def _get_client_creds(self, url): + cp = configparser.ConfigParser() + cp.read(self.settings['secrets']) + client_id = cp.get(url, 'client_id') + client_secret = cp.get(url, 'client_secret') + return client_id, client_secret - def cmd_show_log(self, args): - log = args[0] - path = '/logs/{}'.format(log) - api = self._new_api() - code, text = api.get(path) - self._report(code, 200, text) - self.output.write(text) - - def cmd_show_latest_log(self, args): - project = args[0] - builds = self._get_builds() - project_builds = [ - b - for b in builds['builds'] - if b['project'] == project - ] - if project_builds: - b = project_builds[-1] - self.cmd_show_log([b['build_id']]) - - def cmd_get_blob(self, args): - blob_id = args[0] - api = self._new_api() - blob_api = self._new_blob_api(api) - status_code, blob = blob_api.get(blob_id) - if status_code == 200: - filename = self.settings['output'] - with open(filename, 'wb') as f: - f.write(blob) - else: - sys.exit('Error: {}'.format(status_code)) - - def cmd_put_blob(self, args): - blob_id = args[0] - blob = sys.stdin.read() - api = self._new_api() - blob_api = self._new_blob_api(api) - code, text = blob_api.put(blob_id, blob) - if code != 200: - sys.exit(text) + def _prettyson(self, obj): + json.dump(obj, self.output, indent=4, sort_keys=True) + self.output.write('\n') - def _new_token(self): - wanted_scopes = self.settings['scope'] - cmd = self.settings['token-private-key-cmd'] - if not cmd: - raise cliapp.AppException('no --token-private-cmd specified') - - gen = TokenGenerator() - gen.set_cmd(cmd) - gen.set_scopes(wanted_scopes) - token = gen.new_token() - self.settings['token'] = token - return token - def _new_api(self): - token = self.settings['token'] or self._new_token() - api = API() - api.set_token(token) - api.set_url(self.settings['controller']) - api.set_verify(self.settings['verify-tls']) - return api +class Command: - def _new_blob_api(self, api): - token = self.settings['token'] or self._new_token() - blob_api = BlobAPI() - blob_api.set_token(token) - blob_api.set_url(api.get_artifact_store_url()) - blob_api.set_verify(self.settings['verify-tls']) - return blob_api + def __init__(self, api): + self._api = api - def _new_rc(self, path, field_name): - api = self._new_api() - return ResourceCommands(path, api, field_name) + def execute(self): + raise NotImplementedError() - def _prettyson(self, obj): - json.dump(obj, sys.stdout, indent=4, sort_keys=True) - sys.stdout.write('\n') - def _read_object(self): - return yaml.load(sys.stdin) +class VersionCommand(Command): - def _pretty_table(self, rows, columns): - headings = { - column: column - for column in columns - } - - widths = { - column: 0 - for column in columns - } - - for row in [headings] + rows: - for column in columns: - widths[column] = max(widths[column], len(str(row[column]))) - - underlines = { - column: '-' * widths[column] - for column in columns - } - - for row in [headings, underlines] + rows: - self.output.write( - '{}\n'.format(self._pretty_row(widths, row, columns))) - - def _pretty_row(self, widths, row, columns): - parts = ['%*s' % (widths[c], row[c]) for c in columns] - return ' | '.join(parts) - - -class API: - - def __init__(self): - self._url = None - self._token = None - self._verify = None - - def set_url(self, url): - self._url = url - - def set_token(self, token): - self._token = token - - def set_verify(self, verify): - self._verify = verify - - def get_version(self): - code, text = self.get('/version') - if code == 200: - return json.loads(text) - - def get_artifact_store_url(self): - version = self.get_version() - if version: - return version.get('artifact_store') - - def get(self, path): - assert self._url is not None - assert self._token is not None - - full_url = '{}/{}'.format(self._url, path) - headers = { - 'Authorization': 'Bearer {}'.format(self._token), - } - r = requests.get(full_url, headers=headers, verify=self._verify) - return r.status_code, r.text - - def post(self, path, obj): - assert self._url is not None - assert self._token is not None - - full_url = '{}{}'.format(self._url, path) - headers = { - 'Authorization': 'Bearer {}'.format(self._token), - } - r = requests.post( - full_url, json=obj, headers=headers, verify=self._verify) - return r.status_code, r.text - - def put(self, path, obj): - assert self._url is not None - assert self._token is not None - - full_url = '{}{}'.format(self._url, path) - headers = { - 'Authorization': 'Bearer {}'.format(self._token), - } - r = requests.put( - full_url, json=obj, headers=headers, verify=self._verify) - return r.status_code, r.text - - def delete(self, path): - assert self._url is not None - assert self._token is not None - - full_url = '{}{}'.format(self._url, path) - headers = { - 'Authorization': 'Bearer {}'.format(self._token), - } - r = requests.delete( - full_url, headers=headers, verify=self._verify) - return r.status_code, r.text - - -class BlobAPI: - - def __init__(self): - self._url = None - self._token = None - self._verify = None - - def set_url(self, url): - self._url = url - - def set_token(self, token): - self._token = token - - def set_verify(self, verify): - self._verify = verify - - def get(self, blob_id): - assert self._url is not None - assert self._token is not None - - full_url = '{}/blobs/{}'.format(self._url, blob_id) - headers = { - 'Authorization': 'Bearer {}'.format(self._token), - } - r = requests.get(full_url, headers=headers, verify=self._verify) - print('blob length', len(r.content)) - return r.status_code, r.content - - def put(self, blob_id, blob): - assert self._url is not None - assert self._token is not None - - full_url = '{}/blobs/{}'.format(self._url, blob_id) - headers = { - 'Authorization': 'Bearer {}'.format(self._token), - 'Content-Type': 'application/octet-stream', - } - r = requests.put( - full_url, data=blob, headers=headers, verify=self._verify) - return r.status_code, r.text - - -class ResourceCommands: - - def __init__(self, path, api, name_field): - self._path = path - self._api = api - self._name = name_field - - def list(self): - code, text = self._api.get(self._path) - self._report(code, 200, text) - return json.loads(text) - - def create(self, obj): - code, text = self._api.post(self._path, obj) - self._report(code, 201, text) - - def update(self, obj): - code, text = self._api.put(self._id_path(obj[self._name]), obj) - self._report(code, 200, text) - - def show(self, name): - code, text = self._api.get(self._id_path(name)) - self._report(code, 200, text) - return json.loads(text) - - def exists(self, obj): - code, text = self._api.get(self._id_path(obj[self._name])) - return code == 200 - - def delete(self, name): - code, text = self._api.delete(self._id_path(name)) - self._report(code, 200, text) - - def _id_path(self, name): - return '{}/{}'.format(self._path, name) - - def _report(self, code, expected, text): - if code != expected: - sys.stderr.write('HTTP status {}\n'.format(code)) - sys.stderr.write(text) - if not text.endswith('\n'): - sys.stderr.write('\n') - sys.exit(1) - - -class TokenGenerator: - - def __init__(self): - self._cmd = None - self._scopes = None - - def set_cmd(self, cmd): - self._cmd = cmd - - def set_scopes(self, wanted_scopes): - self._scopes = wanted_scopes - - def new_token(self): - assert self._cmd is not None - assert self._scopes is not None - - # These should agree with how ick controller is configured. - # See the Ansible playbook. They should probably be - # configurable. - iss = 'localhost' - aud = 'localhost' - - privkey = cliapp.runcmd(['sh', '-c', self._cmd]) - key = Crypto.PublicKey.RSA.importKey(privkey) - wanted_scopes = ' '.join(self._scopes) - - now = time.time() - claims = { - 'iss': iss, - 'sub': 'subject-uuid', - 'aud': aud, - 'exp': now + 86400, - 'scope': wanted_scopes, - } - - token = apifw.create_token(claims, key) - return token.decode('ascii') + def execute(self): + self._api.get_version() Icktool(version=ick2.__version__).run() diff --git a/icktool2 b/icktool2 new file mode 100755 index 0000000..df5ccdd --- /dev/null +++ b/icktool2 @@ -0,0 +1,210 @@ +#!/usr/bin/python3 +# Copyright 2017-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 <http://www.gnu.org/licenses/>. + + +import configparser +import json +import logging +import sys +import time + +import apifw +import cliapp +import Crypto.PublicKey.RSA +import requests +import yaml + +import ick2 + + +def scopes(base): + patterns = [ + 'uapi_{}_get', + 'uapi_{}_post', + 'uapi_{}_id_get', + 'uapi_{}_id_put', + 'uapi_{}_id_delete', + ] + return [x.format(base) for x in patterns] + + +def scopes_for_types(typelist): + result = [] + for type_name in typelist: + result.extend(scopes(type_name)) + return result + + +types = [ + 'projects', + 'pipelines', + 'workers', + 'work', + 'builds', + 'logs', +] + + +class Icktool(cliapp.Application): + + _default_scopes = [ + 'uapi_version_get', + 'uapi_work_post', + 'uapi_projects_id_pipelines_id_get', + 'uapi_projects_id_pipelines_id_put', + 'uapi_blobs_id_get', + 'uapi_blobs_id_put', + ] + scopes_for_types(types) + + def add_settings(self): + self.settings.string( + ['controller', 'c'], + 'use URL as the controller base URL', + metavar='URL', + ) + + self.settings.string( + ['auth-url'], + 'use URL as the authentication URL', + metavar='URL', + ) + + self.settings.string( + ['secrets'], + 'use URL as the controller base URL', + metavar='URL', + ) + + self.settings.boolean( + ['verify-tls'], + 'verify API provider TLS certificate ' + '(default is verify, use --no-verify-tls)', + default=True, + ) + + self.settings.string( + ['token'], + 'use TOKEN instead of generating a new one', + metavar='TOKEN', + ) + + self.settings.string_list( + ['scope'], + 'add SCOPE to the list of scope in requested token', + metavar='SCOPE', + default=self._default_scopes, + ) + + def setup(self): + if not self.settings['verify-tls']: + logging.captureWarnings(True) + + def cmd_scopes(self, args): + for scope in self.settings['scope']: + self.output.write('{}\n'.format(scope)) + + def cmd_token(self, args): + token = self._new_token() + self.output.write('{}\n'.format(token)) + + def cmd_version(self, args): + token = self._new_token() + api = self._new_api() + api.set_token(token) + version = api.get_version() + self._prettyson(version) + + def cmd_make_it_so(self, argv): + obj = self._read_object() + + token = self._new_token() + api = self._new_api() + api.set_token(token) + + self._create_resources(api, '/projects', obj.get('projects', [])) + self._create_resources(api, '/pipelines', obj.get('pipelines', [])) + + def _read_object(self): + return yaml.load(sys.stdin) + + def _create_resources(self, api, path, objs): + for obj in objs: + api.create(path, obj) + + def cmd_show(self, args): + token = self._new_token() + api = self._new_api() + api.set_token(token) + if not args: + args = [ + 'projects', + 'pipelines', + ] + + for kind in args: + objs = api.show('/' + kind) + self._prettyson(objs) + + def _new_api(self): + api = ick2.ControllerClient() + api.set_verify_tls(self.settings['verify-tls']) + api.set_controller_url(self.settings['controller']) + return api + + def _new_auth(self): + url = self.settings['auth-url'] + client_id, client_secret = self._get_client_creds(url) + + ac = ick2.AuthClient() + ac.set_auth_url(url) + ac.set_client_creds(client_id, client_secret) + return ac + + def _new_token(self): + if self.settings['token']: + return self.settings['token'] + ac = self._new_auth() + scopes = ' '.join(self.settings['scope']) + return ac.get_token(scopes) + + def _get_client_creds(self, url): + cp = configparser.ConfigParser() + cp.read(self.settings['secrets']) + client_id = cp.get(url, 'client_id') + client_secret = cp.get(url, 'client_secret') + return client_id, client_secret + + def _prettyson(self, obj): + json.dump(obj, self.output, indent=4, sort_keys=True) + self.output.write('\n') + + +class Command: + + def __init__(self, api): + self._api = api + + def execute(self): + raise NotImplementedError() + + +class VersionCommand(Command): + + def execute(self): + self._api.get_version() + + +Icktool(version=ick2.__version__).run() diff --git a/pylint.conf b/pylint.conf index 8ac3b99..ed09e29 100644 --- a/pylint.conf +++ b/pylint.conf @@ -10,6 +10,7 @@ disable= no-self-use, not-callable, too-few-public-methods, + too-many-arguments, too-many-public-methods, unused-argument, unused-variable @@ -1,5 +1,5 @@ #!/bin/sh -# Copyright 2017 Lars Wirzenius +# Copyright 2017-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 diff --git a/worker_manager b/worker_manager index fa92fa9..6fd556c 100755 --- a/worker_manager +++ b/worker_manager @@ -18,9 +18,7 @@ import logging import time -import apifw import cliapp -import Crypto.PublicKey.RSA import urllib3 import ick2 @@ -34,7 +32,6 @@ class WorkerManager(cliapp.Application): def __init__(self, **kwargs): super().__init__(**kwargs) self._token = None - self._token_until = None def add_settings(self): self.settings.string( @@ -49,18 +46,6 @@ class WorkerManager(cliapp.Application): metavar='URL', ) - self.settings.string( - ['token-key'], - 'get token signing private key from FILE', - metavar='FILE', - ) - - self.settings.string( - ['token-key-pub'], - 'this is not used', - metavar='NOPE', - ) - self.settings.integer( ['sleep'], 'sleep for SECS seconds if there is no work currently', @@ -105,9 +90,7 @@ class WorkerManager(cliapp.Application): workspace = self.settings['workspace'] systree = self.settings['systree'] - tg = TokenGenerator() - tg.set_key(self.settings['token-key']) - api = ControllerAPI(name, url, tg) + api = ControllerAPI(name, url) api.set_verify_tls(self.settings['verify-tls']) worker = Worker(name, api, workspace, systree) @@ -128,18 +111,29 @@ class WorkerManager(cliapp.Application): class ControllerAPI: - def __init__(self, name, url, token_generator): - self._token_generator = token_generator + _scopes = ' '.join([ + 'uapi_version_get', + 'uapi_work_id_get', + 'uapi_work_post', + 'uapi_workers_post', + 'uapi_blobs_id_get', + 'uapi_blobs_id_put', + ]) + + def __init__(self, name, url): self._cc = ick2.ControllerClient() self._cc.set_client_name(name) self._cc.set_controller_url(url) + self._ac = None self._blobs = None def set_verify_tls(self, verify): self._cc.set_verify_tls(verify) def get_token(self): - return self._token_generator.get_token() + if self._ac is None: + self._ac = self._cc.get_auth_client() + return self._ac.get_token(self._scopes) def register(self): self._cc.set_token(self.get_token()) @@ -171,67 +165,6 @@ class ControllerAPI: return self._blobs -class TokenGenerator: - - max_age = 3600 # 1 hour - sub = 'subject-uuid' - iss = 'localhost' - aud = 'localhost' - scopes = ' '.join([ - 'uapi_version_get', - 'uapi_work_id_get', - 'uapi_work_post', - 'uapi_workers_post', - 'uapi_blobs_id_get', - 'uapi_blobs_id_put', - ]) - - def __init__(self): - self._token = None - self._token_key = None - self._token_until = None - - def is_valid(self, now): - return ( - self._token is not None and - (self._token_until is None or now <= self._token_until) - ) - - def set_token(self, token): - self._token = token - self._token_until = None - assert self.is_valid(time.time()) - - def set_key(self, filename): - key_text = self.cat(filename) - self._token_key = Crypto.PublicKey.RSA.importKey(key_text) - - def cat(self, filename): - with open(filename) as f: - return f.read() - - def get_token(self): - now = time.time() - if not self.is_valid(now): - self._token = self.create_token() - self._token_until = now + self.max_age - assert self.is_valid(now) - return self._token - - def create_token(self): - now = time.time() - claims = { - 'iss': self.iss, - 'sub': self.sub, - 'aud': self.aud, - 'exp': now + self.max_age, - 'scope': self.scopes, - } - - token = apifw.create_token(claims, self._token_key) - return token.decode('ascii') - - class Worker: def __init__(self, name, api, workspace, systree): |