Skip to content

Commit c86bd42

Browse files
authored
WIP: feat: porting exec-provider from base library (#44)
feat: porting exec-provider from base library
1 parent 6b8fb0a commit c86bd42

File tree

4 files changed

+308
-3
lines changed

4 files changed

+308
-3
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright 2018 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
import asyncio.subprocess
15+
import json
16+
import os
17+
import shlex
18+
import sys
19+
20+
from .config_exception import ConfigException
21+
22+
23+
class ExecProvider(object):
24+
"""
25+
Implementation of the proposal for out-of-tree client authentication providers
26+
as described here --
27+
https://github.com/kubernetes/community/blob/master/contributors/design-proposals/auth/kubectl-exec-plugins.md
28+
29+
Missing from implementation:
30+
31+
* TLS cert support
32+
* caching
33+
"""
34+
35+
def __init__(self, exec_config):
36+
for key in ['command', 'apiVersion']:
37+
if key not in exec_config:
38+
raise ConfigException(
39+
'exec: malformed request. missing key \'%s\'' % key)
40+
self.api_version = exec_config['apiVersion']
41+
self.args = [exec_config['command']]
42+
if 'args' in exec_config:
43+
self.args.extend(exec_config['args'])
44+
self.env = os.environ.copy()
45+
if 'env' in exec_config:
46+
additional_vars = {}
47+
for item in exec_config['env']:
48+
name = item['name']
49+
value = item['value']
50+
additional_vars[name] = value
51+
self.env.update(additional_vars)
52+
53+
async def run(self, previous_response=None):
54+
kubernetes_exec_info = {
55+
'apiVersion': self.api_version,
56+
'kind': 'ExecCredential',
57+
'spec': {
58+
'interactive': sys.stdout.isatty()
59+
}
60+
}
61+
if previous_response:
62+
kubernetes_exec_info['spec']['response'] = previous_response
63+
self.env['KUBERNETES_EXEC_INFO'] = json.dumps(kubernetes_exec_info)
64+
65+
cmd = shlex.split(' '.join(self.args))
66+
cmd_exec = asyncio.create_subprocess_exec(*cmd,
67+
env=self.env,
68+
stdin=None,
69+
stdout=asyncio.subprocess.PIPE,
70+
stderr=asyncio.subprocess.PIPE)
71+
proc = await cmd_exec
72+
73+
stdout = await proc.stdout.read()
74+
stderr = await proc.stderr.read()
75+
exit_code = await proc.wait()
76+
77+
if exit_code != 0:
78+
msg = 'exec: process returned %d' % exit_code
79+
stderr = stderr.strip()
80+
if stderr:
81+
msg += '. %s' % stderr
82+
raise ConfigException(msg)
83+
try:
84+
data = json.loads(stdout)
85+
except ValueError as de:
86+
raise ConfigException(
87+
'exec: failed to decode process output: %s' % de)
88+
for key in ('apiVersion', 'kind', 'status'):
89+
if key not in data:
90+
raise ConfigException(
91+
'exec: malformed response. missing key \'%s\'' % key)
92+
if data['apiVersion'] != self.api_version:
93+
raise ConfigException(
94+
'exec: plugin api version %s does not match %s' %
95+
(data['apiVersion'], self.api_version))
96+
return data['status']
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Copyright 2018 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import json
16+
import os
17+
import sys
18+
19+
from asynctest import ANY, TestCase, main, mock, patch
20+
21+
from .config_exception import ConfigException
22+
from .exec_provider import ExecProvider
23+
24+
25+
class ExecProviderTest(TestCase):
26+
27+
def setUp(self):
28+
self.input_ok = {
29+
'command': 'aws-iam-authenticator token -i dummy',
30+
'apiVersion': 'client.authentication.k8s.io/v1beta1'
31+
}
32+
self.output_ok = """
33+
{
34+
"apiVersion": "client.authentication.k8s.io/v1beta1",
35+
"kind": "ExecCredential",
36+
"status": {
37+
"token": "dummy"
38+
}
39+
}
40+
"""
41+
42+
process_patch = patch('kubernetes_asyncio.config.exec_provider.asyncio.create_subprocess_exec')
43+
self.exec_mock = process_patch.start()
44+
self.process_mock = self.exec_mock.return_value
45+
self.process_mock.stdout.read = mock.CoroutineMock(return_value=self.output_ok)
46+
self.process_mock.stderr.read = mock.CoroutineMock(return_value='')
47+
self.process_mock.wait = mock.CoroutineMock(return_value=0)
48+
49+
def tearDown(self):
50+
patch.stopall()
51+
52+
def test_missing_input_keys(self):
53+
exec_configs = [{}, {'command': ''}, {'apiVersion': ''}]
54+
for exec_config in exec_configs:
55+
with self.assertRaises(ConfigException) as context:
56+
ExecProvider(exec_config)
57+
self.assertIn('exec: malformed request. missing key',
58+
context.exception.args[0])
59+
60+
async def test_error_code_returned(self):
61+
self.process_mock.stdout.read.return_value = ''
62+
self.process_mock.wait.return_value = 1
63+
with self.assertRaisesRegex(ConfigException, 'exec: process returned 1'):
64+
ep = ExecProvider(self.input_ok)
65+
await ep.run()
66+
67+
async def test_nonjson_output_returned(self):
68+
self.process_mock.stdout.read.return_value = ''
69+
with self.assertRaisesRegex(ConfigException, 'exec: failed to decode process output'):
70+
ep = ExecProvider(self.input_ok)
71+
await ep.run()
72+
73+
async def test_missing_output_keys(self):
74+
outputs = [
75+
"""
76+
{
77+
"kind": "ExecCredential",
78+
"status": {
79+
"token": "dummy"
80+
}
81+
}
82+
""", """
83+
{
84+
"apiVersion": "client.authentication.k8s.io/v1beta1",
85+
"status": {
86+
"token": "dummy"
87+
}
88+
}
89+
""", """
90+
{
91+
"apiVersion": "client.authentication.k8s.io/v1beta1",
92+
"kind": "ExecCredential"
93+
}
94+
"""
95+
]
96+
for output in outputs:
97+
self.process_mock.stdout.read.return_value = output
98+
with self.assertRaisesRegex(ConfigException, 'exec: malformed response. missing key'):
99+
ep = ExecProvider(self.input_ok)
100+
await ep.run()
101+
102+
async def test_mismatched_api_version(self):
103+
wrong_api_version = 'client.authentication.k8s.io/v1'
104+
output = """
105+
{
106+
"apiVersion": "%s",
107+
"kind": "ExecCredential",
108+
"status": {
109+
"token": "dummy"
110+
}
111+
}
112+
""" % wrong_api_version
113+
self.process_mock.stdout.read.return_value = output
114+
with self.assertRaisesRegex(ConfigException, 'exec: plugin api version {} does not match'.format(wrong_api_version)):
115+
ep = ExecProvider(self.input_ok)
116+
await ep.run()
117+
118+
async def test_ok_01(self):
119+
ep = ExecProvider(self.input_ok)
120+
result = await ep.run()
121+
self.assertTrue(isinstance(result, dict))
122+
self.assertTrue('token' in result)
123+
self.exec_mock.assert_called_once_with('aws-iam-authenticator', 'token', '-i', 'dummy',
124+
env=ANY, stderr=-1, stdin=None, stdout=-1)
125+
self.process_mock.stdout.read.assert_awaited_once()
126+
self.process_mock.stderr.read.assert_awaited_once()
127+
self.process_mock.wait.assert_awaited_once()
128+
129+
async def test_ok_with_args(self):
130+
self.input_ok['args'] = ['--mock', '90']
131+
ep = ExecProvider(self.input_ok)
132+
result = await ep.run()
133+
self.assertTrue(isinstance(result, dict))
134+
self.assertTrue('token' in result)
135+
self.exec_mock.assert_called_once_with('aws-iam-authenticator', 'token', '-i', 'dummy', '--mock', '90',
136+
env=ANY, stderr=-1, stdin=None, stdout=-1)
137+
self.process_mock.stdout.read.assert_awaited_once()
138+
self.process_mock.stderr.read.assert_awaited_once()
139+
self.process_mock.wait.assert_awaited_once()
140+
141+
async def test_ok_with_env(self):
142+
143+
self.input_ok['env'] = [{'name': 'EXEC_PROVIDER_ENV_NAME',
144+
'value': 'EXEC_PROVIDER_ENV_VALUE'}]
145+
146+
ep = ExecProvider(self.input_ok)
147+
result = await ep.run()
148+
self.assertTrue(isinstance(result, dict))
149+
self.assertTrue('token' in result)
150+
151+
env_used = self.exec_mock.await_args_list[0][1]['env']
152+
self.assertEqual(env_used['EXEC_PROVIDER_ENV_NAME'], 'EXEC_PROVIDER_ENV_VALUE')
153+
self.assertEqual(json.loads(env_used['KUBERNETES_EXEC_INFO']), {'apiVersion':
154+
'client.authentication.k8s.io/v1beta1',
155+
'kind': 'ExecCredential',
156+
'spec': {'interactive': sys.stdout.isatty()}})
157+
self.exec_mock.assert_called_once_with('aws-iam-authenticator', 'token', '-i', 'dummy',
158+
env=ANY, stderr=-1, stdin=None, stdout=-1)
159+
self.process_mock.stdout.read.assert_awaited_once()
160+
self.process_mock.stderr.read.assert_awaited_once()
161+
self.process_mock.wait.assert_awaited_once()

kubernetes_asyncio/config/kube_config.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from .config_exception import ConfigException
2929
from .dateutil import UTC, parse_rfc3339
30+
from .exec_provider import ExecProvider
3031
from .google_auth import google_auth_credentials
3132
from .openid import OpenIDRequestor
3233

@@ -168,9 +169,9 @@ async def _load_authentication(self):
168169
method. The order of authentication methods is:
169170
170171
1. GCP auth-provider
171-
2. token_data
172-
3. token field (point to a token file)
173-
4. oidc auth-provider
172+
2. token field (point to a token file)
173+
3. oidc auth-provider
174+
4. exec provided plugin
174175
5. username/password
175176
"""
176177

@@ -185,6 +186,11 @@ async def _load_authentication(self):
185186
await self._load_oid_token()
186187
return
187188

189+
if 'exec' in self._user:
190+
res_exec_plugin = await self._load_from_exec_plugin()
191+
if res_exec_plugin:
192+
return
193+
188194
if self._load_user_token():
189195
return
190196

@@ -280,6 +286,17 @@ def _retrieve_oidc_cacert(self, provider):
280286

281287
return None
282288

289+
async def _load_from_exec_plugin(self):
290+
try:
291+
status = await ExecProvider(self._user['exec']).run()
292+
if 'token' not in status:
293+
logging.error('exec: missing token field in plugin output')
294+
return None
295+
self.token = "Bearer %s" % status['token']
296+
return True
297+
except Exception as e:
298+
logging.error(str(e))
299+
283300
def _load_user_token(self):
284301
token = FileOrData(
285302
self._user, 'tokenFile', 'token',

kubernetes_asyncio/config/kube_config_test.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,13 @@ class TestKubeConfigLoader(BaseTestCase):
426426
"user": "non_existing_user"
427427
}
428428
},
429+
{
430+
"name": "exec_cred_user",
431+
"context": {
432+
"cluster": "default",
433+
"user": "exec_cred_user"
434+
}
435+
},
429436
],
430437
"clusters": [
431438
{
@@ -574,6 +581,16 @@ class TestKubeConfigLoader(BaseTestCase):
574581
"client-key-data": TEST_CLIENT_KEY_BASE64,
575582
}
576583
},
584+
{
585+
"name": "exec_cred_user",
586+
"user": {
587+
"exec": {
588+
"apiVersion": "client.authentication.k8s.io/v1beta1",
589+
"command": "aws-iam-authenticator",
590+
"args": ["token", "-i", "dummy-cluster"]
591+
}
592+
}
593+
},
577594
]
578595
}
579596

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

736+
@patch('kubernetes_asyncio.config.kube_config.ExecProvider.run')
737+
async def test_user_exec_auth(self, mock):
738+
token = "dummy"
739+
mock.return_value = {
740+
"token": token
741+
}
742+
expected = FakeConfig(host=TEST_HOST, api_key={
743+
"authorization": BEARER_TOKEN_FORMAT % token})
744+
actual = FakeConfig()
745+
await KubeConfigLoader(
746+
config_dict=self.TEST_KUBE_CONFIG,
747+
active_context="exec_cred_user").load_and_set(actual)
748+
self.assertEqual(expected, actual)
749+
719750
async def test_user_pass(self):
720751
expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN)
721752
actual = FakeConfig()

0 commit comments

Comments
 (0)