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

Attempt to implement exec-plugins support in kubeconfig #75

Merged
merged 1 commit into from
Sep 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
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()
28 changes: 22 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 All @@ -16,6 +16,7 @@
import base64
import datetime
import json
import logging
import os
import tempfile
import time
Expand All @@ -30,6 +31,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 +174,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 +343,19 @@ 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
try:
status = 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
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()