Skip to content
This repository has been archived by the owner on Mar 13, 2022. It is now read-only.

Commit

Permalink
Add partial support for out-of-tree client authentication providers (…
Browse files Browse the repository at this point in the history
…token only, no caching)
  • Loading branch information
dovreshef committed Sep 3, 2018
1 parent c9b3113 commit c1bb5cc
Show file tree
Hide file tree
Showing 4 changed files with 280 additions and 7 deletions.
90 changes: 90 additions & 0 deletions config/exec_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 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 subprocess
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)

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)
process = subprocess.Popen(
self.args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=self.env,
universal_newlines=True)
(stdout, stderr) = process.communicate()
exit_code = process.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']
140 changes: 140 additions & 0 deletions config/exec_provider_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# 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
import unittest

import mock

from .config_exception import ConfigException
from .exec_provider import ExecProvider


class ExecProviderTest(unittest.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"
}
}
"""

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])

@mock.patch('subprocess.Popen')
def test_error_code_returned(self, mock):
instance = mock.return_value
instance.wait.return_value = 1
instance.communicate.return_value = ('', '')
with self.assertRaises(ConfigException) as context:
ep = ExecProvider(self.input_ok)
ep.run()
self.assertIn('exec: process returned %d' %
instance.wait.return_value, context.exception.args[0])

@mock.patch('subprocess.Popen')
def test_nonjson_output_returned(self, mock):
instance = mock.return_value
instance.wait.return_value = 0
instance.communicate.return_value = ('', '')
with self.assertRaises(ConfigException) as context:
ep = ExecProvider(self.input_ok)
ep.run()
self.assertIn('exec: failed to decode process output',
context.exception.args[0])

@mock.patch('subprocess.Popen')
def test_missing_output_keys(self, mock):
instance = mock.return_value
instance.wait.return_value = 0
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:
instance.communicate.return_value = (output, '')
with self.assertRaises(ConfigException) as context:
ep = ExecProvider(self.input_ok)
ep.run()
self.assertIn('exec: malformed response. missing key',
context.exception.args[0])

@mock.patch('subprocess.Popen')
def test_mismatched_api_version(self, mock):
instance = mock.return_value
instance.wait.return_value = 0
wrong_api_version = 'client.authentication.k8s.io/v1'
output = """
{
"apiVersion": "%s",
"kind": "ExecCredential",
"status": {
"token": "dummy"
}
}
""" % wrong_api_version
instance.communicate.return_value = (output, '')
with self.assertRaises(ConfigException) as context:
ep = ExecProvider(self.input_ok)
ep.run()
self.assertIn(
'exec: plugin api version %s does not match' %
wrong_api_version,
context.exception.args[0])

@mock.patch('subprocess.Popen')
def test_ok_01(self, mock):
instance = mock.return_value
instance.wait.return_value = 0
instance.communicate.return_value = (self.output_ok, '')
ep = ExecProvider(self.input_ok)
result = ep.run()
self.assertTrue(isinstance(result, dict))
self.assertTrue('token' in result)


if __name__ == '__main__':
unittest.main()
24 changes: 18 additions & 6 deletions config/kube_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2016 The Kubernetes Authors.
# 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.
Expand Down Expand Up @@ -30,6 +30,7 @@
from six import PY3

from kubernetes.client import ApiClient, Configuration
from kubernetes.config.exec_provider import ExecProvider

from .config_exception import ConfigException
from .dateutil import UTC, format_rfc3339, parse_rfc3339
Expand Down Expand Up @@ -172,18 +173,19 @@ def _load_authentication(self):
section of kube-config and stops if it finds a valid authentication
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
5. username/password
1. auth-provider (gcp, azure, oidc)
2. token field (point to a token file)
3. exec provided plugin
4. username/password
"""
if not self._user:
return
if self._load_auth_provider_token():
return
if self._load_user_token():
return
if self._load_from_exec_plugin():
return
self._load_user_pass_token()

def _load_auth_provider_token(self):
Expand Down Expand Up @@ -340,6 +342,16 @@ def _refresh_oidc(self, provider):
provider['config'].value['id-token'] = refresh['id_token']
provider['config'].value['refresh-token'] = refresh['refresh_token']

def _load_from_exec_plugin(self):
if 'exec' not in self._user:
return
status = ExecProvider(self._user['exec']).run()
if 'token' not in status:
raise ConfigException(
'Missing token field in exec plugin response')
self.token = "Bearer %s" % status['token']
return True

def _load_user_token(self):
token = FileOrData(
self._user, 'tokenFile', 'token',
Expand Down
33 changes: 32 additions & 1 deletion config/kube_config_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2016 The Kubernetes Authors.
# 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.
Expand Down Expand Up @@ -422,6 +422,13 @@ class TestKubeConfigLoader(BaseTestCase):
"user": "non_existing_user"
}
},
{
"name": "exec_cred_user",
"context": {
"cluster": "default",
"user": "exec_cred_user"
}
},
],
"clusters": [
{
Expand Down Expand Up @@ -573,6 +580,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 @@ -849,6 +866,20 @@ def test_non_existing_user(self):
active_context="non_existing_user").load_and_set(actual)
self.assertEqual(expected, actual)

@mock.patch('kubernetes.config.kube_config.ExecProvider.run')
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()
KubeConfigLoader(
config_dict=self.TEST_KUBE_CONFIG,
active_context="exec_cred_user").load_and_set(actual)
self.assertEqual(expected, actual)


if __name__ == '__main__':
unittest.main()

0 comments on commit c1bb5cc

Please sign in to comment.