├── code-of-conduct.md ├── tox.ini ├── watch ├── __init__.py ├── watch.py └── watch_test.py ├── stream ├── __init__.py ├── stream.py ├── ws_client_test.py └── ws_client.py ├── config ├── config_exception.py ├── __init__.py ├── dateutil_test.py ├── dateutil.py ├── incluster_config.py ├── incluster_config_test.py ├── kube_config.py └── kube_config_test.py ├── .travis.yml ├── README.md ├── .gitignore ├── run_tox.sh └── LICENSE /code-of-conduct.md: -------------------------------------------------------------------------------- 1 | # Kubernetes Community Code of Conduct 2 | 3 | Please refer to our [Kubernetes Community Code of Conduct](https://git.k8s.io/community/code-of-conduct.md) 4 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | envlist = py27, py34, py35, py36 4 | 5 | [testenv] 6 | passenv = TOXENV CI TRAVIS TRAVIS_* 7 | commands = 8 | python -V 9 | pip install nose 10 | ./run_tox.sh nosetests [] 11 | 12 | -------------------------------------------------------------------------------- /watch/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 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 | from .watch import Watch 16 | -------------------------------------------------------------------------------- /stream/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 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 | from .stream import stream 16 | -------------------------------------------------------------------------------- /config/config_exception.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 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 | 16 | class ConfigException(Exception): 17 | pass 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # ref: https://docs.travis-ci.com/user/languages/python 2 | language: python 3 | dist: trusty 4 | sudo: required 5 | 6 | matrix: 7 | include: 8 | - python: 2.7 9 | env: TOXENV=py27 10 | - python: 2.7 11 | env: TOXENV=py27-functional 12 | - python: 2.7 13 | env: TOXENV=update-pep8 14 | - python: 2.7 15 | env: TOXENV=docs 16 | - python: 2.7 17 | env: TOXENV=coverage,codecov 18 | - python: 3.4 19 | env: TOXENV=py34 20 | - python: 3.5 21 | env: TOXENV=py35 22 | - python: 3.5 23 | env: TOXENV=py35-functional 24 | - python: 3.6 25 | env: TOXENV=py36 26 | - python: 3.6 27 | env: TOXENV=py36-functional 28 | 29 | install: 30 | - pip install tox 31 | 32 | script: 33 | - ./run_tox.sh tox 34 | 35 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 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 | from .config_exception import ConfigException 16 | from .incluster_config import load_incluster_config 17 | from .kube_config import (list_kube_config_contexts, load_kube_config, 18 | new_client_from_config) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-base 2 | 3 | [![Build Status](https://travis-ci.org/kubernetes-client/python-base.svg?branch=master)](https://travis-ci.org/kubernetes-client/python-base) 4 | 5 | This is the utility part of the [python client](https://github.com/kubernetes-client/python). It has been added to the main 6 | repo using git submodules. This structure allow other developers to create 7 | their own kubernetes client and still use standard kubernetes python utilities. 8 | For more information refer to [clients-library-structure](https://github.com/kubernetes-client/community/blob/master/design-docs/clients-library-structure.md). 9 | 10 | # Development 11 | Any changes to utilites in this repo should be send as a PR to this repo. After 12 | the PR is merged, developers should create another PR in the main repo to update 13 | the submodule. See [this document](https://github.com/kubernetes-client/python/blob/master/devel/submodules.md) for more guidelines. 14 | -------------------------------------------------------------------------------- /stream/stream.py: -------------------------------------------------------------------------------- 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 . import ws_client 14 | 15 | 16 | def stream(func, *args, **kwargs): 17 | """Stream given API call using websocket""" 18 | 19 | def _intercept_request_call(*args, **kwargs): 20 | # old generated code's api client has config. new ones has 21 | # configuration 22 | try: 23 | config = func.__self__.api_client.configuration 24 | except AttributeError: 25 | config = func.__self__.api_client.config 26 | 27 | return ws_client.websocket_call(config, *args, **kwargs) 28 | 29 | prev_request = func.__self__.api_client.request 30 | try: 31 | func.__self__.api_client.request = _intercept_request_call 32 | return func(*args, **kwargs) 33 | finally: 34 | func.__self__.api_client.request = prev_request 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # Intellij IDEA files 92 | .idea/* 93 | *.iml 94 | .vscode 95 | 96 | -------------------------------------------------------------------------------- /stream/ws_client_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 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 unittest 16 | 17 | from .ws_client import get_websocket_url 18 | 19 | 20 | class WSClientTest(unittest.TestCase): 21 | 22 | def test_websocket_client(self): 23 | for url, ws_url in [ 24 | ('http://localhost/api', 'ws://localhost/api'), 25 | ('https://localhost/api', 'wss://localhost/api'), 26 | ('https://domain.com/api', 'wss://domain.com/api'), 27 | ('https://api.domain.com/api', 'wss://api.domain.com/api'), 28 | ('http://api.domain.com', 'ws://api.domain.com'), 29 | ('https://api.domain.com', 'wss://api.domain.com'), 30 | ('http://api.domain.com/', 'ws://api.domain.com/'), 31 | ('https://api.domain.com/', 'wss://api.domain.com/'), 32 | ]: 33 | self.assertEqual(get_websocket_url(url), ws_url) 34 | 35 | 36 | if __name__ == '__main__': 37 | unittest.main() 38 | -------------------------------------------------------------------------------- /run_tox.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2017 The Kubernetes Authors. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -o errexit 18 | set -o nounset 19 | set -o pipefail 20 | 21 | RUNNING_DIR=$(pwd) 22 | TMP_DIR=$(mktemp -d) 23 | 24 | function cleanup() 25 | { 26 | cd "${RUNNING_DIR}" 27 | } 28 | trap cleanup EXIT SIGINT 29 | 30 | 31 | SCRIPT_ROOT=$(dirname "${BASH_SOURCE}") 32 | pushd "${SCRIPT_ROOT}" > /dev/null 33 | SCRIPT_ROOT=`pwd` 34 | popd > /dev/null 35 | 36 | cd "${TMP_DIR}" 37 | git clone https://github.com/kubernetes-client/python.git 38 | cd python 39 | git config user.email "kubernetes-client@k8s.com" 40 | git config user.name "kubenetes client" 41 | git rm -rf kubernetes/base 42 | git commit -m "DO NOT MERGE, removing submodule for testing only" 43 | mkdir kubernetes/base 44 | cp -r "${SCRIPT_ROOT}/." kubernetes/base 45 | rm -rf kubernetes/base/.git 46 | rm -rf kubernetes/base/.tox 47 | git add kubernetes/base 48 | git commit -m "DO NOT MERGE, adding changes for testing." 49 | git status 50 | 51 | echo "Running tox from the main repo on $TOXENV environment" 52 | # Run the user-provided command. 53 | "${@}" 54 | 55 | -------------------------------------------------------------------------------- /config/dateutil_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 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 unittest 16 | from datetime import datetime 17 | 18 | from .dateutil import UTC, TimezoneInfo, format_rfc3339, parse_rfc3339 19 | 20 | 21 | class DateUtilTest(unittest.TestCase): 22 | 23 | def _parse_rfc3339_test(self, st, y, m, d, h, mn, s): 24 | actual = parse_rfc3339(st) 25 | expected = datetime(y, m, d, h, mn, s, 0, UTC) 26 | self.assertEqual(expected, actual) 27 | 28 | def test_parse_rfc3339(self): 29 | self._parse_rfc3339_test("2017-07-25T04:44:21Z", 30 | 2017, 7, 25, 4, 44, 21) 31 | self._parse_rfc3339_test("2017-07-25 04:44:21Z", 32 | 2017, 7, 25, 4, 44, 21) 33 | self._parse_rfc3339_test("2017-07-25T04:44:21", 34 | 2017, 7, 25, 4, 44, 21) 35 | self._parse_rfc3339_test("2017-07-25T04:44:21z", 36 | 2017, 7, 25, 4, 44, 21) 37 | self._parse_rfc3339_test("2017-07-25T04:44:21+03:00", 38 | 2017, 7, 25, 1, 44, 21) 39 | self._parse_rfc3339_test("2017-07-25T04:44:21-03:00", 40 | 2017, 7, 25, 7, 44, 21) 41 | 42 | def test_format_rfc3339(self): 43 | self.assertEqual( 44 | format_rfc3339(datetime(2017, 7, 25, 4, 44, 21, 0, UTC)), 45 | "2017-07-25T04:44:21Z") 46 | self.assertEqual( 47 | format_rfc3339(datetime(2017, 7, 25, 4, 44, 21, 0, 48 | TimezoneInfo(2, 0))), 49 | "2017-07-25T02:44:21Z") 50 | self.assertEqual( 51 | format_rfc3339(datetime(2017, 7, 25, 4, 44, 21, 0, 52 | TimezoneInfo(-2, 30))), 53 | "2017-07-25T07:14:21Z") 54 | -------------------------------------------------------------------------------- /config/dateutil.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 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 datetime 16 | import math 17 | import re 18 | 19 | 20 | class TimezoneInfo(datetime.tzinfo): 21 | def __init__(self, h, m): 22 | self._name = "UTC" 23 | if h != 0 and m != 0: 24 | self._name += "%+03d:%2d" % (h, m) 25 | self._delta = datetime.timedelta(hours=h, minutes=math.copysign(m, h)) 26 | 27 | def utcoffset(self, dt): 28 | return self._delta 29 | 30 | def tzname(self, dt): 31 | return self._name 32 | 33 | def dst(self, dt): 34 | return datetime.timedelta(0) 35 | 36 | 37 | UTC = TimezoneInfo(0, 0) 38 | 39 | # ref https://www.ietf.org/rfc/rfc3339.txt 40 | _re_rfc3339 = re.compile(r"(\d\d\d\d)-(\d\d)-(\d\d)" # full-date 41 | r"[ Tt]" # Separator 42 | r"(\d\d):(\d\d):(\d\d)([.,]\d+)?" # partial-time 43 | r"([zZ ]|[-+]\d\d?:\d\d)?", # time-offset 44 | re.VERBOSE + re.IGNORECASE) 45 | _re_timezone = re.compile(r"([-+])(\d\d?):?(\d\d)?") 46 | 47 | 48 | def parse_rfc3339(s): 49 | if isinstance(s, datetime.datetime): 50 | # no need to parse it, just make sure it has a timezone. 51 | if not s.tzinfo: 52 | return s.replace(tzinfo=UTC) 53 | return s 54 | groups = _re_rfc3339.search(s).groups() 55 | dt = [0] * 7 56 | for x in range(6): 57 | dt[x] = int(groups[x]) 58 | if groups[6] is not None: 59 | dt[6] = int(groups[6]) 60 | tz = UTC 61 | if groups[7] is not None and groups[7] != 'Z' and groups[7] != 'z': 62 | tz_groups = _re_timezone.search(groups[7]).groups() 63 | hour = int(tz_groups[1]) 64 | minute = 0 65 | if tz_groups[0] == "-": 66 | hour *= -1 67 | if tz_groups[2]: 68 | minute = int(tz_groups[2]) 69 | tz = TimezoneInfo(hour, minute) 70 | return datetime.datetime( 71 | year=dt[0], month=dt[1], day=dt[2], 72 | hour=dt[3], minute=dt[4], second=dt[5], 73 | microsecond=dt[6], tzinfo=tz) 74 | 75 | 76 | def format_rfc3339(date_time): 77 | if date_time.tzinfo is None: 78 | date_time = date_time.replace(tzinfo=UTC) 79 | date_time = date_time.astimezone(UTC) 80 | return date_time.strftime('%Y-%m-%dT%H:%M:%SZ') 81 | -------------------------------------------------------------------------------- /config/incluster_config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 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 os 16 | 17 | from kubernetes.client import Configuration 18 | 19 | from .config_exception import ConfigException 20 | 21 | SERVICE_HOST_ENV_NAME = "KUBERNETES_SERVICE_HOST" 22 | SERVICE_PORT_ENV_NAME = "KUBERNETES_SERVICE_PORT" 23 | SERVICE_TOKEN_FILENAME = "/var/run/secrets/kubernetes.io/serviceaccount/token" 24 | SERVICE_CERT_FILENAME = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" 25 | 26 | 27 | def _join_host_port(host, port): 28 | """Adapted golang's net.JoinHostPort""" 29 | template = "%s:%s" 30 | host_requires_bracketing = ':' in host or '%' in host 31 | if host_requires_bracketing: 32 | template = "[%s]:%s" 33 | return template % (host, port) 34 | 35 | 36 | class InClusterConfigLoader(object): 37 | 38 | def __init__(self, token_filename, 39 | cert_filename, environ=os.environ): 40 | self._token_filename = token_filename 41 | self._cert_filename = cert_filename 42 | self._environ = environ 43 | 44 | def load_and_set(self): 45 | self._load_config() 46 | self._set_config() 47 | 48 | def _load_config(self): 49 | if (SERVICE_HOST_ENV_NAME not in self._environ or 50 | SERVICE_PORT_ENV_NAME not in self._environ): 51 | raise ConfigException("Service host/port is not set.") 52 | 53 | if (not self._environ[SERVICE_HOST_ENV_NAME] or 54 | not self._environ[SERVICE_PORT_ENV_NAME]): 55 | raise ConfigException("Service host/port is set but empty.") 56 | 57 | self.host = ( 58 | "https://" + _join_host_port(self._environ[SERVICE_HOST_ENV_NAME], 59 | self._environ[SERVICE_PORT_ENV_NAME])) 60 | 61 | if not os.path.isfile(self._token_filename): 62 | raise ConfigException("Service token file does not exists.") 63 | 64 | with open(self._token_filename) as f: 65 | self.token = f.read() 66 | if not self.token: 67 | raise ConfigException("Token file exists but empty.") 68 | 69 | if not os.path.isfile(self._cert_filename): 70 | raise ConfigException( 71 | "Service certification file does not exists.") 72 | 73 | with open(self._cert_filename) as f: 74 | if not f.read(): 75 | raise ConfigException("Cert file exists but empty.") 76 | 77 | self.ssl_ca_cert = self._cert_filename 78 | 79 | def _set_config(self): 80 | configuration = Configuration() 81 | configuration.host = self.host 82 | configuration.ssl_ca_cert = self.ssl_ca_cert 83 | configuration.api_key['authorization'] = "bearer " + self.token 84 | Configuration.set_default(configuration) 85 | 86 | 87 | def load_incluster_config(): 88 | """Use the service account kubernetes gives to pods to connect to kubernetes 89 | cluster. It's intended for clients that expect to be running inside a pod 90 | running on kubernetes. It will raise an exception if called from a process 91 | not running in a kubernetes environment.""" 92 | InClusterConfigLoader(token_filename=SERVICE_TOKEN_FILENAME, 93 | cert_filename=SERVICE_CERT_FILENAME).load_and_set() 94 | -------------------------------------------------------------------------------- /watch/watch.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 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 pydoc 17 | 18 | from kubernetes import client 19 | 20 | PYDOC_RETURN_LABEL = ":return:" 21 | 22 | # Removing this suffix from return type name should give us event's object 23 | # type. e.g., if list_namespaces() returns "NamespaceList" type, 24 | # then list_namespaces(watch=true) returns a stream of events with objects 25 | # of type "Namespace". In case this assumption is not true, user should 26 | # provide return_type to Watch class's __init__. 27 | TYPE_LIST_SUFFIX = "List" 28 | 29 | 30 | class SimpleNamespace: 31 | 32 | def __init__(self, **kwargs): 33 | self.__dict__.update(kwargs) 34 | 35 | 36 | def _find_return_type(func): 37 | for line in pydoc.getdoc(func).splitlines(): 38 | if line.startswith(PYDOC_RETURN_LABEL): 39 | return line[len(PYDOC_RETURN_LABEL):].strip() 40 | return "" 41 | 42 | 43 | def iter_resp_lines(resp): 44 | prev = "" 45 | for seg in resp.read_chunked(decode_content=False): 46 | if isinstance(seg, bytes): 47 | seg = seg.decode('utf8') 48 | seg = prev + seg 49 | lines = seg.split("\n") 50 | if not seg.endswith("\n"): 51 | prev = lines[-1] 52 | lines = lines[:-1] 53 | else: 54 | prev = "" 55 | for line in lines: 56 | if line: 57 | yield line 58 | 59 | 60 | class Watch(object): 61 | 62 | def __init__(self, return_type=None): 63 | self._raw_return_type = return_type 64 | self._stop = False 65 | self._api_client = client.ApiClient() 66 | self.resource_version = 0 67 | 68 | def stop(self): 69 | self._stop = True 70 | 71 | def get_return_type(self, func): 72 | if self._raw_return_type: 73 | return self._raw_return_type 74 | return_type = _find_return_type(func) 75 | if return_type.endswith(TYPE_LIST_SUFFIX): 76 | return return_type[:-len(TYPE_LIST_SUFFIX)] 77 | return return_type 78 | 79 | def unmarshal_event(self, data, return_type): 80 | js = json.loads(data) 81 | js['raw_object'] = js['object'] 82 | if return_type: 83 | obj = SimpleNamespace(data=json.dumps(js['raw_object'])) 84 | js['object'] = self._api_client.deserialize(obj, return_type) 85 | if hasattr(js['object'], 'metadata'): 86 | self.resource_version = js['object'].metadata.resource_version 87 | return js 88 | 89 | def stream(self, func, *args, **kwargs): 90 | """Watch an API resource and stream the result back via a generator. 91 | 92 | :param func: The API function pointer. Any parameter to the function 93 | can be passed after this parameter. 94 | 95 | :return: Event object with these keys: 96 | 'type': The type of event such as "ADDED", "DELETED", etc. 97 | 'raw_object': a dict representing the watched object. 98 | 'object': A model representation of raw_object. The name of 99 | model will be determined based on 100 | the func's doc string. If it cannot be determined, 101 | 'object' value will be the same as 'raw_object'. 102 | 103 | Example: 104 | v1 = kubernetes.client.CoreV1Api() 105 | watch = kubernetes.watch.Watch() 106 | for e in watch.stream(v1.list_namespace, resource_version=1127): 107 | type = e['type'] 108 | object = e['object'] # object is one of type return_type 109 | raw_object = e['raw_object'] # raw_object is a dict 110 | ... 111 | if should_stop: 112 | watch.stop() 113 | """ 114 | 115 | self._stop = False 116 | return_type = self.get_return_type(func) 117 | kwargs['watch'] = True 118 | kwargs['_preload_content'] = False 119 | 120 | timeouts = ('timeout_seconds' in kwargs) 121 | while True: 122 | resp = func(*args, **kwargs) 123 | try: 124 | for line in iter_resp_lines(resp): 125 | yield self.unmarshal_event(line, return_type) 126 | if self._stop: 127 | break 128 | finally: 129 | kwargs['resource_version'] = self.resource_version 130 | resp.close() 131 | resp.release_conn() 132 | 133 | if timeouts or self._stop: 134 | break 135 | -------------------------------------------------------------------------------- /config/incluster_config_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 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 os 16 | import tempfile 17 | import unittest 18 | 19 | from .config_exception import ConfigException 20 | from .incluster_config import (SERVICE_HOST_ENV_NAME, SERVICE_PORT_ENV_NAME, 21 | InClusterConfigLoader, _join_host_port) 22 | 23 | _TEST_TOKEN = "temp_token" 24 | _TEST_CERT = "temp_cert" 25 | _TEST_HOST = "127.0.0.1" 26 | _TEST_PORT = "80" 27 | _TEST_HOST_PORT = "127.0.0.1:80" 28 | _TEST_IPV6_HOST = "::1" 29 | _TEST_IPV6_HOST_PORT = "[::1]:80" 30 | 31 | _TEST_ENVIRON = {SERVICE_HOST_ENV_NAME: _TEST_HOST, 32 | SERVICE_PORT_ENV_NAME: _TEST_PORT} 33 | _TEST_IPV6_ENVIRON = {SERVICE_HOST_ENV_NAME: _TEST_IPV6_HOST, 34 | SERVICE_PORT_ENV_NAME: _TEST_PORT} 35 | 36 | 37 | class InClusterConfigTest(unittest.TestCase): 38 | 39 | def setUp(self): 40 | self._temp_files = [] 41 | 42 | def tearDown(self): 43 | for f in self._temp_files: 44 | os.remove(f) 45 | 46 | def _create_file_with_temp_content(self, content=""): 47 | handler, name = tempfile.mkstemp() 48 | self._temp_files.append(name) 49 | os.write(handler, str.encode(content)) 50 | os.close(handler) 51 | return name 52 | 53 | def get_test_loader( 54 | self, 55 | token_filename=None, 56 | cert_filename=None, 57 | environ=_TEST_ENVIRON): 58 | if not token_filename: 59 | token_filename = self._create_file_with_temp_content(_TEST_TOKEN) 60 | if not cert_filename: 61 | cert_filename = self._create_file_with_temp_content(_TEST_CERT) 62 | return InClusterConfigLoader( 63 | token_filename=token_filename, 64 | cert_filename=cert_filename, 65 | environ=environ) 66 | 67 | def test_join_host_port(self): 68 | self.assertEqual(_TEST_HOST_PORT, 69 | _join_host_port(_TEST_HOST, _TEST_PORT)) 70 | self.assertEqual(_TEST_IPV6_HOST_PORT, 71 | _join_host_port(_TEST_IPV6_HOST, _TEST_PORT)) 72 | 73 | def test_load_config(self): 74 | cert_filename = self._create_file_with_temp_content(_TEST_CERT) 75 | loader = self.get_test_loader(cert_filename=cert_filename) 76 | loader._load_config() 77 | self.assertEqual("https://" + _TEST_HOST_PORT, loader.host) 78 | self.assertEqual(cert_filename, loader.ssl_ca_cert) 79 | self.assertEqual(_TEST_TOKEN, loader.token) 80 | 81 | def _should_fail_load(self, config_loader, reason): 82 | try: 83 | config_loader.load_and_set() 84 | self.fail("Should fail because %s" % reason) 85 | except ConfigException: 86 | # expected 87 | pass 88 | 89 | def test_no_port(self): 90 | loader = self.get_test_loader( 91 | environ={SERVICE_HOST_ENV_NAME: _TEST_HOST}) 92 | self._should_fail_load(loader, "no port specified") 93 | 94 | def test_empty_port(self): 95 | loader = self.get_test_loader( 96 | environ={SERVICE_HOST_ENV_NAME: _TEST_HOST, 97 | SERVICE_PORT_ENV_NAME: ""}) 98 | self._should_fail_load(loader, "empty port specified") 99 | 100 | def test_no_host(self): 101 | loader = self.get_test_loader( 102 | environ={SERVICE_PORT_ENV_NAME: _TEST_PORT}) 103 | self._should_fail_load(loader, "no host specified") 104 | 105 | def test_empty_host(self): 106 | loader = self.get_test_loader( 107 | environ={SERVICE_HOST_ENV_NAME: "", 108 | SERVICE_PORT_ENV_NAME: _TEST_PORT}) 109 | self._should_fail_load(loader, "empty host specified") 110 | 111 | def test_no_cert_file(self): 112 | loader = self.get_test_loader(cert_filename="not_exists_file_1123") 113 | self._should_fail_load(loader, "cert file does not exists") 114 | 115 | def test_empty_cert_file(self): 116 | loader = self.get_test_loader( 117 | cert_filename=self._create_file_with_temp_content()) 118 | self._should_fail_load(loader, "empty cert file provided") 119 | 120 | def test_no_token_file(self): 121 | loader = self.get_test_loader(token_filename="not_exists_file_1123") 122 | self._should_fail_load(loader, "token file does not exists") 123 | 124 | def test_empty_token_file(self): 125 | loader = self.get_test_loader( 126 | token_filename=self._create_file_with_temp_content()) 127 | self._should_fail_load(loader, "empty token file provided") 128 | 129 | 130 | if __name__ == '__main__': 131 | unittest.main() 132 | -------------------------------------------------------------------------------- /watch/watch_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 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 unittest 16 | 17 | from mock import Mock 18 | 19 | from .watch import Watch 20 | 21 | 22 | class WatchTests(unittest.TestCase): 23 | 24 | def test_watch_with_decode(self): 25 | fake_resp = Mock() 26 | fake_resp.close = Mock() 27 | fake_resp.release_conn = Mock() 28 | fake_resp.read_chunked = Mock( 29 | return_value=[ 30 | '{"type": "ADDED", "object": {"metadata": {"name": "test1"}' 31 | ',"spec": {}, "status": {}}}\n', 32 | '{"type": "ADDED", "object": {"metadata": {"name": "test2"}' 33 | ',"spec": {}, "sta', 34 | 'tus": {}}}\n' 35 | '{"type": "ADDED", "object": {"metadata": {"name": "test3"},' 36 | '"spec": {}, "status": {}}}\n', 37 | 'should_not_happened\n']) 38 | 39 | fake_api = Mock() 40 | fake_api.get_namespaces = Mock(return_value=fake_resp) 41 | fake_api.get_namespaces.__doc__ = ':return: V1NamespaceList' 42 | 43 | w = Watch() 44 | count = 1 45 | for e in w.stream(fake_api.get_namespaces): 46 | self.assertEqual("ADDED", e['type']) 47 | # make sure decoder worked and we got a model with the right name 48 | self.assertEqual("test%d" % count, e['object'].metadata.name) 49 | count += 1 50 | # make sure we can stop the watch and the last event with won't be 51 | # returned 52 | if count == 4: 53 | w.stop() 54 | 55 | fake_api.get_namespaces.assert_called_once_with( 56 | _preload_content=False, watch=True) 57 | fake_resp.read_chunked.assert_called_once_with(decode_content=False) 58 | fake_resp.close.assert_called_once() 59 | fake_resp.release_conn.assert_called_once() 60 | 61 | def test_watch_stream_twice(self): 62 | w = Watch(float) 63 | for step in ['first', 'second']: 64 | fake_resp = Mock() 65 | fake_resp.close = Mock() 66 | fake_resp.release_conn = Mock() 67 | fake_resp.read_chunked = Mock( 68 | return_value=['{"type": "ADDED", "object": 1}\n'] * 4) 69 | 70 | fake_api = Mock() 71 | fake_api.get_namespaces = Mock(return_value=fake_resp) 72 | fake_api.get_namespaces.__doc__ = ':return: V1NamespaceList' 73 | 74 | count = 1 75 | for e in w.stream(fake_api.get_namespaces): 76 | count += 1 77 | if count == 3: 78 | w.stop() 79 | 80 | self.assertEqual(count, 3) 81 | fake_api.get_namespaces.assert_called_once_with( 82 | _preload_content=False, watch=True) 83 | fake_resp.read_chunked.assert_called_once_with( 84 | decode_content=False) 85 | fake_resp.close.assert_called_once() 86 | fake_resp.release_conn.assert_called_once() 87 | 88 | def test_watch_stream_loop(self): 89 | w = Watch(float) 90 | 91 | fake_resp = Mock() 92 | fake_resp.close = Mock() 93 | fake_resp.release_conn = Mock() 94 | fake_resp.read_chunked = Mock( 95 | return_value=['{"type": "ADDED", "object": 1}\n']) 96 | 97 | fake_api = Mock() 98 | fake_api.get_namespaces = Mock(return_value=fake_resp) 99 | fake_api.get_namespaces.__doc__ = ':return: V1NamespaceList' 100 | 101 | count = 0 102 | 103 | # when timeout_seconds is set, auto-exist when timeout reaches 104 | for e in w.stream(fake_api.get_namespaces, timeout_seconds=1): 105 | count = count + 1 106 | self.assertEqual(count, 1) 107 | 108 | # when no timeout_seconds, only exist when w.stop() is called 109 | for e in w.stream(fake_api.get_namespaces): 110 | count = count + 1 111 | if count == 2: 112 | w.stop() 113 | 114 | self.assertEqual(count, 2) 115 | self.assertEqual(fake_api.get_namespaces.call_count, 2) 116 | self.assertEqual(fake_resp.read_chunked.call_count, 2) 117 | self.assertEqual(fake_resp.close.call_count, 2) 118 | self.assertEqual(fake_resp.release_conn.call_count, 2) 119 | 120 | def test_unmarshal_with_float_object(self): 121 | w = Watch() 122 | event = w.unmarshal_event('{"type": "ADDED", "object": 1}', 'float') 123 | self.assertEqual("ADDED", event['type']) 124 | self.assertEqual(1.0, event['object']) 125 | self.assertTrue(isinstance(event['object'], float)) 126 | self.assertEqual(1, event['raw_object']) 127 | 128 | def test_unmarshal_with_no_return_type(self): 129 | w = Watch() 130 | event = w.unmarshal_event('{"type": "ADDED", "object": ["test1"]}', 131 | None) 132 | self.assertEqual("ADDED", event['type']) 133 | self.assertEqual(["test1"], event['object']) 134 | self.assertEqual(["test1"], event['raw_object']) 135 | 136 | def test_watch_with_exception(self): 137 | fake_resp = Mock() 138 | fake_resp.close = Mock() 139 | fake_resp.release_conn = Mock() 140 | fake_resp.read_chunked = Mock(side_effect=KeyError('expected')) 141 | 142 | fake_api = Mock() 143 | fake_api.get_thing = Mock(return_value=fake_resp) 144 | 145 | w = Watch() 146 | try: 147 | for _ in w.stream(fake_api.get_thing): 148 | self.fail(self, "Should fail on exception.") 149 | except KeyError: 150 | pass 151 | # expected 152 | 153 | fake_api.get_thing.assert_called_once_with( 154 | _preload_content=False, watch=True) 155 | fake_resp.read_chunked.assert_called_once_with(decode_content=False) 156 | fake_resp.close.assert_called_once() 157 | fake_resp.release_conn.assert_called_once() 158 | 159 | 160 | if __name__ == '__main__': 161 | unittest.main() 162 | -------------------------------------------------------------------------------- /stream/ws_client.py: -------------------------------------------------------------------------------- 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 kubernetes.client.rest import ApiException 14 | 15 | import select 16 | import certifi 17 | import time 18 | import collections 19 | from websocket import WebSocket, ABNF, enableTrace 20 | import six 21 | import ssl 22 | from six.moves.urllib.parse import urlencode, quote_plus, urlparse, urlunparse 23 | 24 | STDIN_CHANNEL = 0 25 | STDOUT_CHANNEL = 1 26 | STDERR_CHANNEL = 2 27 | ERROR_CHANNEL = 3 28 | RESIZE_CHANNEL = 4 29 | 30 | 31 | class WSClient: 32 | def __init__(self, configuration, url, headers): 33 | """A websocket client with support for channels. 34 | 35 | Exec command uses different channels for different streams. for 36 | example, 0 is stdin, 1 is stdout and 2 is stderr. Some other API calls 37 | like port forwarding can forward different pods' streams to different 38 | channels. 39 | """ 40 | enableTrace(False) 41 | header = [] 42 | self._connected = False 43 | self._channels = {} 44 | self._all = "" 45 | 46 | # We just need to pass the Authorization, ignore all the other 47 | # http headers we get from the generated code 48 | if headers and 'authorization' in headers: 49 | header.append("authorization: %s" % headers['authorization']) 50 | 51 | if headers and 'sec-websocket-protocol' in headers: 52 | header.append("sec-websocket-protocol: %s" % headers['sec-websocket-protocol']) 53 | else: 54 | header.append("sec-websocket-protocol: v4.channel.k8s.io") 55 | 56 | if url.startswith('wss://') and configuration.verify_ssl: 57 | ssl_opts = { 58 | 'cert_reqs': ssl.CERT_REQUIRED, 59 | 'ca_certs': configuration.ssl_ca_cert or certifi.where(), 60 | } 61 | if configuration.assert_hostname is not None: 62 | ssl_opts['check_hostname'] = configuration.assert_hostname 63 | else: 64 | ssl_opts = {'cert_reqs': ssl.CERT_NONE} 65 | 66 | if configuration.cert_file: 67 | ssl_opts['certfile'] = configuration.cert_file 68 | if configuration.key_file: 69 | ssl_opts['keyfile'] = configuration.key_file 70 | 71 | self.sock = WebSocket(sslopt=ssl_opts, skip_utf8_validation=False) 72 | self.sock.connect(url, header=header) 73 | self._connected = True 74 | 75 | def peek_channel(self, channel, timeout=0): 76 | """Peek a channel and return part of the input, 77 | empty string otherwise.""" 78 | self.update(timeout=timeout) 79 | if channel in self._channels: 80 | return self._channels[channel] 81 | return "" 82 | 83 | def read_channel(self, channel, timeout=0): 84 | """Read data from a channel.""" 85 | if channel not in self._channels: 86 | ret = self.peek_channel(channel, timeout) 87 | else: 88 | ret = self._channels[channel] 89 | if channel in self._channels: 90 | del self._channels[channel] 91 | return ret 92 | 93 | def readline_channel(self, channel, timeout=None): 94 | """Read a line from a channel.""" 95 | if timeout is None: 96 | timeout = float("inf") 97 | start = time.time() 98 | while self.is_open() and time.time() - start < timeout: 99 | if channel in self._channels: 100 | data = self._channels[channel] 101 | if "\n" in data: 102 | index = data.find("\n") 103 | ret = data[:index] 104 | data = data[index+1:] 105 | if data: 106 | self._channels[channel] = data 107 | else: 108 | del self._channels[channel] 109 | return ret 110 | self.update(timeout=(timeout - time.time() + start)) 111 | 112 | def write_channel(self, channel, data): 113 | """Write data to a channel.""" 114 | self.sock.send(chr(channel) + data) 115 | 116 | def peek_stdout(self, timeout=0): 117 | """Same as peek_channel with channel=1.""" 118 | return self.peek_channel(STDOUT_CHANNEL, timeout=timeout) 119 | 120 | def read_stdout(self, timeout=None): 121 | """Same as read_channel with channel=1.""" 122 | return self.read_channel(STDOUT_CHANNEL, timeout=timeout) 123 | 124 | def readline_stdout(self, timeout=None): 125 | """Same as readline_channel with channel=1.""" 126 | return self.readline_channel(STDOUT_CHANNEL, timeout=timeout) 127 | 128 | def peek_stderr(self, timeout=0): 129 | """Same as peek_channel with channel=2.""" 130 | return self.peek_channel(STDERR_CHANNEL, timeout=timeout) 131 | 132 | def read_stderr(self, timeout=None): 133 | """Same as read_channel with channel=2.""" 134 | return self.read_channel(STDERR_CHANNEL, timeout=timeout) 135 | 136 | def readline_stderr(self, timeout=None): 137 | """Same as readline_channel with channel=2.""" 138 | return self.readline_channel(STDERR_CHANNEL, timeout=timeout) 139 | 140 | def read_all(self): 141 | """Return buffered data received on stdout and stderr channels. 142 | This is useful for non-interactive call where a set of command passed 143 | to the API call and their result is needed after the call is concluded. 144 | Should be called after run_forever() or update() 145 | 146 | TODO: Maybe we can process this and return a more meaningful map with 147 | channels mapped for each input. 148 | """ 149 | out = self._all 150 | self._all = "" 151 | self._channels = {} 152 | return out 153 | 154 | def is_open(self): 155 | """True if the connection is still alive.""" 156 | return self._connected 157 | 158 | def write_stdin(self, data): 159 | """The same as write_channel with channel=0.""" 160 | self.write_channel(STDIN_CHANNEL, data) 161 | 162 | def update(self, timeout=0): 163 | """Update channel buffers with at most one complete frame of input.""" 164 | if not self.is_open(): 165 | return 166 | if not self.sock.connected: 167 | self._connected = False 168 | return 169 | r, _, _ = select.select( 170 | (self.sock.sock, ), (), (), timeout) 171 | if r: 172 | op_code, frame = self.sock.recv_data_frame(True) 173 | if op_code == ABNF.OPCODE_CLOSE: 174 | self._connected = False 175 | return 176 | elif op_code == ABNF.OPCODE_BINARY or op_code == ABNF.OPCODE_TEXT: 177 | data = frame.data 178 | if six.PY3: 179 | data = data.decode("utf-8") 180 | if len(data) > 1: 181 | channel = ord(data[0]) 182 | data = data[1:] 183 | if data: 184 | if channel in [STDOUT_CHANNEL, STDERR_CHANNEL]: 185 | # keeping all messages in the order they received for 186 | # non-blocking call. 187 | self._all += data 188 | if channel not in self._channels: 189 | self._channels[channel] = data 190 | else: 191 | self._channels[channel] += data 192 | 193 | def run_forever(self, timeout=None): 194 | """Wait till connection is closed or timeout reached. Buffer any input 195 | received during this time.""" 196 | if timeout: 197 | start = time.time() 198 | while self.is_open() and time.time() - start < timeout: 199 | self.update(timeout=(timeout - time.time() + start)) 200 | else: 201 | while self.is_open(): 202 | self.update(timeout=None) 203 | 204 | def close(self, **kwargs): 205 | """ 206 | close websocket connection. 207 | """ 208 | self._connected = False 209 | if self.sock: 210 | self.sock.close(**kwargs) 211 | 212 | 213 | WSResponse = collections.namedtuple('WSResponse', ['data']) 214 | 215 | 216 | def get_websocket_url(url): 217 | parsed_url = urlparse(url) 218 | parts = list(parsed_url) 219 | if parsed_url.scheme == 'http': 220 | parts[0] = 'ws' 221 | elif parsed_url.scheme == 'https': 222 | parts[0] = 'wss' 223 | return urlunparse(parts) 224 | 225 | 226 | def websocket_call(configuration, *args, **kwargs): 227 | """An internal function to be called in api-client when a websocket 228 | connection is required. args and kwargs are the parameters of 229 | apiClient.request method.""" 230 | 231 | url = args[1] 232 | _request_timeout = kwargs.get("_request_timeout", 60) 233 | _preload_content = kwargs.get("_preload_content", True) 234 | headers = kwargs.get("headers") 235 | 236 | # Expand command parameter list to indivitual command params 237 | query_params = [] 238 | for key, value in kwargs.get("query_params", {}): 239 | if key == 'command' and isinstance(value, list): 240 | for command in value: 241 | query_params.append((key, command)) 242 | else: 243 | query_params.append((key, value)) 244 | 245 | if query_params: 246 | url += '?' + urlencode(query_params) 247 | 248 | try: 249 | client = WSClient(configuration, get_websocket_url(url), headers) 250 | if not _preload_content: 251 | return client 252 | client.run_forever(timeout=_request_timeout) 253 | return WSResponse('%s' % ''.join(client.read_all())) 254 | except (Exception, KeyboardInterrupt, SystemExit) as e: 255 | raise ApiException(status=0, reason=str(e)) 256 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /config/kube_config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 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 atexit 16 | import base64 17 | import datetime 18 | import json 19 | import os 20 | import tempfile 21 | 22 | import google.auth 23 | import google.auth.transport.requests 24 | import oauthlib.oauth2 25 | import urllib3 26 | import yaml 27 | from requests_oauthlib import OAuth2Session 28 | from six import PY3 29 | 30 | from kubernetes.client import ApiClient, Configuration 31 | 32 | from .config_exception import ConfigException 33 | from .dateutil import UTC, format_rfc3339, parse_rfc3339 34 | 35 | EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5) 36 | KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config') 37 | _temp_files = {} 38 | 39 | 40 | def _cleanup_temp_files(): 41 | global _temp_files 42 | for temp_file in _temp_files.values(): 43 | try: 44 | os.remove(temp_file) 45 | except OSError: 46 | pass 47 | _temp_files = {} 48 | 49 | 50 | def _create_temp_file_with_content(content): 51 | if len(_temp_files) == 0: 52 | atexit.register(_cleanup_temp_files) 53 | # Because we may change context several times, try to remember files we 54 | # created and reuse them at a small memory cost. 55 | content_key = str(content) 56 | if content_key in _temp_files: 57 | return _temp_files[content_key] 58 | _, name = tempfile.mkstemp() 59 | _temp_files[content_key] = name 60 | with open(name, 'wb') as fd: 61 | fd.write(content.encode() if isinstance(content, str) else content) 62 | return name 63 | 64 | 65 | def _is_expired(expiry): 66 | return ((parse_rfc3339(expiry) + EXPIRY_SKEW_PREVENTION_DELAY) <= 67 | datetime.datetime.utcnow().replace(tzinfo=UTC)) 68 | 69 | 70 | class FileOrData(object): 71 | """Utility class to read content of obj[%data_key_name] or file's 72 | content of obj[%file_key_name] and represent it as file or data. 73 | Note that the data is preferred. The obj[%file_key_name] will be used iff 74 | obj['%data_key_name'] is not set or empty. Assumption is file content is 75 | raw data and data field is base64 string. The assumption can be changed 76 | with base64_file_content flag. If set to False, the content of the file 77 | will assumed to be base64 and read as is. The default True value will 78 | result in base64 encode of the file content after read.""" 79 | 80 | def __init__(self, obj, file_key_name, data_key_name=None, 81 | file_base_path="", base64_file_content=True): 82 | if not data_key_name: 83 | data_key_name = file_key_name + "-data" 84 | self._file = None 85 | self._data = None 86 | self._base64_file_content = base64_file_content 87 | if data_key_name in obj: 88 | self._data = obj[data_key_name] 89 | elif file_key_name in obj: 90 | self._file = os.path.normpath( 91 | os.path.join(file_base_path, obj[file_key_name])) 92 | 93 | def as_file(self): 94 | """If obj[%data_key_name] exists, return name of a file with base64 95 | decoded obj[%data_key_name] content otherwise obj[%file_key_name].""" 96 | use_data_if_no_file = not self._file and self._data 97 | if use_data_if_no_file: 98 | if self._base64_file_content: 99 | self._file = _create_temp_file_with_content( 100 | base64.decodestring(self._data.encode())) 101 | else: 102 | self._file = _create_temp_file_with_content(self._data) 103 | if self._file and not os.path.isfile(self._file): 104 | raise ConfigException("File does not exists: %s" % self._file) 105 | return self._file 106 | 107 | def as_data(self): 108 | """If obj[%data_key_name] exists, Return obj[%data_key_name] otherwise 109 | base64 encoded string of obj[%file_key_name] file content.""" 110 | use_file_if_no_data = not self._data and self._file 111 | if use_file_if_no_data: 112 | with open(self._file) as f: 113 | if self._base64_file_content: 114 | self._data = bytes.decode( 115 | base64.encodestring(str.encode(f.read()))) 116 | else: 117 | self._data = f.read() 118 | return self._data 119 | 120 | 121 | class KubeConfigLoader(object): 122 | 123 | def __init__(self, config_dict, active_context=None, 124 | get_google_credentials=None, 125 | config_base_path="", 126 | config_persister=None): 127 | self._config = ConfigNode('kube-config', config_dict) 128 | self._current_context = None 129 | self._user = None 130 | self._cluster = None 131 | self.set_active_context(active_context) 132 | self._config_base_path = config_base_path 133 | self._config_persister = config_persister 134 | 135 | def _refresh_credentials(): 136 | credentials, project_id = google.auth.default( 137 | scopes=['https://www.googleapis.com/auth/cloud-platform'] 138 | ) 139 | request = google.auth.transport.requests.Request() 140 | credentials.refresh(request) 141 | return credentials 142 | 143 | if get_google_credentials: 144 | self._get_google_credentials = get_google_credentials 145 | else: 146 | self._get_google_credentials = _refresh_credentials 147 | 148 | def set_active_context(self, context_name=None): 149 | if context_name is None: 150 | context_name = self._config['current-context'] 151 | self._current_context = self._config['contexts'].get_with_name( 152 | context_name) 153 | if (self._current_context['context'].safe_get('user') and 154 | self._config.safe_get('users')): 155 | user = self._config['users'].get_with_name( 156 | self._current_context['context']['user'], safe=True) 157 | if user: 158 | self._user = user['user'] 159 | else: 160 | self._user = None 161 | else: 162 | self._user = None 163 | self._cluster = self._config['clusters'].get_with_name( 164 | self._current_context['context']['cluster'])['cluster'] 165 | 166 | def _load_authentication(self): 167 | """Read authentication from kube-config user section if exists. 168 | 169 | This function goes through various authentication methods in user 170 | section of kube-config and stops if it finds a valid authentication 171 | method. The order of authentication methods is: 172 | 173 | 1. GCP auth-provider 174 | 2. token_data 175 | 3. token field (point to a token file) 176 | 4. oidc auth-provider 177 | 5. username/password 178 | """ 179 | if not self._user: 180 | return 181 | if self._load_gcp_token(): 182 | return 183 | if self._load_user_token(): 184 | return 185 | if self._load_oid_token(): 186 | return 187 | self._load_user_pass_token() 188 | 189 | def _load_gcp_token(self): 190 | if 'auth-provider' not in self._user: 191 | return 192 | provider = self._user['auth-provider'] 193 | if 'name' not in provider: 194 | return 195 | if provider['name'] != 'gcp': 196 | return 197 | 198 | if (('config' not in provider) or 199 | ('access-token' not in provider['config']) or 200 | ('expiry' in provider['config'] and 201 | _is_expired(provider['config']['expiry']))): 202 | # token is not available or expired, refresh it 203 | self._refresh_gcp_token() 204 | 205 | self.token = "Bearer %s" % provider['config']['access-token'] 206 | return self.token 207 | 208 | def _refresh_gcp_token(self): 209 | if 'config' not in self._user['auth-provider']: 210 | self._user['auth-provider'].value['config'] = {} 211 | provider = self._user['auth-provider']['config'] 212 | credentials = self._get_google_credentials() 213 | provider.value['access-token'] = credentials.token 214 | provider.value['expiry'] = format_rfc3339(credentials.expiry) 215 | if self._config_persister: 216 | self._config_persister(self._config.value) 217 | 218 | def _load_oid_token(self): 219 | if 'auth-provider' not in self._user: 220 | return 221 | provider = self._user['auth-provider'] 222 | 223 | if 'name' not in provider or 'config' not in provider: 224 | return 225 | 226 | if provider['name'] != 'oidc': 227 | return 228 | 229 | parts = provider['config']['id-token'].split('.') 230 | 231 | if len(parts) != 3: # Not a valid JWT 232 | return None 233 | 234 | if PY3: 235 | jwt_attributes = json.loads( 236 | base64.b64decode(parts[1]).decode('utf-8') 237 | ) 238 | else: 239 | jwt_attributes = json.loads( 240 | base64.b64decode(parts[1] + "==") 241 | ) 242 | 243 | expire = jwt_attributes.get('exp') 244 | 245 | if ((expire is not None) and 246 | (_is_expired(datetime.datetime.fromtimestamp(expire, 247 | tz=UTC)))): 248 | self._refresh_oidc(provider) 249 | 250 | if self._config_persister: 251 | self._config_persister(self._config.value) 252 | 253 | self.token = "Bearer %s" % provider['config']['id-token'] 254 | 255 | return self.token 256 | 257 | def _refresh_oidc(self, provider): 258 | ca_cert = tempfile.NamedTemporaryFile(delete=True) 259 | 260 | if PY3: 261 | cert = base64.b64decode( 262 | provider['config']['idp-certificate-authority-data'] 263 | ).decode('utf-8') 264 | else: 265 | cert = base64.b64decode( 266 | provider['config']['idp-certificate-authority-data'] + "==" 267 | ) 268 | 269 | with open(ca_cert.name, 'w') as fh: 270 | fh.write(cert) 271 | 272 | config = Configuration() 273 | config.ssl_ca_cert = ca_cert.name 274 | 275 | client = ApiClient(configuration=config) 276 | 277 | response = client.request( 278 | method="GET", 279 | url="%s/.well-known/openid-configuration" 280 | % provider['config']['idp-issuer-url'] 281 | ) 282 | 283 | if response.status != 200: 284 | return 285 | 286 | response = json.loads(response.data) 287 | 288 | request = OAuth2Session( 289 | client_id=provider['config']['client-id'], 290 | token=provider['config']['refresh-token'], 291 | auto_refresh_kwargs={ 292 | 'client_id': provider['config']['client-id'], 293 | 'client_secret': provider['config']['client-secret'] 294 | }, 295 | auto_refresh_url=response['token_endpoint'] 296 | ) 297 | 298 | try: 299 | refresh = request.refresh_token( 300 | token_url=response['token_endpoint'], 301 | refresh_token=provider['config']['refresh-token'], 302 | auth=(provider['config']['client-id'], 303 | provider['config']['client-secret']), 304 | verify=ca_cert.name 305 | ) 306 | except oauthlib.oauth2.rfc6749.errors.InvalidClientIdError: 307 | return 308 | 309 | provider['config'].value['id-token'] = refresh['id_token'] 310 | provider['config'].value['refresh-token'] = refresh['refresh_token'] 311 | 312 | def _load_user_token(self): 313 | token = FileOrData( 314 | self._user, 'tokenFile', 'token', 315 | file_base_path=self._config_base_path, 316 | base64_file_content=False).as_data() 317 | if token: 318 | self.token = "Bearer %s" % token 319 | return True 320 | 321 | def _load_user_pass_token(self): 322 | if 'username' in self._user and 'password' in self._user: 323 | self.token = urllib3.util.make_headers( 324 | basic_auth=(self._user['username'] + ':' + 325 | self._user['password'])).get('authorization') 326 | return True 327 | 328 | def _load_cluster_info(self): 329 | if 'server' in self._cluster: 330 | self.host = self._cluster['server'] 331 | if self.host.startswith("https"): 332 | self.ssl_ca_cert = FileOrData( 333 | self._cluster, 'certificate-authority', 334 | file_base_path=self._config_base_path).as_file() 335 | self.cert_file = FileOrData( 336 | self._user, 'client-certificate', 337 | file_base_path=self._config_base_path).as_file() 338 | self.key_file = FileOrData( 339 | self._user, 'client-key', 340 | file_base_path=self._config_base_path).as_file() 341 | if 'insecure-skip-tls-verify' in self._cluster: 342 | self.verify_ssl = not self._cluster['insecure-skip-tls-verify'] 343 | 344 | def _set_config(self, client_configuration): 345 | if 'token' in self.__dict__: 346 | client_configuration.api_key['authorization'] = self.token 347 | # copy these keys directly from self to configuration object 348 | keys = ['host', 'ssl_ca_cert', 'cert_file', 'key_file', 'verify_ssl'] 349 | for key in keys: 350 | if key in self.__dict__: 351 | setattr(client_configuration, key, getattr(self, key)) 352 | 353 | def load_and_set(self, client_configuration): 354 | self._load_authentication() 355 | self._load_cluster_info() 356 | self._set_config(client_configuration) 357 | 358 | def list_contexts(self): 359 | return [context.value for context in self._config['contexts']] 360 | 361 | @property 362 | def current_context(self): 363 | return self._current_context.value 364 | 365 | 366 | class ConfigNode(object): 367 | """Remembers each config key's path and construct a relevant exception 368 | message in case of missing keys. The assumption is all access keys are 369 | present in a well-formed kube-config.""" 370 | 371 | def __init__(self, name, value): 372 | self.name = name 373 | self.value = value 374 | 375 | def __contains__(self, key): 376 | return key in self.value 377 | 378 | def __len__(self): 379 | return len(self.value) 380 | 381 | def safe_get(self, key): 382 | if (isinstance(self.value, list) and isinstance(key, int) or 383 | key in self.value): 384 | return self.value[key] 385 | 386 | def __getitem__(self, key): 387 | v = self.safe_get(key) 388 | if not v: 389 | raise ConfigException( 390 | 'Invalid kube-config file. Expected key %s in %s' 391 | % (key, self.name)) 392 | if isinstance(v, dict) or isinstance(v, list): 393 | return ConfigNode('%s/%s' % (self.name, key), v) 394 | else: 395 | return v 396 | 397 | def get_with_name(self, name, safe=False): 398 | if not isinstance(self.value, list): 399 | raise ConfigException( 400 | 'Invalid kube-config file. Expected %s to be a list' 401 | % self.name) 402 | result = None 403 | for v in self.value: 404 | if 'name' not in v: 405 | raise ConfigException( 406 | 'Invalid kube-config file. ' 407 | 'Expected all values in %s list to have \'name\' key' 408 | % self.name) 409 | if v['name'] == name: 410 | if result is None: 411 | result = v 412 | else: 413 | raise ConfigException( 414 | 'Invalid kube-config file. ' 415 | 'Expected only one object with name %s in %s list' 416 | % (name, self.name)) 417 | if result is not None: 418 | return ConfigNode('%s[name=%s]' % (self.name, name), result) 419 | if safe: 420 | return None 421 | raise ConfigException( 422 | 'Invalid kube-config file. ' 423 | 'Expected object with name %s in %s list' % (name, self.name)) 424 | 425 | 426 | def _get_kube_config_loader_for_yaml_file(filename, **kwargs): 427 | with open(filename) as f: 428 | return KubeConfigLoader( 429 | config_dict=yaml.load(f), 430 | config_base_path=os.path.abspath(os.path.dirname(filename)), 431 | **kwargs) 432 | 433 | 434 | def list_kube_config_contexts(config_file=None): 435 | 436 | if config_file is None: 437 | config_file = os.path.expanduser(KUBE_CONFIG_DEFAULT_LOCATION) 438 | 439 | loader = _get_kube_config_loader_for_yaml_file(config_file) 440 | return loader.list_contexts(), loader.current_context 441 | 442 | 443 | def load_kube_config(config_file=None, context=None, 444 | client_configuration=None, 445 | persist_config=True): 446 | """Loads authentication and cluster information from kube-config file 447 | and stores them in kubernetes.client.configuration. 448 | 449 | :param config_file: Name of the kube-config file. 450 | :param context: set the active context. If is set to None, current_context 451 | from config file will be used. 452 | :param client_configuration: The kubernetes.client.Configuration to 453 | set configs to. 454 | :param persist_config: If True, config file will be updated when changed 455 | (e.g GCP token refresh). 456 | """ 457 | 458 | if config_file is None: 459 | config_file = os.path.expanduser(KUBE_CONFIG_DEFAULT_LOCATION) 460 | 461 | config_persister = None 462 | if persist_config: 463 | def _save_kube_config(config_map): 464 | with open(config_file, 'w') as f: 465 | yaml.safe_dump(config_map, f, default_flow_style=False) 466 | config_persister = _save_kube_config 467 | 468 | loader = _get_kube_config_loader_for_yaml_file( 469 | config_file, active_context=context, 470 | config_persister=config_persister) 471 | if client_configuration is None: 472 | config = type.__call__(Configuration) 473 | loader.load_and_set(config) 474 | Configuration.set_default(config) 475 | else: 476 | loader.load_and_set(client_configuration) 477 | 478 | 479 | def new_client_from_config( 480 | config_file=None, 481 | context=None, 482 | persist_config=True): 483 | """Loads configuration the same as load_kube_config but returns an ApiClient 484 | to be used with any API object. This will allow the caller to concurrently 485 | talk with multiple clusters.""" 486 | client_config = type.__call__(Configuration) 487 | load_kube_config(config_file=config_file, context=context, 488 | client_configuration=client_config, 489 | persist_config=persist_config) 490 | return ApiClient(configuration=client_config) 491 | -------------------------------------------------------------------------------- /config/kube_config_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 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 base64 16 | import datetime 17 | import json 18 | import os 19 | import shutil 20 | import tempfile 21 | import unittest 22 | 23 | import mock 24 | import yaml 25 | from six import PY3 26 | 27 | from .config_exception import ConfigException 28 | from .dateutil import parse_rfc3339 29 | from .kube_config import (ConfigNode, FileOrData, KubeConfigLoader, 30 | _cleanup_temp_files, _create_temp_file_with_content, 31 | list_kube_config_contexts, load_kube_config, 32 | new_client_from_config) 33 | 34 | BEARER_TOKEN_FORMAT = "Bearer %s" 35 | 36 | NON_EXISTING_FILE = "zz_non_existing_file_472398324" 37 | 38 | 39 | def _base64(string): 40 | return base64.encodestring(string.encode()).decode() 41 | 42 | 43 | def _raise_exception(st): 44 | raise Exception(st) 45 | 46 | 47 | TEST_FILE_KEY = "file" 48 | TEST_DATA_KEY = "data" 49 | TEST_FILENAME = "test-filename" 50 | 51 | TEST_DATA = "test-data" 52 | TEST_DATA_BASE64 = _base64(TEST_DATA) 53 | 54 | TEST_ANOTHER_DATA = "another-test-data" 55 | TEST_ANOTHER_DATA_BASE64 = _base64(TEST_ANOTHER_DATA) 56 | 57 | TEST_HOST = "test-host" 58 | TEST_USERNAME = "me" 59 | TEST_PASSWORD = "pass" 60 | # token for me:pass 61 | TEST_BASIC_TOKEN = "Basic bWU6cGFzcw==" 62 | 63 | TEST_SSL_HOST = "https://test-host" 64 | TEST_CERTIFICATE_AUTH = "cert-auth" 65 | TEST_CERTIFICATE_AUTH_BASE64 = _base64(TEST_CERTIFICATE_AUTH) 66 | TEST_CLIENT_KEY = "client-key" 67 | TEST_CLIENT_KEY_BASE64 = _base64(TEST_CLIENT_KEY) 68 | TEST_CLIENT_CERT = "client-cert" 69 | TEST_CLIENT_CERT_BASE64 = _base64(TEST_CLIENT_CERT) 70 | 71 | 72 | TEST_OIDC_TOKEN = "test-oidc-token" 73 | TEST_OIDC_INFO = "{\"name\": \"test\"}" 74 | TEST_OIDC_BASE = _base64(TEST_OIDC_TOKEN) + "." + _base64(TEST_OIDC_INFO) 75 | TEST_OIDC_LOGIN = TEST_OIDC_BASE + "." + TEST_CLIENT_CERT_BASE64 76 | TEST_OIDC_TOKEN = "Bearer %s" % TEST_OIDC_LOGIN 77 | TEST_OIDC_EXP = "{\"name\": \"test\",\"exp\": 536457600}" 78 | TEST_OIDC_EXP_BASE = _base64(TEST_OIDC_TOKEN) + "." + _base64(TEST_OIDC_EXP) 79 | TEST_OIDC_EXPIRED_LOGIN = TEST_OIDC_EXP_BASE + "." + TEST_CLIENT_CERT_BASE64 80 | TEST_OIDC_CA = _base64(TEST_CERTIFICATE_AUTH) 81 | 82 | 83 | class BaseTestCase(unittest.TestCase): 84 | 85 | def setUp(self): 86 | self._temp_files = [] 87 | 88 | def tearDown(self): 89 | for f in self._temp_files: 90 | os.remove(f) 91 | 92 | def _create_temp_file(self, content=""): 93 | handler, name = tempfile.mkstemp() 94 | self._temp_files.append(name) 95 | os.write(handler, str.encode(content)) 96 | os.close(handler) 97 | return name 98 | 99 | def expect_exception(self, func, message_part, *args, **kwargs): 100 | with self.assertRaises(ConfigException) as context: 101 | func(*args, **kwargs) 102 | self.assertIn(message_part, str(context.exception)) 103 | 104 | 105 | class TestFileOrData(BaseTestCase): 106 | 107 | @staticmethod 108 | def get_file_content(filename): 109 | with open(filename) as f: 110 | return f.read() 111 | 112 | def test_file_given_file(self): 113 | temp_filename = _create_temp_file_with_content(TEST_DATA) 114 | obj = {TEST_FILE_KEY: temp_filename} 115 | t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY) 116 | self.assertEqual(TEST_DATA, self.get_file_content(t.as_file())) 117 | 118 | def test_file_given_non_existing_file(self): 119 | temp_filename = NON_EXISTING_FILE 120 | obj = {TEST_FILE_KEY: temp_filename} 121 | t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY) 122 | self.expect_exception(t.as_file, "does not exists") 123 | 124 | def test_file_given_data(self): 125 | obj = {TEST_DATA_KEY: TEST_DATA_BASE64} 126 | t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY, 127 | data_key_name=TEST_DATA_KEY) 128 | self.assertEqual(TEST_DATA, self.get_file_content(t.as_file())) 129 | 130 | def test_file_given_data_no_base64(self): 131 | obj = {TEST_DATA_KEY: TEST_DATA} 132 | t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY, 133 | data_key_name=TEST_DATA_KEY, base64_file_content=False) 134 | self.assertEqual(TEST_DATA, self.get_file_content(t.as_file())) 135 | 136 | def test_data_given_data(self): 137 | obj = {TEST_DATA_KEY: TEST_DATA_BASE64} 138 | t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY, 139 | data_key_name=TEST_DATA_KEY) 140 | self.assertEqual(TEST_DATA_BASE64, t.as_data()) 141 | 142 | def test_data_given_file(self): 143 | obj = { 144 | TEST_FILE_KEY: self._create_temp_file(content=TEST_DATA)} 145 | t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY) 146 | self.assertEqual(TEST_DATA_BASE64, t.as_data()) 147 | 148 | def test_data_given_file_no_base64(self): 149 | obj = { 150 | TEST_FILE_KEY: self._create_temp_file(content=TEST_DATA)} 151 | t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY, 152 | base64_file_content=False) 153 | self.assertEqual(TEST_DATA, t.as_data()) 154 | 155 | def test_data_given_file_and_data(self): 156 | obj = { 157 | TEST_DATA_KEY: TEST_DATA_BASE64, 158 | TEST_FILE_KEY: self._create_temp_file( 159 | content=TEST_ANOTHER_DATA)} 160 | t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY, 161 | data_key_name=TEST_DATA_KEY) 162 | self.assertEqual(TEST_DATA_BASE64, t.as_data()) 163 | 164 | def test_file_given_file_and_data(self): 165 | obj = { 166 | TEST_DATA_KEY: TEST_DATA_BASE64, 167 | TEST_FILE_KEY: self._create_temp_file( 168 | content=TEST_ANOTHER_DATA)} 169 | t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY, 170 | data_key_name=TEST_DATA_KEY) 171 | self.assertEqual(TEST_DATA, self.get_file_content(t.as_file())) 172 | 173 | def test_file_with_custom_dirname(self): 174 | tempfile = self._create_temp_file(content=TEST_DATA) 175 | tempfile_dir = os.path.dirname(tempfile) 176 | tempfile_basename = os.path.basename(tempfile) 177 | obj = {TEST_FILE_KEY: tempfile_basename} 178 | t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY, 179 | file_base_path=tempfile_dir) 180 | self.assertEqual(TEST_DATA, self.get_file_content(t.as_file())) 181 | 182 | def test_create_temp_file_with_content(self): 183 | self.assertEqual(TEST_DATA, 184 | self.get_file_content( 185 | _create_temp_file_with_content(TEST_DATA))) 186 | _cleanup_temp_files() 187 | 188 | 189 | class TestConfigNode(BaseTestCase): 190 | 191 | test_obj = {"key1": "test", "key2": ["a", "b", "c"], 192 | "key3": {"inner_key": "inner_value"}, 193 | "with_names": [{"name": "test_name", "value": "test_value"}, 194 | {"name": "test_name2", 195 | "value": {"key1", "test"}}, 196 | {"name": "test_name3", "value": [1, 2, 3]}], 197 | "with_names_dup": [{"name": "test_name", "value": "test_value"}, 198 | {"name": "test_name", 199 | "value": {"key1", "test"}}, 200 | {"name": "test_name3", "value": [1, 2, 3]}]} 201 | 202 | def setUp(self): 203 | super(TestConfigNode, self).setUp() 204 | self.node = ConfigNode("test_obj", self.test_obj) 205 | 206 | def test_normal_map_array_operations(self): 207 | self.assertEqual("test", self.node['key1']) 208 | self.assertEqual(5, len(self.node)) 209 | 210 | self.assertEqual("test_obj/key2", self.node['key2'].name) 211 | self.assertEqual(["a", "b", "c"], self.node['key2'].value) 212 | self.assertEqual("b", self.node['key2'][1]) 213 | self.assertEqual(3, len(self.node['key2'])) 214 | 215 | self.assertEqual("test_obj/key3", self.node['key3'].name) 216 | self.assertEqual({"inner_key": "inner_value"}, self.node['key3'].value) 217 | self.assertEqual("inner_value", self.node['key3']["inner_key"]) 218 | self.assertEqual(1, len(self.node['key3'])) 219 | 220 | def test_get_with_name(self): 221 | node = self.node["with_names"] 222 | self.assertEqual( 223 | "test_value", 224 | node.get_with_name("test_name")["value"]) 225 | self.assertTrue( 226 | isinstance(node.get_with_name("test_name2"), ConfigNode)) 227 | self.assertTrue( 228 | isinstance(node.get_with_name("test_name3"), ConfigNode)) 229 | self.assertEqual("test_obj/with_names[name=test_name2]", 230 | node.get_with_name("test_name2").name) 231 | self.assertEqual("test_obj/with_names[name=test_name3]", 232 | node.get_with_name("test_name3").name) 233 | 234 | def test_key_does_not_exists(self): 235 | self.expect_exception(lambda: self.node['not-exists-key'], 236 | "Expected key not-exists-key in test_obj") 237 | self.expect_exception(lambda: self.node['key3']['not-exists-key'], 238 | "Expected key not-exists-key in test_obj/key3") 239 | 240 | def test_get_with_name_on_invalid_object(self): 241 | self.expect_exception( 242 | lambda: self.node['key2'].get_with_name('no-name'), 243 | "Expected all values in test_obj/key2 list to have \'name\' key") 244 | 245 | def test_get_with_name_on_non_list_object(self): 246 | self.expect_exception( 247 | lambda: self.node['key3'].get_with_name('no-name'), 248 | "Expected test_obj/key3 to be a list") 249 | 250 | def test_get_with_name_on_name_does_not_exists(self): 251 | self.expect_exception( 252 | lambda: self.node['with_names'].get_with_name('no-name'), 253 | "Expected object with name no-name in test_obj/with_names list") 254 | 255 | def test_get_with_name_on_duplicate_name(self): 256 | self.expect_exception( 257 | lambda: self.node['with_names_dup'].get_with_name('test_name'), 258 | "Expected only one object with name test_name in test_obj/with_names_dup list") 259 | 260 | 261 | class FakeConfig: 262 | 263 | FILE_KEYS = ["ssl_ca_cert", "key_file", "cert_file"] 264 | 265 | def __init__(self, token=None, **kwargs): 266 | self.api_key = {} 267 | if token: 268 | self.api_key['authorization'] = token 269 | 270 | self.__dict__.update(kwargs) 271 | 272 | def __eq__(self, other): 273 | if len(self.__dict__) != len(other.__dict__): 274 | return 275 | for k, v in self.__dict__.items(): 276 | if k not in other.__dict__: 277 | return 278 | if k in self.FILE_KEYS: 279 | if v and other.__dict__[k]: 280 | try: 281 | with open(v) as f1, open(other.__dict__[k]) as f2: 282 | if f1.read() != f2.read(): 283 | return 284 | except IOError: 285 | # fall back to only compare filenames in case we are 286 | # testing the passing of filenames to the config 287 | if other.__dict__[k] != v: 288 | return 289 | else: 290 | if other.__dict__[k] != v: 291 | return 292 | else: 293 | if other.__dict__[k] != v: 294 | return 295 | return True 296 | 297 | def __repr__(self): 298 | rep = "\n" 299 | for k, v in self.__dict__.items(): 300 | val = v 301 | if k in self.FILE_KEYS: 302 | try: 303 | with open(v) as f: 304 | val = "FILE: %s" % str.decode(f.read()) 305 | except IOError as e: 306 | val = "ERROR: %s" % str(e) 307 | rep += "\t%s: %s\n" % (k, val) 308 | return "Config(%s\n)" % rep 309 | 310 | 311 | class TestKubeConfigLoader(BaseTestCase): 312 | TEST_KUBE_CONFIG = { 313 | "current-context": "no_user", 314 | "contexts": [ 315 | { 316 | "name": "no_user", 317 | "context": { 318 | "cluster": "default" 319 | } 320 | }, 321 | { 322 | "name": "simple_token", 323 | "context": { 324 | "cluster": "default", 325 | "user": "simple_token" 326 | } 327 | }, 328 | { 329 | "name": "gcp", 330 | "context": { 331 | "cluster": "default", 332 | "user": "gcp" 333 | } 334 | }, 335 | { 336 | "name": "expired_gcp", 337 | "context": { 338 | "cluster": "default", 339 | "user": "expired_gcp" 340 | } 341 | }, 342 | { 343 | "name": "oidc", 344 | "context": { 345 | "cluster": "default", 346 | "user": "oidc" 347 | } 348 | }, 349 | { 350 | "name": "expired_oidc", 351 | "context": { 352 | "cluster": "default", 353 | "user": "expired_oidc" 354 | } 355 | }, 356 | { 357 | "name": "user_pass", 358 | "context": { 359 | "cluster": "default", 360 | "user": "user_pass" 361 | } 362 | }, 363 | { 364 | "name": "ssl", 365 | "context": { 366 | "cluster": "ssl", 367 | "user": "ssl" 368 | } 369 | }, 370 | { 371 | "name": "no_ssl_verification", 372 | "context": { 373 | "cluster": "no_ssl_verification", 374 | "user": "ssl" 375 | } 376 | }, 377 | { 378 | "name": "ssl-no_file", 379 | "context": { 380 | "cluster": "ssl-no_file", 381 | "user": "ssl-no_file" 382 | } 383 | }, 384 | { 385 | "name": "ssl-local-file", 386 | "context": { 387 | "cluster": "ssl-local-file", 388 | "user": "ssl-local-file" 389 | } 390 | }, 391 | { 392 | "name": "non_existing_user", 393 | "context": { 394 | "cluster": "default", 395 | "user": "non_existing_user" 396 | } 397 | }, 398 | ], 399 | "clusters": [ 400 | { 401 | "name": "default", 402 | "cluster": { 403 | "server": TEST_HOST 404 | } 405 | }, 406 | { 407 | "name": "ssl-no_file", 408 | "cluster": { 409 | "server": TEST_SSL_HOST, 410 | "certificate-authority": TEST_CERTIFICATE_AUTH, 411 | } 412 | }, 413 | { 414 | "name": "ssl-local-file", 415 | "cluster": { 416 | "server": TEST_SSL_HOST, 417 | "certificate-authority": "cert_test", 418 | } 419 | }, 420 | { 421 | "name": "ssl", 422 | "cluster": { 423 | "server": TEST_SSL_HOST, 424 | "certificate-authority-data": TEST_CERTIFICATE_AUTH_BASE64, 425 | } 426 | }, 427 | { 428 | "name": "no_ssl_verification", 429 | "cluster": { 430 | "server": TEST_SSL_HOST, 431 | "insecure-skip-tls-verify": "true", 432 | } 433 | }, 434 | ], 435 | "users": [ 436 | { 437 | "name": "simple_token", 438 | "user": { 439 | "token": TEST_DATA_BASE64, 440 | "username": TEST_USERNAME, # should be ignored 441 | "password": TEST_PASSWORD, # should be ignored 442 | } 443 | }, 444 | { 445 | "name": "gcp", 446 | "user": { 447 | "auth-provider": { 448 | "name": "gcp", 449 | "config": { 450 | "access-token": TEST_DATA_BASE64, 451 | } 452 | }, 453 | "token": TEST_DATA_BASE64, # should be ignored 454 | "username": TEST_USERNAME, # should be ignored 455 | "password": TEST_PASSWORD, # should be ignored 456 | } 457 | }, 458 | { 459 | "name": "expired_gcp", 460 | "user": { 461 | "auth-provider": { 462 | "name": "gcp", 463 | "config": { 464 | "access-token": TEST_DATA_BASE64, 465 | "expiry": "2000-01-01T12:00:00Z", # always in past 466 | } 467 | }, 468 | "token": TEST_DATA_BASE64, # should be ignored 469 | "username": TEST_USERNAME, # should be ignored 470 | "password": TEST_PASSWORD, # should be ignored 471 | } 472 | }, 473 | { 474 | "name": "oidc", 475 | "user": { 476 | "auth-provider": { 477 | "name": "oidc", 478 | "config": { 479 | "id-token": TEST_OIDC_LOGIN 480 | } 481 | } 482 | } 483 | }, 484 | { 485 | "name": "expired_oidc", 486 | "user": { 487 | "auth-provider": { 488 | "name": "oidc", 489 | "config": { 490 | "client-id": "tectonic-kubectl", 491 | "client-secret": "FAKE_SECRET", 492 | "id-token": TEST_OIDC_EXPIRED_LOGIN, 493 | "idp-certificate-authority-data": TEST_OIDC_CA, 494 | "idp-issuer-url": "https://example.org/identity", 495 | "refresh-token": "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk" 496 | } 497 | } 498 | } 499 | }, 500 | { 501 | "name": "user_pass", 502 | "user": { 503 | "username": TEST_USERNAME, # should be ignored 504 | "password": TEST_PASSWORD, # should be ignored 505 | } 506 | }, 507 | { 508 | "name": "ssl-no_file", 509 | "user": { 510 | "token": TEST_DATA_BASE64, 511 | "client-certificate": TEST_CLIENT_CERT, 512 | "client-key": TEST_CLIENT_KEY, 513 | } 514 | }, 515 | { 516 | "name": "ssl-local-file", 517 | "user": { 518 | "tokenFile": "token_file", 519 | "client-certificate": "client_cert", 520 | "client-key": "client_key", 521 | } 522 | }, 523 | { 524 | "name": "ssl", 525 | "user": { 526 | "token": TEST_DATA_BASE64, 527 | "client-certificate-data": TEST_CLIENT_CERT_BASE64, 528 | "client-key-data": TEST_CLIENT_KEY_BASE64, 529 | } 530 | }, 531 | ] 532 | } 533 | 534 | def test_no_user_context(self): 535 | expected = FakeConfig(host=TEST_HOST) 536 | actual = FakeConfig() 537 | KubeConfigLoader( 538 | config_dict=self.TEST_KUBE_CONFIG, 539 | active_context="no_user").load_and_set(actual) 540 | self.assertEqual(expected, actual) 541 | 542 | def test_simple_token(self): 543 | expected = FakeConfig(host=TEST_HOST, 544 | token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) 545 | actual = FakeConfig() 546 | KubeConfigLoader( 547 | config_dict=self.TEST_KUBE_CONFIG, 548 | active_context="simple_token").load_and_set(actual) 549 | self.assertEqual(expected, actual) 550 | 551 | def test_load_user_token(self): 552 | loader = KubeConfigLoader( 553 | config_dict=self.TEST_KUBE_CONFIG, 554 | active_context="simple_token") 555 | self.assertTrue(loader._load_user_token()) 556 | self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, loader.token) 557 | 558 | def test_gcp_no_refresh(self): 559 | expected = FakeConfig( 560 | host=TEST_HOST, 561 | token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) 562 | actual = FakeConfig() 563 | KubeConfigLoader( 564 | config_dict=self.TEST_KUBE_CONFIG, 565 | active_context="gcp", 566 | get_google_credentials=lambda: _raise_exception( 567 | "SHOULD NOT BE CALLED")).load_and_set(actual) 568 | self.assertEqual(expected, actual) 569 | 570 | def test_load_gcp_token_no_refresh(self): 571 | loader = KubeConfigLoader( 572 | config_dict=self.TEST_KUBE_CONFIG, 573 | active_context="gcp", 574 | get_google_credentials=lambda: _raise_exception( 575 | "SHOULD NOT BE CALLED")) 576 | self.assertTrue(loader._load_gcp_token()) 577 | self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, 578 | loader.token) 579 | 580 | def test_load_gcp_token_with_refresh(self): 581 | 582 | def cred(): return None 583 | cred.token = TEST_ANOTHER_DATA_BASE64 584 | cred.expiry = datetime.datetime.now() 585 | 586 | loader = KubeConfigLoader( 587 | config_dict=self.TEST_KUBE_CONFIG, 588 | active_context="expired_gcp", 589 | get_google_credentials=lambda: cred) 590 | self.assertTrue(loader._load_gcp_token()) 591 | self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64, 592 | loader.token) 593 | 594 | def test_oidc_no_refresh(self): 595 | loader = KubeConfigLoader( 596 | config_dict=self.TEST_KUBE_CONFIG, 597 | active_context="oidc", 598 | ) 599 | self.assertTrue(loader._load_oid_token()) 600 | self.assertEqual(TEST_OIDC_TOKEN, loader.token) 601 | 602 | @mock.patch('kubernetes.config.kube_config.OAuth2Session.refresh_token') 603 | @mock.patch('kubernetes.config.kube_config.ApiClient.request') 604 | def test_oidc_with_refresh(self, mock_ApiClient, mock_OAuth2Session): 605 | mock_response = mock.MagicMock() 606 | type(mock_response).status = mock.PropertyMock( 607 | return_value=200 608 | ) 609 | type(mock_response).data = mock.PropertyMock( 610 | return_value=json.dumps({ 611 | "token_endpoint": "https://example.org/identity/token" 612 | }) 613 | ) 614 | 615 | mock_ApiClient.return_value = mock_response 616 | 617 | mock_OAuth2Session.return_value = {"id_token": "abc123", 618 | "refresh_token": "newtoken123"} 619 | 620 | loader = KubeConfigLoader( 621 | config_dict=self.TEST_KUBE_CONFIG, 622 | active_context="expired_oidc", 623 | ) 624 | self.assertTrue(loader._load_oid_token()) 625 | self.assertEqual("Bearer abc123", loader.token) 626 | 627 | def test_user_pass(self): 628 | expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN) 629 | actual = FakeConfig() 630 | KubeConfigLoader( 631 | config_dict=self.TEST_KUBE_CONFIG, 632 | active_context="user_pass").load_and_set(actual) 633 | self.assertEqual(expected, actual) 634 | 635 | def test_load_user_pass_token(self): 636 | loader = KubeConfigLoader( 637 | config_dict=self.TEST_KUBE_CONFIG, 638 | active_context="user_pass") 639 | self.assertTrue(loader._load_user_pass_token()) 640 | self.assertEqual(TEST_BASIC_TOKEN, loader.token) 641 | 642 | def test_ssl_no_cert_files(self): 643 | loader = KubeConfigLoader( 644 | config_dict=self.TEST_KUBE_CONFIG, 645 | active_context="ssl-no_file") 646 | self.expect_exception( 647 | loader.load_and_set, 648 | "does not exists", 649 | FakeConfig()) 650 | 651 | def test_ssl(self): 652 | expected = FakeConfig( 653 | host=TEST_SSL_HOST, 654 | token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, 655 | cert_file=self._create_temp_file(TEST_CLIENT_CERT), 656 | key_file=self._create_temp_file(TEST_CLIENT_KEY), 657 | ssl_ca_cert=self._create_temp_file(TEST_CERTIFICATE_AUTH) 658 | ) 659 | actual = FakeConfig() 660 | KubeConfigLoader( 661 | config_dict=self.TEST_KUBE_CONFIG, 662 | active_context="ssl").load_and_set(actual) 663 | self.assertEqual(expected, actual) 664 | 665 | def test_ssl_no_verification(self): 666 | expected = FakeConfig( 667 | host=TEST_SSL_HOST, 668 | token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, 669 | cert_file=self._create_temp_file(TEST_CLIENT_CERT), 670 | key_file=self._create_temp_file(TEST_CLIENT_KEY), 671 | verify_ssl=False, 672 | ssl_ca_cert=None, 673 | ) 674 | actual = FakeConfig() 675 | KubeConfigLoader( 676 | config_dict=self.TEST_KUBE_CONFIG, 677 | active_context="no_ssl_verification").load_and_set(actual) 678 | self.assertEqual(expected, actual) 679 | 680 | def test_list_contexts(self): 681 | loader = KubeConfigLoader( 682 | config_dict=self.TEST_KUBE_CONFIG, 683 | active_context="no_user") 684 | actual_contexts = loader.list_contexts() 685 | expected_contexts = ConfigNode("", self.TEST_KUBE_CONFIG)['contexts'] 686 | for actual in actual_contexts: 687 | expected = expected_contexts.get_with_name(actual['name']) 688 | self.assertEqual(expected.value, actual) 689 | 690 | def test_current_context(self): 691 | loader = KubeConfigLoader(config_dict=self.TEST_KUBE_CONFIG) 692 | expected_contexts = ConfigNode("", self.TEST_KUBE_CONFIG)['contexts'] 693 | self.assertEqual(expected_contexts.get_with_name("no_user").value, 694 | loader.current_context) 695 | 696 | def test_set_active_context(self): 697 | loader = KubeConfigLoader(config_dict=self.TEST_KUBE_CONFIG) 698 | loader.set_active_context("ssl") 699 | expected_contexts = ConfigNode("", self.TEST_KUBE_CONFIG)['contexts'] 700 | self.assertEqual(expected_contexts.get_with_name("ssl").value, 701 | loader.current_context) 702 | 703 | def test_ssl_with_relative_ssl_files(self): 704 | expected = FakeConfig( 705 | host=TEST_SSL_HOST, 706 | token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, 707 | cert_file=self._create_temp_file(TEST_CLIENT_CERT), 708 | key_file=self._create_temp_file(TEST_CLIENT_KEY), 709 | ssl_ca_cert=self._create_temp_file(TEST_CERTIFICATE_AUTH) 710 | ) 711 | try: 712 | temp_dir = tempfile.mkdtemp() 713 | actual = FakeConfig() 714 | with open(os.path.join(temp_dir, "cert_test"), "wb") as fd: 715 | fd.write(TEST_CERTIFICATE_AUTH.encode()) 716 | with open(os.path.join(temp_dir, "client_cert"), "wb") as fd: 717 | fd.write(TEST_CLIENT_CERT.encode()) 718 | with open(os.path.join(temp_dir, "client_key"), "wb") as fd: 719 | fd.write(TEST_CLIENT_KEY.encode()) 720 | with open(os.path.join(temp_dir, "token_file"), "wb") as fd: 721 | fd.write(TEST_DATA_BASE64.encode()) 722 | KubeConfigLoader( 723 | config_dict=self.TEST_KUBE_CONFIG, 724 | active_context="ssl-local-file", 725 | config_base_path=temp_dir).load_and_set(actual) 726 | self.assertEqual(expected, actual) 727 | finally: 728 | shutil.rmtree(temp_dir) 729 | 730 | def test_load_kube_config(self): 731 | expected = FakeConfig(host=TEST_HOST, 732 | token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) 733 | config_file = self._create_temp_file(yaml.dump(self.TEST_KUBE_CONFIG)) 734 | actual = FakeConfig() 735 | load_kube_config(config_file=config_file, context="simple_token", 736 | client_configuration=actual) 737 | self.assertEqual(expected, actual) 738 | 739 | def test_list_kube_config_contexts(self): 740 | config_file = self._create_temp_file(yaml.dump(self.TEST_KUBE_CONFIG)) 741 | contexts, active_context = list_kube_config_contexts( 742 | config_file=config_file) 743 | self.assertDictEqual(self.TEST_KUBE_CONFIG['contexts'][0], 744 | active_context) 745 | if PY3: 746 | self.assertCountEqual(self.TEST_KUBE_CONFIG['contexts'], 747 | contexts) 748 | else: 749 | self.assertItemsEqual(self.TEST_KUBE_CONFIG['contexts'], 750 | contexts) 751 | 752 | def test_new_client_from_config(self): 753 | config_file = self._create_temp_file(yaml.dump(self.TEST_KUBE_CONFIG)) 754 | client = new_client_from_config( 755 | config_file=config_file, context="simple_token") 756 | self.assertEqual(TEST_HOST, client.configuration.host) 757 | self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, 758 | client.configuration.api_key['authorization']) 759 | 760 | def test_no_users_section(self): 761 | expected = FakeConfig(host=TEST_HOST) 762 | actual = FakeConfig() 763 | test_kube_config = self.TEST_KUBE_CONFIG.copy() 764 | del test_kube_config['users'] 765 | KubeConfigLoader( 766 | config_dict=test_kube_config, 767 | active_context="gcp").load_and_set(actual) 768 | self.assertEqual(expected, actual) 769 | 770 | def test_non_existing_user(self): 771 | expected = FakeConfig(host=TEST_HOST) 772 | actual = FakeConfig() 773 | KubeConfigLoader( 774 | config_dict=self.TEST_KUBE_CONFIG, 775 | active_context="non_existing_user").load_and_set(actual) 776 | self.assertEqual(expected, actual) 777 | 778 | 779 | if __name__ == '__main__': 780 | unittest.main() 781 | --------------------------------------------------------------------------------