Skip to content

Commit df1f320

Browse files
committed
Implementation for /exec using websocket
inspired by the POC from @chekolyn * Adds a new requirement on websocket-client * Add a new class WSClient that uses WebSocketApp from the websocket-client. * Make sure we pass Authorization header * Make sure we honor the SSL settings in configuration * Some of the code will get overwritten when we generate fresh classes from swagger definition. To remind us added a e2e test so we don't lose the changes * Added a new configuration option to enable/disable failures when hostnames in certificates don't match Fixes #58
1 parent 1d3cd13 commit df1f320

File tree

8 files changed

+176
-18
lines changed

8 files changed

+176
-18
lines changed

kubernetes/client/api_client.py

+8
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from __future__ import absolute_import
2222

2323
from . import models
24+
from . import ws_client
2425
from .rest import RESTClientObject
2526
from .rest import ApiException
2627

@@ -343,6 +344,13 @@ def request(self, method, url, query_params=None, headers=None,
343344
"""
344345
Makes the HTTP request using RESTClient.
345346
"""
347+
if url.endswith('/exec') and method == "GET":
348+
return ws_client.GET(self.config,
349+
url,
350+
query_params=query_params,
351+
_request_timeout=_request_timeout,
352+
headers=headers)
353+
346354
if method == "GET":
347355
return self.rest_client.GET(url,
348356
query_params=query_params,

kubernetes/client/apis/core_v1_api.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1007,7 +1007,7 @@ def connect_get_namespaced_pod_exec_with_http_info(self, name, namespace, **kwar
10071007
auth_settings=auth_settings,
10081008
callback=params.get('callback'),
10091009
_return_http_data_only=params.get('_return_http_data_only'),
1010-
_preload_content=params.get('_preload_content', True),
1010+
_preload_content=params.get('_preload_content', False),
10111011
_request_timeout=params.get('_request_timeout'),
10121012
collection_formats=collection_formats)
10131013

kubernetes/client/configuration.py

+3
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ def __init__(self):
8585
self.cert_file = None
8686
# client key file
8787
self.key_file = None
88+
# check host name
89+
# Set this to True/False to enable/disable SSL hostname verification.
90+
self.assert_hostname = None
8891

8992
@property
9093
def logger_file(self):

kubernetes/client/rest.py

+12-5
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,20 @@ def __init__(self, pools_size=4, config=configuration):
9595
# key file
9696
key_file = config.key_file
9797

98+
kwargs = {
99+
'num_pools': pools_size,
100+
'cert_reqs': cert_reqs,
101+
'ca_certs': ca_certs,
102+
'cert_file': cert_file,
103+
'key_file': key_file,
104+
}
105+
106+
if configuration.assert_hostname is not None:
107+
kwargs['assert_hostname'] = configuration.assert_hostname
108+
98109
# https pool manager
99110
self.pool_manager = urllib3.PoolManager(
100-
num_pools=pools_size,
101-
cert_reqs=cert_reqs,
102-
ca_certs=ca_certs,
103-
cert_file=cert_file,
104-
key_file=key_file
111+
**kwargs
105112
)
106113

107114
def request(self, method, url, query_params=None, headers=None,

kubernetes/client/ws_client.py

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
2+
# not use this file except in compliance with the License. You may obtain
3+
# a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10+
# License for the specific language governing permissions and limitations
11+
# under the License.
12+
13+
from .configuration import configuration
14+
from .rest import ApiException
15+
16+
import certifi
17+
import websocket
18+
import six
19+
import ssl
20+
from six.moves.urllib.parse import urlencode
21+
from six.moves.urllib.parse import quote_plus
22+
23+
24+
class WSClient:
25+
def __init__(self, configuration, url, headers):
26+
self.messages = []
27+
self.errors = []
28+
websocket.enableTrace(False)
29+
header = None
30+
31+
# We just need to pass the Authorization, ignore all the other
32+
# http headers we get from the generated code
33+
if 'Authorization' in headers:
34+
header = "Authorization: %s" % headers['Authorization']
35+
36+
self.ws = websocket.WebSocketApp(url,
37+
on_message=self.on_message,
38+
on_error=self.on_error,
39+
on_close=self.on_close,
40+
header=[header] if header else None)
41+
self.ws.on_open = self.on_open
42+
43+
if url.startswith('wss://') and configuration.verify_ssl:
44+
ssl_opts = {
45+
'cert_reqs': ssl.CERT_REQUIRED,
46+
'keyfile': configuration.key_file,
47+
'certfile': configuration.cert_file,
48+
'ca_certs': configuration.ssl_ca_cert or certifi.where(),
49+
}
50+
if configuration.assert_hostname is not None:
51+
ssl_opts['check_hostname'] = configuration.assert_hostname
52+
else:
53+
ssl_opts = {'cert_reqs': ssl.CERT_NONE}
54+
55+
self.ws.run_forever(sslopt=ssl_opts)
56+
57+
def on_message(self, ws, message):
58+
if message[0] == '\x01':
59+
message = message[1:]
60+
if message:
61+
if six.PY3 and isinstance(message, six.binary_type):
62+
message = message.decode('utf-8')
63+
self.messages.append(message)
64+
65+
def on_error(self, ws, error):
66+
self.errors.append(error)
67+
68+
def on_close(self, ws):
69+
pass
70+
71+
def on_open(self, ws):
72+
pass
73+
74+
75+
def GET(configuration, url, query_params, _request_timeout, headers):
76+
# switch protocols from http to websocket
77+
url = url.replace('http://', 'ws://')
78+
url = url.replace('https://', 'wss://')
79+
80+
# patch extra /
81+
url = url.replace('//api', '/api')
82+
83+
# Extract the command from the list of tuples
84+
commands = None
85+
for key, value in query_params:
86+
if key == 'command':
87+
commands = value
88+
break
89+
90+
# drop command from query_params as we will be processing it separately
91+
query_params = [(key, value) for key, value in query_params if
92+
key != 'command']
93+
94+
# if we still have query params then encode them
95+
if query_params:
96+
url += '?' + urlencode(query_params)
97+
98+
# tack on the actual command to execute at the end
99+
if isinstance(commands, list):
100+
for command in commands:
101+
url += "&command=%s&" % quote_plus(command)
102+
else:
103+
url += '&command=' + quote_plus(commands)
104+
105+
client = WSClient(configuration, url, headers)
106+
if client.errors:
107+
raise ApiException(
108+
status=0,
109+
reason='\n'.join([str(error) for error in client.errors])
110+
)
111+
return ''.join(client.messages)

kubernetes/e2e_test/test_client.py

+38-10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# License for the specific language governing permissions and limitations
1313
# under the License.
1414

15+
import time
1516
import unittest
1617
import uuid
1718

@@ -27,22 +28,49 @@ def test_pod_apis(self):
2728
client = api_client.ApiClient('http://127.0.0.1:8080/')
2829
api = core_v1_api.CoreV1Api(client)
2930

30-
name = 'test-' + str(uuid.uuid4())
31-
pod_manifest = {'apiVersion': 'v1',
32-
'kind': 'Pod',
33-
'metadata': {'color': 'blue', 'name': name},
34-
'spec': {'containers': [{'image': 'dockerfile/redis',
35-
'name': 'redis'}]}}
31+
name = 'busybox-test-' + str(uuid.uuid4())
32+
pod_manifest = {
33+
'apiVersion': 'v1',
34+
'kind': 'Pod',
35+
'metadata': {
36+
'name': name
37+
},
38+
'spec': {
39+
'containers': [{
40+
'image': 'busybox',
41+
'name': 'sleep',
42+
"args": [
43+
"/bin/sh",
44+
"-c",
45+
"while true;do date;sleep 5; done"
46+
]
47+
}]
48+
}
49+
}
3650

3751
resp = api.create_namespaced_pod(body=pod_manifest,
3852
namespace='default')
3953
self.assertEqual(name, resp.metadata.name)
4054
self.assertTrue(resp.status.phase)
4155

42-
resp = api.read_namespaced_pod(name=name,
43-
namespace='default')
44-
self.assertEqual(name, resp.metadata.name)
45-
self.assertTrue(resp.status.phase)
56+
while True:
57+
resp = api.read_namespaced_pod(name=name,
58+
namespace='default')
59+
self.assertEqual(name, resp.metadata.name)
60+
self.assertTrue(resp.status.phase)
61+
if resp.status.phase != 'Pending':
62+
break
63+
time.sleep(1)
64+
65+
exec_command = ['/bin/sh',
66+
'-c',
67+
'for i in $(seq 1 3); do date; sleep 1; done']
68+
resp = api.connect_get_namespaced_pod_exec(name, 'default',
69+
command=exec_command,
70+
stderr=False, stdin=False,
71+
stdout=True, tty=False)
72+
print('EXEC response : %s' % resp)
73+
self.assertEqual(3, len(resp.splitlines()))
4674

4775
number_of_pods = len(api.list_pod_for_all_namespaces().items)
4876
self.assertTrue(number_of_pods > 0)

requirements.txt

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
certifi >= 14.05.14
2-
six == 1.8.0
2+
six>=1.9.0
33
python_dateutil >= 2.5.3
44
setuptools >= 21.0.0
55
urllib3 >= 1.19.1
66
pyyaml >= 3.12
77
oauth2client >= 4.0.0
88
ipaddress >= 1.0.17
9-
9+
websocket-client>=0.32.0

tox.ini

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ passenv = TOXENV CI TRAVIS TRAVIS_*
66
usedevelop = True
77
install_command = pip install -U {opts} {packages}
88
deps = -r{toxinidir}/test-requirements.txt
9+
-r{toxinidir}/requirements.txt
910
commands =
1011
python -V
1112
nosetests []

0 commit comments

Comments
 (0)