diff --git a/pykube/config.py b/pykube/config.py index 459f940..a0710c1 100644 --- a/pykube/config.py +++ b/pykube/config.py @@ -183,6 +183,22 @@ def user(self): raise exceptions.PyKubeError("current context not set; call set_current_context") return self.users.get(self.contexts[self.current_context].get("user", ""), {}) + def persist_doc(self): + if not hasattr(self, "filename") or not self.filename: + # Config was provided as string, not way to persit it + return + with open(self.filename, "w") as f: + yaml.safe_dump(self.doc, f, encoding='utf-8', + allow_unicode=True, default_flow_style=False) + + def reload(self): + if hasattr(self, "_users"): + delattr(self, "_users") + if hasattr(self, "_contexts"): + delattr(self, "_contexts") + if hasattr(self, "_clusters"): + delattr(self, "_clusters") + class BytesOrFile(object): """ diff --git a/pykube/http.py b/pykube/http.py index 7ad40c1..c7ecc23 100644 --- a/pykube/http.py +++ b/pykube/http.py @@ -6,10 +6,10 @@ import re import sys import warnings -import requests from six.moves.urllib.parse import urlparse +from .session import build_session from .exceptions import HTTPError @@ -21,16 +21,25 @@ class HTTPClient(object): Client for interfacing with the Kubernetes API. """ - def __init__(self, config): + _session = None + + def __init__(self, config, gcloud_file=None): """ Creates a new instance of the HTTPClient. :Parameters: - `config`: The configuration instance + - `gcloud_file`: For GCP deployments, override gcloud credentials file location """ self.config = config + self.gcloud_file = gcloud_file self.url = self.config.cluster["server"] - self.session = self.build_session() + + @property + def session(self): + if not self._session: + self._session = build_session(self.config, self.gcloud_file) + return self._session @property def url(self): @@ -43,38 +52,6 @@ def url(self, value): warnings.warn("IP address hostnames are not supported with Python < 3.5. Please see https://github.com/kelproject/pykube/issues/29 for more info.", RuntimeWarning) self._url = pr.geturl() - def _set_bearer_token(self, session, token): - """ - Set the bearer authorization token for the session. - """ - session.headers["Authorization"] = "Bearer {}".format(token) - - def build_session(self): - """ - Creates a new session for the client. - """ - s = requests.Session() - if "certificate-authority" in self.config.cluster: - s.verify = self.config.cluster["certificate-authority"].filename() - elif "insecure-skip-tls-verify" in self.config.cluster: - s.verify = not self.config.cluster["insecure-skip-tls-verify"] - if "token" in self.config.user and self.config.user["token"]: - self._set_bearer_token(s, self.config.user["token"]) - elif "auth-provider" in self.config.user: - token = self.config.user['auth-provider'].get('config', {}).get('access-token') - if token is not None: - self._set_bearer_token(s, token) - elif "client-certificate" in self.config.user: - s.cert = ( - self.config.user["client-certificate"].filename(), - self.config.user["client-key"].filename(), - ) - elif self.config.user.get("username") and self.config.user.get("password"): - s.auth = (self.config.user["username"], self.config.user["password"]) - else: # no user present; don't configure anything - pass - return s - def get_kwargs(self, **kwargs): """ Creates a full URL to request based on arguments. diff --git a/pykube/session.py b/pykube/session.py new file mode 100644 index 0000000..83a1aa2 --- /dev/null +++ b/pykube/session.py @@ -0,0 +1,125 @@ +import os +import json +import requests +import datetime + +from tzlocal import get_localzone +from requests_oauthlib import OAuth2Session +from oauthlib.oauth2 import BackendApplicationClient + +from .exceptions import PyKubeError + + +def build_session(config, gcloud_file=None): + """ + Creates a new session for the client. + """ + if "token" in config.user and config.user["token"]: + s = _session_object("token") + _set_bearer_token(s, config.user["token"]) + elif "auth-provider" in config.user: + s = _session_object("gcp", config, gcloud_file) + elif "client-certificate" in config.user: + s = _session_object("client-certificate") + s.cert = ( + config.user["client-certificate"].filename(), + config.user["client-key"].filename(), + ) + elif config.user.get("username") and config.user.get("password"): + s = _session_object("basic-auth") + s.auth = (config.user["username"], config.user["password"]) + else: # no user present; don't configure anything + s = _session_object() + + if "certificate-authority" in config.cluster: + s.verify = config.cluster["certificate-authority"].filename() + elif "insecure-skip-tls-verify" in config.cluster: + s.verify = not config.cluster["insecure-skip-tls-verify"] + return s + + +def _session_object(strategy=None, config=None, gcloud_file=None): + if strategy in ["token", "client-certificate", "basic-auth"]: + return requests.Session() + elif strategy in ["gcp"]: + return GCPSession(config, gcloud_file).create() + else: + return requests.Session() + + +def _set_bearer_token(session, token): + """ + Set the bearer authorization token for the session. + """ + session.headers["Authorization"] = "Bearer {}".format(token) + + +class GCPSession(object): + + oauth = None + token_url = u'https://www.googleapis.com/oauth2/v4/token' + userinfo_url = u'https://www.googleapis.com/oauth2/v1/userinfo' + gcloud_well_known_file = os.path.join(os.path.expanduser('~'), + ".config/gcloud/application_default_credentials.json") + + scope = ["https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/appengine.admin", + "https://www.googleapis.com/auth/compute", + "https://www.googleapis.com/auth/plus.me"] + + def __init__(self, config, gcloud_file=None): + self.config = config + if gcloud_file: + self.gcloud_well_known_file = gcloud_file + self.client_id, self.client_secret, self.refresh_token = self._load_default_gcloud_credentials() + client = BackendApplicationClient(client_id=self.client_id) + self.oauth = OAuth2Session(client=client, scope=self.scope) + token = { + 'access_token': self.access_token, + 'refresh_token': self.refresh_token, + 'token_type': 'Bearer', + 'expires_in': '3600', + } + + self.oauth.token = token + + @property + def access_token(self): + return self.config.user['auth-provider'].get('config', {}).get('access-token') + + @property + def expired_token(self): + return self.oauth.get(self.userinfo_url).status_code == 401 + + def create(self): + if not self.access_token or self.expired_token: + # Getting access token from gcp + self._update_token() + + return self.oauth + + def _update_token(self): + tok = self.oauth.refresh_token(self.token_url, client_id=self.client_id, + client_secret=self.client_secret, + refresh_token=self.refresh_token) + self._persist_token(tok) + + def _persist_token(self, tok): + user_name = self.config.contexts[self.config.current_context]['user'] + user = [u['user'] for u in self.config.doc['users'] if u['name'] == user_name][0] + if 'config' not in user['auth-provider']: + user['auth-provider']['config'] = {} + user['auth-provider']['config']['access-token'] = tok['access_token'] + date_expires = datetime.datetime.fromtimestamp(tok['expires_at']) + local_tz = get_localzone() + user['auth-provider']['config']['expiry'] = local_tz.localize(date_expires).isoformat() + self.config.persist_doc() + self.config.reload() + + def _load_default_gcloud_credentials(self): + if not os.path.exists(self.gcloud_well_known_file): + raise PyKubeError('Google cloud well known file missing, configure your gcloud session') + with open(self.gcloud_well_known_file) as f: + data = json.loads(f.read()) + return data['client_id'], data['client_secret'], data['refresh_token'] diff --git a/setup.py b/setup.py index 3b038ba..6dd5a80 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,9 @@ packages=find_packages(), install_requires=[ "requests", + "requests-oauthlib", "PyYAML", "six", + "tzlocal", ], ) diff --git a/test/test_http.py b/test/test_http.py index 523c10b..c53170a 100644 --- a/test/test_http.py +++ b/test/test_http.py @@ -23,5 +23,5 @@ def tearDown(self): def test_build_session_basic(self): """ """ - session = HTTPClient(self.cfg).build_session() + session = HTTPClient(self.cfg).session self.assertEqual(session.auth, ('adm', 'somepassword')) diff --git a/test/test_httpclient.py b/test/test_httpclient.py index f267f36..18e3b0d 100644 --- a/test/test_httpclient.py +++ b/test/test_httpclient.py @@ -102,22 +102,15 @@ def test_no_auth_with_no_user(self): client = pykube.HTTPClient(pykube.KubeConfig(doc=config)) self.ensure_no_auth(client) - def test_build_session_auth_provider(self): - """Test that HTTPClient correctly parses the auth-provider config. - - Observed in GKE with kubelet v1.3. + def test_build_session_bearer_token(self): + """Test that HTTPClient correctly parses the token """ self.config.update({ 'users': [ { 'name': 'test-user', 'user': { - 'auth-provider': { - 'config': { - 'access-token': 'abc', - 'expiry': '2016-08-24T16:19:17.19878675-07:00', - }, - }, + 'token': 'test' }, }, ] @@ -127,4 +120,4 @@ def test_build_session_auth_provider(self): client = pykube.HTTPClient(pykube.KubeConfig(doc=self.config)) _log.debug('Checking headers %s', client.session.headers) self.assertIn('Authorization', client.session.headers) - self.assertEqual(client.session.headers['Authorization'], 'Bearer abc') + self.assertEqual(client.session.headers['Authorization'], 'Bearer test') diff --git a/test/test_session.py b/test/test_session.py new file mode 100644 index 0000000..9520f25 --- /dev/null +++ b/test/test_session.py @@ -0,0 +1,93 @@ +""" +pykube.http unittests +""" +import os +import copy +import logging +import tempfile + +import pykube + +from . import TestCase + +BASE_CONFIG = { + "clusters": [ + { + "name": "test-cluster", + "cluster": { + "server": "http://localhost:8080", + } + } + ], + "contexts": [ + { + "name": "test-cluster", + "context": { + "cluster": "test-cluster", + "user": "test-user", + } + } + ], + "users": [ + { + 'name': 'test-user', + 'user': {}, + } + ], + "current-context": "test-cluster", +} + +_log = logging.getLogger(__name__) + + +class TestSession(TestCase): + + def setUp(self): + self.config = copy.deepcopy(BASE_CONFIG) + + def test_build_session_auth_provider(self): + """Test that HTTPClient correctly parses the auth-provider config. + + Observed in GKE with kubelet v1.3. + """ + self.config.update({ + 'users': [ + { + 'name': 'test-user', + 'user': { + 'auth-provider': { + 'config': { + 'access-token': 'abc', + 'expiry': '2016-08-24T16:19:17.19878675-07:00', + }, + }, + }, + }, + ] + }) + + gcloud_content = """ +{ + "client_id": "myclientid", + "client_secret": "myclientsecret", + "refresh_token": "myrefreshtoken", + "type": "authorized_user" +} + +""" + + _log.info('Built config: %s', self.config) + try: + tmp = tempfile.mktemp() + with open(tmp, 'w') as f: + f.write(gcloud_content) + + session= pykube.session.GCPSession(pykube.KubeConfig(doc=self.config), tmp) + self.assertEquals(session.oauth.token['access_token'], 'abc') + self.assertEquals(session.oauth.token['refresh_token'], 'myrefreshtoken') + self.assertEquals(session.client_id, 'myclientid') + self.assertEquals(session.client_secret, 'myclientsecret') + finally: + if os.path.exists(tmp): + os.remove(tmp) +