From 201c4285a0b88dd6e2c17908e566e19bc9afbb87 Mon Sep 17 00:00:00 2001 From: Tomasz Prus Date: Sat, 6 Oct 2018 02:05:37 +0200 Subject: [PATCH 1/3] feat: porting exec-provider from base library --- kubernetes_asyncio/config/exec_provider.py | 96 +++++++++++++ .../config/exec_provider_test.py | 126 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 kubernetes_asyncio/config/exec_provider.py create mode 100644 kubernetes_asyncio/config/exec_provider_test.py diff --git a/kubernetes_asyncio/config/exec_provider.py b/kubernetes_asyncio/config/exec_provider.py new file mode 100644 index 000000000..6a9864bdc --- /dev/null +++ b/kubernetes_asyncio/config/exec_provider.py @@ -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'] diff --git a/kubernetes_asyncio/config/exec_provider_test.py b/kubernetes_asyncio/config/exec_provider_test.py new file mode 100644 index 000000000..91fbf0b2d --- /dev/null +++ b/kubernetes_asyncio/config/exec_provider_test.py @@ -0,0 +1,126 @@ +# 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 os + +from asynctest import 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.process_mock = process_patch.start().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.process_mock.stdout.read.assert_awaited_once() + self.process_mock.stderr.read.assert_awaited_once() + self.process_mock.wait.assert_awaited_once() + + +if __name__ == '__main__': + main() From e315485a4ea81001d4b430c9f43b7e03eecfea91 Mon Sep 17 00:00:00 2001 From: Tomasz Prus Date: Sun, 7 Oct 2018 00:13:04 +0200 Subject: [PATCH 2/3] test: add more tests --- .../config/exec_provider_test.py | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/kubernetes_asyncio/config/exec_provider_test.py b/kubernetes_asyncio/config/exec_provider_test.py index 91fbf0b2d..6c91e68bb 100644 --- a/kubernetes_asyncio/config/exec_provider_test.py +++ b/kubernetes_asyncio/config/exec_provider_test.py @@ -12,9 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import os -from asynctest import TestCase, main, mock, patch +from asynctest import ANY, TestCase, main, mock, patch from .config_exception import ConfigException from .exec_provider import ExecProvider @@ -38,7 +39,8 @@ def setUp(self): """ process_patch = patch('kubernetes_asyncio.config.exec_provider.asyncio.create_subprocess_exec') - self.process_mock = process_patch.start().return_value + 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) @@ -117,10 +119,42 @@ async def test_ok_01(self): 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'}] -if __name__ == '__main__': - main() + 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': True}}) + 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() From 821548c69dc23f14efa2246bfde680197138eb2a Mon Sep 17 00:00:00 2001 From: Tomasz Prus Date: Sun, 7 Oct 2018 00:37:43 +0200 Subject: [PATCH 3/3] feat: exec provider integrated with kube-config --- .../config/exec_provider_test.py | 3 +- kubernetes_asyncio/config/kube_config.py | 23 ++++++++++++-- kubernetes_asyncio/config/kube_config_test.py | 31 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/kubernetes_asyncio/config/exec_provider_test.py b/kubernetes_asyncio/config/exec_provider_test.py index 6c91e68bb..6c156161e 100644 --- a/kubernetes_asyncio/config/exec_provider_test.py +++ b/kubernetes_asyncio/config/exec_provider_test.py @@ -14,6 +14,7 @@ import json import os +import sys from asynctest import ANY, TestCase, main, mock, patch @@ -152,7 +153,7 @@ async def test_ok_with_env(self): self.assertEqual(json.loads(env_used['KUBERNETES_EXEC_INFO']), {'apiVersion': 'client.authentication.k8s.io/v1beta1', 'kind': 'ExecCredential', - 'spec': {'interactive': True}}) + '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() diff --git a/kubernetes_asyncio/config/kube_config.py b/kubernetes_asyncio/config/kube_config.py index 27cdc3364..38064c1f3 100644 --- a/kubernetes_asyncio/config/kube_config.py +++ b/kubernetes_asyncio/config/kube_config.py @@ -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 @@ -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 """ @@ -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 @@ -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', diff --git a/kubernetes_asyncio/config/kube_config_test.py b/kubernetes_asyncio/config/kube_config_test.py index b9a7d0af2..8ae40e179 100644 --- a/kubernetes_asyncio/config/kube_config_test.py +++ b/kubernetes_asyncio/config/kube_config_test.py @@ -426,6 +426,13 @@ class TestKubeConfigLoader(BaseTestCase): "user": "non_existing_user" } }, + { + "name": "exec_cred_user", + "context": { + "cluster": "default", + "user": "exec_cred_user" + } + }, ], "clusters": [ { @@ -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"] + } + } + }, ] } @@ -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()