Skip to content

Commit eed19e4

Browse files
authored
Merge pull request #492 from tlm/kube-proxy-support-2
#492 Adds support for Kubernetes client proxies introduced in Juju 2.9. Relates to bug https://bugs.launchpad.net/juju/+bug/1926595
2 parents ab33f33 + 5757cf4 commit eed19e4

8 files changed

Lines changed: 218 additions & 6 deletions

File tree

juju/client/connection.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ async def connect(
231231
retries=3,
232232
retry_backoff=10,
233233
specified_facades=None,
234+
proxy=None,
234235
):
235236
"""Connect to the websocket.
236237
@@ -308,6 +309,10 @@ async def connect(
308309
max_frame_size = self.MAX_FRAME_SIZE
309310
self.max_frame_size = max_frame_size
310311

312+
self.proxy = proxy
313+
if self.proxy is not None:
314+
self.proxy.connect()
315+
311316
_endpoints = [(endpoint, cacert)] if isinstance(endpoint, str) else [(e, cacert) for e in endpoint]
312317
for _ep in _endpoints:
313318
try:
@@ -348,12 +353,23 @@ async def _open(self, endpoint, cacert):
348353
else:
349354
url = "wss://{}/api".format(endpoint)
350355

356+
# We need to establish a server_hostname here for TLS sni if we are
357+
# connecting through a proxy as the Juju controller certificates will
358+
# not be covering the proxy
359+
sock = None
360+
server_hostname = None
361+
if self.proxy is not None:
362+
sock = self.proxy.socket()
363+
server_hostname = "juju-app"
364+
351365
return (await websockets.connect(
352366
url,
353367
ssl=self._get_ssl(cacert),
354368
loop=self.loop,
355369
max_size=self.max_frame_size,
356-
), url, endpoint, cacert)
370+
server_hostname=server_hostname,
371+
sock=sock,
372+
)), url, endpoint, cacert
357373

358374
async def close(self):
359375
if not self.ws:
@@ -364,6 +380,9 @@ async def close(self):
364380
await self.ws.close()
365381
self.ws = None
366382

383+
if self.proxy is not None:
384+
self.proxy.close()
385+
367386
async def _recv(self, request_id):
368387
if not self.is_open:
369388
raise websockets.exceptions.ConnectionClosed(0, 'websocket closed')
@@ -551,11 +570,9 @@ async def clone(self):
551570
return await Connection.connect(**self.connect_params())
552571

553572
def connect_params(self):
554-
"""Return a tuple of parameters suitable for passing to
573+
"""Return a dict of parameters suitable for passing to
555574
Connection.connect that can be used to make a new connection
556-
to the same controller (and model if specified. The first
557-
element in the returned tuple holds the endpoint argument;
558-
the other holds a dict of the keyword args.
575+
to the same controller (and model if specified).
559576
"""
560577
return {
561578
'endpoint': self.endpoint,
@@ -566,6 +583,7 @@ def connect_params(self):
566583
'bakery_client': self.bakery_client,
567584
'loop': self.loop,
568585
'max_frame_size': self.max_frame_size,
586+
'proxy': self.proxy,
569587
}
570588

571589
async def controller(self):

juju/client/connector.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from juju.client.connection import Connection
77
from juju.client.gocookies import GoCookieJar, go_to_py_cookie
88
from juju.client.jujudata import FileJujuData
9+
from juju.client.proxy.factory import proxy_from_config
910
from juju.errors import JujuConnectionError, JujuError
1011

1112
log = logging.getLogger('connector')
@@ -55,6 +56,7 @@ async def connect(self, **kwargs):
5556
5657
kwargs are passed through to Connection.connect()
5758
"""
59+
5860
kwargs.setdefault('loop', self.loop)
5961
kwargs.setdefault('max_frame_size', self.max_frame_size)
6062
kwargs.setdefault('bakery_client', self.bakery_client)
@@ -89,6 +91,8 @@ async def connect_controller(self, controller_name=None, specified_facades=None)
8991
endpoints = controller['api-endpoints']
9092
accounts = self.jujudata.accounts().get(controller_name, {})
9193

94+
proxy = proxy_from_config(controller.get('proxy-config', None))
95+
9296
await self.connect(
9397
endpoint=endpoints,
9498
uuid=None,
@@ -97,6 +101,7 @@ async def connect_controller(self, controller_name=None, specified_facades=None)
97101
cacert=controller.get('ca-cert'),
98102
bakery_client=self.bakery_client_for_controller(controller_name),
99103
specified_facades=specified_facades,
104+
proxy=proxy,
100105
)
101106
self.controller_name = controller_name
102107
self.controller_uuid = controller["uuid"]
@@ -121,9 +126,12 @@ async def connect_model(self, model_name=None):
121126
account = self.jujudata.accounts().get(controller_name, {})
122127
models = self.jujudata.models().get(controller_name, {}).get('models',
123128
{})
129+
124130
if model_name not in models:
125131
raise JujuConnectionError('Model not found: {}'.format(model_name))
126132

133+
proxy = proxy_from_config(controller.get('proxy-config', None))
134+
127135
# TODO if there's no record for the required model name, connect
128136
# to the controller to find out the model's uuid, then connect
129137
# to that. This will let connect_model work with models that
@@ -137,6 +145,7 @@ async def connect_model(self, model_name=None):
137145
password=account.get('password'),
138146
cacert=controller.get('ca-cert'),
139147
bakery_client=self.bakery_client_for_controller(controller_name),
148+
proxy=proxy,
140149
)
141150
self.controller_name = controller_name
142151
self.model_name = controller_name + ':' + model_name

juju/client/proxy/factory.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from juju.client.proxy.kubernetes.proxy import KubernetesProxy
2+
3+
4+
def proxy_from_config(conf):
5+
if conf is None:
6+
return None
7+
8+
if 'type' not in conf:
9+
return None
10+
11+
proxy_type = conf['type']
12+
if proxy_type != 'kubernetes-port-forward':
13+
raise ValueError('unknown proxy type %s' % proxy_type)
14+
15+
return _construct_kube_proxy(conf['config'])
16+
17+
18+
def _construct_kube_proxy(config):
19+
return KubernetesProxy(
20+
config.get('api-host', ''),
21+
config.get('namespace', ''),
22+
config.get('remote-port', ''),
23+
config.get('service', ''),
24+
config.get('service-account-token', ''),
25+
config.get('ca-cert', None),
26+
)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import tempfile
2+
3+
from juju.client.proxy.proxy import Proxy, ProxyNotConnectedError
4+
from kubernetes import client
5+
from kubernetes.stream import portforward
6+
7+
8+
class KubernetesProxy(Proxy):
9+
def __init__(
10+
self,
11+
api_host,
12+
namespace,
13+
remote_port,
14+
service,
15+
service_account_token,
16+
ca_cert=None,
17+
):
18+
config = client.Configuration()
19+
config.host = api_host
20+
config.ssl_ca_cert = ca_cert
21+
config.api_key = {"authorization": "Bearer " + service_account_token}
22+
23+
self.namespace = namespace
24+
self.remote_port = remote_port
25+
self.service = service
26+
27+
try:
28+
self.remote_port = int(remote_port)
29+
except ValueError:
30+
raise ValueError("Invalid port number: {}".format(remote_port))
31+
32+
if ca_cert:
33+
self.temp_ca_file = tempfile.NamedTemporaryFile()
34+
self.temp_ca_file.write(bytes(ca_cert, 'utf-8'))
35+
self.temp_ca_file.flush()
36+
config.ssl_ca_cert = self.temp_ca_file.name
37+
38+
self.api_client = client.ApiClient(config)
39+
40+
def connect(self):
41+
corev1 = client.CoreV1Api(self.api_client)
42+
service = corev1.read_namespaced_service(self.service, self.namespace)
43+
44+
label_selector = ','.join(k + '=' + v for k, v in service.spec.selector.items())
45+
46+
pods = corev1.list_namespaced_pod(
47+
namespace=self.namespace,
48+
label_selector=label_selector,
49+
)
50+
51+
self.port_forwarder = portforward(
52+
corev1.connect_get_namespaced_pod_portforward,
53+
pods.items[0].metadata.name,
54+
self.namespace,
55+
ports=str(self.remote_port),
56+
)
57+
58+
def __del__(self):
59+
self.close()
60+
61+
def close(self):
62+
try:
63+
self.port_forwarder.close()
64+
self.temp_ca_file.close()
65+
except AttributeError:
66+
pass
67+
68+
def socket(self):
69+
if self.port_forwarder is not None:
70+
return self.port_forwarder.socket(self.remote_port)._socket
71+
raise ProxyNotConnectedError()

juju/client/proxy/proxy.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from abc import abstractmethod
2+
3+
4+
class ProxyNotConnectedError(Exception):
5+
pass
6+
7+
8+
class Proxy():
9+
"""
10+
Abstract class to represent a generic controller connection proxy
11+
"""
12+
13+
@abstractmethod
14+
def connect(self):
15+
raise NotImplementedError()
16+
17+
@abstractmethod
18+
def close(self):
19+
raise NotImplementedError()
20+
21+
@abstractmethod
22+
def socket(self):
23+
raise NotImplementedError()

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
'paramiko>=2.4.0,<3.0.0',
4141
'pyasn1>=0.4.4',
4242
'toposort>=1.5,<2',
43-
'typing_inspect>=0.6.0'
43+
'typing_inspect>=0.6.0',
44+
'kubernetes>=12.0.1',
4445
],
4546
include_package_data=True,
4647
maintainer='Juju Ecosystem Engineering',

tests/unit/test_proxy.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import unittest
2+
3+
from juju.client.proxy.factory import proxy_from_config
4+
from juju.client.proxy.kubernetes.proxy import KubernetesProxy
5+
6+
7+
class TestJujuDataFactory(unittest.TestCase):
8+
9+
def test_proxy_from_config_unknown_type(self):
10+
"""
11+
Test that a unknown proxy type results in a UnknownProxyTypeError
12+
exception
13+
"""
14+
self.assertRaises(ValueError, proxy_from_config, {
15+
"config": {},
16+
"type": "does-not-exists",
17+
})
18+
19+
def test_proxy_from_config_missing_type(self):
20+
"""
21+
Test that a nil proxy type returns None
22+
"""
23+
self.assertIsNone(proxy_from_config({
24+
"config": {},
25+
}))
26+
27+
def test_proxy_from_config_non_arg(self):
28+
"""
29+
Tests that providing an empty proxy config results in a None proxy
30+
"""
31+
self.assertIsNone(proxy_from_config(None))
32+
33+
def test_proxy_from_config_kubernetes(self):
34+
"""
35+
Tests that a Kubernetes proxy is correctly created from config
36+
"""
37+
proxy = proxy_from_config({
38+
"type": "kubernetes-port-forward",
39+
"config": {
40+
"api-host": "https://localhost:8456",
41+
"namespace": "controller-python-test",
42+
"remote-port": "1234",
43+
"service": "controller",
44+
"service-account-token": "==AA",
45+
"ca-cert": "==AA",
46+
},
47+
})
48+
49+
self.assertIs(type(proxy), KubernetesProxy)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import unittest
2+
from juju.client.proxy.kubernetes.proxy import KubernetesProxy
3+
4+
5+
class TestKubernetesProxy(unittest.TestCase):
6+
def test_remote_port_error(self):
7+
self.assertRaises(
8+
ValueError,
9+
KubernetesProxy,
10+
api_host="https://localhost:1234",
11+
namespace="controller",
12+
remote_port="not-a-integer-port",
13+
service="service",
14+
service_account_token="==AA",
15+
)

0 commit comments

Comments
 (0)