Skip to content
This repository was archived by the owner on Dec 1, 2018. It is now read-only.
Open
7 changes: 4 additions & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
language: python
python:
- "2.7"
- "3.3"
- "3.4"
- "3.5"
- "3.6"
install:
- pip install tox coveralls
- pip install --upgrade pip # required for py34 at least
- pip install tox tox-travis coveralls
script:
- tox -e py${TRAVIS_PYTHON_VERSION//[.]/}
- tox
after_success:
- coveralls
119 changes: 105 additions & 14 deletions pykube/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,23 @@ def retry(send_kwargs):

return retry

def send(self, request, **kwargs):
if "kube_config" in kwargs:
config = kwargs.pop("kube_config")
else:
config = self.kube_config
def _auth_exec_plugins(self, request, config):
plugin = AuthExecPlugin(config)
try:
token = plugin.execute()
except AuthPluginFailed as e:
raise ImportError("Unable to retrive auth bearer token from exec plugin: {}".format(str(e)))

_retry_attempt = kwargs.pop("_retry_attempt", 0)
retry_func = None
return self._auth_token(request, token)

# setup cluster API authentication
def _auth_token(self, request, token):
request.headers["Authorization"] = "Bearer {}".format(token)
return request

def _setup_auth(self, request, config):
retry_func = None
if "token" in config.user and config.user["token"]:
request.headers["Authorization"] = "Bearer {}".format(config.user["token"])
request = self._auth_token(request, config.user["token"])
elif "auth-provider" in config.user:
auth_provider = config.user["auth-provider"]
if auth_provider.get("name") == "gcp":
Expand Down Expand Up @@ -104,17 +108,32 @@ def send(self, request, **kwargs):
auth_config.get("expiry"),
config,
)
# @@@ support oidc
elif "client-certificate" in config.user:
elif config.user.get("username") and config.user.get("password"):
request.prepare_auth((config.user["username"], config.user["password"]))
elif config.user.get("exec") and "command" in config.user.get("exec"):
request = self._auth_exec_plugins(request, config)

return request, retry_func

def send(self, request, **kwargs):
if "kube_config" in kwargs:
config = kwargs.pop("kube_config")
else:
config = self.kube_config

_retry_attempt = kwargs.pop("_retry_attempt", 0)
retry_func = None

# setup cluster API authentication
request, retry_func = self._setup_auth(request, config)
# @@@ support oidc
if "client-certificate" in config.user:
kwargs["cert"] = (
config.user["client-certificate"].filename(),
config.user["client-key"].filename(),
)
elif config.user.get("username") and config.user.get("password"):
request.prepare_auth((config.user["username"], config.user["password"]))

# setup certificate verification

if "certificate-authority" in config.cluster:
kwargs["verify"] = config.cluster["certificate-authority"].filename()
elif "insecure-skip-tls-verify" in config.cluster:
Expand Down Expand Up @@ -320,3 +339,75 @@ def delete(self, *args, **kwargs):
- `kwargs`: Keyword arguments
"""
return self.session.delete(*args, **self.get_kwargs(**kwargs))


class AuthPluginFailed(Exception):
def __init__(self, command, message):
super(AuthPluginFailed, self).__init__(
"Exec plugin failed: '{}' - {}".format(command, message)
)


class AuthPluginExecFailed(AuthPluginFailed):
def __init__(self, command, message):
super(AuthPluginExecFailed, self).__init__(command, 'subprocess error: {}'.format(message))


class AuthPluginParsingFailed(AuthPluginFailed):
def __init__(self, command, message):
super(AuthPluginParsingFailed, self).__init__(command, 'parsing failed: {}'.format(message))


class AuthPluginVersionFailed(AuthPluginFailed):
def __init__(self, command, message):
super(AuthPluginVersionFailed, self).__init__(command, 'api version mismatch: {}'.format(message))


class AuthExecPlugin(object):
def __init__(self, config):
self._exec = config.user['exec']
self.command = self.build_command()

def build_command(self):
cmd = [self._exec['command']]
if 'args' in self._exec:
cmd += self._exec['args']
return cmd

def parse(self, output):
try:
parsed = json.loads(output)
except ValueError as e:
raise AuthPluginParsingFailed(self.command, str(e))

try:
if parsed["kind"] != 'ExecCredential':
raise AuthPluginParsingFailed(
self.command,
"expected 'kind' to be 'ExecCredential', was: {}".format(parsed["kind"])
)
except KeyError as e:
raise AuthPluginParsingFailed(self.command, str(e))

try:
if parsed['apiVersion'] != self._exec['apiVersion']:
raise AuthPluginVersionFailed(
self.command,
"expected: '{}', got: '{}'".format(self._exec['apiVersion'], parsed['apiVersion'])
)
except KeyError as e:
raise AuthPluginParsingFailed(self.command, str(e))
return parsed

def execute(self):
try:
output = subprocess.check_output(self.command)
except Exception as e:
raise AuthPluginExecFailed(self.command, str(e))
parsed = self.parse(output)
if 'token' not in parsed['status']:
raise AuthPluginFailed(
self.command,
"token not found in auth plugin response. pykube only supports plugins supplying bearer tokens."
)
return parsed["status"]["token"]
29 changes: 29 additions & 0 deletions test/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,32 @@ class TestCase(unittest.TestCase):
Parent class for all unittests.
"""
pass


BASE_CONFIG = {
"clusters": [
{
"name": "test-cluster",
"cluster": {
"server": "http://localhost:8080",
}
}
],
"contexts": [
{
"name": "test-cluster",
"context": {
"cluster": "test-cluster",
"user": "test-user",
}
}
],
"users": [
{
'name': 'test-user',
'user': {},
}
],
"current-context": "test-cluster",
}

1 change: 1 addition & 0 deletions test/fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
AUTHPLUGIN_FIXTURE = '{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1alpha1","spec":{},"status":{"token":"test"}}'
85 changes: 85 additions & 0 deletions test/test_authexec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from copy import deepcopy

import json
import mock

import pykube
from pykube.http import (
AuthPluginFailed,
AuthPluginExecFailed,
AuthPluginParsingFailed,
AuthPluginVersionFailed,
)

from . import BASE_CONFIG, TestCase
from .fixtures import AUTHPLUGIN_FIXTURE


class TestAuthExecPlugin(TestCase):
def setUp(self):
cfg = deepcopy(BASE_CONFIG)
cfg.update({
'users': [
{
'name': 'test-user',
'user': {
'exec': {
'command': 'heptio-authenticator-aws',
'args': [
"token",
"-i",
"test-pykube-mock-eks-cluster"
],
'apiVersion': 'client.authentication.k8s.io/v1alpha1',
},
},
},
]
})
self.config = pykube.KubeConfig(doc=cfg)

def test_builds_command(self):
plugin = pykube.http.AuthExecPlugin(self.config)
self.assertEquals(plugin.command, ["heptio-authenticator-aws", "token", "-i", "test-pykube-mock-eks-cluster"])

def test_parse_raises_parsing_failed_on_invalid_json(self):
plugin = pykube.http.AuthExecPlugin(self.config)
invalid_json_output = "This is not json, just some string."
with self.assertRaises(AuthPluginParsingFailed):
plugin.parse(invalid_json_output)

def test_parse_raises_parsing_failed_on_invalid_kind(self):
plugin = pykube.http.AuthExecPlugin(self.config)
invalid_kind_output = '{"kind": "something else entirely"}'
with self.assertRaises(AuthPluginParsingFailed):
plugin.parse(invalid_kind_output)

def test_parse_raises_version_failed_if_not_expected_version(self):
plugin = pykube.http.AuthExecPlugin(self.config)
output = json.loads(AUTHPLUGIN_FIXTURE)
output['apiVersion'] = 'this is not the expected api version'
with self.assertRaises(AuthPluginVersionFailed):
plugin.parse(json.dumps(output))

def test_parse_raises_parsing_failed_if_no_api_version_in_output(self):
plugin = pykube.http.AuthExecPlugin(self.config)
output = json.loads(AUTHPLUGIN_FIXTURE)
del output['apiVersion']
with self.assertRaises(AuthPluginParsingFailed):
plugin.parse(json.dumps(output))

def test_execute_raises_exec_failed_if_subprocess_raises_exception(self):
plugin = pykube.http.AuthExecPlugin(self.config)
with mock.patch('pykube.http.subprocess') as mock_subprocess:
mock_subprocess.check_output = mock.Mock(side_effect=Exception)
with self.assertRaises(AuthPluginExecFailed):
plugin.execute()

def test_execute_raises_generic_failed_if_no_token_in_parsed_output(self):
plugin = pykube.http.AuthExecPlugin(self.config)
output = json.loads(AUTHPLUGIN_FIXTURE)
del output['status']['token']
with mock.patch('pykube.http.subprocess') as mock_subprocess:
mock_subprocess.check_output = mock.Mock(return_value=json.dumps(output))
with self.assertRaises(AuthPluginFailed):
plugin.execute()
6 changes: 5 additions & 1 deletion test/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
pykube.http unittests
"""

import mock
import os

from pykube.http import HTTPClient
Expand All @@ -24,4 +25,7 @@ def test_build_session_basic(self):
"""
"""
session = HTTPClient(self.cfg).session
self.assertEqual(session.auth, ('adm', 'somepassword'))
for adapter in ('https://', 'http://'):
adapter = session.adapters[adapter]
request, _ = adapter._setup_auth(mock.Mock(), self.cfg)
request.prepare_auth.assert_called_with(('adm', 'somepassword'))
76 changes: 45 additions & 31 deletions test/test_httpclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,12 @@
import copy
import logging

import mock
import pykube

from . import TestCase
from . import BASE_CONFIG, TestCase
from .fixtures import AUTHPLUGIN_FIXTURE

BASE_CONFIG = {
"clusters": [
{
"name": "test-cluster",
"cluster": {
"server": "http://localhost:8080",
}
}
],
"contexts": [
{
"name": "test-cluster",
"context": {
"cluster": "test-cluster",
"user": "test-user",
}
}
],
"users": [
{
'name': 'test-user',
'user': {},
}
],
"current-context": "test-cluster",
}

_log = logging.getLogger(__name__)

Expand Down Expand Up @@ -102,6 +78,13 @@ def test_no_auth_with_no_user(self):
client = pykube.HTTPClient(pykube.KubeConfig(doc=config))
self.ensure_no_auth(client)


class TestHTTPAdapterSendMixin(TestCase):
def setUp(self):
self.config = copy.deepcopy(BASE_CONFIG)
self.request = mock.Mock()
self.request.headers = {}

def test_build_session_bearer_token(self):
"""Test that HTTPClient correctly parses the token
"""
Expand All @@ -117,7 +100,38 @@ def test_build_session_bearer_token(self):
})
_log.info('Built config: %s', self.config)

client = pykube.HTTPClient(pykube.KubeConfig(doc=self.config))
_log.debug('Checking headers %s', client.session.headers)
self.assertIn('Authorization', client.session.headers)
self.assertEqual(client.session.headers['Authorization'], 'Bearer test')
adapter = pykube.http.KubernetesHTTPAdapterSendMixin()
request, _ = adapter._setup_auth(self.request, pykube.KubeConfig(doc=self.config))
_log.debug('Checking headers %s', request.headers)
self.assertIn('Authorization', request.headers)
self.assertEqual(request.headers['Authorization'], 'Bearer test')

def test_exec_plugin_auth(self):
self.config.update({
'users': [
{
'name': 'test-user',
'user': {
'exec': {
'command': 'heptio-authenticator-aws',
'apiVersion': 'client.authentication.k8s.io/v1alpha1',
'args': [
"token",
"-i",
"test-pykube-mock-eks-cluster"
],
},
},
},
]
})
_log.info('Built config: %s', self.config)
with mock.patch('pykube.http.subprocess') as mock_subprocess:
mock_subprocess.check_output = mock.Mock(return_value=AUTHPLUGIN_FIXTURE)
adapter = pykube.http.KubernetesHTTPAdapterSendMixin()
request, _ = adapter._setup_auth(self.request, pykube.KubeConfig(doc=self.config))
_log.debug('Checking headers %s', request.headers)
self.assertTrue(mock_subprocess.check_output.called)
self.assertIn('Authorization', request.headers)
self.assertEqual(request.headers['Authorization'], 'Bearer test')

Loading