Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: porting exec-provider from base library #44

Merged
merged 3 commits into from
Oct 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions kubernetes_asyncio/config/exec_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Copyright 2018 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio.subprocess
import json
import os
import shlex
import sys

from .config_exception import ConfigException


class ExecProvider(object):
"""
Implementation of the proposal for out-of-tree client authentication providers
as described here --
https://github.com/kubernetes/community/blob/master/contributors/design-proposals/auth/kubectl-exec-plugins.md

Missing from implementation:

* TLS cert support
* caching
"""

def __init__(self, exec_config):
for key in ['command', 'apiVersion']:
if key not in exec_config:
raise ConfigException(
'exec: malformed request. missing key \'%s\'' % key)
self.api_version = exec_config['apiVersion']
self.args = [exec_config['command']]
if 'args' in exec_config:
self.args.extend(exec_config['args'])
self.env = os.environ.copy()
if 'env' in exec_config:
additional_vars = {}
for item in exec_config['env']:
name = item['name']
value = item['value']
additional_vars[name] = value
self.env.update(additional_vars)

async def run(self, previous_response=None):
kubernetes_exec_info = {
'apiVersion': self.api_version,
'kind': 'ExecCredential',
'spec': {
'interactive': sys.stdout.isatty()
}
}
if previous_response:
kubernetes_exec_info['spec']['response'] = previous_response
self.env['KUBERNETES_EXEC_INFO'] = json.dumps(kubernetes_exec_info)

cmd = shlex.split(' '.join(self.args))
cmd_exec = asyncio.create_subprocess_exec(*cmd,
env=self.env,
stdin=None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE)
proc = await cmd_exec

stdout = await proc.stdout.read()
stderr = await proc.stderr.read()
exit_code = await proc.wait()

if exit_code != 0:
msg = 'exec: process returned %d' % exit_code
stderr = stderr.strip()
if stderr:
msg += '. %s' % stderr
raise ConfigException(msg)
try:
data = json.loads(stdout)
except ValueError as de:
raise ConfigException(
'exec: failed to decode process output: %s' % de)
for key in ('apiVersion', 'kind', 'status'):
if key not in data:
raise ConfigException(
'exec: malformed response. missing key \'%s\'' % key)
if data['apiVersion'] != self.api_version:
raise ConfigException(
'exec: plugin api version %s does not match %s' %
(data['apiVersion'], self.api_version))
return data['status']
161 changes: 161 additions & 0 deletions kubernetes_asyncio/config/exec_provider_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Copyright 2018 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
import os
import sys

from asynctest import ANY, TestCase, main, mock, patch

from .config_exception import ConfigException
from .exec_provider import ExecProvider


class ExecProviderTest(TestCase):

def setUp(self):
self.input_ok = {
'command': 'aws-iam-authenticator token -i dummy',
'apiVersion': 'client.authentication.k8s.io/v1beta1'
}
self.output_ok = """
{
"apiVersion": "client.authentication.k8s.io/v1beta1",
"kind": "ExecCredential",
"status": {
"token": "dummy"
}
}
"""

process_patch = patch('kubernetes_asyncio.config.exec_provider.asyncio.create_subprocess_exec')
self.exec_mock = process_patch.start()
self.process_mock = self.exec_mock.return_value
self.process_mock.stdout.read = mock.CoroutineMock(return_value=self.output_ok)
self.process_mock.stderr.read = mock.CoroutineMock(return_value='')
self.process_mock.wait = mock.CoroutineMock(return_value=0)

def tearDown(self):
patch.stopall()

def test_missing_input_keys(self):
exec_configs = [{}, {'command': ''}, {'apiVersion': ''}]
for exec_config in exec_configs:
with self.assertRaises(ConfigException) as context:
ExecProvider(exec_config)
self.assertIn('exec: malformed request. missing key',
context.exception.args[0])

async def test_error_code_returned(self):
self.process_mock.stdout.read.return_value = ''
self.process_mock.wait.return_value = 1
with self.assertRaisesRegex(ConfigException, 'exec: process returned 1'):
ep = ExecProvider(self.input_ok)
await ep.run()

async def test_nonjson_output_returned(self):
self.process_mock.stdout.read.return_value = ''
with self.assertRaisesRegex(ConfigException, 'exec: failed to decode process output'):
ep = ExecProvider(self.input_ok)
await ep.run()

async def test_missing_output_keys(self):
outputs = [
"""
{
"kind": "ExecCredential",
"status": {
"token": "dummy"
}
}
""", """
{
"apiVersion": "client.authentication.k8s.io/v1beta1",
"status": {
"token": "dummy"
}
}
""", """
{
"apiVersion": "client.authentication.k8s.io/v1beta1",
"kind": "ExecCredential"
}
"""
]
for output in outputs:
self.process_mock.stdout.read.return_value = output
with self.assertRaisesRegex(ConfigException, 'exec: malformed response. missing key'):
ep = ExecProvider(self.input_ok)
await ep.run()

async def test_mismatched_api_version(self):
wrong_api_version = 'client.authentication.k8s.io/v1'
output = """
{
"apiVersion": "%s",
"kind": "ExecCredential",
"status": {
"token": "dummy"
}
}
""" % wrong_api_version
self.process_mock.stdout.read.return_value = output
with self.assertRaisesRegex(ConfigException, 'exec: plugin api version {} does not match'.format(wrong_api_version)):
ep = ExecProvider(self.input_ok)
await ep.run()

async def test_ok_01(self):
ep = ExecProvider(self.input_ok)
result = await ep.run()
self.assertTrue(isinstance(result, dict))
self.assertTrue('token' in result)
self.exec_mock.assert_called_once_with('aws-iam-authenticator', 'token', '-i', 'dummy',
env=ANY, stderr=-1, stdin=None, stdout=-1)
self.process_mock.stdout.read.assert_awaited_once()
self.process_mock.stderr.read.assert_awaited_once()
self.process_mock.wait.assert_awaited_once()

async def test_ok_with_args(self):
self.input_ok['args'] = ['--mock', '90']
ep = ExecProvider(self.input_ok)
result = await ep.run()
self.assertTrue(isinstance(result, dict))
self.assertTrue('token' in result)
self.exec_mock.assert_called_once_with('aws-iam-authenticator', 'token', '-i', 'dummy', '--mock', '90',
env=ANY, stderr=-1, stdin=None, stdout=-1)
self.process_mock.stdout.read.assert_awaited_once()
self.process_mock.stderr.read.assert_awaited_once()
self.process_mock.wait.assert_awaited_once()

async def test_ok_with_env(self):

self.input_ok['env'] = [{'name': 'EXEC_PROVIDER_ENV_NAME',
'value': 'EXEC_PROVIDER_ENV_VALUE'}]

ep = ExecProvider(self.input_ok)
result = await ep.run()
self.assertTrue(isinstance(result, dict))
self.assertTrue('token' in result)

env_used = self.exec_mock.await_args_list[0][1]['env']
self.assertEqual(env_used['EXEC_PROVIDER_ENV_NAME'], 'EXEC_PROVIDER_ENV_VALUE')
self.assertEqual(json.loads(env_used['KUBERNETES_EXEC_INFO']), {'apiVersion':
'client.authentication.k8s.io/v1beta1',
'kind': 'ExecCredential',
'spec': {'interactive': sys.stdout.isatty()}})
self.exec_mock.assert_called_once_with('aws-iam-authenticator', 'token', '-i', 'dummy',
env=ANY, stderr=-1, stdin=None, stdout=-1)
self.process_mock.stdout.read.assert_awaited_once()
self.process_mock.stderr.read.assert_awaited_once()
self.process_mock.wait.assert_awaited_once()
23 changes: 20 additions & 3 deletions kubernetes_asyncio/config/kube_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from .config_exception import ConfigException
from .dateutil import UTC, parse_rfc3339
from .exec_provider import ExecProvider
from .google_auth import google_auth_credentials
from .openid import OpenIDRequestor

Expand Down Expand Up @@ -168,9 +169,9 @@ async def _load_authentication(self):
method. The order of authentication methods is:

1. GCP auth-provider
2. token_data
3. token field (point to a token file)
4. oidc auth-provider
2. token field (point to a token file)
3. oidc auth-provider
4. exec provided plugin
5. username/password
"""

Expand All @@ -185,6 +186,11 @@ async def _load_authentication(self):
await self._load_oid_token()
return

if 'exec' in self._user:
res_exec_plugin = await self._load_from_exec_plugin()
if res_exec_plugin:
return

if self._load_user_token():
return

Expand Down Expand Up @@ -280,6 +286,17 @@ def _retrieve_oidc_cacert(self, provider):

return None

async def _load_from_exec_plugin(self):
try:
status = await ExecProvider(self._user['exec']).run()
if 'token' not in status:
logging.error('exec: missing token field in plugin output')
return None
self.token = "Bearer %s" % status['token']
return True
except Exception as e:
logging.error(str(e))

def _load_user_token(self):
token = FileOrData(
self._user, 'tokenFile', 'token',
Expand Down
31 changes: 31 additions & 0 deletions kubernetes_asyncio/config/kube_config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,13 @@ class TestKubeConfigLoader(BaseTestCase):
"user": "non_existing_user"
}
},
{
"name": "exec_cred_user",
"context": {
"cluster": "default",
"user": "exec_cred_user"
}
},
],
"clusters": [
{
Expand Down Expand Up @@ -574,6 +581,16 @@ class TestKubeConfigLoader(BaseTestCase):
"client-key-data": TEST_CLIENT_KEY_BASE64,
}
},
{
"name": "exec_cred_user",
"user": {
"exec": {
"apiVersion": "client.authentication.k8s.io/v1beta1",
"command": "aws-iam-authenticator",
"args": ["token", "-i", "dummy-cluster"]
}
}
},
]
}

Expand Down Expand Up @@ -716,6 +733,20 @@ async def test_invalid_refresh(self):
with self.assertRaises(ConfigException):
await loader._refresh_oidc({'config': {}})

@patch('kubernetes_asyncio.config.kube_config.ExecProvider.run')
async def test_user_exec_auth(self, mock):
token = "dummy"
mock.return_value = {
"token": token
}
expected = FakeConfig(host=TEST_HOST, api_key={
"authorization": BEARER_TOKEN_FORMAT % token})
actual = FakeConfig()
await KubeConfigLoader(
config_dict=self.TEST_KUBE_CONFIG,
active_context="exec_cred_user").load_and_set(actual)
self.assertEqual(expected, actual)

async def test_user_pass(self):
expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN)
actual = FakeConfig()
Expand Down